- Purge misplaced RFCs, scratch tokens, and node-proprietary assets - Decouple remote provisioning to ci/scripts/init-node.py - Add targeted composition orchestration in actions/compose.py - Add host-side Xray stale socket cleanup in /dev/shm - Add actions/env.py for shell exports and .env synchronization - Harden UFW, backup retention, host tempfiles, and notify dispatcher - Add pyproject.toml and Makefile with 78 passing unit tests
122 lines
6.5 KiB
Python
122 lines
6.5 KiB
Python
"""Pinned, non-destructive checkout enrollment as the node administrator."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
import subprocess
|
|
|
|
from engine.identity import read_env
|
|
from . import ENROLLMENT_INTERFACE
|
|
from .state import ProvisionError, safe_path
|
|
|
|
|
|
def as_adam(args, cwd=None):
|
|
environment = dict(os.environ, GIT_TERMINAL_PROMPT='0',
|
|
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
|
|
LC_ALL='C')
|
|
result = subprocess.run(['runuser', '-u', 'adam', '--', *args], cwd=cwd,
|
|
env=environment, text=True, capture_output=True, timeout=300)
|
|
if result.returncode:
|
|
# Git/hook stderr may contain credential files or remote URL credentials.
|
|
raise ProvisionError('repository operation failed; check administrator Git access and the pinned checkout')
|
|
return result.stdout.strip()
|
|
|
|
|
|
def verify_checkout_setup(destination, account):
|
|
if as_adam(['git', 'config', '--get', 'core.hooksPath'], destination) != 'commons/githooks':
|
|
raise ProvisionError('checkout hook configuration is missing or incompatible')
|
|
home = safe_path(account.pw_dir)
|
|
bashrc = safe_path(home / '.bashrc')
|
|
entry = 'export PATH="' + str(destination / 'commons') + ':$PATH"'
|
|
if not bashrc.is_file() or bashrc.read_text().splitlines().count(entry) != 1:
|
|
raise ProvisionError('checkout PATH setup is missing or duplicated; reconcile init.sh before retrying')
|
|
vim = home / '.vimrc'
|
|
if not vim.is_symlink() or vim.resolve() != (destination / 'commons/etc/.vimrc').resolve():
|
|
raise ProvisionError('checkout editor link is missing or incompatible')
|
|
|
|
|
|
def reconcile_repository(spec, state, save, srv=Path('/srv')):
|
|
repository = spec.repository
|
|
destination = safe_path(Path(srv) / repository.name)
|
|
staging = safe_path(Path(srv) / ('.apex-clone-' + repository.name))
|
|
expected = {'name': repository.name, 'url': repository.url, 'ref': repository.ref}
|
|
account = pwd.getpwnam('adam')
|
|
record = state.setdefault('repository', {})
|
|
if record and record.get('request') != expected:
|
|
raise ProvisionError('repository enrollment conflicts with its recorded request')
|
|
if not record:
|
|
if destination.exists() or staging.exists():
|
|
raise ProvisionError('unrecorded repository path exists; explicit adoption is required')
|
|
record.update(request=expected, clone_intent=True)
|
|
save()
|
|
if not destination.exists():
|
|
if staging.exists():
|
|
# An interrupted clone may be incomplete: never erase or guess its state.
|
|
raise ProvisionError('an interrupted clone remains; inspect the staging checkout before retrying')
|
|
staging.mkdir(mode=0o755)
|
|
os.chown(staging, account.pw_uid, account.pw_gid)
|
|
as_adam(['git', 'clone', '--no-checkout', '--', repository.url, str(staging)])
|
|
os.rename(staging, destination)
|
|
safe_path(destination / '.git')
|
|
origin = as_adam(['git', 'remote', 'get-url', 'origin'], destination)
|
|
if origin != repository.url:
|
|
raise ProvisionError('existing checkout has a different origin; it was preserved')
|
|
if 'commit' not in record:
|
|
reference = repository.ref or 'HEAD'
|
|
if repository.ref:
|
|
as_adam(['git', 'fetch', '--no-tags', 'origin', reference], destination)
|
|
reference = 'FETCH_HEAD'
|
|
commit = as_adam(['git', 'rev-parse', '--verify', reference + '^{commit}'], destination)
|
|
record['commit'] = commit
|
|
record['checkout_intent'] = True
|
|
save()
|
|
commit = record['commit']
|
|
if record.get('checkout_intent'):
|
|
# --no-checkout index intentionally looks dirty before the first checkout.
|
|
if any(p.name != '.git' for p in destination.iterdir()):
|
|
head = as_adam(['git', 'rev-parse', 'HEAD'], destination)
|
|
dirty = as_adam(['git', 'status', '--porcelain'], destination)
|
|
if head != commit or dirty:
|
|
raise ProvisionError('interrupted checkout has unexpected contents; it was preserved')
|
|
else:
|
|
as_adam(['git', 'checkout', '--detach', commit], destination)
|
|
record.pop('checkout_intent')
|
|
save()
|
|
if as_adam(['git', 'rev-parse', 'HEAD'], destination) != commit:
|
|
raise ProvisionError('checkout revision changed; initialization will not reset it')
|
|
if as_adam(['git', 'status', '--porcelain'], destination):
|
|
raise ProvisionError('checkout contains local changes; initialization will not reset it')
|
|
node_env = safe_path(destination / 'node.env')
|
|
node_identity = read_env(str(node_env))
|
|
if node_identity.get('APEX_NODE_FQDN') != spec.hostname:
|
|
raise ProvisionError('repository node.env does not declare the requested hostname')
|
|
if node_identity.get('APEX_ENROLLMENT_INTERFACE') != str(ENROLLMENT_INTERFACE):
|
|
raise ProvisionError('node checkout enrollment interface is missing or unsupported; update its init.sh and node.env together')
|
|
as_adam(['git', 'submodule', 'update', '--init', '--recursive'], destination)
|
|
commons = safe_path(destination / 'commons')
|
|
if not commons.is_dir():
|
|
raise ProvisionError('node checkout does not contain its pinned commons submodule')
|
|
commons_commit = as_adam(['git', 'rev-parse', 'HEAD'], commons)
|
|
gitlink = as_adam(['git', 'ls-tree', 'HEAD', '--', 'commons'], destination).split()
|
|
if gitlink != ['160000', 'commit', commons_commit, 'commons']:
|
|
raise ProvisionError('commons is not the exact pinned Git submodule')
|
|
if Path(as_adam(['git', 'rev-parse', '--show-toplevel'], commons)).resolve() != commons.resolve():
|
|
raise ProvisionError('commons does not have its own Git worktree')
|
|
if record.get('commons_commit', commons_commit) != commons_commit:
|
|
raise ProvisionError('pinned commons revision changed unexpectedly')
|
|
hook = safe_path(destination / 'init.sh')
|
|
if not hook.is_file():
|
|
raise ProvisionError('node repository is missing its checkout initializer')
|
|
if not record.get('hook_complete'):
|
|
as_adam(['bash', './init.sh'], destination)
|
|
verify_checkout_setup(destination, account)
|
|
record['hook_complete'] = True
|
|
else:
|
|
verify_checkout_setup(destination, account)
|
|
if as_adam(['git', 'status', '--porcelain'], destination):
|
|
raise ProvisionError('checkout initializer changed tracked files; inspect before resuming')
|
|
record['commons_commit'] = commons_commit
|
|
save()
|
|
return {'path': str(destination), 'commit': commit, 'commons_commit': commons_commit}
|