Files
apex.ermnvldmr.com/ci/scripts/init-node/provisioning/target.py
T
deytenit 6463bad5ee feat(engine): de-slop commons, harden security, add targeted compose and env bridge
- Purge misplaced RFCs, scratch tokens, and node-proprietary assets
- Decouple remote provisioning to ci/scripts/init-node.py
- Add targeted composition orchestration in actions/compose.py
- Add host-side Xray stale socket cleanup in /dev/shm
- Add actions/env.py for shell exports and .env synchronization
- Harden UFW, backup retention, host tempfiles, and notify dispatcher
- Add pyproject.toml and Makefile with 78 passing unit tests
2026-10-26 00:03:33 +03:00

171 lines
7.5 KiB
Python

"""Shared root runner for remote and cloud-init enrollment."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import platform
import pwd
import subprocess
import sys
import tempfile
from .state import Journal, ProvisionError, atomic_write, exclusive_lock, safe_path
from .userdata import decode_user_data
STATE = Path('/var/lib/apex-init')
def run(args, **kwargs):
result = subprocess.run(args, text=True, capture_output=True, timeout=300, **kwargs)
if result.returncode:
raise ProvisionError('required target operation failed: ' + args[0])
return result.stdout.strip()
def preflight():
if os.geteuid() != 0:
raise ProvisionError('target enrollment requires root privileges')
release = {}
for line in Path('/etc/os-release').read_text().splitlines():
key, separator, value = line.partition('=')
if separator:
release[key] = value.strip('"')
if release.get('ID') != 'debian' or release.get('VERSION_ID') != '13' or platform.machine() != 'x86_64':
raise ProvisionError('initialization supports Debian 13 amd64 only')
def validate_keys(spec):
for key in spec.authorized_keys:
with tempfile.NamedTemporaryFile(mode='w') as stream:
stream.write(key + '\n')
stream.flush()
try:
run(['ssh-keygen', '-l', '-f', stream.name])
except ProvisionError:
raise ProvisionError('administrator public key failed cryptographic validation') from None
def administrator(spec):
try:
account = pwd.getpwnam('adam')
except KeyError:
run(['useradd', '--create-home', '--shell', '/bin/bash', '--groups', 'sudo,docker', 'adam'])
account = pwd.getpwnam('adam')
if account.pw_dir != '/home/adam' or account.pw_uid == 0:
raise ProvisionError('existing administrator has incompatible home or UID')
safe_path(account.pw_dir)
if account.pw_shell != '/bin/bash':
run(['usermod', '--shell', '/bin/bash', 'adam'])
run(['usermod', '--append', '--groups', 'sudo,docker', 'adam'])
directory = safe_path(Path(account.pw_dir) / '.ssh')
directory.mkdir(mode=0o700, exist_ok=True)
os.chown(directory, account.pw_uid, account.pw_gid)
os.chmod(directory, 0o700)
authorized = safe_path(directory / 'authorized_keys')
existing = authorized.read_text().splitlines() if authorized.exists() else []
keys = existing + [key for key in spec.authorized_keys if key not in existing]
atomic_write(authorized, '\n'.join(keys) + '\n', uid=account.pw_uid, gid=account.pw_gid)
policy = 'adam ALL=(ALL:ALL) NOPASSWD: ALL\n'
with tempfile.NamedTemporaryFile(mode='w') as stream:
stream.write(policy)
stream.flush()
run(['visudo', '-cf', stream.name])
atomic_write('/etc/sudoers.d/99-apex-adam', policy, 0o440)
run(['visudo', '-c'])
run(['runuser', '-u', 'adam', '--', 'sudo', '-n', 'true'])
for item in spec.files:
uid, gid = (0, 0) if item.owner == 'root:root' else (account.pw_uid, account.pw_gid)
atomic_write(item.path, item.content, item.mode, uid=uid, gid=gid)
if run(['hostname']) != spec.hostname:
run(['hostnamectl', 'set-hostname', spec.hostname])
groups = run(['id', '-nG', 'adam']).split()
if not {'sudo', 'docker'} <= set(groups):
raise ProvisionError('administrator groups did not converge')
return {'administrator': 'adam', 'hostname': spec.hostname, 'sudo': 'verified'}
def enroll(spec, revision, bundle_digest, heartbeat=None):
from .baseline import apply_baseline
from .storage import acknowledge_storage_ownership, reconcile_storage
from .repository import reconcile_repository
preflight()
validate_keys(spec)
safe_path(STATE).mkdir(mode=0o700, exist_ok=True)
if STATE.stat().st_uid != 0:
raise ProvisionError('enrollment state directory must be root-owned')
os.chmod(STATE, 0o700)
with exclusive_lock(STATE / 'init.lock'):
journal = Journal(STATE / 'enrollment.json', spec.identity(), revision, bundle_digest)
journal.save()
journal.stage('baseline', apply_baseline, heartbeat)
journal.stage('administrator', lambda: administrator(spec), heartbeat)
result = journal.stage('storage', lambda: reconcile_storage(spec, journal.data, journal.save), heartbeat)
account = pwd.getpwnam('adam')
for path in result['ownership_pending']:
os.chown(safe_path(path), account.pw_uid, account.pw_gid)
acknowledge_storage_ownership(spec, journal.data, [path], journal.save)
journal.stage('repository', lambda: reconcile_repository(spec, journal.data, journal.save), heartbeat)
journal.data['status'] = 'enrolled'
journal.data['external_access'] = 'pending'
journal.save()
return journal.data
def main(argv=None):
parser = argparse.ArgumentParser()
parser.add_argument('operation', choices=('validate', 'enroll', 'status', 'complete'))
parser.add_argument('--user-data')
parser.add_argument('--revision', default='unknown')
parser.add_argument('--bundle-digest', default='unknown')
parser.add_argument('--heartbeat')
args = parser.parse_args(argv)
try:
preflight()
if args.operation == 'status':
path = safe_path(STATE / 'enrollment.json')
print(path.read_text() if path.exists() else json.dumps({'status': 'not-started'}))
return 0
if args.operation == 'complete':
with exclusive_lock(STATE / 'init.lock'):
access = json.loads(safe_path(STATE / 'access/state.json').read_text())
path = safe_path(STATE / 'enrollment.json')
record = json.loads(path.read_text())
if access.get('status') != 'committed' or record.get('status') not in ('enrolled', 'complete'):
raise ProvisionError('enrollment and access must be verified before completion')
record['external_access'] = 'verified'
record['status'] = 'complete'
atomic_write(path, json.dumps(record, sort_keys=True) + '\n')
print(json.dumps(record, sort_keys=True))
return 0
if not args.user_data:
raise ProvisionError('--user-data is required')
path = safe_path(args.user_data)
if path.stat().st_uid != 0 or path.stat().st_mode & 0o077:
raise ProvisionError('user-data must be root-owned and private')
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ProvisionError('user-data must be a regular file no larger than 1 MiB')
spec = decode_user_data(path.read_bytes())
validate_keys(spec)
if args.operation == 'validate':
print(json.dumps({'status': 'valid', 'hostname': spec.hostname}))
else:
print(json.dumps(enroll(spec, args.revision, args.bundle_digest, args.heartbeat), sort_keys=True))
return 0
except Exception as error:
from .contract import ContractError
from .baseline import BaselineError
from .storage import StorageError
if isinstance(error, (ProvisionError, ContractError, BaselineError, StorageError)):
print(str(error), file=sys.stderr)
else:
# Unexpected dependency failures must not render raw credential input.
print('initialization failed (' + type(error).__name__ + '); inspect stage status', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())