Files
computer-playbook/cli/administration/deploy/development/compose.py
T
kevinveenbirkenbachandClaude Opus 4.7 a26bfc4e94 refactor(cli): reorg admin/contributing tree + per-folder help system
Module moves (with imports, paths, argv literals, scripts, docs, and
parents[N] formulas updated in lockstep):

- cli/deploy             -> cli/administration/deploy
- cli/validate/inventory -> cli/administration/inventory/validate
- cli/vault              -> cli/administration/secrets/vault
- cli/administration/inventory/encrypt -> cli/administration/secrets/seal
- cli/mirror             -> cli/contributing/mirror

The new cli/administration/secrets cluster collocates the whole-file
ansible-vault wrapper next to the inventory-aware per-field sealer.
The user-facing CLI subcommand paths follow the package layout, e.g.
"infinito administration deploy dedicated <inv>" and
"infinito administration inventory validate <inv>".

Help system redesign in cli/core/{discovery,help,app}.py:

- Discovery now distinguishes runnable commands (directories with
  __main__.py) from category folders (directories that only group
  further commands). Empty support packages are filtered out.
- Each category folder MAY carry a README.md; its H1 heading is used
  as the title and its first paragraph as the one-line description in
  the help output.
- "infinito" / "infinito --help" prints only the top-level entries
  (one level deep). "infinito <category>" navigates into a folder and
  shows its sub-folders and commands one level deep, with the README
  title as the page heading and the invocation as a dim subtitle.
- New "--tree [N]" flag walks the tree under the (optional) path
  prefix recursively. N caps the depth (1-based); omit N for an
  unbounded walk. The output uses Unicode tree connectors, an emoji
  per entry (folder for categories, gear for commands), a globally
  aligned description column, and multi-line wrapped descriptions
  with tree-continuity bars on continuation lines.
- Category and command listings use the full invocation
  ("infinito meta callorder") rather than the bare leaf name, so the
  output is copy-pasteable.

Convention doc + 17 CLI READMEs:

- New docs/contributing/artefact/files/cli/readme_md.md describes the
  H1+paragraph layout the help system reads, and how it differs from
  role/docs READMEs.
- README.md added for cli/{administration, build, contributing, meta}
  and their immediate sub-folders, plus cli/meta/roles/applications
  and its resolution sub-folder. Each follows the "title with trailing
  emoji + single descriptive paragraph" convention.

Mirror resolver/sync description strings (the help system reads them
from argparse --help output) added so they no longer surface as
"options:" in the global index. cli/mirror/cleanup already had a
description and is unchanged.

Fixes uncovered by the move:

