#!/usr/bin/env python3 """Root-only, staged replacement of the WebGUI add-on. Never runs pip for AIM. Run from a freshly unpacked release directory, not the active add-on directory. Linux + systemd + Python >=3.11. See docs/DEPLOYMENT.md before use. """ from __future__ import annotations import argparse from dataclasses import dataclass from datetime import datetime, timezone import fcntl import grp import hashlib import json import os from pathlib import Path import pwd import re import shutil import sqlite3 import subprocess import sys import tempfile import time import tomllib from urllib.request import Request, ProxyHandler, build_opener from urllib.parse import urlsplit from fetch_assets import prepare SERVICE = 'aim-web.service' PREFIX = Path('/opt/aim-web') STATE = Path('/var/lib/aim/webgui') BACKUPS = Path('/var/backups/aim-web') UNIT = Path('/etc/systemd/system') / SERVICE META = PREFIX / 'deployment.json' PENDING = PREFIX / 'pending.json' CLI_LINK = Path('/usr/local/bin/aim-web') EXECUTOR_UNIT = UNIT.with_name('aim-web-executor.service') EXECUTOR_STATE = Path('/var/lib/aim-web-executor') LEGACY_FILES = ( Path('/usr/local/libexec/aim-web-key-export'), Path('/etc/sudoers.d/aim-web-key-export'), Path('/etc/systemd/system/aim-web-worker.service.d/10-key-export-capabilities.conf'), ) def executor_service(action): if EXECUTOR_UNIT.is_file(): run(['systemctl', action, EXECUTOR_UNIT.name]) def unit_text(user, group, config, command, *, executor=False, executor_group=None, supplementary_group=None, executor_local_home=None): description = 'AIM WebGUI core executor (AIM remains separately managed)' if executor else 'AIM WebGUI ' + command state = EXECUTOR_STATE if executor else STATE runtime = 'RuntimeDirectory=aim-web-executor\nRuntimeDirectoryMode=0711\n' if executor else '' # Native core takes its own inventory locks. DAC permissions still decide write # access. The add-on does not chown or otherwise modify the inventory tree. executor_home = EXECUTOR_STATE staging = executor_home / '.ansible/tmp' executor_local_tmp = Path(executor_local_home) / '.ansible/tmp' if executor and executor_local_home is not None else None writable = f'{state} {staging} {executor_local_tmp} -/etc/ansible/inventories' if executor else str(state) after = 'After=network.target\n' if executor else 'After=network.target aim-web-executor.service\nWants=aim-web-executor.service\n' executor_env = f'Environment=HOME={executor_home}\n' if executor else '' executor_pre = f'ExecStartPre={PREFIX}/current/bin/aim-web --config {config} core-staging-check\n' if executor else '' service_group = executor_group if executor and executor_group is not None else group supplementary = f'SupplementaryGroups={supplementary_group}\n' if executor and supplementary_group is not None else '' return f'''[Unit] Description={description} {after} [Service] Type=simple User={user} Group={service_group} {supplementary}WorkingDirectory=/ ExecStart={PREFIX}/current/bin/aim-web --config {config} {command} Environment=PYTHONDONTWRITEBYTECODE=1 Environment=PYTHONNOUSERSITE=1 {executor_env}{executor_pre}UMask=0077 Restart=on-failure RestartSec=3 KillMode=control-group TimeoutStopSec=30 NoNewPrivileges=true PrivateTmp=true PrivateDevices=true ProtectSystem=strict ProtectHome={'read-only' if executor else 'true'} ReadWritePaths={writable} {runtime}RestrictSUIDSGID=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 CapabilityBoundingSet= AmbientCapabilities= LockPersonality=true LimitCORE=0 [Install] WantedBy=multi-user.target ''' def provision_executor_staging(executor_user): account=pwd.getpwnam(executor_user) base=EXECUTOR_STATE / '.ansible' staging=base / 'tmp' local_base=Path(account.pw_dir) / '.ansible' local_staging=local_base / 'tmp' for path in (EXECUTOR_STATE,base,staging,local_base,local_staging): path.mkdir(mode=0o700,parents=True,exist_ok=True) os.chown(path,account.pw_uid,account.pw_gid) path.chmod(0o700) def require_owner_mode(path: Path, uid: int, gid: int, mode: int, *, kind: str = 'path'): if path.is_symlink() or not path.exists(): raise ValueError(f'Managed {kind} is missing or a symlink: {path}') st=path.stat() actual=st.st_mode & 0o777 if st.st_uid!=uid or st.st_gid!=gid or actual!=mode: raise ValueError( f'Managed {kind} has unexpected ownership/mode: {path}; ' f'expected uid={uid} gid={gid} mode={mode:04o}, got ' f'uid={st.st_uid} gid={st.st_gid} mode={actual:04o}.') def validate_managed_permissions(service_user: str, executor_user: str, config: Path): web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user) require_owner_mode(STATE,web.pw_uid,web.pw_gid,0o700,kind='WebGUI state directory') require_owner_mode(config,0,web.pw_gid,0o640,kind='WebGUI configuration') require_owner_mode(EXECUTOR_STATE,executor.pw_uid,executor.pw_gid,0o700,kind='executor state directory') require_owner_mode(EXECUTOR_STATE/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor Ansible directory') require_owner_mode(EXECUTOR_STATE/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor controller-local staging directory') require_owner_mode(Path(executor.pw_dir)/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local Ansible directory') require_owner_mode(Path(executor.pw_dir)/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local staging directory') for path in (UNIT,worker_unit(),EXECUTOR_UNIT): require_owner_mode(path,0,0,0o644,kind='systemd unit') def validate_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path): web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user) require_owner_mode(socket_path.parent,executor.pw_uid,executor.pw_gid,0o711,kind='executor runtime directory') require_owner_mode(socket_path,executor.pw_uid,web.pw_gid,0o660,kind='executor socket') def wait_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path, *, timeout: float = 10.0): """Wait for the Type=simple executor to bind and permission its managed socket. systemctl start returns after the process is launched, not after Executor.run() has completed capability negotiation and listener.bind(). Treat a missing socket during that short window as startup-in-progress, not as a migration failure. """ deadline=time.monotonic()+timeout last=None while True: try: validate_executor_runtime_permissions(service_user,executor_user,socket_path) return except ValueError as exc: last=exc active=subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name]).returncode==0 if not active: raise ValueError('Executor service exited before its managed socket became ready.') from last if time.monotonic()>=deadline: raise ValueError(f'Executor managed socket did not become ready within {timeout:g}s: {socket_path}') from last time.sleep(0.1) def validate_legacy_files(): for path in LEGACY_FILES: if path.is_symlink(): raise ValueError(f'Refusing a symlink at legacy bridge: {path}') if not path.exists(): continue text = path.read_text() if path.name == 'aim-web-key-export': if 'key' not in text or ('aim-web' not in text and 'private' not in text.lower()): raise ValueError(f'Unrecognized legacy bridge; review manually: {path}') elif 'CapabilityBoundingSet=CAP_SETUID CAP_SETGID' not in text: raise ValueError(f'Unexpected worker capability override; review manually: {path}') # Other unit overrides can silently retain the old identity/capabilities. for name in ('aim-web.service','aim-web-worker.service','aim-web-executor.service'): directory = UNIT.parent / (name + '.d') if directory.exists(): unexpected = [p for p in directory.glob('*.conf') if p not in LEGACY_FILES] if unexpected: raise ValueError('Review and temporarily move unmanaged unit drop-ins before migration: ' + ', '.join(map(str,unexpected))) def restore_extra(snapshot, record): for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)): saved=snapshot/f'extra-{number}' if saved.exists(): path.parent.mkdir(parents=True,exist_ok=True) atomic_bytes(path,saved.read_bytes(),record['extra_modes'][str(path)]) os.chown(path,0,0) else: path.unlink(missing_ok=True) def run(args, *, capture=False, **kwargs): return subprocess.run([str(a) for a in args], check=True, text=True, capture_output=capture, **kwargs) def atomic_bytes(path: Path, data: bytes, mode=0o600): if path.is_symlink(): raise ValueError(f'Refusing a symlink at managed file: {path}') fd, name = tempfile.mkstemp(prefix='.aim-web-', dir=path.parent) temp = Path(name) try: with os.fdopen(fd, 'wb') as stream: stream.write(data) stream.flush() os.fsync(stream.fileno()) temp.chmod(mode) os.replace(temp, path) directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory) finally: os.close(directory) finally: temp.unlink(missing_ok=True) def write_json(path, value): atomic_bytes(path, (json.dumps(value, indent=2) + '\n').encode()) def sqlite_backup(source: Path, destination: Path): """Create a consistent SQLite checkpoint without importing any WebGUI runtime.""" if not source.is_file(): raise ValueError(f'Authentication database is missing: {source}') destination.unlink(missing_ok=True) src = sqlite3.connect(f'file:{source}?mode=ro', uri=True) dst = sqlite3.connect(destination) try: src.backup(dst) dst.commit() finally: dst.close() src.close() destination.chmod(0o600) def current_env() -> Path | None: link = PREFIX / 'current' return link.resolve() if link.is_symlink() else None def switch_env(env: Path): link = PREFIX / '.current-next' if link.exists() or link.is_symlink(): link.unlink() link.symlink_to(env) os.replace(link, PREFIX / 'current') def ensure_cli_link(): """Expose the active release on PATH without copying a versioned executable.""" target = PREFIX / 'current/bin/aim-web' if CLI_LINK.exists() and not CLI_LINK.is_symlink(): raise ValueError(f'Refusing to overwrite an unmanaged CLI file: {CLI_LINK}') if CLI_LINK.is_symlink(): if Path(os.readlink(CLI_LINK)) != target: raise ValueError(f'Refusing to replace an unrelated CLI symlink: {CLI_LINK}') return CLI_LINK.parent.mkdir(parents=True, mode=0o755, exist_ok=True) temporary = CLI_LINK.parent / ('.aim-web-link-' + new_id()) try: temporary.symlink_to(target) os.replace(temporary, CLI_LINK) finally: temporary.unlink(missing_ok=True) def remove_managed_cli_link(): target = PREFIX / 'current/bin/aim-web' if CLI_LINK.is_symlink() and Path(os.readlink(CLI_LINK)) == target: CLI_LINK.unlink() def worker_unit() -> Path: return UNIT.with_name('aim-web-worker.service') def worker_active() -> bool: return worker_unit().is_file() and subprocess.run( ['systemctl', 'is-active', '--quiet', 'aim-web-worker.service'], check=False).returncode == 0 def worker_service(action): if worker_unit().is_file(): run(['systemctl', action, 'aim-web-worker.service']) def active() -> bool: return subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False).returncode == 0 def service(action): run(['systemctl', action, SERVICE]) def web_config_value(config: dict, section: str, key: str, legacy: str, default): values = config.get(section, {}) if isinstance(values, dict) and key in values: return values[key] return config.get(legacy, default) def safe_absolute(path: Path) -> Path: # Protect systemd syntax and prevent unexpectedly following operator symlinks. if not path.is_absolute() or not re.fullmatch(r'/[A-Za-z0-9_./-]+', str(path)): raise ValueError('Deployment paths must be absolute and contain only letters, digits, /, _, . and -.') if path.resolve() != path: raise ValueError(f'Use a canonical path without symlinks or dot components: {path}') return path def release_order(version: str) -> tuple[int, ...]: match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:rc(\d+))?', version) if not match: raise ValueError('Use independent x.y.z or x.y.zrcN add-on versions.') major, minor, patch, rc = match.groups() return (int(major), int(minor), int(patch), int(rc is None), int(rc or 0)) def new_id() -> str: return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') @dataclass class Deployment: scripts: Path user: str required_gid: int @property def target(self): return self.scripts / 'addons/webgui' @property def config(self): return self.scripts / 'config/webgui.toml' def as_user(self, command, *, capture=False): account = pwd.getpwnam(self.user) groups = list(set(os.getgrouplist(self.user, account.pw_gid) + [self.required_gid])) def drop(): os.setgroups(groups) os.setgid(account.pw_gid) os.setuid(account.pw_uid) os.umask(0o077) env = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin', 'HOME':str(STATE), 'LANG':'C.UTF-8', 'PYTHONDONTWRITEBYTECODE':'1', 'PYTHONNOUSERSITE':'1'} return run(command, preexec_fn=drop, cwd='/', env=env, capture=capture) def as_executor(self, user, command, *, capture=False): account=pwd.getpwnam(user) web=pwd.getpwnam(self.user) def drop(): # Match the managed systemd identity: preserve the executor account's # normal primary GID and add only the WebGUI group needed for the # release-managed config/socket boundary. No /etc/group mutation. os.setgroups(list(set(os.getgrouplist(user,account.pw_gid) + [web.pw_gid]))) os.setgid(account.pw_gid) os.setuid(account.pw_uid) os.umask(0o077) environment={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(EXECUTOR_STATE),'LANG':'C.UTF-8', 'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'} return run(command,preexec_fn=drop,cwd='/',env=environment,capture=capture) def cli(self, env, *args, capture=False): return self.as_user([env / 'bin/aim-web', '--config', self.config, *args], capture=capture) def snapshot(self, old: dict | None) -> Path: stamp = BACKUPS / new_id() stamp.mkdir(mode=0o700) data = {'previous':old, 'was_active':active(), 'worker_active':worker_active(), 'worker_present':worker_unit().exists(), 'config_present':self.config.exists(), 'unit_present':UNIT.exists(), 'database_present':STATE.joinpath('webgui.sqlite3').exists()} if self.target.exists(): shutil.copytree(self.target, stamp / 'source', symlinks=True) if self.config.exists(): shutil.copy2(self.config, stamp / 'webgui.toml') if UNIT.exists(): shutil.copy2(UNIT, stamp / 'aim-web.service') if worker_unit().exists(): shutil.copy2(worker_unit(), stamp / 'aim-web-worker.service') if data['database_present']: # Use SQLite's online backup API directly. Snapshotting must not depend on # the previous WebGUI runtime being able to parse the current config. sqlite_backup(STATE / 'webgui.sqlite3', stamp / 'webgui.sqlite3') os.chown(stamp / 'webgui.sqlite3', 0, 0) data['executor_active'] = EXECUTOR_UNIT.exists() and subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name],check=False).returncode==0 data['extra_modes']={} for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)): if path.exists(): if path.is_symlink():raise ValueError(f'Unexpected symlink: {path}') shutil.copy2(path,stamp/f'extra-{number}') data['extra_modes'][str(path)] = path.stat().st_mode & 0o777 write_json(stamp / 'snapshot.json', data) return stamp def restore_db(self, snapshot: Path): source = snapshot / 'webgui.sqlite3' if not source.is_file(): raise ValueError('This snapshot has no database to restore.') target = STATE / 'webgui.sqlite3' temporary = STATE / '.restore.sqlite3' temporary.unlink(missing_ok=True) shutil.copyfile(source, temporary) account = pwd.getpwnam(self.user) os.chown(temporary, account.pw_uid, account.pw_gid) temporary.chmod(0o600) # Service must be stopped. Preserve failed data in the snapshot before replacement. for suffix in ('', '-wal', '-shm', '-journal'): file = Path(str(target) + suffix) if file.exists(): shutil.copy2(file, snapshot / ('pre-restore-' + new_id() + suffix + '.sqlite3')) if suffix: file.unlink() os.replace(temporary, target) def restore(self, snapshot: Path, *, database: bool, config: bool = False): record = json.loads((snapshot / 'snapshot.json').read_text()) old = record['previous'] worker_service('stop') executor_service('stop') restore_extra(snapshot,record) if old is None: # An early first-install failure may not have created/loaded a unit. if UNIT.exists() or active(): service('stop') # A failed first install preserves private state for a deliberate retry. if self.target.exists(): shutil.rmtree(self.target) (PREFIX / 'current').unlink(missing_ok=True) remove_managed_cli_link() META.unlink(missing_ok=True) subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) UNIT.unlink(missing_ok=True) worker_unit().unlink(missing_ok=True) if config: self.config.unlink(missing_ok=True) run(['systemctl', 'daemon-reload']) return service('stop') if database: self.restore_db(snapshot) staged = self.target.parent / ('.webgui-restore-' + new_id()) shutil.copytree(snapshot / 'source', staged, symlinks=True) displaced = self.target.parent / ('.webgui-displaced-' + new_id()) if self.target.exists(): os.replace(self.target, displaced) os.replace(staged, self.target) if displaced.exists(): shutil.rmtree(displaced) switch_env(Path(old['venv'])) ensure_cli_link() if config and record['config_present']: atomic_bytes(self.config, (snapshot / 'webgui.toml').read_bytes(), 0o640) os.chown(self.config, 0, pwd.getpwnam(self.user).pw_gid) if record['unit_present']: atomic_bytes(UNIT, (snapshot / 'aim-web.service').read_bytes(), 0o644) if record.get('worker_present'): atomic_bytes(worker_unit(), (snapshot / 'aim-web-worker.service').read_bytes(), 0o644) else: if worker_unit().exists(): run(['systemctl', 'disable', 'aim-web-worker.service']) worker_unit().unlink() write_json(META, old) run(['systemctl', 'daemon-reload']) # Revoking sessions also avoids restoring live session tokens from backups. self.as_user([Path(old['venv']) / 'bin/python', '-c', 'from aim_webgui.auth.service import Auth; from aim_webgui.config import Settings; ' 'from pathlib import Path; import sys; a=Auth(Settings.load(Path(sys.argv[1]))); ' 'db=a.store.connect(); db.execute("DELETE FROM sessions"); db.commit(); db.close()', self.config]) if release_order(old['version'])[0] < 2: # The core was separately upgraded to 3.3.0rc8. The legacy 1.x adapter # cannot be declared healthy here. Restored history/code remains stopped. subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service'],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) print('Restored legacy WebGUI but left services STOPPED and disabled: coordinate a separate AIM core rollback before restarting. AIM was not changed.',file=sys.stderr) return # Source and state restoration must not restart an adapter whose exact # independently managed Core contract is no longer available. Probe using # the restored package/config and the executor identity, never root. if not self.restored_core_compatible(old): subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name], check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) print('Restored add-on source/config/state; services remain STOPPED and disabled. ' 'The restored adapter does not qualify the currently installed Core. ' 'Coordinate the independent Core rollback, then explicitly re-enable services. ' 'No Core files were changed.',file=sys.stderr) return if record.get('executor_active'): executor_service('start') self.cli(Path(old['venv']), 'check') if record['was_active']: service('start') self.health() if record.get('worker_active'): worker_service('start') def restored_core_compatible(self, old): try: self.as_executor(old.get('executor_user','svc_bf-ansible'), [Path(old['venv'])/'bin/python','-B','-c', 'from dataclasses import replace; from pathlib import Path; import sys; ' 'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; ' 'CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio"))',self.config],capture=True) return True except (OSError,ValueError,subprocess.SubprocessError): return False def health(self): with self.config.open('rb') as stream: config = tomllib.load(stream) host = web_config_value(config, 'server', 'host', 'host', '127.0.0.1') port = web_config_value(config, 'server', 'port', 'port', 8080) # A wildcard listener is not a routable health-check destination. check_host = '127.0.0.1' if host in {'0.0.0.0', '::'} else host authority = f'[{check_host}]' if ':' in check_host else check_host url = f'http://{authority}:{port}/readyz' origin = urlsplit(web_config_value(config, 'server', 'public_url', 'public_url', 'http://127.0.0.1:8080')) opener = build_opener(ProxyHandler({})) for _ in range(12): try: req = Request(url, headers={'Host':origin.netloc}) with opener.open(req, timeout=10) as response: if response.status == 200 and json.load(response).get('status') == 'ready': return except Exception: pass time.sleep(.5) raise RuntimeError('Readiness check failed. Inspect journalctl -u aim-web.service.') def verify_release(source): """Verify the ZIP payload manifest before staging/provisioning any resource. The separately verified ZIP digest checks the expected release file; this manifest detects extraction damage, not publisher signatures. """ manifest=source/'MANIFEST.sha256' if not manifest.is_file() or manifest.is_symlink(): raise ValueError('Missing release MANIFEST.sha256. Use a fresh release ZIP.') seen=set() for line in manifest.read_text(encoding='utf-8').splitlines(): if not line: continue digest,sep,name=line.partition(' ') rel=Path(name) normalized=rel.as_posix() if not sep or not re.fullmatch('[0-9a-f]{64}',digest) or not name or rel.is_absolute() or '..' in rel.parts or normalized in seen: raise ValueError('Invalid release manifest entry.') seen.add(normalized) path=source/rel if any(p.is_symlink() for p in (path,*path.parents)) or not path.is_file(): raise ValueError('Release manifest references an unsafe or missing file.') if hashlib.sha256(path.read_bytes()).hexdigest()!=digest: raise ValueError('Release integrity mismatch: '+name) if not {'pyproject.toml','deploy/deploy.py','src/aim_webgui/__init__.py'}<=seen: raise ValueError('Release manifest does not cover required files.') def install(args): scripts = safe_absolute(args.aim_scripts) source = Path(__file__).resolve().parents[1] verify_release(source) target = scripts / 'addons/webgui' if source == target: raise ValueError('Unpack the new ZIP in a separate staging directory; do not update from the active source.') if not scripts.is_dir(): raise ValueError('Deploy the separately managed AIM core first.') core_launcher = args.aimctl.absolute() if not core_launcher.is_file() or not os.access(core_launcher,os.X_OK): raise ValueError('The configured aimctl launcher is missing or not executable. Deploy AIM 3.3.0rc8 first; this installer never creates it.') safe_absolute(args.core_config) if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}',args.executor_user):raise ValueError('Invalid executor account name.') try: executor_account=pwd.getpwnam(args.executor_user) except KeyError: raise ValueError('Provision and authorize the existing non-root AIM execution/key-owning account first.') from None if executor_account.pw_uid==0 or args.executor_user==args.service_user: raise ValueError('Managed topology needs distinct non-root web and execution accounts.') validate_legacy_files() if any(p.exists() for p in LEGACY_FILES) and not args.migrate_core: raise ValueError('Legacy key-export/capability files exist. Review migration and pass --migrate-core to retire only these backed-up files.') with (source / 'pyproject.toml').open('rb') as stream: project = tomllib.load(stream)['project'] version = project['version'] if not re.fullmatch(r'[0-9A-Za-z.+-]+', version): raise ValueError('Invalid add-on version.') previous = json.loads(META.read_text()) if META.exists() else None if args.command == 'update' and previous is None: raise ValueError('No managed add-on installation exists. Use install first.') if args.command == 'install' and previous: raise ValueError('Add-on already installed. Use update; existing accounts will be retained.') if previous and release_order(previous['version'])[0] < 2 and not args.migrate_core: raise ValueError('Major core integration migration: pass --migrate-core after reviewing MIGRATION-3.1.md. Old queued work is stopped; schema changes require a backup.') if previous and (previous['scripts'] != str(scripts) or previous['user'] != args.service_user): raise ValueError('Update must retain the installed AIM scripts path and service identity.') if previous and release_order(version) < release_order(previous['version']): raise ValueError('Downgrades use rollback, not update.') if previous and previous['version'] == version: raise ValueError('This add-on version is already installed. Published versions are immutable; use a new release number.') if target.exists() and (not previous or not (target / '.aim-web-managed').is_file()): raise ValueError('Target is not a recognized managed WebGUI directory. Back it up and move it aside manually.') if not previous and UNIT.exists(): raise ValueError('An unmanaged aim-web.service already exists; refusing to overwrite it.') if target.is_symlink(): raise ValueError('The active add-on source must be a real directory, not a symlink.') for path in source.rglob('*'): if path.is_symlink(): raise ValueError(f'Release contains an unexpected symlink: {path}') if not target.parent.exists(): target.parent.mkdir(parents=True, mode=0o755) target.parent.chmod(0o755) stage = Path(tempfile.mkdtemp(prefix='.webgui-stage-', dir=target.parent)) environment = PREFIX / 'venvs' / (version + '-' + new_id()) snapshot = None deployment = None activated = False try: shutil.copytree(source, stage, dirs_exist_ok=True, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.pytest_cache', '.credentials', '*.egg-info', 'build', 'dist', 'wheelhouse', 'offline-assets', '*.zip')) # Network/offline artifacts are prepared BEFORE stopping the running service. prepare(stage / 'src/aim_webgui/static/vendor', args.assets_dir, reuse=target / 'src/aim_webgui/static/vendor' if previous else None) environment.parent.mkdir(parents=True, mode=0o755, exist_ok=True) environment.parent.chmod(0o755) # Runtime code is root-owned but readable/executable by the service account. os.umask(0o022) run([args.python, '-m', 'venv', environment]) pip = [environment / 'bin/python', '-m', 'pip', 'install', '--no-compile', '--no-cache-dir'] if args.wheelhouse: pip.extend(['--no-index', '--find-links', args.wheelhouse]) run([*pip, '-c', stage / 'constraints.txt', 'setuptools']) run([*pip, '--no-build-isolation', '-c', stage / 'constraints.txt', stage]) run([environment / 'bin/python', '-m', 'pip', 'check']) identity = run([environment / 'bin/python', '-B', '-c', 'import aim_webgui,importlib.metadata; ' 'print(aim_webgui.__version__); print(importlib.metadata.version("aim-webgui"))'], capture=True) if identity.stdout.splitlines() != [version, version]: raise ValueError('Candidate package identity does not match this release; nothing was activated.') os.umask(0o077) # Do not parse or import private core configuration. Public protocol probe follows. if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', args.service_user): raise ValueError('Use a local service account name, not a shell expression.') try: account = pwd.getpwnam(args.service_user) except KeyError: shell = shutil.which('nologin') or '/usr/sbin/nologin' run(['useradd','--system','--user-group','--home-dir',STATE,'--shell',shell,args.service_user]) account = pwd.getpwnam(args.service_user) if account.pw_uid == 0: raise ValueError('The web service must not run as root.') if not STATE.parent.exists(): STATE.parent.mkdir(parents=True, mode=0o755) STATE.parent.chmod(0o755) STATE.mkdir(mode=0o700, exist_ok=True) if STATE.is_symlink(): raise ValueError('State directory must not be a symlink.') os.chown(STATE, account.pw_uid, account.pw_gid) STATE.chmod(0o700) required_gid = account.pw_gid deployment = Deployment(scripts, args.service_user, required_gid) if not deployment.config.parent.exists(): deployment.config.parent.mkdir(parents=True, mode=0o755) deployment.config.parent.chmod(0o755) # Stage and parse the candidate config without changing the installed config. text=(stage/'deploy/webgui.example.toml').read_text().replace('/etc/ansible/scripts',str(scripts)) text=text.replace('command = ["/usr/local/bin/aimctl"]',f'command = [{json.dumps(str(core_launcher))}]') text=text.replace('config = "/etc/ansible/scripts/aim.yml"',f'config = {json.dumps(str(args.core_config))}') text=text.replace('executor_user = "svc_bf-ansible"',f'executor_user = {json.dumps(args.executor_user)}') text=text.replace('client_user = "aim-web"',f'client_user = {json.dumps(args.service_user)}') release_config=tomllib.loads(text) if release_config['state']['state_dir']!=str(STATE):raise ValueError('Unexpected managed state location.') candidate=stage/'candidate.toml' candidate.write_text(text);candidate.chmod(0o640);os.chown(candidate,0,account.pw_gid);stage.chmod(0o755) deployment.as_user([environment/'bin/aim-web','--config',candidate,'check','--without-db','--without-core']) # Test public capabilities and authorization in the EXISTING core environment, # as the eventual executor identity. No dependency installation or core writes. probe=deployment.as_executor(args.executor_user,[environment/'bin/python','-B','-c', 'from dataclasses import replace; from pathlib import Path; import sys; ' 'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; ' 'a=CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio")); ' 'print(a.version); print("Authorized customer listing:",len(a.customers()))',candidate],capture=True) print(probe.stdout.strip()) candidate.unlink() snapshot=deployment.snapshot(previous) write_json(PENDING,{'backup':snapshot.name,'candidate':str(environment),'phase':'before-stop', 'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid}) if previous: worker_service('stop');service('stop');executor_service('stop') sqlite_backup(STATE/'webgui.sqlite3',snapshot/'webgui.sqlite3') os.chown(snapshot/'webgui.sqlite3',0,0) atomic_bytes(deployment.config,text.encode(),0o640);os.chown(deployment.config,0,account.pw_gid) # Neither group memberships nor inventory/key ownership are touched. if EXECUTOR_STATE.exists() and (EXECUTOR_STATE.is_symlink() or EXECUTOR_STATE.stat().st_uid!=executor_account.pw_uid): raise ValueError('Existing executor state directory has an unexpected owner or type.') EXECUTOR_STATE.mkdir(mode=0o700,exist_ok=True);os.chown(EXECUTOR_STATE,executor_account.pw_uid,executor_account.pw_gid);EXECUTOR_STATE.chmod(0o700) provision_executor_staging(args.executor_user) if previous:deployment.cli(environment,'db','migrate') else:deployment.cli(environment,'init') deployment.cli(environment,'check','--without-core') for directory in list(stage.rglob('__pycache__')) + list(stage.glob('src/*.egg-info')) + [stage / 'build', stage / 'dist']: if directory.is_dir(): shutil.rmtree(directory) # Source is replaced wholesale. No stale files, git, patch hunks or core overlay. for item in stage.rglob('*'): if not item.is_symlink(): item.chmod(0o755 if item.is_dir() else 0o644) (stage / '.aim-web-managed').write_text('aim-webgui ' + version + '\n') (stage / '.credentials').symlink_to(STATE / '.credentials') stage.chmod(0o755) if target.exists(): displaced = target.parent / ('.webgui-old-' + new_id()) os.replace(target, displaced) else: displaced = None os.replace(stage, target) activated = True if displaced: shutil.rmtree(displaced) switch_env(environment) ensure_cli_link() atomic_bytes(UNIT,unit_text(args.service_user,account.pw_gid,deployment.config,'serve').encode(),0o644) atomic_bytes(worker_unit(),unit_text(args.service_user,account.pw_gid,deployment.config,'worker').encode(),0o644) atomic_bytes(EXECUTOR_UNIT,unit_text(args.executor_user,account.pw_gid,deployment.config,'executor',executor=True,executor_group=executor_account.pw_gid,supplementary_group=account.pw_gid,executor_local_home=executor_account.pw_dir).encode(),0o644) for managed_unit in (UNIT,worker_unit(),EXECUTOR_UNIT): os.chown(managed_unit,0,0);managed_unit.chmod(0o644) validate_managed_permissions(args.service_user,args.executor_user,deployment.config) # Approved major migration retires the known old bridge and elevated worker # override; snapshot restoration restores matching legacy files if needed. for path in LEGACY_FILES:path.unlink(missing_ok=True) write_json(META, {'version':version,'scripts':str(scripts),'user':args.service_user, 'required_gid':required_gid,'venv':str(environment),'backup':snapshot.name, 'executor_user':args.executor_user,'core_version':'3.3.0rc8','api_version':'1.0'}) write_json(PENDING, {'backup':snapshot.name,'candidate':str(environment),'phase':'activated', 'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid}) run(['systemctl','daemon-reload']) run(['systemctl','enable',EXECUTOR_UNIT.name]) executor_service('start') wait_executor_runtime_permissions(args.service_user,args.executor_user,Path(release_config['core']['socket'])) run(['systemctl','enable',SERVICE]) service('start') deployment.health() if release_config.get('execution', {}).get('enabled', False): run(['systemctl', 'enable', 'aim-web-worker.service']) worker_service('start') else: worker_service('stop') run(['systemctl', 'disable', 'aim-web-worker.service']) deployment.as_user([environment / 'bin/python', '-B', '-c', 'import sys; from pathlib import Path; from aim_webgui.config import Settings; ' 'from aim_webgui.db.store import Store,audit; s=Store(Settings.load(Path(sys.argv[1])).database); ' 'db=s.connect(); audit(db,"deployer","release-installed",sys.argv[2]); db.commit(); db.close()', deployment.config, version]) PENDING.unlink() print(f'Installed AIM WebGUI {version}; separately managed AIM 3.3.0rc8 was not modified.') print(f'Add-on source: {target}') print(f'Configuration: {deployment.config}') print(f'Rollback snapshot: {snapshot.name}') if (STATE / '.credentials').exists(): print(f'Initial admin credentials (local file only): {(target / ".credentials").as_uri()}') print(f'Read locally: sudo cat {STATE}/.credentials') print(f'CLI: {CLI_LINK} -> {PREFIX}/current/bin/aim-web') print('Service: systemctl status aim-web.service') except BaseException: if snapshot and deployment and PENDING.exists(): print('Deployment failed. Attempting to restore the prior add-on; AIM was not modified.', file=sys.stderr) try: deployment.restore(snapshot, database=bool(previous), config=True) PENDING.unlink(missing_ok=True) except Exception: print(f'Automatic recovery did not complete. Keep service stopped; recovery snapshot: {snapshot}', file=sys.stderr) raise finally: if stage.exists(): shutil.rmtree(stage) # Failed venvs are left for diagnosis; never delete one referenced by a snapshot. def rollback(args): if not META.exists(): raise ValueError('No active deployment metadata. Recover using docs/DEPLOYMENT.md and pending.json.') latest = json.loads(META.read_text()) if not args.backup or not re.fullmatch(r'[0-9]{8}T[0-9]{12}Z', args.backup): raise ValueError('Use --backup with the exact snapshot identifier printed during deployment.') selected = BACKUPS / args.backup record = json.loads((selected / 'snapshot.json').read_text()) if not record.get('previous'): raise ValueError('This is the first-install checkpoint, not an earlier installed version.') if record['previous']['scripts'] != latest['scripts']: raise ValueError('Snapshot is for a different installation.') deployment = Deployment(Path(latest['scripts']), latest['user'], latest['required_gid']) if not args.restore_auth_db: # Validate the rollback runtime against the configuration stored with that release. old_env = Path(record['previous']['venv']) schema = deployment.as_user([old_env / 'bin/python', '-B', '-c', 'from aim_webgui import SCHEMA_VERSION; print(SCHEMA_VERSION)'], capture=True) with sqlite3.connect((STATE / 'webgui.sqlite3').as_uri() + '?mode=ro', uri=True) as db: current_schema = db.execute('PRAGMA user_version').fetchone()[0] if current_schema != int(schema.stdout.strip()): raise ValueError('Rollback crosses a database schema boundary. Use --restore-auth-db explicitly after backing up current data.') with tempfile.TemporaryDirectory(prefix='.rollback-config-', dir=PREFIX) as directory: check_dir = Path(directory) check_dir.chmod(0o755) check_config = check_dir / 'webgui.toml' shutil.copyfile(selected / 'webgui.toml', check_config) check_config.chmod(0o640) os.chown(check_config, 0, pwd.getpwnam(latest['user']).pw_gid) deployment.as_user([old_env / 'bin/aim-web', '--config', check_config, 'check', '--without-db', '--without-core']) current_snapshot = deployment.snapshot(latest) write_json(PENDING, {'backup':current_snapshot.name,'phase':'manual-rollback', 'scripts':latest['scripts'],'user':latest['user'],'required_gid':latest['required_gid']}) try: deployment.restore(selected, database=args.restore_auth_db, config=True) PENDING.unlink(missing_ok=True) print('Rollback completed. Release configuration restored. All WebGUI sessions revoked.') print('Auth database restored from snapshot.' if args.restore_auth_db else 'Current users and passwords retained.') print('Undo-rollback snapshot:', current_snapshot.name) except BaseException: deployment.restore(current_snapshot, database=args.restore_auth_db, config=True) PENDING.unlink(missing_ok=True) raise def recover(): if not PENDING.is_file(): raise ValueError('No interrupted deployment journal exists.') pending = json.loads(PENDING.read_text()) snapshot = BACKUPS / pending['backup'] record = json.loads((snapshot / 'snapshot.json').read_text()) deployment = Deployment(Path(pending['scripts']), pending['user'], pending['required_gid']) deployment.restore(snapshot, database=bool(record['previous']), config=True) PENDING.unlink() print('Interrupted deployment recovered. AIM was not changed.') def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('command', choices=['install','update','rollback','recover']) parser.add_argument('--aim-scripts', type=Path, default=Path('/etc/ansible/scripts')) parser.add_argument('--service-user', default='aim-web') parser.add_argument('--executor-user',default='svc_bf-ansible',help='Existing authorized non-root key-owning AIM account; never created or modified.') parser.add_argument('--aimctl',type=Path,default=Path('/usr/local/bin/aimctl')) parser.add_argument('--core-config',type=Path,default=Path('/etc/ansible/scripts/aim.yml')) parser.add_argument('--migrate-core',action='store_true',help='Acknowledge 1.x to 2.x API/state/service migration; read MIGRATION-3.1.md first.') parser.add_argument('--python', default=sys.executable) parser.add_argument('--wheelhouse', type=Path, help='Offline wheels for this Python/OS/architecture, including build dependencies.') parser.add_argument('--assets-dir', type=Path, help='Offline pinned bootstrap.min.css and htmx.min.js.') parser.add_argument('--backup', help='Rollback snapshot identifier.') parser.add_argument('--restore-auth-db', action='store_true', help='DESTRUCTIVE: discard account changes since selected snapshot.') args = parser.parse_args() if os.geteuid() != 0: parser.exit(1, 'Run deployment with sudo/root. Runtime will use an unprivileged account.\n') if not Path('/run/systemd/system').is_dir(): parser.exit(1, 'Managed deployment requires Linux/systemd. See manual installation instructions.\n') os.umask(0o077) for location in (PREFIX, BACKUPS, STATE): safe_absolute(location) PREFIX.mkdir(mode=0o755, parents=True, exist_ok=True) if PREFIX.stat().st_uid != 0: parser.exit(1, 'The managed /opt/aim-web directory must be root-owned.\n') PREFIX.chmod(0o755) BACKUPS.mkdir(mode=0o700, parents=True, exist_ok=True) if BACKUPS.stat().st_uid != 0: parser.exit(1, 'The WebGUI backup directory must be root-owned.\n') BACKUPS.chmod(0o700) lock = os.open(PREFIX / '.deploy.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) if PENDING.exists() and args.command != 'recover': raise ValueError('Interrupted deployment detected in /opt/aim-web/pending.json. Recover before retrying.') if args.command == 'recover': recover() elif args.command == 'rollback': rollback(args) else: install(args) except Exception as exc: parser.exit(1, f'Deployment stopped: {exc}\n') finally: os.close(lock) if __name__ == '__main__': main()