refactor(enroll-bundle): extract bundle_builder and move DTOs to swarm models
Pure tarball construction (_build_tarball, _render_*, _iter_included, _SYSTEMD_UNITS) moved to decnet/swarm/bundle_builder.py — no FastAPI dependency, independently testable. EnrollBundleRequest/Response moved to decnet/web/db/models/swarm.py alongside the other swarm DTOs. Router drops from 504 to 260 lines; keeps only the in-memory token registry, sweeper, and endpoints.
This commit is contained in:
209
decnet/swarm/bundle_builder.py
Normal file
209
decnet/swarm/bundle_builder.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Tarball + bootstrap construction for agent-enrollment bundles.
|
||||
|
||||
Pure I/O, no FastAPI dependency — independently testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from decnet.swarm import pki
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include / exclude manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Explicit include list — fails closed. Stray files on the master
|
||||
# (dev venvs, .env files, editor scratch) cannot leak into the bundle.
|
||||
_INCLUDED_ROOT_FILES: tuple[str, ...] = ("pyproject.toml",)
|
||||
_INCLUDED_DIRS: tuple[str, ...] = ("decnet",)
|
||||
|
||||
# Subtrees of _INCLUDED_DIRS that must NOT ship (relative to repo root).
|
||||
# * decnet/web — FastAPI master app, unused on agents.
|
||||
# * decnet/mutator — swarm-wide respawn scheduler, master-only.
|
||||
# * decnet/profiler — rebuilds profiles against master DB, master-only.
|
||||
_EXCLUDED_DECNET_SUBTREES: frozenset[str] = frozenset({
|
||||
"decnet/web",
|
||||
"decnet/mutator",
|
||||
"decnet/profiler",
|
||||
})
|
||||
|
||||
# Agent-side systemd units. Profiler stays master-side intentionally.
|
||||
_SYSTEMD_UNITS = (
|
||||
"decnet-agent", "decnet-forwarder", "decnet-engine", "decnet-updater",
|
||||
"decnet-collector", "decnet-prober", "decnet-sniffer",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _repo_root() -> pathlib.Path:
|
||||
# decnet/swarm/bundle_builder.py -> parents[2] = repo root.
|
||||
return pathlib.Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _templates_dir() -> pathlib.Path:
|
||||
return pathlib.Path(__file__).resolve().parents[1] / "web" / "templates"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem walk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _iter_included(root: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
|
||||
"""Return ``(full_path, arcname)`` pairs for every file the agent needs.
|
||||
|
||||
Walk is pruned in-place: ``__pycache__`` and master-only subtrees are
|
||||
skipped at directory level so we never descend into them.
|
||||
"""
|
||||
found: list[tuple[pathlib.Path, str]] = []
|
||||
|
||||
for rel in _INCLUDED_ROOT_FILES:
|
||||
p = root / rel
|
||||
if p.is_file():
|
||||
found.append((p, rel))
|
||||
|
||||
for top in _INCLUDED_DIRS:
|
||||
start = root / top
|
||||
if not start.is_dir():
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(start, topdown=True, followlinks=False):
|
||||
dir_path = pathlib.Path(dirpath)
|
||||
rel_dir = dir_path.relative_to(root).as_posix()
|
||||
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d != "__pycache__"
|
||||
and f"{rel_dir}/{d}" not in _EXCLUDED_DECNET_SUBTREES
|
||||
]
|
||||
|
||||
for fn in filenames:
|
||||
if fn.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
full = dir_path / fn
|
||||
if full.is_symlink():
|
||||
continue
|
||||
found.append((full, f"{rel_dir}/{fn}"))
|
||||
|
||||
found.sort(key=lambda t: t[1])
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_decnet_ini(
|
||||
master_host: str,
|
||||
host_uuid: str,
|
||||
use_ipvlan: bool = False,
|
||||
swarmctl_port: int = 8770,
|
||||
) -> bytes:
|
||||
ipvlan_line = f"ipvlan = {'true' if use_ipvlan else 'false'}\n"
|
||||
return (
|
||||
"; Generated by DECNET agent-enrollment bundle.\n"
|
||||
"[decnet]\n"
|
||||
"mode = agent\n"
|
||||
"disallow-master = true\n"
|
||||
"log-directory = /var/log/decnet\n"
|
||||
f"{ipvlan_line}"
|
||||
"\n"
|
||||
"[agent]\n"
|
||||
f"master-host = {master_host}\n"
|
||||
f"swarmctl-port = {swarmctl_port}\n"
|
||||
"swarm-syslog-port = 6514\n"
|
||||
"agent-port = 8765\n"
|
||||
"agent-dir = /etc/decnet/agent\n"
|
||||
"updater-dir = /etc/decnet/updater\n"
|
||||
f"host-uuid = {host_uuid}\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes, mode: int = 0o644) -> None:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
info.mode = mode
|
||||
info.mtime = int(datetime.now(timezone.utc).timestamp())
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _render_systemd_unit(name: str, agent_name: str, master_host: str) -> bytes:
|
||||
tpl_path = _templates_dir() / f"{name}.service.j2"
|
||||
tpl = tpl_path.read_text()
|
||||
return (
|
||||
tpl.replace("{{ agent_name }}", agent_name)
|
||||
.replace("{{ master_host }}", master_host)
|
||||
).encode()
|
||||
|
||||
|
||||
def render_bootstrap(
|
||||
agent_name: str,
|
||||
master_host: str,
|
||||
tarball_url: str,
|
||||
expires_at: datetime,
|
||||
with_updater: bool,
|
||||
) -> bytes:
|
||||
tpl_path = _templates_dir() / "enroll_bootstrap.sh.j2"
|
||||
tpl = tpl_path.read_text()
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
rendered = (
|
||||
tpl.replace("{{ agent_name }}", agent_name)
|
||||
.replace("{{ master_host }}", master_host)
|
||||
.replace("{{ tarball_url }}", tarball_url)
|
||||
.replace("{{ generated_at }}", now)
|
||||
.replace("{{ expires_at }}", expires_at.replace(microsecond=0).isoformat())
|
||||
.replace("{{ with_updater }}", "true" if with_updater else "false")
|
||||
)
|
||||
return rendered.encode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_tarball(
|
||||
master_host: str,
|
||||
agent_name: str,
|
||||
host_uuid: str,
|
||||
issued: pki.IssuedCert,
|
||||
services_ini: Optional[str],
|
||||
updater_issued: Optional[pki.IssuedCert] = None,
|
||||
use_ipvlan: bool = False,
|
||||
) -> bytes:
|
||||
"""Return a gzipped tarball ready to be handed to the enrolling agent."""
|
||||
root = _repo_root()
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
for path, arcname in _iter_included(root):
|
||||
tar.add(path, arcname=arcname, recursive=False)
|
||||
|
||||
_add_bytes(
|
||||
tar,
|
||||
"etc/decnet/decnet.ini",
|
||||
_render_decnet_ini(master_host, host_uuid, use_ipvlan),
|
||||
)
|
||||
for unit in _SYSTEMD_UNITS:
|
||||
_add_bytes(
|
||||
tar,
|
||||
f"etc/systemd/system/{unit}.service",
|
||||
_render_systemd_unit(unit, agent_name, master_host),
|
||||
)
|
||||
_add_bytes(tar, "home/.decnet/agent/ca.crt", issued.ca_cert_pem)
|
||||
_add_bytes(tar, "home/.decnet/agent/worker.crt", issued.cert_pem)
|
||||
_add_bytes(tar, "home/.decnet/agent/worker.key", issued.key_pem, mode=0o600)
|
||||
|
||||
if updater_issued is not None:
|
||||
_add_bytes(tar, "home/.decnet/updater/ca.crt", updater_issued.ca_cert_pem)
|
||||
_add_bytes(tar, "home/.decnet/updater/updater.crt", updater_issued.cert_pem)
|
||||
_add_bytes(tar, "home/.decnet/updater/updater.key", updater_issued.key_pem, mode=0o600)
|
||||
|
||||
if services_ini:
|
||||
_add_bytes(tar, "services.ini", services_ini.encode())
|
||||
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user