- import unittest.mock added to four dedicated-deploy tests so
  unittest.mock.patch resolves under stdlib unittest discovery
  (Python 3.13 no longer surfaces unittest.mock through "import
  unittest" alone).
- parents[N] index in the new
  cli/administration/inventory/validate/__init__.py corrected from
  3 to 4 to match the deeper layout.
- test_paths.INVENTORY_VALIDATOR_PATH assertion and test_runner
  substring assertions updated to match the new validator path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:17:09 +02:00

358 lines
11 KiB
Python

from __future__ import annotations
import os
import subprocess
import time
from typing import TYPE_CHECKING
from .common import cache_env_overrides, compose_file_args
from .coredns import CoreDNSCorefileRenderer
from .network import detect_outer_network_mtu
from .proc import run_streaming
from .profile import Profile
if TYPE_CHECKING:
from pathlib import Path
class Compose:
"""Wrapper around `docker compose` for the dev/CI stack."""
def __init__(self, repo_root: Path, distro: str) -> None:
self.repo_root = repo_root
self.distro = distro
self.profile = Profile()
def _base_env(self) -> dict[str, str]:
env = dict(os.environ)
env["INFINITO_DISTRO"] = self.distro
outer_network_mtu = detect_outer_network_mtu(env)
if outer_network_mtu:
env["INFINITO_OUTER_NETWORK_MTU"] = outer_network_mtu
if not env.get("INFINITO_IMAGE"):
local_image_script = (
self.repo_root / "scripts" / "meta" / "resolve" / "image" / "local.sh"
)
result = subprocess.run(
[str(local_image_script)],
cwd=self.repo_root,
env=env,
capture_output=True,
text=True,
check=True,
)
env["INFINITO_IMAGE"] = result.stdout.strip()
env.update(cache_env_overrides())
return env
def run(
self,
args: list[str],
*,
check: bool = True,
capture: bool = False,
live: bool = False,
text: bool = True,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
cmd = [
"docker",
"compose",
*compose_file_args(),
*self.profile.args(),
*args,
]
env = self._base_env()
if extra_env:
env.update({k: str(v) for k, v in extra_env.items()})
if live:
r = run_streaming(cmd, cwd=self.repo_root, env=env, text=text)
else:
r = subprocess.run(
cmd,
cwd=self.repo_root,
env=env,
check=False,
capture_output=capture,
text=text,
)
if check and int(r.returncode) != 0:
raise subprocess.CalledProcessError(
int(r.returncode), cmd, output=r.stdout, stderr=r.stderr
)
return r
def _compose_up_with_retries(
self,
args: list[str],
*,
attempts: int = 6,
delay_s: int = 30,
) -> None:
"""Retry compose up to mitigate transient registry errors."""
last_exc: Exception | None = None
for i in range(1, int(attempts) + 1):
try:
self.run(args, check=True)
except Exception as exc:
last_exc = exc
if i >= int(attempts):
raise
print(
f">>> WARNING: compose up failed (attempt {i}/{attempts}): {exc}\n"
f">>> Retrying in {int(delay_s)}s..."
)
time.sleep(int(delay_s))
else:
return
if last_exc is not None:
raise last_exc
def _bootstrap_package_cache(self, env: dict[str, str]) -> None:
"""Run the host-side Nexus bootstrap helper. Idempotent."""
helper = self.repo_root / "scripts" / "docker" / "cache" / "package.sh"
print(">>> Bootstrapping package-cache proxy repos")
r = subprocess.run(
[str(helper)],
cwd=self.repo_root,
env=env,
check=False,
text=True,
)
if r.returncode != 0:
print(
f">>> WARNING: package-cache bootstrap exited rc={r.returncode}; "
f"re-run {helper} manually or inspect docker logs infinito-package-cache"
)
def _generate_package_frontend_certs(self, env: dict[str, str]) -> None:
"""Generate frontend CA + leaf certs before nginx starts."""
helper = (
self.repo_root
/ "scripts"
/ "docker"
/ "cache"
/ "package-frontend-certs.sh"
)
print(">>> Generating package-cache-frontend CA + per-hostname certs")
subprocess.run(
[str(helper)],
cwd=self.repo_root,
env=env,
check=True,
text=True,
)
def _install_package_frontend_ca_in_runner(self) -> None:
"""Install the frontend CA in the runner trust store. Idempotent."""
print(">>> Installing package-cache-frontend CA into runner trust store")
r = self.exec(
["sh", "-lc", "/usr/local/bin/package-frontend-ca.sh"],
check=False,
live=False,
)
if r.returncode != 0:
print(
">>> WARNING: package-frontend-ca installer exited "
f"rc={r.returncode}; cache TLS via DNS-hijack may fail. "
"Re-run /usr/local/bin/package-frontend-ca.sh inside the "
"runner manually."
)
def _render_coredns_corefile(self) -> None:
renderer = CoreDNSCorefileRenderer(repo_root=self.repo_root)
out = renderer.render(show_preview=True, preview_lines=25)
print(f"[compose] Corefile generated at: {out}")
print(
f"[compose] Corefile exists={out.exists()} "
f"size={out.stat().st_size if out.exists() else 'n/a'}"
)
def up(self, *, run_entry_init: bool = True) -> None:
print(">>> Rendering CoreDNS Corefile from template")
self._render_coredns_corefile()
print(">>> Starting compose stack (coredns + infinito)")
env = self._base_env()
keys = [
"INFINITO_DISTRO",
"INFINITO_IMAGE",
"INFINITO_IMAGE_TAG",
"INFINITO_PULL_POLICY",
"INFINITO_NO_BUILD",
"GITHUB_SHA",
]
print(">>> env:", {k: env.get(k) for k in keys})
print(">>> NIX_CONFIG:", "<set>" if env.get("NIX_CONFIG") else "<empty>")
no_build = env.get("INFINITO_NO_BUILD", "0") == "1"
# Compose env-file precedence: later files override earlier ones.
args = ["--env-file", "env.ci"]
env_local = self.repo_root / "env.development"
if env_local.exists():
print(f">>> Using local env override: {env_local}")
args += ["--env-file", "env.development"]
else:
print(">>> No env.local found (skipping)")
args += ["up", "-d"]
if no_build:
args.append("--no-build")
# Cache services have `required: false` on infinito; list them
# explicitly so they boot before the runner.
if self.profile.registry_cache_active():
self._generate_package_frontend_certs(env)
args += ["registry-cache", "package-cache", "package-cache-frontend"]
args += ["coredns", "infinito"]
self._compose_up_with_retries(args, attempts=6, delay_s=30)
self.wait_for_healthy()
if self.profile.registry_cache_active():
self._bootstrap_package_cache(env)
self._install_package_frontend_ca_in_runner()
if run_entry_init:
print(">>> Running infinito entry.sh init")
r = self.exec(
["sh", "-lc", "/opt/src/infinito/scripts/docker/entry.sh true"],
workdir="/opt/src/infinito",
check=False,
live=True,
extra_env={
"ANSIBLE_FORCE_COLOR": "1",
"PY_COLORS": "1",
"TERM": "xterm-256color",
},
)
if r.returncode != 0:
raise RuntimeError(f"entry.sh init failed (rc={r.returncode})")
def down(self) -> None:
"""Tear down the stack."""
from .down import down_stack
down_stack(repo_root=self.repo_root, distro=self.distro)
def exec(
self,
cmd: list[str],
*,
check: bool = True,
workdir: str | None = None,
capture: bool = False,
live: bool = False,
tty: bool | None = None,
extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Execute inside the infinito container.
tty=None auto-selects (local: True, CI: False).
live=True streams stdout/stderr.
"""
if tty is None:
tty = not self.profile.is_ci()
args = ["exec"]
if not tty:
args.append("-T")
if workdir:
args += ["-w", workdir]
if extra_env:
for k, v in extra_env.items():
args += ["-e", f"{k}={v}"]
args += ["infinito", *cmd]
return self.run(
args,
check=check,
capture=capture,
live=live,
)
def _get_infinito_container_id(self) -> str:
r = self.run(["ps", "-q", "infinito"], capture=True, check=True)
cid = (r.stdout or "").strip()
if not cid:
raise RuntimeError(
"infinito container not found (docker compose ps -q infinito returned empty)"
)
return cid
def wait_for_healthy(self, *, timeout_s: int | None = None) -> None:
"""Wait for the infinito container's healthcheck."""
if timeout_s is None:
timeout_s = int(os.environ.get("INFINITO_WAIT_HEALTH_TIMEOUT_S", "200"))
print(">>> Waiting for infinito container to become healthy")
cid = self._get_infinito_container_id()
start = time.time()
while True:
r = subprocess.run(
["docker", "inspect", "-f", "{{.State.Health.Status}}", cid],
cwd=self.repo_root,
capture_output=True,
text=True,
check=False,
)
status = r.stdout.strip() if r.returncode == 0 else ""
if status == "healthy":
print(">>> infinito container is healthy")
return
if status == "unhealthy":
print(">>> infinito container is unhealthy")
if (time.time() - start) > timeout_s:
print(
">>> ERROR: infinito container not healthy, dumping last 200 log lines\n"
)
logs = self.exec(
["sh", "-lc", "journalctl -n 200 --no-pager || true"],
check=False,
capture=True,
)
print("===== journalctl (last 200 lines) =====")
print(logs.stdout or "<no output>")
print("======================================\n")
docker_logs = subprocess.run(
["docker", "logs", "--tail", "200", cid],
cwd=self.repo_root,
capture_output=True,
text=True,
check=False,
)
print("===== docker logs (last 200 lines) =====")
print(docker_logs.stdout or "<no output>")
print("=======================================\n")
raise RuntimeError(
f"infinito container not healthy after {timeout_s}s "
f"(last status: {status})"
)
time.sleep(2)