"""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()