|
|
|
@@ -0,0 +1,890 @@
|
|
|
|
|
#!/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()
|