Files
ServerKit/backend/app/services/bootstrap_service.py
T
Juan DenisandClaude Opus 5 28d26a73a2 refactor: finish the §G1 subprocess migration (census 195 -> 28)
Plan 75 §G1, the remaining 33 files. Every raw subprocess call site in
backend/app that can go through a door now does. What is left is what
should be:

  8  app/utils/system.py        the doors themselves
  8  database_service           mysqldump|gzip, gunzip|mysql, pg_dump|gzip,
                                gunzip|psql — a pipe is two processes
  7  build/docker/db_sync/ftp   streaming output, gzip pipes
  1  log_service                tail -f
  2  serverkit-analytics        sibling repo; see below

Extensions were migrated in BOTH trees. builtin-extensions/ is the source
and backend/app/plugins/ the live copy, compared by normalised hash;
test_builtin_extension_drift stays green. serverkit-email has both sides
tracked, serverkit-ftp and serverkit-cloud-provision only the source.

serverkit-analytics (2 calls) is deliberately untouched: it has no source
in builtin-extensions/ at all, and backend/app/plugins/serverkit-analytics
is a gitignored materialised copy. Editing it would be a change that
cannot be committed and is overwritten on the next sync — that fix belongs
in the serverkit-analytics repo.

Exit-code semantics are preserved wherever they carry meaning, with a
comment at each site, because they are the thing a mechanical migration
destroys: clamscan 1 = INFECTED, grype 1 = vulnerabilities FOUND, yara
1 = matches, cron_runner 127 = never launched. Those read
result['returncode'], never result['success'].

run_checked gained two things real call sites needed: it yields when the
caller directs the streams itself (writing straight to a log file), rather
than adding capture_output on top and raising ValueError; and text/errors
pass through, so site_importers/ssh keeps decoding with errors='replace'
instead of gaining a UnicodeDecodeError on a bad byte.

A regression this migration introduced, caught by the suite and fixed:
install_profile_service._docker_usable() is deliberately TRI-STATE —
None means the probe could not run, because inside LXC the docker client
is usually present while the daemon never started, and callers "must not
treat a broken probe as proof of absence". Writing it as
run_checked(...)['success'] turned an exec failure into False, i.e. "docker
is not usable" — a fact nobody established. That is the §A violation this
plan exists to remove, reintroduced by the migration meant to prevent it.
test_probe_honesty_doctor caught it. Every other run_checked(...)['success']
caller was then audited; the rest are genuinely two-state, and their
originals also returned False on a missing binary.

Seven more slips of the same shape were caught before running by an AST
check for `x = run_checked(...)` followed by `x.stdout/.stderr/.returncode`
— a dict read as an object, which raises only on the failure path, so
happy-path tests stay green.

api_controller_boundary_baseline.json: pure deletion. Routing api/docker.py
through DockerService removed the last 6 subprocess crossings, so the
`subprocess` category is gone from the API layer entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:38:25 -04:00

53 lines
2.3 KiB
Python

"""One-shot appliance first-boot bootstrap (plan 35).
Some appliances generate a config/certificate tree exactly once, on first run.
Abusing a compose init container leaves it behind; abusing an entrypoint reruns
it every deploy. Instead this runs the declared ``bootstrap.command`` ONCE via
``docker compose run --rm <service> <command>`` — after the volumes exist and
before the first ``up`` — and flips ``Application.bootstrap_done`` on success so
it never re-runs. Failure is a visible, retryable apply-step failure.
The runner is injectable (``set_runner``) so the apply engine can be proven
without Docker.
"""
import shlex
from typing import Any, Callable, Dict, Optional
class BootstrapService:
_runner: Optional[Callable] = None # test seam
@classmethod
def set_runner(cls, fn: Optional[Callable]) -> None:
"""Install/clear an injected runner ``fn(app, service, command, timeout)``
returning ``{'success': bool, 'output'|'error': str}``."""
cls._runner = fn
@classmethod
def run_once(cls, app, command: str, timeout_seconds: Optional[int] = None,
service: Optional[str] = None) -> Dict[str, Any]:
service = service or app.name
if cls._runner is not None:
return cls._runner(app, service, command, timeout_seconds)
return cls._docker_run(app, service, command, timeout_seconds)
@staticmethod
def _docker_run(app, service: str, command: str,
timeout_seconds: Optional[int]) -> Dict[str, Any]:
from app.utils.system import run_checked
root = getattr(app, 'root_path', None)
if not root:
return {'success': False,
'error': 'app has no deployed compose project to run bootstrap in'}
cmd = ['docker', 'compose', 'run', '--rm', service] + shlex.split(command)
proc = run_checked(cmd, cwd=root, timeout=timeout_seconds or 300)
if proc['returncode'] is None:
# timed out, or docker is absent
return {'success': False, 'error': proc['error']}
if not proc['success']:
return {'success': False,
'error': (proc['error'] or proc['output'] or 'bootstrap failed')[:500]}
return {'success': True, 'output': proc['output'].strip()[:500]}