aim-web2.1.0rc9
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# AIM core ZIP deployment - 3.3.0rc8
|
||||
|
||||
This standard-library operational helper installs a complete source release and
|
||||
its two source-bound command launchers. No Git, patch files, release manifest,
|
||||
package installation, add-on inspection or account/group migration is used.
|
||||
Python 3.11+ on Linux is required. Development validators are not distributed.
|
||||
|
||||
## Verify and preview
|
||||
|
||||
Obtain the ZIP and its SHA-256 sidecar through a trusted channel. The sidecar is an
|
||||
integrity check, not a publisher signature. Stage outside the installation tree:
|
||||
|
||||
```bash
|
||||
cd /var/tmp
|
||||
sha256sum -c AIM-Ansible-3.3.0rc8.zip.sha256
|
||||
unzip AIM-Ansible-3.3.0rc8.zip
|
||||
cd aim-core-3.3.0rc8
|
||||
sudo python3 deploy/deploy.py update --dry-run
|
||||
```
|
||||
|
||||
The target defaults to `/etc/ansible`. `update` requires existing core source;
|
||||
`install` is for a fresh tree. Preview is the default without `--apply`.
|
||||
Do not extract over the live installation. Source and target must not overlap;
|
||||
symlink paths and unexpected source files are rejected.
|
||||
|
||||
3.3.0rc8 checks the existing **AIM** Python interpreter before making changes. It can
|
||||
infer it only from an unambiguous installed `aim` Python shebang. It does not assume
|
||||
that `sudo python3`, the Ansible interpreter or an add-on environment contains AIM's
|
||||
dependencies. It preserves a virtual environment's Python path without resolving
|
||||
its symlink to the system Python.
|
||||
|
||||
When discovery is unavailable (for example sudo has a different PATH), provide the
|
||||
already identified AIM interpreter explicitly. In the operator test session,
|
||||
`AIM_PYTHON` is the interpreter that successfully ran `scripts/aimctl.py`:
|
||||
|
||||
```bash
|
||||
test -x "$AIM_PYTHON" || { echo 'Set AIM_PYTHON to the existing AIM interpreter first.'; exit 1; }
|
||||
sudo python3 deploy/deploy.py update --aim-python "$AIM_PYTHON" --dry-run
|
||||
```
|
||||
|
||||
Shell wrappers and `#!/usr/bin/env ...` shebangs are not guessed. The interpreter
|
||||
must be an absolute executable Python 3.11+ path with no spaces (up to 120 characters),
|
||||
and must already contain the dependencies from `scripts/pyproject.toml`.
|
||||
|
||||
## Command directory and multiple installations
|
||||
|
||||
The preview prints the chosen interpreter, command directory, source operations
|
||||
and `@launchers/aim` / `@launchers/aimctl` operations. `@launchers` is a journal
|
||||
identifier, not a directory shipped in the source archive.
|
||||
|
||||
By default the command directory is the existing `aim` command's parent directory,
|
||||
or `/usr/local/bin` when no command exists and an interpreter was supplied. Override
|
||||
with `--bin-dir /absolute/command/directory`. A nondefault `--target` **requires** its
|
||||
own explicit `--bin-dir` so development deployment cannot silently replace production
|
||||
commands. Use the same chosen interpreter/directory on preview and apply.
|
||||
|
||||
Only recognized AIM Python entry scripts or AIM-managed launchers for this target
|
||||
may be replaced. Unrelated programs, unsafe symlink command destinations and launchers for
|
||||
another installation are refused. There is no automatic force-overwrite escape hatch.
|
||||
Choose a reviewed unused command directory when the existing layout is nonstandard.
|
||||
Ensure the selected directory is on the intended operator's PATH; aliases and another
|
||||
installation earlier on PATH remain the operator's responsibility.
|
||||
|
||||
## Apply while quiesced
|
||||
|
||||
Stop new jobs and exit active AIM/Ansible sessions. `--quiesced` acknowledges that
|
||||
source writers/runners have been stopped; it does not discover, kill or pause jobs.
|
||||
|
||||
```bash
|
||||
sudo python3 deploy/deploy.py update --apply --quiesced
|
||||
# Include the same --aim-python and --bin-dir options used in the preview, if any.
|
||||
hash -r
|
||||
command -v aim
|
||||
command -v aimctl
|
||||
aim --version
|
||||
aimctl --version
|
||||
aimctl capabilities
|
||||
```
|
||||
|
||||
The helper verifies the installed `aim --version` and `aimctl capabilities` against
|
||||
the source release before reporting success. It uses an explicit installed config
|
||||
path for its capability check. It does not invoke Ansible or contact managed hosts.
|
||||
Both launchers import the deployed `scripts/src/aim` source with the selected AIM
|
||||
Python. Old launchers and core source are included in protected recovery data.
|
||||
|
||||
A stable deployment lock prevents another cooperating deployment. Each source file
|
||||
is replaced atomically, preserving existing UID/GID/mode/extended attributes.
|
||||
New source files use 0644; new launchers use 0755, and recognized existing launchers
|
||||
retain their metadata with executable bits enabled. Directories use normal caller
|
||||
ownership/inheritance. This is not a recursive ownership-policy migration.
|
||||
|
||||
Recovery defaults to `/var/backups/aim-core`; the helper prints the exact private
|
||||
0700 recovery directory. `--backup-dir /private/path` selects another root outside
|
||||
source and installation. Keep sufficient space and apply your retention policy.
|
||||
|
||||
Obsolete files inside `scripts/src/aim/` and the explicitly retired Core documents below are pruned. Unknown customer files in
|
||||
other locations are retained. Add-on code must use its own namespace, not the core
|
||||
Python namespace.
|
||||
|
||||
**Preserved when present:** operator `scripts/aim.yml`, customer `.aim.yml`, inventories,
|
||||
Vaults, SSH keys, add-ons and their state/configuration, environments, external assets,
|
||||
unknown playbooks/roles and staged agent files. No dependencies, services, accounts,
|
||||
LDAP/local group memberships, remote credentials or add-on version gates are modified.
|
||||
Missing new settings are not automatically inserted into an operator's existing YAML.
|
||||
|
||||
## Python package and runtime scope
|
||||
|
||||
These launchers are source-bound entry points, not a pip reinstall. The helper does
|
||||
not change installed wheel/distribution metadata or upgrade packages. Machine clients
|
||||
calling the installed `aimctl` reach the deployed source. In-process Python clients
|
||||
must also resolve `aim` to this source (for example an existing editable installation
|
||||
or an explicitly configured source import path), not an old separately installed
|
||||
wheel. Verify `aim.__file__` and `aim.__version__` in that client's own environment.
|
||||
No add-on's Python environment is changed by core deployment.
|
||||
|
||||
For a fresh installation, provision an AIM Python 3.11+ environment and its declared
|
||||
`ruamel.yaml`/`rich` dependencies first, then pass that interpreter to `install`.
|
||||
Provide the separate canonical Ansible Core **2.19.11** runtime and approved collections
|
||||
from `requirements-controller.txt` / `requirements.yml` explicitly. The helper does
|
||||
not install from the network or mutate a system-managed Ansible installation.
|
||||
|
||||
For a nondefault controller root, maintain that installation's operator configuration
|
||||
and use `aimctl --config /absolute/root/scripts/aim.yml ...` when necessary. Existing
|
||||
terminal configuration discovery is unchanged; creating another launcher does not
|
||||
silently redirect a terminal's global configuration.
|
||||
|
||||
## Opt-in API execution
|
||||
|
||||
Follow `scripts/docs/SANITY.md` (historical evidence is in
|
||||
`scripts/docs/VALIDATION.md`). External execution remains disabled by
|
||||
default and is not enabled by deployment, readiness or the launcher smoke test.
|
||||
Preserve the existing YAML and merge only deliberately approved settings:
|
||||
|
||||
```yaml
|
||||
addons:
|
||||
execution_enabled: true
|
||||
runtime:
|
||||
ansible_playbook: /usr/bin/ansible-playbook
|
||||
```
|
||||
|
||||
The executable path is the operator's approved native runtime, not an assumption
|
||||
for every installation. Worker authorization, filesystem/key access, collections,
|
||||
connection dependencies and same-UID execution still apply. Do not make private keys
|
||||
group-readable to bypass an unsupported cross-user deployment.
|
||||
|
||||
## Recovery and interruption
|
||||
|
||||
Ordinary application/launcher-check failures attempt to restore touched source and
|
||||
launchers. The update is not a whole-tree atomic transaction: power loss or SIGKILL
|
||||
can leave partial source. Keep jobs stopped until recovery/version checks complete.
|
||||
Recovery journals contain intent, hashes and original metadata; they are local
|
||||
recovery records, not a distributed release manifest or inventory backup.
|
||||
|
||||
Use this 3.3.0rc8 deployer and the printed recovery directory; include the same target
|
||||
for a nondefault installation. Launcher paths are recorded in the journal.
|
||||
|
||||
```bash
|
||||
sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --dry-run
|
||||
sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --apply --quiesced
|
||||
hash -r
|
||||
aim --version
|
||||
```
|
||||
|
||||
Rollback validates installed/recovery hashes and refuses to overwrite subsequently
|
||||
modified source or launchers. Existing aim.yml is never rolled back or removed,
|
||||
even after a fresh install. Empty directories may remain. A newly created aimctl
|
||||
launcher is removed when restoring a previous release that had no such launcher.
|
||||
|
||||
No remote Ansible action, dependency installation, account/group change or add-on
|
||||
state is reversed. Keep independent backups of runtime/customer data.
|
||||
|
||||
## Required executor staging (independently provisioned units)
|
||||
|
||||
Read `scripts/docs/EXECUTOR_STAGING.md` in the installed tree. New add-on executor
|
||||
installers should provision owner-only staging and a narrow directory write
|
||||
exception by default, then run `aimctl staging-check` inside the actual unit at
|
||||
startup. Ordinary `sudo -u` success does not reproduce mount/syscall restrictions.
|
||||
|
||||
The source deployer does not inspect/edit/restart services. It preserves the
|
||||
working exception already applied by the operator. The automatic Core preflight
|
||||
is on by default, but cannot make a read-only mount writable. Do not remove the
|
||||
exception, broaden home write access or recursively chown anything during this
|
||||
update. The new startup command is available after the normal launcher refresh.
|
||||
|
||||
## Documentation consolidation in 3.3.0rc8
|
||||
|
||||
Current docs have stable topic names with one validation record and one acceptance
|
||||
checklist. Upgrade removes only these explicitly retired Core filenames (with
|
||||
protected rollback copies), including locally modified versions of those exact files:
|
||||
|
||||
- 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
|
||||
|
||||
Review REMOVE entries before applying. Move any operator notes out of these retired
|
||||
Core-owned names before deployment. Unknown Markdown files and other operator
|
||||
documents are preserved. Rollback restores retired document bytes/metadata through
|
||||
the existing recovery journal. No recursive docs purge or runtime deletion occurs.
|
||||
|
||||
The new playbooks/filter_plugins/*.py files and playbooks/schemas/*.yml files are
|
||||
Core-owned reporting source. They are deployed with the playbooks; no add-on Python
|
||||
environment or collection is modified.
|
||||
@@ -0,0 +1,593 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user