Files
Ansible/deploy/deploy.py
T
2026-09-22 19:23:17 +02:00

594 lines
30 KiB
Python

#!/usr/bin/env python3
"""Operational full-source install/update, not a Git patcher or release validator.
Python 3.11+, standard library only. Does not install dependencies, change groups,
start services, touch inventory/add-on data, or replace operator configuration.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import base64
from contextlib import contextmanager
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import tomllib
import sys
import tempfile
class DeploymentError(Exception):
pass
def no_symlink(path: Path):
for part in (path, *path.parents):
if part.is_symlink():
raise DeploymentError('Refusing a symlink in a deployment path: ' + str(part))
def no_symlink_parents(path: Path):
for part in path.parents:
if part.is_symlink():
raise DeploymentError('Refusing a symlink in a deployment parent path: ' + str(part))
def canonical(path: Path) -> Path:
# Check before normalization so an intermediate symlink cannot disappear.
no_symlink(path.absolute())
return Path(os.path.abspath(path))
@contextmanager
def deployment_lock(target: Path):
lock = target / '.aim-core-deploy.lock'
fd = os.open(lock, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT | os.O_CLOEXEC | getattr(os, 'O_NOFOLLOW', 0), 0o600)
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise DeploymentError('Deployment lock is not a regular file.')
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def allowed(relative: Path) -> bool:
parts = relative.parts
if relative.as_posix() in {'requirements.yml', 'requirements-controller.txt', 'deploy/deploy.py', 'deploy/README.md'}:
return True
if len(parts) >= 3 and parts[:3] == ('scripts', 'src', 'aim'):
return relative.suffix == '.py' or relative.as_posix() == 'scripts/src/aim/integrations/ansible.cfg'
if len(parts) >= 3 and parts[:2] == ('scripts', 'docs'):
return relative.suffix in ('.md', '.json')
if len(parts) == 2 and parts[0] == 'scripts':
return parts[1] in {'pyproject.toml', 'aim.yml', 'AIM-WinRM-OneTime.ps1', 'aimctl.py'}
if len(parts) == 3 and parts[:2] == ('playbooks', 'filter_plugins'):
return relative.suffix == '.py'
if len(parts) >= 2 and parts[0] == 'playbooks':
return relative.suffix in ('.yml', '.yaml', '.md')
if len(parts) >= 3 and parts[0] == 'roles':
if relative.name == 'README.md':
return True
return len(parts) >= 4 and parts[2] in ('tasks', 'defaults', 'handlers', 'meta', 'vars', 'templates') and relative.suffix in ('.yml', '.yaml', '.j2')
return False
def source_files(source: Path) -> dict[str, Path]:
result = {}
for parent, dirs, files in os.walk(source, followlinks=False):
dirs[:] = sorted(d for d in dirs if d != '__pycache__')
for d in dirs:
no_symlink(Path(parent) / d)
for name in sorted(files):
path = Path(parent) / name
relative = path.relative_to(source)
if path.suffix == '.pyc':
continue
if path.is_symlink() or not path.is_file() or not allowed(relative):
raise DeploymentError('Unexpected source entry; refusing deployment: ' + str(relative))
result[relative.as_posix()] = path
required = {'scripts/src/aim/__init__.py', 'scripts/pyproject.toml', 'scripts/docs/AGENTS.md', 'scripts/docs/ADDON_AGENTS.md', 'playbooks/aim_catalog.yml'}
if not required.issubset(result):
raise DeploymentError('Not a complete AIM replacement source tree.')
return result
def digest(path: Path | bytes) -> str:
if isinstance(path, bytes):
return hashlib.sha256(path).hexdigest()
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
LAUNCHER_MARKER = '# AIM core managed launcher v1'
def _launcher_digest(path: Path) -> str | None:
if path.is_symlink():
return digest(('symlink:' + os.readlink(path)).encode('utf-8'))
if path.exists():
return digest(path)
return None
def _read_launcher_text(path: Path) -> str:
candidate = path.resolve(strict=True) if path.is_symlink() else path
if not candidate.is_file() or candidate.stat().st_size > 65536:
raise DeploymentError('A non-launcher occupies ' + str(path))
try:
return candidate.read_text(encoding='utf-8')
except UnicodeDecodeError:
raise DeploymentError('Refusing to replace an unrecognized launcher: ' + str(path)) from None
def _restore_symlink(destination: Path, target: str):
no_symlink_parents(destination)
temporary = destination.parent / ('.aim-link-' + next(tempfile._get_candidate_names()))
try:
os.symlink(target, temporary)
os.replace(temporary, destination)
dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
temporary.unlink(missing_ok=True)
def _launcher_path(item, target: Path, launcher_dir: Path | None = None):
relative = Path(item['path'])
if item.get('kind') == 'launcher':
if launcher_dir is None or relative.parts not in (('@launchers', 'aim'), ('@launchers', 'aimctl')):
raise DeploymentError('Invalid launcher recovery path.')
directory = canonical(launcher_dir)
if not directory.is_absolute() or directory == Path('/'):
raise DeploymentError('Invalid launcher directory.')
destination = directory / relative.name
else:
if relative.is_absolute() or '..' in relative.parts or relative.parts[:1] == ('@launchers',):
raise DeploymentError('Invalid core source path.')
destination = target / relative
if item.get('kind') == 'launcher':
no_symlink_parents(destination)
else:
no_symlink(destination)
return destination
def _command(args, *, timeout=20):
env = os.environ.copy()
for name in ('PYTHONPATH', 'PYTHONHOME', 'PYTHONSTARTUP', 'PYTHONINSPECT'):
env.pop(name, None)
env['PYTHONDONTWRITEBYTECODE'] = '1'
try:
result = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=timeout, env=env, cwd='/')
except (OSError, subprocess.TimeoutExpired):
raise DeploymentError('AIM interpreter/launcher check could not complete; no dependency installation is attempted.') from None
if result.returncode:
raise DeploymentError('AIM interpreter/launcher check failed. Supply the existing AIM environment with --aim-python; ensure its Python 3.11+, ruamel.yaml and rich dependencies and operator configuration are usable.')
return result.stdout
@dataclass(frozen=True)
class EntryPoints:
python: Path
directory: Path
target: Path
def contents(self):
result = {}
for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
metadata = json.dumps({'target': str(self.target), 'python': str(self.python), 'command': name}, sort_keys=True)
content = (f'#!{self.python}\n{LAUNCHER_MARKER}\n# {metadata}\n'
'import sys\n'
'sys.dont_write_bytecode = True\n'
f'sys.path.insert(0, {str(self.target / "scripts/src")!r})\n'
f'from {module} import main\n'
'if __name__ == "__main__":\n raise SystemExit(main())\n')
result['@launchers/' + name] = content.encode('utf-8')
return result
def verify_python(self, source):
# An explicit interpreter is an administrator-selected executable, not
# user-controlled input to an elevated web wrapper. Preserve venv symlinks.
expected = tomllib.loads((source / 'scripts/pyproject.toml').read_text())['project']['version']
code = ('import sys,json; assert sys.version_info >= (3,11); '
f'sys.path.insert(0, {str(source / "scripts/src")!r}); '
'import ruamel.yaml,rich,aim; from aim.ui.app import App; '
'from aim.services.v1 import AimService; from aim.ctl import main; '
'print(json.dumps({"version":aim.__version__}))')
value = json.loads(_command([str(self.python), '-I', '-B', '-c', code]))
if value.get('version') != expected:
raise DeploymentError('Extracted source/package versions disagree.')
return expected
def verify_installed(self, version):
got = _command([str(self.directory / 'aim'), '--version']).strip()
value = json.loads(_command([str(self.directory / 'aimctl'), '--config',
str(self.target / 'scripts/aim.yml'), 'capabilities']))
if got != 'AIM ' + version or not value.get('ok') or value.get('result', {}).get('core_version') != version:
raise DeploymentError('Installed AIM/aimctl launchers do not report the deployed core version.')
print('PASS installed aim --version and aimctl capabilities (' + version + ').')
def entry_points(target: Path, python: Path | None, directory: Path | None) -> EntryPoints:
existing = shutil.which('aim')
if target != Path('/etc/ansible') and directory is None:
raise DeploymentError('A nondefault --target requires an explicit --bin-dir to avoid replacing another installation\'s commands.')
if python is None:
if not existing:
raise DeploymentError('Cannot discover the AIM interpreter. Supply --aim-python /absolute/path/to/the/existing/AIM/bin/python.')
with Path(existing).open(encoding='utf-8') as stream:
first = stream.readline().strip()
if not first.startswith('#!/') or len(first[2:].split()) != 1 or Path(first[2:]).name not in ('python', 'python3', 'python3.11', 'python3.12', 'python3.13', 'python3.14'):
raise DeploymentError('The existing aim launcher has no unambiguous Python shebang. Supply --aim-python explicitly; shell/env wrappers are not guessed.')
python = Path(first[2:])
if not python.is_absolute() or not python.is_file() or not os.access(python, os.X_OK) or any(c.isspace() for c in str(python)) or len(str(python)) > 120:
raise DeploymentError('--aim-python must be an executable absolute, space-free Python path (maximum 120 characters). Venv symlinks are supported.')
python = Path(os.path.abspath(python)) # do NOT resolve a venv symlink to the system Python
directory = directory if directory is not None else (Path(existing).parent if existing else Path('/usr/local/bin'))
if not directory.is_absolute():
raise DeploymentError('--bin-dir must be absolute.')
directory = canonical(directory)
if directory == Path('/') or directory == target or directory.is_relative_to(target / 'scripts/src'):
raise DeploymentError('Use a dedicated command directory, not the root or core source namespace.')
if directory.exists() and not directory.is_dir():
raise DeploymentError('The command directory is not a directory.')
for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
dest = directory / name
no_symlink_parents(dest)
if dest.exists() or dest.is_symlink():
if dest.is_symlink():
try:
resolved = dest.resolve(strict=True)
except (OSError, RuntimeError):
raise DeploymentError('Refusing a broken launcher symlink: ' + str(dest)) from None
if not resolved.is_file():
raise DeploymentError('Launcher symlink does not resolve to a regular file: ' + str(dest))
text = _read_launcher_text(dest)
if LAUNCHER_MARKER in text:
try:
info = json.loads(text.splitlines()[2][2:])
except (ValueError, IndexError):
raise DeploymentError('Invalid AIM launcher metadata: ' + str(dest)) from None
if info.get('target') != str(target):
raise DeploymentError('AIM launcher belongs to another installation; choose its own --bin-dir.')
elif f'from {module} import main' not in text:
raise DeploymentError('Refusing to replace an unrecognized launcher. Choose a reviewed --bin-dir: ' + str(dest))
return EntryPoints(python, directory, target)
def plan(source, target, mode, *, entrypoints=None):
source, target = canonical(source), canonical(target)
no_symlink(source)
no_symlink(target)
if target == Path('/') or source == target or source.is_relative_to(target) or target.is_relative_to(source):
raise DeploymentError('Use separate extracted-source and installation directories; never / as target.')
if mode == 'update' and not (target / 'scripts/src/aim/__init__.py').is_file():
raise DeploymentError('Existing AIM source was not found; use install for a fresh tree.')
files = source_files(source)
operations = []
for relative, src in sorted(files.items()):
dest = target / relative
no_symlink(dest)
if dest.exists() and not dest.is_file():
raise DeploymentError('A non-file occupies a core destination: ' + relative)
if relative == 'scripts/aim.yml' and dest.exists():
continue # operator-owned, always preserved, even on first install
if dest.exists() and digest(src) == digest(dest):
continue
operations.append({'path': relative, 'action': 'replace' if dest.exists() else 'create',
'old_sha256': digest(dest) if dest.exists() else None,
'new_sha256': digest(src)})
# Only the Python core namespace is an authoritative replaceable directory.
# Other unlisted playbooks/roles/files remain operator-owned additions.
core = target / 'scripts/src/aim'
if core.exists():
for parent, dirs, names in os.walk(core, followlinks=False):
for d in dirs:
no_symlink(Path(parent) / d)
for name in names:
existing = Path(parent) / name
no_symlink(existing)
relative = existing.relative_to(target).as_posix()
if relative not in files:
if not existing.is_file():
raise DeploymentError('Unsupported core namespace entry: ' + relative)
operations.append({'path': relative, 'action': 'remove', 'old_sha256': digest(existing), 'new_sha256': None})
# Explicitly retired Core document names only; unknown operator documents stay.
# Removal is previewed and recorded in the same protected rollback journal.
retired_docs = (
# Root/scripts-root AIM-owned docs moved into scripts/docs.
# Component-local docs (deploy/README.md, role READMEs, playbook docs) stay beside their code.
'AGENTS.md', 'ADDON_AGENTS.md', 'scripts/CHANGELOG.md',
'scripts/docs/RC19_HANDOFF.md', 'scripts/docs/SANITY_3.2.0.md',
'scripts/docs/SANITY_3.2.1rc2.md', 'scripts/docs/VERIFICATION.md',
'scripts/docs/LOCAL_VALIDATION.md', 'scripts/docs/CONTROLLER_ACCEPTANCE.md',
)
for relative in retired_docs:
existing = target / relative
no_symlink(existing)
if existing.exists() and relative not in files:
if not existing.is_file():
raise DeploymentError('Non-file occupies a retired document: ' + relative)
operations.append({'path': relative, 'action': 'remove',
'old_sha256': digest(existing), 'new_sha256': None})
if entrypoints is not None:
if entrypoints.target != target or entrypoints.directory == source or entrypoints.directory.is_relative_to(source):
raise DeploymentError('Launcher target/directory conflicts with the extracted source.')
for relative, content in entrypoints.contents().items():
dest = _launcher_path({'path': relative, 'kind': 'launcher'}, target, entrypoints.directory)
files[relative] = content
current = _launcher_digest(dest)
if not dest.is_symlink() and dest.exists() and current == digest(content) and os.access(dest, os.X_OK):
continue
operations.append({'path': relative, 'kind': 'launcher', 'action': 'replace' if (dest.exists() or dest.is_symlink()) else 'create',
'old_sha256': current, 'new_sha256': digest(content)})
return files, operations
def atomic_file(source: Path | bytes, destination: Path, *, metadata=None, executable=False, allow_replace_symlink=False):
destination.parent.mkdir(parents=True, exist_ok=True)
if allow_replace_symlink:
no_symlink_parents(destination)
else:
no_symlink(destination)
fd, filename = tempfile.mkstemp(prefix='.aim-install-', dir=destination.parent)
staged = Path(filename)
try:
with os.fdopen(fd, 'wb') as stream:
if isinstance(source, bytes):
stream.write(source)
else:
with source.open('rb') as incoming:
shutil.copyfileobj(incoming, stream)
stream.flush()
os.fsync(stream.fileno())
if metadata is None and destination.exists() and not destination.is_symlink():
old = destination.stat()
os.chown(staged, old.st_uid, old.st_gid)
# Preserve ACLs/attributes, not the old file timestamp.
for key in os.listxattr(destination):
os.setxattr(staged, key, os.getxattr(destination, key))
staged.chmod(stat.S_IMODE(old.st_mode) | (0o111 if executable else 0))
elif metadata is not None:
os.chown(staged, metadata['uid'], metadata['gid'])
for key, value in metadata.get('xattrs', {}).items():
os.setxattr(staged, key, base64.b64decode(value, validate=True))
staged.chmod(metadata['mode'])
else:
staged.chmod(0o755 if executable else 0o644)
os.replace(staged, destination)
dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
staged.unlink(missing_ok=True)
def apply(source, target, mode, backup_root, *, entrypoints=None):
source, target, backup_root = canonical(source), canonical(target), canonical(backup_root)
files, operations = plan(source, target, mode, entrypoints=entrypoints)
version = entrypoints.verify_python(source) if entrypoints else None
launcher_dir = entrypoints.directory if entrypoints else None
if not operations:
if entrypoints:
entrypoints.verify_installed(version)
print('AIM source and selected entry points are already current; operator configuration was preserved.')
return None
no_symlink(backup_root)
if backup_root == target or backup_root.is_relative_to(target) or backup_root == source or backup_root.is_relative_to(source):
raise DeploymentError('Backups must be outside the installation and extracted source trees.')
backup_root.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backup_root))
backup.chmod(0o700)
record = {'target': str(target), 'state': 'preparing', 'entries': [], 'created_directories': [],
'format': 2, 'launcher_dir': str(launcher_dir) if launcher_dir else None}
for operation in operations:
dest = _launcher_path(operation, target, launcher_dir)
item = dict(operation)
if dest.is_symlink():
item['old_kind'] = 'symlink'
item['link_target'] = os.readlink(dest)
elif dest.exists():
st = dest.stat()
item['old_kind'] = 'file'
item['metadata'] = dict(uid=st.st_uid, gid=st.st_gid, mode=stat.S_IMODE(st.st_mode),
xattrs={key: base64.b64encode(os.getxattr(dest, key)).decode('ascii') for key in os.listxattr(dest)})
recovery = backup / 'files' / operation['path']
recovery.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(dest, recovery)
recovery.chmod(0o600)
record['entries'].append(item)
def save_record():
fd, temporary = tempfile.mkstemp(prefix='.recovery-', dir=backup)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
json.dump(record, stream, indent=2)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, backup / 'recovery.json')
dfd = os.open(backup, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
finally:
Path(temporary).unlink(missing_ok=True)
save_record()
try:
record['state'] = 'applying'
save_record()
for item in record['entries']:
dest = _launcher_path(item, target, launcher_dir)
current = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if current != item['old_sha256']:
raise DeploymentError('Source changed during installation; stop writers and retry.')
item['started'] = True
save_record() # persist intent before changing any installed file
if item['action'] == 'remove':
dest.unlink()
else:
if digest(files[item['path']]) != item['new_sha256']:
raise DeploymentError('Extracted release source changed during installation.')
missing_dirs = []
parent = dest.parent
while not parent.exists():
missing_dirs.append(str(parent))
parent = parent.parent
record['created_directories'].extend(missing_dirs)
atomic_file(files[item['path']], dest, executable=item.get('kind') == 'launcher',
allow_replace_symlink=item.get('kind') == 'launcher')
item['applied'] = True
save_record()
if entrypoints:
entrypoints.verify_installed(version)
record['state'] = 'completed'
save_record()
print('AIM core source installed. Recovery directory: ' + str(backup))
print('Preserved existing scripts/aim.yml, inventories, Vaults, keys, add-ons, virtual environments and unlisted customer files.')
print('No dependencies, OS identities, permissions policy or services were provisioned.')
if entrypoints:
print('AIM and aimctl launchers: ' + str(entrypoints.directory))
print('Ensure this directory is in the operator PATH; use hash -r in existing shells.')
return backup
except BaseException:
# Ordinary failures can restore the source files already touched. A power
# loss/kill -9 is not an atomic whole-tree transaction: retain recovery data.
for item in reversed(record['entries']):
if not item.get('started') or item['path'] == 'scripts/aim.yml':
continue
dest = _launcher_path(item, target, launcher_dir)
now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
continue
if now != item['new_sha256']:
raise DeploymentError('A concurrently modified file prevented rollback; use the protected recovery directory.')
if item['old_sha256'] is None:
dest.unlink(missing_ok=True)
elif item.get('old_kind') == 'symlink':
_restore_symlink(dest, item['link_target'])
else:
atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
record['state'] = 'rolled_back_after_error'
save_record()
raise
def rollback(backup: Path, target: Path, *, execute: bool):
backup, target = canonical(backup), canonical(target)
path = backup / 'recovery.json'
if path.is_symlink() or not path.is_file():
raise DeploymentError('Recovery metadata is missing or unsafe.')
record = json.loads(path.read_text(encoding='utf-8'))
if record.get('target') != str(target) or record.get('state') not in ('completed', 'applying'):
raise DeploymentError('Recovery target/state does not match this installation.')
actions = []
launcher_dir = Path(record['launcher_dir']) if record.get('launcher_dir') else None
for item in record['entries']:
if not (item.get('started') or item.get('applied')):
continue
relative = Path(item['path'])
if relative.is_absolute() or '..' in relative.parts or relative.as_posix() == 'scripts/aim.yml':
# Initial install may have created aim.yml: never delete an operator's
# subsequent configuration through rollback. Always preserve this file.
if relative.as_posix() == 'scripts/aim.yml':
continue
raise DeploymentError('Unsafe recovery path.')
if item.get('kind') != 'launcher' and not allowed(relative) and relative.parts[:3] != ('scripts', 'src', 'aim'):
raise DeploymentError('Recovery may only address the core source namespace.')
dest = _launcher_path(item, target, launcher_dir)
now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
continue # interruption before publication, or an already restored entry
if now != item['new_sha256']:
raise DeploymentError('Installed core source changed since deployment; refusing to overwrite it during rollback: ' + str(relative))
if item['old_sha256'] is not None and item.get('old_kind') != 'symlink':
recovered = backup / 'files' / relative
no_symlink(recovered)
if digest(recovered) != item['old_sha256']:
raise DeploymentError('Recovery file checksum mismatch.')
actions.append(item)
print('Rollback source files: ' + str(len(actions)))
if not execute:
print('Dry run only. Use --apply --quiesced to restore these source files.')
return
for item in reversed(actions):
dest = _launcher_path(item, target, launcher_dir)
if item['old_sha256'] is None:
dest.unlink(missing_ok=True)
elif item.get('old_kind') == 'symlink':
_restore_symlink(dest, item['link_target'])
else:
atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
print('Core source and recorded launchers restored; no remote Ansible work was reversed. Use hash -r and verify aim/aimctl for the restored release.')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=('install', 'update', 'rollback'))
parser.add_argument('--target', type=Path, default=Path('/etc/ansible'))
parser.add_argument('--backup-dir', type=Path, default=Path('/var/backups/aim-core'))
parser.add_argument('--from-backup', type=Path)
parser.add_argument('--aim-python', type=Path, help='Existing AIM interpreter; inferred only from an unambiguous installed aim Python shebang')
parser.add_argument('--bin-dir', type=Path, help='Install aim and aimctl here; defaults to the existing aim command directory or /usr/local/bin')
group = parser.add_mutually_exclusive_group()
group.add_argument('--apply', action='store_true', help='Apply the planned core-source replacement')
group.add_argument('--dry-run', action='store_true', help='Preview only (the default)')
parser.add_argument('--quiesced', action='store_true', help='Confirm CLI/add-on jobs and other source writers have been stopped')
args = parser.parse_args(argv)
try:
if not args.target.is_absolute():
raise DeploymentError('An absolute non-root installation directory is required.')
args.target = canonical(args.target)
if args.target == Path('/'):
raise DeploymentError('An absolute non-root installation directory is required.')
if args.apply and not args.quiesced:
raise DeploymentError('Stop active CLI/add-on jobs and retry with --apply --quiesced.')
if args.operation == 'rollback':
if not args.from_backup:
raise DeploymentError('rollback requires --from-backup.')
if args.apply:
with deployment_lock(args.target):
rollback(args.from_backup, args.target, execute=True)
else:
rollback(args.from_backup, args.target, execute=False)
return 0
source = Path(__file__).absolute().parents[1]
entrypoints = entry_points(args.target, args.aim_python, args.bin_dir)
entrypoints.verify_python(source)
_, operations = plan(source, args.target, args.operation, entrypoints=entrypoints)
print('Target: ' + str(args.target))
print('AIM interpreter: ' + str(entrypoints.python))
print('Command directory: ' + str(entrypoints.directory))
for operation in operations:
print(operation['action'].upper() + ' ' + operation['path'])
print('Planned file operations: ' + str(len(operations)))
print('KEEP existing scripts/aim.yml and all unlisted runtime/add-on/customer files.')
if not args.apply:
print('Dry run only. Apply from this extracted archive with --apply --quiesced.')
return 0
args.target.mkdir(parents=True, exist_ok=True)
with deployment_lock(args.target):
apply(source, args.target, args.operation, args.backup_dir.absolute(), entrypoints=entrypoints)
return 0
except (DeploymentError, OSError, ValueError, KeyError, TypeError) as exc:
print('AIM deployment stopped: ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())