1797 lines
64 KiB
Python
1797 lines
64 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture bounded, metadata-only G7 deployment topology evidence.
|
|
|
|
``linux_compose`` pins two running Docker Compose containers (API and Caddy)
|
|
to their exact project/service labels, container IDs, image IDs, init PIDs,
|
|
start timestamps, and restart counts. ``windows_host`` instead pins the
|
|
Windows API and cloudflared processes to their PIDs, creation times,
|
|
executable hashes, command-line hashes, working directories, and one Git SHA.
|
|
Every sample validates its mode-specific pins both before and after collection
|
|
so a restart or executable/source replacement cannot be reported as one
|
|
continuous run.
|
|
|
|
Only infrastructure counters are retained. Socket endpoints, request data,
|
|
audio, transcripts, command output, and Cloudflare-internal queue data are
|
|
never written to evidence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import ntpath
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal, InvalidOperation
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
from typing import Any, Protocol, Sequence
|
|
|
|
|
|
SCHEMA_VERSION = "vignette.g7-topology-evidence.v1"
|
|
LINUX_COMPOSE_MODE = "linux_compose"
|
|
WINDOWS_HOST_MODE = "windows_host"
|
|
PINNED_PSUTIL_VERSION = "6.1.1"
|
|
DEFAULT_INTERVAL_SECONDS = 1.0
|
|
DEFAULT_SAMPLES = 10
|
|
MAX_SAMPLES = 7_200
|
|
MAX_INTERVAL_SECONDS = 60.0
|
|
MAX_CAPTURE_WINDOW_SECONDS = 7_200.0
|
|
_SHA256_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
_RAW_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
_GIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40,64}$")
|
|
_VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+){2}(?:[A-Za-z0-9.+-]*)?$")
|
|
_WINDOWS_PINNED_SCRIPTS = {
|
|
"runner": "scripts/run-g7-external-proof-window.py",
|
|
"collector": "scripts/capture-g7-topology-evidence.py",
|
|
"checker": "scripts/check-g7-external-proof.py",
|
|
}
|
|
_BYTE_VALUE_PATTERN = re.compile(r"^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)$")
|
|
_RETRANSMIT_PATTERN = re.compile(r"(?:^|\s)retrans:(\d+)(?:/(\d+))?(?:\s|$)")
|
|
_BYTE_MULTIPLIERS = {
|
|
"B": Decimal(1),
|
|
"KB": Decimal(1_000),
|
|
"MB": Decimal(1_000_000),
|
|
"GB": Decimal(1_000_000_000),
|
|
"TB": Decimal(1_000_000_000_000),
|
|
"KIB": Decimal(1_024),
|
|
"MIB": Decimal(1_048_576),
|
|
"GIB": Decimal(1_073_741_824),
|
|
"TIB": Decimal(1_099_511_627_776),
|
|
}
|
|
|
|
|
|
class EvidenceFailure(RuntimeError):
|
|
"""A required evidence source or pinned runtime contract failed."""
|
|
|
|
|
|
class CommandRunner(Protocol):
|
|
def available(self, command: str) -> bool: ...
|
|
|
|
def run(self, args: Sequence[str]) -> str: ...
|
|
|
|
|
|
class FileSource(Protocol):
|
|
def read_text(self, path: str) -> str: ...
|
|
|
|
def list_names(self, path: str) -> list[str]: ...
|
|
|
|
|
|
class WindowsSource(Protocol):
|
|
def dependency_version(self, name: str) -> str: ...
|
|
|
|
def read_identity(
|
|
self,
|
|
*,
|
|
pid: int,
|
|
role: str,
|
|
git_sha: str,
|
|
) -> "WindowsProcessIdentity": ...
|
|
|
|
def read_metrics(self, *, pid: int, role: str) -> dict[str, int | float]: ...
|
|
|
|
def read_tcp_metrics(
|
|
self,
|
|
*,
|
|
api_pid: int,
|
|
cloudflared_pid: int,
|
|
api_listen_port: int,
|
|
) -> dict[str, object]: ...
|
|
|
|
|
|
class SubprocessCommandRunner:
|
|
def available(self, command: str) -> bool:
|
|
return shutil.which(command) is not None
|
|
|
|
def run(self, args: Sequence[str]) -> str:
|
|
try:
|
|
completed = subprocess.run(
|
|
list(args),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=30,
|
|
shell=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise EvidenceFailure(f"command_unavailable:{args[0]}") from exc
|
|
if completed.returncode != 0:
|
|
raise EvidenceFailure(f"command_failed:{args[0]}")
|
|
return completed.stdout
|
|
|
|
|
|
class LocalFileSource:
|
|
def read_text(self, path: str) -> str:
|
|
try:
|
|
return Path(path).read_text(encoding="utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise EvidenceFailure("required_linux_source_unreadable") from exc
|
|
|
|
def list_names(self, path: str) -> list[str]:
|
|
try:
|
|
return os.listdir(path)
|
|
except OSError as exc:
|
|
raise EvidenceFailure("required_linux_source_unreadable") from exc
|
|
|
|
|
|
def _sha256_file(path: str, *, role: str) -> str:
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
while chunk := handle.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
except OSError as exc:
|
|
raise EvidenceFailure(f"process_executable_unreadable:{role}") from exc
|
|
return digest.hexdigest()
|
|
|
|
|
|
class LocalWindowsSource:
|
|
"""Read process and TCP metadata through psutil without retaining endpoints."""
|
|
|
|
def __init__(self) -> None:
|
|
if os.name != "nt":
|
|
raise EvidenceFailure("windows_host_required")
|
|
try:
|
|
import psutil
|
|
except ImportError as exc:
|
|
raise EvidenceFailure("command_unavailable:psutil") from exc
|
|
self._psutil = psutil
|
|
self._processes: dict[int, Any] = {}
|
|
|
|
def dependency_version(self, name: str) -> str:
|
|
if name != "psutil":
|
|
raise EvidenceFailure(f"runtime_dependency_unknown:{name}")
|
|
version = getattr(self._psutil, "__version__", None)
|
|
if not isinstance(version, str) or not version:
|
|
raise EvidenceFailure("psutil_version_unreadable")
|
|
return version
|
|
|
|
def _process(self, pid: int, *, role: str):
|
|
cached = self._processes.get(pid)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
process = self._psutil.Process(pid)
|
|
except (self._psutil.NoSuchProcess, self._psutil.AccessDenied) as exc:
|
|
raise EvidenceFailure(f"process_unreadable:{role}") from exc
|
|
self._processes[pid] = process
|
|
return process
|
|
|
|
def read_identity(
|
|
self,
|
|
*,
|
|
pid: int,
|
|
role: str,
|
|
git_sha: str,
|
|
) -> "WindowsProcessIdentity":
|
|
process = self._process(pid, role=role)
|
|
try:
|
|
with process.oneshot():
|
|
executable_path = process.exe()
|
|
executable_name = process.name()
|
|
cwd = process.cwd()
|
|
started_epoch = process.create_time()
|
|
command_line = process.cmdline()
|
|
except (
|
|
self._psutil.NoSuchProcess,
|
|
self._psutil.AccessDenied,
|
|
self._psutil.ZombieProcess,
|
|
OSError,
|
|
) as exc:
|
|
raise EvidenceFailure(f"process_identity_unreadable:{role}") from exc
|
|
if not executable_path or not executable_name or not cwd or not command_line:
|
|
raise EvidenceFailure(f"process_identity_incomplete:{role}")
|
|
command_line_sha256 = hashlib.sha256(
|
|
"\0".join(command_line).encode("utf-8", errors="strict")
|
|
).hexdigest()
|
|
return WindowsProcessIdentity(
|
|
role=role,
|
|
pid=pid,
|
|
started_at=datetime.fromtimestamp(started_epoch, UTC)
|
|
.isoformat()
|
|
.replace("+00:00", "Z"),
|
|
executable_name=executable_name,
|
|
executable_sha256=_sha256_file(executable_path, role=role),
|
|
command_line_sha256=command_line_sha256,
|
|
git_sha=git_sha,
|
|
cwd=ntpath.normpath(cwd),
|
|
)
|
|
|
|
def read_metrics(self, *, pid: int, role: str) -> dict[str, int | float]:
|
|
process = self._process(pid, role=role)
|
|
try:
|
|
with process.oneshot():
|
|
memory = process.memory_info()
|
|
cpu = process.cpu_times()
|
|
cpu_percent = process.cpu_percent(interval=None)
|
|
handles = process.num_handles()
|
|
threads = process.num_threads()
|
|
except (
|
|
self._psutil.NoSuchProcess,
|
|
self._psutil.AccessDenied,
|
|
self._psutil.ZombieProcess,
|
|
OSError,
|
|
) as exc:
|
|
raise EvidenceFailure(f"process_metrics_unreadable:{role}") from exc
|
|
peak_rss = getattr(memory, "peak_wset", None)
|
|
if peak_rss is None:
|
|
raise EvidenceFailure(f"process_peak_rss_unavailable:{role}")
|
|
return {
|
|
"rss_bytes": int(memory.rss),
|
|
"peak_rss_bytes": int(peak_rss),
|
|
"cpu_time_seconds": float(cpu.user + cpu.system),
|
|
"cpu_percent": float(cpu_percent),
|
|
"handles": int(handles),
|
|
"threads": int(threads),
|
|
}
|
|
|
|
def read_tcp_metrics(
|
|
self,
|
|
*,
|
|
api_pid: int,
|
|
cloudflared_pid: int,
|
|
api_listen_port: int,
|
|
) -> dict[str, object]:
|
|
try:
|
|
connections = self._psutil.net_connections(kind="tcp")
|
|
except (self._psutil.AccessDenied, OSError) as exc:
|
|
raise EvidenceFailure("windows_tcp_unreadable") from exc
|
|
roles = {"api": api_pid, "cloudflared": cloudflared_pid}
|
|
processes: dict[str, dict[str, int]] = {
|
|
role: {"connections": 0, "established": 0, "listeners": 0}
|
|
for role in roles
|
|
}
|
|
owned_listener_count = 0
|
|
conflicting_listener_count = 0
|
|
for connection in connections:
|
|
pid = connection.pid
|
|
status = str(connection.status).upper()
|
|
for role, role_pid in roles.items():
|
|
if pid == role_pid:
|
|
processes[role]["connections"] += 1
|
|
if status == "ESTABLISHED":
|
|
processes[role]["established"] += 1
|
|
if status == "LISTEN":
|
|
processes[role]["listeners"] += 1
|
|
local = connection.laddr
|
|
local_port = getattr(local, "port", None)
|
|
if status == "LISTEN" and local_port == api_listen_port:
|
|
if pid == api_pid:
|
|
owned_listener_count += 1
|
|
else:
|
|
conflicting_listener_count += 1
|
|
return {
|
|
"processes": processes,
|
|
"host_tcp": {"connections": len(connections)},
|
|
"api_listener": {
|
|
"port": api_listen_port,
|
|
"owned_listener_count": owned_listener_count,
|
|
"conflicting_listener_count": conflicting_listener_count,
|
|
},
|
|
}
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CaptureConfig:
|
|
compose_project: str
|
|
public_host: str
|
|
api_container: str
|
|
api_service: str
|
|
api_image_digest: str
|
|
caddy_container: str
|
|
caddy_service: str
|
|
caddy_image_digest: str
|
|
samples: int = DEFAULT_SAMPLES
|
|
interval_seconds: float = DEFAULT_INTERVAL_SECONDS
|
|
proc_root: str = "/proc"
|
|
cgroup_root: str = "/sys/fs/cgroup"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WindowsHostConfig:
|
|
public_host: str
|
|
repo_root: str
|
|
git_sha: str
|
|
git_tree_sha: str
|
|
runner_script_sha256: str
|
|
collector_script_sha256: str
|
|
checker_script_sha256: str
|
|
psutil_version: str
|
|
api_pid: int
|
|
api_executable_name: str
|
|
api_executable_sha256: str
|
|
api_cwd: str
|
|
api_listen_port: int
|
|
cloudflared_pid: int
|
|
cloudflared_executable_name: str
|
|
cloudflared_executable_sha256: str
|
|
cloudflared_cwd: str
|
|
samples: int = DEFAULT_SAMPLES
|
|
interval_seconds: float = DEFAULT_INTERVAL_SECONDS
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ContainerIdentity:
|
|
role: str
|
|
compose_project: str
|
|
compose_service: str
|
|
container_id: str
|
|
container_name: str
|
|
image_digest: str
|
|
init_pid: int
|
|
started_at: str
|
|
restart_count: int
|
|
cgroup_path: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class WindowsProcessIdentity:
|
|
role: str
|
|
pid: int
|
|
started_at: str
|
|
executable_name: str
|
|
executable_sha256: str
|
|
command_line_sha256: str
|
|
git_sha: str
|
|
cwd: str
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _clean_identifier(value: object, *, code: str) -> str:
|
|
if not isinstance(value, str):
|
|
raise EvidenceFailure(code)
|
|
cleaned = value.strip()
|
|
if not cleaned or len(cleaned) > 256 or any(ord(char) < 32 for char in cleaned):
|
|
raise EvidenceFailure(code)
|
|
return cleaned
|
|
|
|
|
|
def _parse_nonnegative_int(value: object, *, code: str) -> int:
|
|
if isinstance(value, bool):
|
|
raise EvidenceFailure(code)
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise EvidenceFailure(code) from exc
|
|
if parsed < 0:
|
|
raise EvidenceFailure(code)
|
|
return parsed
|
|
|
|
|
|
def validate_config(config: CaptureConfig) -> None:
|
|
for value, code in (
|
|
(config.compose_project, "compose_project_invalid"),
|
|
(config.public_host, "public_host_invalid"),
|
|
(config.api_container, "api_container_invalid"),
|
|
(config.api_service, "api_service_invalid"),
|
|
(config.caddy_container, "caddy_container_invalid"),
|
|
(config.caddy_service, "caddy_service_invalid"),
|
|
):
|
|
_clean_identifier(value, code=code)
|
|
if config.api_service == config.caddy_service:
|
|
raise EvidenceFailure("compose_services_not_distinct")
|
|
for digest, code in (
|
|
(config.api_image_digest, "api_image_digest_invalid"),
|
|
(config.caddy_image_digest, "caddy_image_digest_invalid"),
|
|
):
|
|
if not _SHA256_PATTERN.fullmatch(digest.lower()):
|
|
raise EvidenceFailure(code)
|
|
if not 1 <= config.samples <= MAX_SAMPLES:
|
|
raise EvidenceFailure("sample_count_out_of_bounds")
|
|
if not math.isfinite(config.interval_seconds) or not (
|
|
0.05 <= config.interval_seconds <= MAX_INTERVAL_SECONDS
|
|
):
|
|
raise EvidenceFailure("sample_interval_out_of_bounds")
|
|
if (config.samples - 1) * config.interval_seconds > MAX_CAPTURE_WINDOW_SECONDS:
|
|
raise EvidenceFailure("capture_window_out_of_bounds")
|
|
for root, code in (
|
|
(config.proc_root, "proc_root_invalid"),
|
|
(config.cgroup_root, "cgroup_root_invalid"),
|
|
):
|
|
pure = PurePosixPath(root)
|
|
if not pure.is_absolute() or ".." in pure.parts:
|
|
raise EvidenceFailure(code)
|
|
|
|
|
|
def _windows_path(value: object, *, code: str) -> PureWindowsPath:
|
|
cleaned = _clean_identifier(value, code=code)
|
|
path = PureWindowsPath(cleaned)
|
|
if not path.is_absolute() or ".." in path.parts:
|
|
raise EvidenceFailure(code)
|
|
return path
|
|
|
|
|
|
def validate_windows_config(config: WindowsHostConfig) -> None:
|
|
_clean_identifier(config.public_host, code="public_host_invalid")
|
|
repo_root = _windows_path(config.repo_root, code="repo_root_invalid")
|
|
api_cwd = _windows_path(config.api_cwd, code="api_cwd_invalid")
|
|
cloudflared_cwd = _windows_path(
|
|
config.cloudflared_cwd, code="cloudflared_cwd_invalid"
|
|
)
|
|
for cwd, code in (
|
|
(api_cwd, "api_cwd_outside_repo"),
|
|
(cloudflared_cwd, "cloudflared_cwd_outside_repo"),
|
|
):
|
|
if not cwd.is_relative_to(repo_root):
|
|
raise EvidenceFailure(code)
|
|
for value, code in (
|
|
(config.git_sha, "git_sha_invalid"),
|
|
(config.git_tree_sha, "git_tree_sha_invalid"),
|
|
):
|
|
if not _GIT_SHA_PATTERN.fullmatch(value.lower()):
|
|
raise EvidenceFailure(code)
|
|
for digest, code in (
|
|
(config.runner_script_sha256, "runner_script_sha256_invalid"),
|
|
(config.collector_script_sha256, "collector_script_sha256_invalid"),
|
|
(config.checker_script_sha256, "checker_script_sha256_invalid"),
|
|
):
|
|
if not _RAW_SHA256_PATTERN.fullmatch(digest.lower()):
|
|
raise EvidenceFailure(code)
|
|
if (
|
|
not _VERSION_PATTERN.fullmatch(config.psutil_version)
|
|
or config.psutil_version != PINNED_PSUTIL_VERSION
|
|
):
|
|
raise EvidenceFailure("psutil_version_invalid")
|
|
for pid, code in (
|
|
(config.api_pid, "api_pid_invalid"),
|
|
(config.cloudflared_pid, "cloudflared_pid_invalid"),
|
|
):
|
|
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
|
raise EvidenceFailure(code)
|
|
if config.api_pid == config.cloudflared_pid:
|
|
raise EvidenceFailure("process_targets_not_distinct")
|
|
for name, code in (
|
|
(config.api_executable_name, "api_executable_name_invalid"),
|
|
(
|
|
config.cloudflared_executable_name,
|
|
"cloudflared_executable_name_invalid",
|
|
),
|
|
):
|
|
cleaned = _clean_identifier(name, code=code)
|
|
if PureWindowsPath(cleaned).name != cleaned:
|
|
raise EvidenceFailure(code)
|
|
for digest, code in (
|
|
(config.api_executable_sha256, "api_executable_sha256_invalid"),
|
|
(
|
|
config.cloudflared_executable_sha256,
|
|
"cloudflared_executable_sha256_invalid",
|
|
),
|
|
):
|
|
if not _RAW_SHA256_PATTERN.fullmatch(digest.lower()):
|
|
raise EvidenceFailure(code)
|
|
if not 1 <= config.api_listen_port <= 65_535:
|
|
raise EvidenceFailure("api_listen_port_invalid")
|
|
if not 1 <= config.samples <= MAX_SAMPLES:
|
|
raise EvidenceFailure("sample_count_out_of_bounds")
|
|
if not math.isfinite(config.interval_seconds) or not (
|
|
0.05 <= config.interval_seconds <= MAX_INTERVAL_SECONDS
|
|
):
|
|
raise EvidenceFailure("sample_interval_out_of_bounds")
|
|
if (config.samples - 1) * config.interval_seconds > MAX_CAPTURE_WINDOW_SECONDS:
|
|
raise EvidenceFailure("capture_window_out_of_bounds")
|
|
|
|
|
|
def _normalized_windows_path(value: str) -> str:
|
|
return ntpath.normcase(ntpath.normpath(value))
|
|
|
|
|
|
def _read_git_sha(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
) -> str:
|
|
value = runner.run(
|
|
["git", "-C", config.repo_root, "rev-parse", "--verify", "HEAD"]
|
|
).strip()
|
|
if not _GIT_SHA_PATTERN.fullmatch(value.lower()):
|
|
raise EvidenceFailure("git_sha_unreadable")
|
|
if value.lower() != config.git_sha.lower():
|
|
raise EvidenceFailure("git_sha_drift")
|
|
return value.lower()
|
|
|
|
|
|
def _sha256_evidence_script(repo_root: str, relative_path: str, *, role: str) -> str:
|
|
path = Path(repo_root, *PurePosixPath(relative_path).parts)
|
|
digest = hashlib.sha256()
|
|
try:
|
|
with path.open("rb") as handle:
|
|
while chunk := handle.read(1024 * 1024):
|
|
digest.update(chunk)
|
|
except OSError as exc:
|
|
raise EvidenceFailure(f"evidence_script_unreadable:{role}") from exc
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _expected_script_sha256(config: WindowsHostConfig, role: str) -> str:
|
|
if role == "runner":
|
|
return config.runner_script_sha256.lower()
|
|
if role == "collector":
|
|
return config.collector_script_sha256.lower()
|
|
if role == "checker":
|
|
return config.checker_script_sha256.lower()
|
|
raise EvidenceFailure(f"evidence_script_role_invalid:{role}")
|
|
|
|
|
|
def read_windows_source_provenance(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
) -> dict[str, object]:
|
|
"""Observe and enforce one detached, tracked-clean source/toolchain pin."""
|
|
|
|
git_sha = _read_git_sha(config, runner)
|
|
branch = runner.run(
|
|
["git", "-C", config.repo_root, "rev-parse", "--abbrev-ref", "HEAD"]
|
|
).strip()
|
|
if branch != "HEAD":
|
|
raise EvidenceFailure("git_head_not_detached")
|
|
tracked_status = runner.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
config.repo_root,
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--untracked-files=no",
|
|
]
|
|
)
|
|
if tracked_status.strip():
|
|
raise EvidenceFailure("git_tracked_worktree_dirty")
|
|
git_tree_sha = runner.run(
|
|
["git", "-C", config.repo_root, "rev-parse", "--verify", "HEAD^{tree}"]
|
|
).strip().lower()
|
|
if not _GIT_SHA_PATTERN.fullmatch(git_tree_sha):
|
|
raise EvidenceFailure("git_tree_sha_unreadable")
|
|
if git_tree_sha != config.git_tree_sha.lower():
|
|
raise EvidenceFailure("git_tree_sha_drift")
|
|
|
|
scripts: dict[str, str] = {}
|
|
for role, relative_path in _WINDOWS_PINNED_SCRIPTS.items():
|
|
digest = _sha256_evidence_script(config.repo_root, relative_path, role=role)
|
|
if digest != _expected_script_sha256(config, role):
|
|
raise EvidenceFailure(f"evidence_script_sha256_drift:{role}")
|
|
scripts[role] = digest
|
|
|
|
psutil_version = source.dependency_version("psutil")
|
|
if psutil_version != config.psutil_version:
|
|
raise EvidenceFailure("psutil_version_drift")
|
|
return {
|
|
"detached_head": True,
|
|
"tracked_clean": True,
|
|
"git_sha": git_sha,
|
|
"git_tree_sha": git_tree_sha,
|
|
"script_sha256": scripts,
|
|
"runtime_dependencies": {"psutil": psutil_version},
|
|
}
|
|
|
|
|
|
def validate_windows_source_provenance(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
baseline: dict[str, object],
|
|
) -> None:
|
|
current = read_windows_source_provenance(config, runner, source)
|
|
if current != baseline:
|
|
raise EvidenceFailure("windows_source_provenance_drift")
|
|
|
|
|
|
def _expected_windows_target(
|
|
config: WindowsHostConfig,
|
|
role: str,
|
|
) -> tuple[int, str, str, str]:
|
|
if role == "api":
|
|
return (
|
|
config.api_pid,
|
|
config.api_executable_name,
|
|
config.api_executable_sha256,
|
|
config.api_cwd,
|
|
)
|
|
if role == "cloudflared":
|
|
return (
|
|
config.cloudflared_pid,
|
|
config.cloudflared_executable_name,
|
|
config.cloudflared_executable_sha256,
|
|
config.cloudflared_cwd,
|
|
)
|
|
raise EvidenceFailure("windows_process_role_invalid")
|
|
|
|
|
|
def _validate_windows_target(
|
|
config: WindowsHostConfig,
|
|
target: WindowsProcessIdentity,
|
|
) -> None:
|
|
pid, executable_name, executable_sha256, cwd = _expected_windows_target(
|
|
config, target.role
|
|
)
|
|
if target.pid != pid:
|
|
raise EvidenceFailure(f"process_pid_mismatch:{target.role}")
|
|
if target.executable_name.casefold() != executable_name.casefold():
|
|
raise EvidenceFailure(f"process_executable_name_mismatch:{target.role}")
|
|
if target.executable_sha256.lower() != executable_sha256.lower():
|
|
raise EvidenceFailure(f"process_executable_sha256_mismatch:{target.role}")
|
|
if not _RAW_SHA256_PATTERN.fullmatch(target.command_line_sha256.lower()):
|
|
raise EvidenceFailure(f"process_command_line_sha256_invalid:{target.role}")
|
|
if target.git_sha.lower() != config.git_sha.lower():
|
|
raise EvidenceFailure(f"process_git_sha_mismatch:{target.role}")
|
|
if _normalized_windows_path(target.cwd) != _normalized_windows_path(cwd):
|
|
raise EvidenceFailure(f"process_cwd_mismatch:{target.role}")
|
|
try:
|
|
started_at = datetime.fromisoformat(target.started_at.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise EvidenceFailure(f"process_started_at_invalid:{target.role}") from exc
|
|
if started_at.tzinfo is None:
|
|
raise EvidenceFailure(f"process_started_at_invalid:{target.role}")
|
|
if started_at.astimezone(UTC) > datetime.now(UTC):
|
|
raise EvidenceFailure(f"process_started_at_future:{target.role}")
|
|
|
|
|
|
def pin_windows_targets(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
) -> dict[str, WindowsProcessIdentity]:
|
|
git_sha = _read_git_sha(config, runner)
|
|
targets: dict[str, WindowsProcessIdentity] = {}
|
|
for role in ("api", "cloudflared"):
|
|
pid, _, _, _ = _expected_windows_target(config, role)
|
|
target = source.read_identity(pid=pid, role=role, git_sha=git_sha)
|
|
_validate_windows_target(config, target)
|
|
targets[role] = target
|
|
if targets["api"].pid == targets["cloudflared"].pid:
|
|
raise EvidenceFailure("process_targets_not_distinct")
|
|
return targets
|
|
|
|
|
|
def validate_windows_pins(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
targets: dict[str, WindowsProcessIdentity],
|
|
) -> None:
|
|
git_sha = _read_git_sha(config, runner)
|
|
for role in ("api", "cloudflared"):
|
|
baseline = targets[role]
|
|
current = source.read_identity(pid=baseline.pid, role=role, git_sha=git_sha)
|
|
_validate_windows_target(config, current)
|
|
for field, code in (
|
|
("started_at", "process_start_drift"),
|
|
("executable_sha256", "process_executable_drift"),
|
|
("command_line_sha256", "process_command_line_drift"),
|
|
("cwd", "process_cwd_drift"),
|
|
("git_sha", "process_git_sha_drift"),
|
|
):
|
|
baseline_value = getattr(baseline, field)
|
|
current_value = getattr(current, field)
|
|
if field == "cwd":
|
|
matches = _normalized_windows_path(
|
|
baseline_value
|
|
) == _normalized_windows_path(current_value)
|
|
else:
|
|
matches = baseline_value.casefold() == current_value.casefold()
|
|
if not matches:
|
|
raise EvidenceFailure(f"{code}:{role}")
|
|
|
|
|
|
def parse_container_inspect(stdout: str) -> list[dict[str, object]]:
|
|
try:
|
|
payload = json.loads(stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise EvidenceFailure("docker_inspect_invalid_json") from exc
|
|
if not isinstance(payload, list) or not payload:
|
|
raise EvidenceFailure("docker_inspect_empty")
|
|
if not all(isinstance(item, dict) for item in payload):
|
|
raise EvidenceFailure("docker_inspect_invalid_shape")
|
|
return payload
|
|
|
|
|
|
def _identity_from_inspect(
|
|
item: dict[str, object],
|
|
*,
|
|
role: str,
|
|
expected_project: str,
|
|
expected_service: str,
|
|
expected_digest: str,
|
|
cgroup_path: str,
|
|
) -> ContainerIdentity:
|
|
config = item.get("Config")
|
|
state = item.get("State")
|
|
if not isinstance(config, dict) or not isinstance(state, dict):
|
|
raise EvidenceFailure(f"docker_inspect_fields_missing:{role}")
|
|
labels = config.get("Labels")
|
|
if not isinstance(labels, dict):
|
|
raise EvidenceFailure(f"compose_labels_missing:{role}")
|
|
project = labels.get("com.docker.compose.project")
|
|
service = labels.get("com.docker.compose.service")
|
|
if project != expected_project:
|
|
raise EvidenceFailure(f"compose_project_mismatch:{role}")
|
|
if service != expected_service:
|
|
raise EvidenceFailure(f"compose_service_mismatch:{role}")
|
|
container_id = _clean_identifier(
|
|
item.get("Id"), code=f"container_id_missing:{role}"
|
|
).lower()
|
|
if not re.fullmatch(r"[0-9a-f]{64}", container_id):
|
|
raise EvidenceFailure(f"container_id_invalid:{role}")
|
|
image_digest = _clean_identifier(
|
|
item.get("Image"), code=f"image_digest_missing:{role}"
|
|
).lower()
|
|
if image_digest != expected_digest.lower():
|
|
raise EvidenceFailure(f"image_digest_mismatch:{role}")
|
|
if state.get("Running") is not True:
|
|
raise EvidenceFailure(f"container_not_running:{role}")
|
|
init_pid = _parse_nonnegative_int(state.get("Pid"), code=f"init_pid_missing:{role}")
|
|
if init_pid <= 0:
|
|
raise EvidenceFailure(f"init_pid_invalid:{role}")
|
|
started_at = _clean_identifier(
|
|
state.get("StartedAt"), code=f"started_at_missing:{role}"
|
|
)
|
|
restart_count = _parse_nonnegative_int(
|
|
item.get("RestartCount"), code=f"restart_count_missing:{role}"
|
|
)
|
|
container_name = _clean_identifier(
|
|
item.get("Name"), code=f"container_name_missing:{role}"
|
|
).lstrip("/")
|
|
if not container_name:
|
|
raise EvidenceFailure(f"container_name_missing:{role}")
|
|
return ContainerIdentity(
|
|
role=role,
|
|
compose_project=expected_project,
|
|
compose_service=expected_service,
|
|
container_id=container_id,
|
|
container_name=container_name,
|
|
image_digest=image_digest,
|
|
init_pid=init_pid,
|
|
started_at=started_at,
|
|
restart_count=restart_count,
|
|
cgroup_path=cgroup_path,
|
|
)
|
|
|
|
|
|
def _posix_join(root: str, *parts: str) -> str:
|
|
result = PurePosixPath(root)
|
|
for part in parts:
|
|
result /= part
|
|
return str(result)
|
|
|
|
|
|
def read_cgroup_path(source: FileSource, *, proc_root: str, pid: int, role: str) -> str:
|
|
path = _posix_join(proc_root, str(pid), "cgroup")
|
|
try:
|
|
content = source.read_text(path)
|
|
except Exception as exc:
|
|
if isinstance(exc, EvidenceFailure):
|
|
raise
|
|
raise EvidenceFailure(f"proc_cgroup_missing:{role}") from exc
|
|
unified_paths = []
|
|
for line in content.splitlines():
|
|
parts = line.split(":", 2)
|
|
if len(parts) == 3 and parts[0] == "0" and parts[1] == "":
|
|
unified_paths.append(parts[2].strip())
|
|
if len(unified_paths) != 1:
|
|
raise EvidenceFailure(f"cgroup_v2_path_missing:{role}")
|
|
cgroup_path = PurePosixPath(unified_paths[0])
|
|
if not cgroup_path.is_absolute() or ".." in cgroup_path.parts:
|
|
raise EvidenceFailure(f"cgroup_v2_path_invalid:{role}")
|
|
return str(cgroup_path)
|
|
|
|
|
|
def _inspect(
|
|
runner: CommandRunner,
|
|
selectors: Sequence[str],
|
|
) -> list[dict[str, object]]:
|
|
stdout = runner.run(["docker", "inspect", "--type", "container", *selectors])
|
|
items = parse_container_inspect(stdout)
|
|
if len(items) != len(selectors):
|
|
raise EvidenceFailure("docker_inspect_target_count_mismatch")
|
|
return items
|
|
|
|
|
|
def pin_targets(
|
|
config: CaptureConfig,
|
|
runner: CommandRunner,
|
|
source: FileSource,
|
|
) -> dict[str, ContainerIdentity]:
|
|
items = _inspect(runner, [config.api_container, config.caddy_container])
|
|
by_service: dict[str, dict[str, object]] = {}
|
|
for item in items:
|
|
item_config = item.get("Config")
|
|
labels = item_config.get("Labels") if isinstance(item_config, dict) else None
|
|
service = (
|
|
labels.get("com.docker.compose.service")
|
|
if isinstance(labels, dict)
|
|
else None
|
|
)
|
|
if isinstance(service, str):
|
|
if service in by_service:
|
|
raise EvidenceFailure("docker_inspect_duplicate_service")
|
|
by_service[service] = item
|
|
|
|
targets: dict[str, ContainerIdentity] = {}
|
|
for role, service, digest in (
|
|
("api", config.api_service, config.api_image_digest),
|
|
("caddy", config.caddy_service, config.caddy_image_digest),
|
|
):
|
|
item = by_service.get(service)
|
|
if item is None:
|
|
raise EvidenceFailure(f"compose_service_missing:{role}")
|
|
state = item.get("State")
|
|
if not isinstance(state, dict):
|
|
raise EvidenceFailure(f"docker_inspect_fields_missing:{role}")
|
|
pid = _parse_nonnegative_int(state.get("Pid"), code=f"init_pid_missing:{role}")
|
|
cgroup_path = read_cgroup_path(
|
|
source,
|
|
proc_root=config.proc_root,
|
|
pid=pid,
|
|
role=role,
|
|
)
|
|
targets[role] = _identity_from_inspect(
|
|
item,
|
|
role=role,
|
|
expected_project=config.compose_project,
|
|
expected_service=service,
|
|
expected_digest=digest,
|
|
cgroup_path=cgroup_path,
|
|
)
|
|
if targets["api"].container_id == targets["caddy"].container_id:
|
|
raise EvidenceFailure("container_targets_not_distinct")
|
|
return targets
|
|
|
|
|
|
def validate_pins(
|
|
runner: CommandRunner,
|
|
source: FileSource,
|
|
config: CaptureConfig,
|
|
targets: dict[str, ContainerIdentity],
|
|
) -> None:
|
|
ordered = [targets["api"], targets["caddy"]]
|
|
items = _inspect(runner, [target.container_id for target in ordered])
|
|
by_id = {
|
|
str(item.get("Id") or "").lower(): item
|
|
for item in items
|
|
if isinstance(item, dict)
|
|
}
|
|
for baseline in ordered:
|
|
item = by_id.get(baseline.container_id)
|
|
if item is None:
|
|
raise EvidenceFailure(f"container_id_drift:{baseline.role}")
|
|
current_image_digest = str(item.get("Image") or "").strip().lower()
|
|
if current_image_digest != baseline.image_digest:
|
|
raise EvidenceFailure(f"image_digest_drift:{baseline.role}")
|
|
current = _identity_from_inspect(
|
|
item,
|
|
role=baseline.role,
|
|
expected_project=baseline.compose_project,
|
|
expected_service=baseline.compose_service,
|
|
expected_digest=baseline.image_digest,
|
|
cgroup_path=baseline.cgroup_path,
|
|
)
|
|
if current.container_name != baseline.container_name:
|
|
raise EvidenceFailure(f"container_name_drift:{baseline.role}")
|
|
if current.init_pid != baseline.init_pid:
|
|
raise EvidenceFailure(f"init_pid_drift:{baseline.role}")
|
|
if (
|
|
current.started_at != baseline.started_at
|
|
or current.restart_count != baseline.restart_count
|
|
):
|
|
raise EvidenceFailure(f"container_restart_drift:{baseline.role}")
|
|
current_cgroup_path = read_cgroup_path(
|
|
source,
|
|
proc_root=config.proc_root,
|
|
pid=current.init_pid,
|
|
role=current.role,
|
|
)
|
|
if current_cgroup_path != baseline.cgroup_path:
|
|
raise EvidenceFailure(f"cgroup_path_drift:{baseline.role}")
|
|
|
|
|
|
def _read_source(source: FileSource, path: str, *, code: str) -> str:
|
|
try:
|
|
value = source.read_text(path)
|
|
except Exception as exc:
|
|
if isinstance(exc, EvidenceFailure):
|
|
raise
|
|
raise EvidenceFailure(code) from exc
|
|
if not value.strip():
|
|
raise EvidenceFailure(code)
|
|
return value
|
|
|
|
|
|
def parse_cpu_stat(content: str, *, role: str) -> dict[str, int]:
|
|
parsed: dict[str, int] = {}
|
|
for line in content.splitlines():
|
|
parts = line.split()
|
|
if len(parts) != 2:
|
|
raise EvidenceFailure(f"cgroup_cpu_stat_invalid:{role}")
|
|
key, raw_value = parts
|
|
if key in parsed:
|
|
raise EvidenceFailure(f"cgroup_cpu_stat_duplicate:{role}")
|
|
parsed[key] = _parse_nonnegative_int(
|
|
raw_value, code=f"cgroup_cpu_stat_invalid:{role}"
|
|
)
|
|
required = {"usage_usec", "user_usec", "system_usec"}
|
|
if not required.issubset(parsed):
|
|
raise EvidenceFailure(f"cgroup_cpu_stat_fields_missing:{role}")
|
|
return dict(sorted(parsed.items()))
|
|
|
|
|
|
def read_cgroup_metrics(
|
|
source: FileSource,
|
|
*,
|
|
cgroup_root: str,
|
|
target: ContainerIdentity,
|
|
) -> dict[str, object]:
|
|
base = _posix_join(cgroup_root, target.cgroup_path.lstrip("/"))
|
|
|
|
def read_integer(filename: str) -> int:
|
|
content = _read_source(
|
|
source,
|
|
_posix_join(base, filename),
|
|
code=f"cgroup_source_missing:{target.role}:{filename}",
|
|
)
|
|
return _parse_nonnegative_int(
|
|
content.strip(), code=f"cgroup_source_invalid:{target.role}:{filename}"
|
|
)
|
|
|
|
cpu_stat = parse_cpu_stat(
|
|
_read_source(
|
|
source,
|
|
_posix_join(base, "cpu.stat"),
|
|
code=f"cgroup_source_missing:{target.role}:cpu.stat",
|
|
),
|
|
role=target.role,
|
|
)
|
|
return {
|
|
"memory_current_bytes": read_integer("memory.current"),
|
|
"memory_peak_bytes": read_integer("memory.peak"),
|
|
"cpu_stat": cpu_stat,
|
|
"pids_current": read_integer("pids.current"),
|
|
}
|
|
|
|
|
|
def parse_proc_status(content: str, *, role: str) -> dict[str, int]:
|
|
values: dict[str, int] = {}
|
|
for line in content.splitlines():
|
|
if ":" not in line:
|
|
continue
|
|
key, raw = line.split(":", 1)
|
|
if key in {"VmRSS", "VmHWM"}:
|
|
parts = raw.split()
|
|
if len(parts) != 2 or parts[1] != "kB":
|
|
raise EvidenceFailure(f"proc_status_field_invalid:{role}:{key}")
|
|
values[f"{key}_bytes"] = (
|
|
_parse_nonnegative_int(
|
|
parts[0], code=f"proc_status_field_invalid:{role}:{key}"
|
|
)
|
|
* 1_024
|
|
)
|
|
elif key == "Threads":
|
|
values["Threads"] = _parse_nonnegative_int(
|
|
raw.strip(), code=f"proc_status_field_invalid:{role}:Threads"
|
|
)
|
|
required = {"VmRSS_bytes", "VmHWM_bytes", "Threads"}
|
|
if not required.issubset(values):
|
|
raise EvidenceFailure(f"proc_status_fields_missing:{role}")
|
|
return {
|
|
"vm_rss_bytes": values["VmRSS_bytes"],
|
|
"vm_hwm_bytes": values["VmHWM_bytes"],
|
|
"threads": values["Threads"],
|
|
}
|
|
|
|
|
|
def read_proc_metrics(
|
|
source: FileSource,
|
|
*,
|
|
proc_root: str,
|
|
target: ContainerIdentity,
|
|
) -> dict[str, int]:
|
|
status_path = _posix_join(proc_root, str(target.init_pid), "status")
|
|
metrics = parse_proc_status(
|
|
_read_source(
|
|
source,
|
|
status_path,
|
|
code=f"proc_status_missing:{target.role}",
|
|
),
|
|
role=target.role,
|
|
)
|
|
try:
|
|
fd_names = source.list_names(_posix_join(proc_root, str(target.init_pid), "fd"))
|
|
except Exception as exc:
|
|
if isinstance(exc, EvidenceFailure):
|
|
raise
|
|
raise EvidenceFailure(f"proc_fd_missing:{target.role}") from exc
|
|
metrics["fd_count"] = len(fd_names)
|
|
return metrics
|
|
|
|
|
|
def parse_human_bytes(value: str, *, code: str) -> int:
|
|
match = _BYTE_VALUE_PATTERN.fullmatch(value.strip())
|
|
if match is None:
|
|
raise EvidenceFailure(code)
|
|
try:
|
|
number = Decimal(match.group(1))
|
|
except InvalidOperation as exc:
|
|
raise EvidenceFailure(code) from exc
|
|
multiplier = _BYTE_MULTIPLIERS.get(match.group(2).upper())
|
|
if multiplier is None:
|
|
raise EvidenceFailure(code)
|
|
return int(number * multiplier)
|
|
|
|
|
|
def _parse_percent(value: object, *, code: str) -> float:
|
|
if not isinstance(value, str) or not value.strip().endswith("%"):
|
|
raise EvidenceFailure(code)
|
|
try:
|
|
result = float(value.strip()[:-1])
|
|
except ValueError as exc:
|
|
raise EvidenceFailure(code) from exc
|
|
if not math.isfinite(result) or result < 0:
|
|
raise EvidenceFailure(code)
|
|
return result
|
|
|
|
|
|
def _parse_pair(value: object, *, code: str) -> tuple[int, int]:
|
|
if not isinstance(value, str):
|
|
raise EvidenceFailure(code)
|
|
parts = value.split("/")
|
|
if len(parts) != 2:
|
|
raise EvidenceFailure(code)
|
|
return (
|
|
parse_human_bytes(parts[0], code=code),
|
|
parse_human_bytes(parts[1], code=code),
|
|
)
|
|
|
|
|
|
def parse_docker_stats(
|
|
stdout: str,
|
|
targets: dict[str, ContainerIdentity],
|
|
) -> dict[str, dict[str, object]]:
|
|
records: list[dict[str, object]] = []
|
|
for line in stdout.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise EvidenceFailure("docker_stats_invalid_json") from exc
|
|
if not isinstance(record, dict):
|
|
raise EvidenceFailure("docker_stats_invalid_shape")
|
|
records.append(record)
|
|
if len(records) != len(targets):
|
|
raise EvidenceFailure("docker_stats_target_count_mismatch")
|
|
|
|
result: dict[str, dict[str, object]] = {}
|
|
for role, target in targets.items():
|
|
matches = []
|
|
for record in records:
|
|
stats_id = str(record.get("ID") or record.get("Container") or "").lower()
|
|
if stats_id and target.container_id.startswith(stats_id):
|
|
matches.append(record)
|
|
if len(matches) != 1:
|
|
raise EvidenceFailure(f"docker_stats_target_missing:{role}")
|
|
record = matches[0]
|
|
memory_usage, memory_limit = _parse_pair(
|
|
record.get("MemUsage"), code=f"docker_stats_mem_usage_invalid:{role}"
|
|
)
|
|
network_rx, network_tx = _parse_pair(
|
|
record.get("NetIO"), code=f"docker_stats_net_io_invalid:{role}"
|
|
)
|
|
block_read, block_write = _parse_pair(
|
|
record.get("BlockIO"), code=f"docker_stats_block_io_invalid:{role}"
|
|
)
|
|
result[role] = {
|
|
"cpu_percent": _parse_percent(
|
|
record.get("CPUPerc"), code=f"docker_stats_cpu_invalid:{role}"
|
|
),
|
|
"memory_usage_bytes": memory_usage,
|
|
"memory_limit_bytes": memory_limit,
|
|
"memory_percent": _parse_percent(
|
|
record.get("MemPerc"), code=f"docker_stats_mem_percent_invalid:{role}"
|
|
),
|
|
"network_rx_bytes": network_rx,
|
|
"network_tx_bytes": network_tx,
|
|
"block_read_bytes": block_read,
|
|
"block_write_bytes": block_write,
|
|
"pids": _parse_nonnegative_int(
|
|
record.get("PIDs"), code=f"docker_stats_pids_invalid:{role}"
|
|
),
|
|
}
|
|
return result
|
|
|
|
|
|
def parse_ss_tcp(stdout: str) -> dict[str, int]:
|
|
lines = stdout.splitlines()
|
|
if not lines:
|
|
raise EvidenceFailure("ss_output_empty")
|
|
header_index = -1
|
|
recv_index = -1
|
|
send_index = -1
|
|
for index, line in enumerate(lines):
|
|
tokens = line.split()
|
|
if "Recv-Q" in tokens and "Send-Q" in tokens:
|
|
header_index = index
|
|
recv_index = tokens.index("Recv-Q")
|
|
send_index = tokens.index("Send-Q")
|
|
break
|
|
if header_index < 0:
|
|
raise EvidenceFailure("ss_queue_fields_missing")
|
|
|
|
connections = 0
|
|
recv_q_total = 0
|
|
send_q_total = 0
|
|
retransmit_current_total = 0
|
|
retransmit_cumulative_total = 0
|
|
retransmit_fields_observed = 0
|
|
for line in lines[header_index + 1 :]:
|
|
if not line.strip():
|
|
continue
|
|
if not line[0].isspace():
|
|
tokens = line.split()
|
|
if len(tokens) <= max(recv_index, send_index):
|
|
raise EvidenceFailure("ss_connection_fields_missing")
|
|
recv_q_total += _parse_nonnegative_int(
|
|
tokens[recv_index], code="ss_recv_q_invalid"
|
|
)
|
|
send_q_total += _parse_nonnegative_int(
|
|
tokens[send_index], code="ss_send_q_invalid"
|
|
)
|
|
connections += 1
|
|
for match in _RETRANSMIT_PATTERN.finditer(line):
|
|
current = int(match.group(1))
|
|
cumulative = int(match.group(2) or match.group(1))
|
|
retransmit_current_total += current
|
|
retransmit_cumulative_total += cumulative
|
|
retransmit_fields_observed += 1
|
|
return {
|
|
"connections": connections,
|
|
"recv_q_bytes_total": recv_q_total,
|
|
"send_q_bytes_total": send_q_total,
|
|
"retransmit_current_total": retransmit_current_total,
|
|
"retransmit_cumulative_total": retransmit_cumulative_total,
|
|
"retransmit_fields_observed": retransmit_fields_observed,
|
|
}
|
|
|
|
|
|
def collect_sample(
|
|
runner: CommandRunner,
|
|
source: FileSource,
|
|
config: CaptureConfig,
|
|
targets: dict[str, ContainerIdentity],
|
|
*,
|
|
sequence: int,
|
|
) -> dict[str, object]:
|
|
validate_pins(runner, source, config, targets)
|
|
container_metrics: dict[str, dict[str, object]] = {}
|
|
for role, target in targets.items():
|
|
container_metrics[role] = {
|
|
"cgroup_v2": read_cgroup_metrics(
|
|
source,
|
|
cgroup_root=config.cgroup_root,
|
|
target=target,
|
|
),
|
|
"init_process": read_proc_metrics(
|
|
source,
|
|
proc_root=config.proc_root,
|
|
target=target,
|
|
),
|
|
}
|
|
stats_stdout = runner.run(
|
|
[
|
|
"docker",
|
|
"stats",
|
|
"--no-stream",
|
|
"--format",
|
|
"{{json .}}",
|
|
targets["api"].container_id,
|
|
targets["caddy"].container_id,
|
|
]
|
|
)
|
|
stats = parse_docker_stats(stats_stdout, targets)
|
|
for role in targets:
|
|
container_metrics[role]["docker_stats"] = stats[role]
|
|
host_tcp = parse_ss_tcp(runner.run(["ss", "-tinm"]))
|
|
validate_pins(runner, source, config, targets)
|
|
return {
|
|
"sequence": sequence,
|
|
"observed_at_utc": utc_now(),
|
|
"containers": container_metrics,
|
|
"host_tcp": host_tcp,
|
|
}
|
|
|
|
|
|
def _summary(samples: list[dict[str, object]]) -> dict[str, object]:
|
|
containers: dict[str, dict[str, int | float]] = {}
|
|
for role in ("api", "caddy"):
|
|
role_samples = [sample["containers"][role] for sample in samples] # type: ignore[index]
|
|
containers[role] = {
|
|
"cgroup_memory_current_bytes_max": max(
|
|
item["cgroup_v2"]["memory_current_bytes"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"cgroup_memory_peak_bytes_max": max(
|
|
item["cgroup_v2"]["memory_peak_bytes"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"cgroup_cpu_usage_usec_max": max(
|
|
item["cgroup_v2"]["cpu_stat"]["usage_usec"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"cgroup_pids_current_max": max(
|
|
item["cgroup_v2"]["pids_current"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"proc_vm_rss_bytes_max": max(
|
|
item["init_process"]["vm_rss_bytes"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"proc_vm_hwm_bytes_max": max(
|
|
item["init_process"]["vm_hwm_bytes"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"proc_threads_max": max(
|
|
item["init_process"]["threads"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"proc_fd_count_max": max(
|
|
item["init_process"]["fd_count"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"docker_cpu_percent_max": max(
|
|
item["docker_stats"]["cpu_percent"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"docker_memory_usage_bytes_max": max(
|
|
item["docker_stats"]["memory_usage_bytes"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
"docker_pids_max": max(
|
|
item["docker_stats"]["pids"]
|
|
for item in role_samples # type: ignore[index]
|
|
),
|
|
}
|
|
tcp_samples = [sample["host_tcp"] for sample in samples]
|
|
host_tcp = {
|
|
f"{field}_max": max(item[field] for item in tcp_samples) # type: ignore[index]
|
|
for field in (
|
|
"connections",
|
|
"recv_q_bytes_total",
|
|
"send_q_bytes_total",
|
|
"retransmit_current_total",
|
|
"retransmit_cumulative_total",
|
|
)
|
|
}
|
|
return {"containers": containers, "host_tcp": host_tcp}
|
|
|
|
|
|
def _scope() -> dict[str, object]:
|
|
return {
|
|
"metadata_only": True,
|
|
"raw_command_output_retained": False,
|
|
"socket_endpoints_retained": False,
|
|
"request_payloads_retained": False,
|
|
"audio_retained": False,
|
|
"transcripts_retained": False,
|
|
"host_tcp_scope": "all_tcp_sockets_visible_to_host_sampler",
|
|
"cloudflare_edge": {
|
|
"measured": False,
|
|
"internal_queue_measured": False,
|
|
"evidence_boundary": "separate_external_artifact_required",
|
|
},
|
|
}
|
|
|
|
|
|
def base_evidence(config: CaptureConfig) -> dict[str, object]:
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"topology_mode": LINUX_COMPOSE_MODE,
|
|
"status": "running",
|
|
"started_at_utc": utc_now(),
|
|
"ended_at_utc": None,
|
|
"scope": _scope(),
|
|
"requested": {
|
|
"compose_project": config.compose_project,
|
|
"public_host": config.public_host,
|
|
"samples": config.samples,
|
|
"interval_seconds": config.interval_seconds,
|
|
"roles": {
|
|
"api": {
|
|
"compose_service": config.api_service,
|
|
"expected_image_digest": config.api_image_digest.lower(),
|
|
},
|
|
"caddy": {
|
|
"compose_service": config.caddy_service,
|
|
"expected_image_digest": config.caddy_image_digest.lower(),
|
|
},
|
|
},
|
|
},
|
|
"targets": {},
|
|
"samples_completed": 0,
|
|
"samples": [],
|
|
"summary": {},
|
|
"failure_type": None,
|
|
}
|
|
|
|
|
|
def capture_evidence(
|
|
config: CaptureConfig,
|
|
runner: CommandRunner,
|
|
source: FileSource,
|
|
*,
|
|
sleep=time.sleep,
|
|
) -> dict[str, object]:
|
|
validate_config(config)
|
|
for command in ("docker", "ss"):
|
|
if not runner.available(command):
|
|
raise EvidenceFailure(f"command_unavailable:{command}")
|
|
targets = pin_targets(config, runner, source)
|
|
evidence = base_evidence(config)
|
|
evidence["targets"] = {role: asdict(target) for role, target in targets.items()}
|
|
samples: list[dict[str, object]] = []
|
|
for index in range(config.samples):
|
|
if index:
|
|
sleep(config.interval_seconds)
|
|
samples.append(
|
|
collect_sample(
|
|
runner,
|
|
source,
|
|
config,
|
|
targets,
|
|
sequence=index + 1,
|
|
)
|
|
)
|
|
evidence["samples"] = samples
|
|
evidence["samples_completed"] = len(samples)
|
|
evidence["summary"] = _summary(samples)
|
|
evidence["status"] = "passed"
|
|
evidence["ended_at_utc"] = utc_now()
|
|
return evidence
|
|
|
|
|
|
def _validate_windows_process_metrics(
|
|
metrics: dict[str, int | float],
|
|
*,
|
|
role: str,
|
|
) -> None:
|
|
for field in ("rss_bytes", "peak_rss_bytes", "handles", "threads"):
|
|
value = metrics.get(field)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
raise EvidenceFailure(f"process_metric_invalid:{role}:{field}")
|
|
for field, positive in (("cpu_time_seconds", True), ("cpu_percent", False)):
|
|
value = metrics.get(field)
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
raise EvidenceFailure(f"process_metric_invalid:{role}:{field}")
|
|
numeric = float(value)
|
|
if not math.isfinite(numeric) or numeric < 0 or (positive and numeric == 0):
|
|
raise EvidenceFailure(f"process_metric_invalid:{role}:{field}")
|
|
|
|
|
|
def _validate_windows_tcp_metrics(
|
|
metrics: dict[str, object],
|
|
*,
|
|
config: WindowsHostConfig,
|
|
) -> None:
|
|
processes = metrics.get("processes")
|
|
host_tcp = metrics.get("host_tcp")
|
|
listener = metrics.get("api_listener")
|
|
if not isinstance(processes, dict) or set(processes) != {"api", "cloudflared"}:
|
|
raise EvidenceFailure("windows_tcp_processes_invalid")
|
|
for role in ("api", "cloudflared"):
|
|
values = processes.get(role)
|
|
if not isinstance(values, dict):
|
|
raise EvidenceFailure(f"windows_tcp_process_invalid:{role}")
|
|
for field in ("connections", "established", "listeners"):
|
|
value = values.get(field)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
raise EvidenceFailure(f"windows_tcp_metric_invalid:{role}:{field}")
|
|
if not isinstance(host_tcp, dict):
|
|
raise EvidenceFailure("windows_host_tcp_invalid")
|
|
host_connections = host_tcp.get("connections")
|
|
if (
|
|
isinstance(host_connections, bool)
|
|
or not isinstance(host_connections, int)
|
|
or host_connections <= 0
|
|
):
|
|
raise EvidenceFailure("windows_host_tcp_invalid")
|
|
if not isinstance(listener, dict):
|
|
raise EvidenceFailure("api_listener_invalid")
|
|
if listener.get("port") != config.api_listen_port:
|
|
raise EvidenceFailure("api_listener_port_mismatch")
|
|
owned = listener.get("owned_listener_count")
|
|
conflicts = listener.get("conflicting_listener_count")
|
|
if isinstance(owned, bool) or not isinstance(owned, int) or owned <= 0:
|
|
raise EvidenceFailure("api_listener_not_owned")
|
|
if isinstance(conflicts, bool) or not isinstance(conflicts, int) or conflicts != 0:
|
|
raise EvidenceFailure("api_listener_owner_conflict")
|
|
api = processes["api"]
|
|
cloudflared = processes["cloudflared"]
|
|
if api.get("listeners", 0) <= 0:
|
|
raise EvidenceFailure("api_process_listener_missing")
|
|
if cloudflared.get("established", 0) <= 0:
|
|
raise EvidenceFailure("cloudflared_tunnel_connection_missing")
|
|
|
|
|
|
def collect_windows_sample(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
targets: dict[str, WindowsProcessIdentity],
|
|
source_provenance: dict[str, object],
|
|
*,
|
|
sequence: int,
|
|
) -> dict[str, object]:
|
|
validate_windows_source_provenance(config, runner, source, source_provenance)
|
|
validate_windows_pins(config, runner, source, targets)
|
|
process_metrics: dict[str, dict[str, int | float]] = {}
|
|
for role, target in targets.items():
|
|
metrics = source.read_metrics(pid=target.pid, role=role)
|
|
_validate_windows_process_metrics(metrics, role=role)
|
|
process_metrics[role] = metrics
|
|
tcp_metrics = source.read_tcp_metrics(
|
|
api_pid=targets["api"].pid,
|
|
cloudflared_pid=targets["cloudflared"].pid,
|
|
api_listen_port=config.api_listen_port,
|
|
)
|
|
_validate_windows_tcp_metrics(tcp_metrics, config=config)
|
|
validate_windows_pins(config, runner, source, targets)
|
|
validate_windows_source_provenance(config, runner, source, source_provenance)
|
|
return {
|
|
"sequence": sequence,
|
|
"observed_at_utc": utc_now(),
|
|
"processes": process_metrics,
|
|
"process_tcp": tcp_metrics["processes"],
|
|
"host_tcp": tcp_metrics["host_tcp"],
|
|
"api_listener": tcp_metrics["api_listener"],
|
|
}
|
|
|
|
|
|
def _validate_windows_cpu_monotonic(
|
|
previous: dict[str, object],
|
|
current: dict[str, object],
|
|
) -> None:
|
|
for role in ("api", "cloudflared"):
|
|
previous_value = previous["processes"][role]["cpu_time_seconds"] # type: ignore[index]
|
|
current_value = current["processes"][role]["cpu_time_seconds"] # type: ignore[index]
|
|
if current_value < previous_value: # type: ignore[operator]
|
|
raise EvidenceFailure(f"process_cpu_time_regressed:{role}")
|
|
|
|
|
|
def _windows_summary(samples: list[dict[str, object]]) -> dict[str, object]:
|
|
processes: dict[str, dict[str, int | float]] = {}
|
|
for role in ("api", "cloudflared"):
|
|
process_samples = [sample["processes"][role] for sample in samples] # type: ignore[index]
|
|
tcp_samples = [sample["process_tcp"][role] for sample in samples] # type: ignore[index]
|
|
processes[role] = {
|
|
f"{field}_max": max(item[field] for item in process_samples) # type: ignore[index]
|
|
for field in (
|
|
"rss_bytes",
|
|
"peak_rss_bytes",
|
|
"cpu_time_seconds",
|
|
"cpu_percent",
|
|
"handles",
|
|
"threads",
|
|
)
|
|
}
|
|
processes[role].update(
|
|
{
|
|
f"tcp_{field}_max": max(item[field] for item in tcp_samples) # type: ignore[index]
|
|
for field in ("connections", "established", "listeners")
|
|
}
|
|
)
|
|
listener_samples = [sample["api_listener"] for sample in samples]
|
|
host_samples = [sample["host_tcp"] for sample in samples]
|
|
return {
|
|
"processes": processes,
|
|
"api_listener": {
|
|
"port": listener_samples[0]["port"], # type: ignore[index]
|
|
"owned_listener_count_min": min(
|
|
item["owned_listener_count"] for item in listener_samples # type: ignore[index]
|
|
),
|
|
"conflicting_listener_count_max": max(
|
|
item["conflicting_listener_count"] for item in listener_samples # type: ignore[index]
|
|
),
|
|
},
|
|
"host_tcp": {
|
|
"connections_max": max(item["connections"] for item in host_samples) # type: ignore[index]
|
|
},
|
|
}
|
|
|
|
|
|
def base_windows_evidence(config: WindowsHostConfig) -> dict[str, object]:
|
|
scope = _scope()
|
|
scope["topology_boundary"] = "windows_host_api_and_cloudflared_processes"
|
|
scope["configured_listener_port_retained"] = True
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"topology_mode": WINDOWS_HOST_MODE,
|
|
"status": "running",
|
|
"started_at_utc": utc_now(),
|
|
"ended_at_utc": None,
|
|
"scope": scope,
|
|
"requested": {
|
|
"public_host": config.public_host,
|
|
"repo_root": config.repo_root,
|
|
"git_sha": config.git_sha.lower(),
|
|
"source_pin": {
|
|
"git_tree_sha": config.git_tree_sha.lower(),
|
|
"script_sha256": {
|
|
"runner": config.runner_script_sha256.lower(),
|
|
"collector": config.collector_script_sha256.lower(),
|
|
"checker": config.checker_script_sha256.lower(),
|
|
},
|
|
"runtime_dependencies": {"psutil": config.psutil_version},
|
|
},
|
|
"samples": config.samples,
|
|
"interval_seconds": config.interval_seconds,
|
|
"api_listen_port": config.api_listen_port,
|
|
"roles": {
|
|
"api": {
|
|
"pid": config.api_pid,
|
|
"expected_executable_name": config.api_executable_name,
|
|
"expected_executable_sha256": (
|
|
config.api_executable_sha256.lower()
|
|
),
|
|
"expected_cwd": config.api_cwd,
|
|
},
|
|
"cloudflared": {
|
|
"pid": config.cloudflared_pid,
|
|
"expected_executable_name": config.cloudflared_executable_name,
|
|
"expected_executable_sha256": (
|
|
config.cloudflared_executable_sha256.lower()
|
|
),
|
|
"expected_cwd": config.cloudflared_cwd,
|
|
},
|
|
},
|
|
},
|
|
"source_provenance": {},
|
|
"targets": {},
|
|
"samples_completed": 0,
|
|
"samples": [],
|
|
"summary": {},
|
|
"failure_type": None,
|
|
}
|
|
|
|
|
|
def capture_windows_evidence(
|
|
config: WindowsHostConfig,
|
|
runner: CommandRunner,
|
|
source: WindowsSource,
|
|
*,
|
|
sleep=time.sleep,
|
|
) -> dict[str, object]:
|
|
validate_windows_config(config)
|
|
if not runner.available("git"):
|
|
raise EvidenceFailure("command_unavailable:git")
|
|
source_provenance = read_windows_source_provenance(config, runner, source)
|
|
targets = pin_windows_targets(config, runner, source)
|
|
evidence = base_windows_evidence(config)
|
|
evidence["source_provenance"] = source_provenance
|
|
evidence["targets"] = {role: asdict(target) for role, target in targets.items()}
|
|
samples: list[dict[str, object]] = []
|
|
for index in range(config.samples):
|
|
if index:
|
|
sleep(config.interval_seconds)
|
|
sample = collect_windows_sample(
|
|
config,
|
|
runner,
|
|
source,
|
|
targets,
|
|
source_provenance,
|
|
sequence=index + 1,
|
|
)
|
|
if samples:
|
|
_validate_windows_cpu_monotonic(samples[-1], sample)
|
|
samples.append(sample)
|
|
evidence["samples"] = samples
|
|
evidence["samples_completed"] = len(samples)
|
|
evidence["summary"] = _windows_summary(samples)
|
|
evidence["status"] = "passed"
|
|
evidence["ended_at_utc"] = utc_now()
|
|
return evidence
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
result = argparse.ArgumentParser(
|
|
description="Bounded metadata-only G7 deployment topology sampler"
|
|
)
|
|
result.add_argument(
|
|
"--topology-mode",
|
|
choices=("linux-compose", "windows-host"),
|
|
default="linux-compose",
|
|
)
|
|
result.add_argument("--compose-project")
|
|
result.add_argument(
|
|
"--public-host",
|
|
required=True,
|
|
help="public WSS/API host bound to this exact deployment",
|
|
)
|
|
result.add_argument("--api-container")
|
|
result.add_argument("--api-service", default="api")
|
|
result.add_argument("--api-image-digest")
|
|
result.add_argument("--caddy-container")
|
|
result.add_argument("--caddy-service", default="caddy")
|
|
result.add_argument("--caddy-image-digest")
|
|
result.add_argument("--repo-root")
|
|
result.add_argument("--git-sha")
|
|
result.add_argument("--git-tree-sha")
|
|
result.add_argument("--runner-script-sha256")
|
|
result.add_argument("--collector-script-sha256")
|
|
result.add_argument("--checker-script-sha256")
|
|
result.add_argument("--psutil-version")
|
|
result.add_argument("--api-pid", type=int)
|
|
result.add_argument("--api-executable-name")
|
|
result.add_argument("--api-executable-sha256")
|
|
result.add_argument("--api-cwd")
|
|
result.add_argument("--api-listen-port", type=int)
|
|
result.add_argument("--cloudflared-pid", type=int)
|
|
result.add_argument("--cloudflared-executable-name")
|
|
result.add_argument("--cloudflared-executable-sha256")
|
|
result.add_argument("--cloudflared-cwd")
|
|
result.add_argument("--samples", type=int, default=DEFAULT_SAMPLES)
|
|
result.add_argument(
|
|
"--interval-seconds", type=float, default=DEFAULT_INTERVAL_SECONDS
|
|
)
|
|
result.add_argument("--proc-root", default="/proc")
|
|
result.add_argument("--cgroup-root", default="/sys/fs/cgroup")
|
|
result.add_argument("--evidence-output", type=Path)
|
|
return result
|
|
|
|
|
|
def _required_cli_text(args: argparse.Namespace, name: str) -> str:
|
|
value = getattr(args, name)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise EvidenceFailure(f"required_argument_missing:{name.replace('_', '-')}")
|
|
return value.strip()
|
|
|
|
|
|
def _required_cli_int(args: argparse.Namespace, name: str) -> int:
|
|
value = getattr(args, name)
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise EvidenceFailure(f"required_argument_missing:{name.replace('_', '-')}")
|
|
return value
|
|
|
|
|
|
def _compose_config_from_args(args: argparse.Namespace) -> CaptureConfig:
|
|
return CaptureConfig(
|
|
compose_project=_required_cli_text(args, "compose_project"),
|
|
public_host=args.public_host,
|
|
api_container=_required_cli_text(args, "api_container"),
|
|
api_service=args.api_service,
|
|
api_image_digest=_required_cli_text(args, "api_image_digest"),
|
|
caddy_container=_required_cli_text(args, "caddy_container"),
|
|
caddy_service=args.caddy_service,
|
|
caddy_image_digest=_required_cli_text(args, "caddy_image_digest"),
|
|
samples=args.samples,
|
|
interval_seconds=args.interval_seconds,
|
|
proc_root=args.proc_root,
|
|
cgroup_root=args.cgroup_root,
|
|
)
|
|
|
|
|
|
def _windows_config_from_args(args: argparse.Namespace) -> WindowsHostConfig:
|
|
return WindowsHostConfig(
|
|
public_host=args.public_host,
|
|
repo_root=_required_cli_text(args, "repo_root"),
|
|
git_sha=_required_cli_text(args, "git_sha"),
|
|
git_tree_sha=_required_cli_text(args, "git_tree_sha"),
|
|
runner_script_sha256=_required_cli_text(args, "runner_script_sha256"),
|
|
collector_script_sha256=_required_cli_text(
|
|
args, "collector_script_sha256"
|
|
),
|
|
checker_script_sha256=_required_cli_text(args, "checker_script_sha256"),
|
|
psutil_version=_required_cli_text(args, "psutil_version"),
|
|
api_pid=_required_cli_int(args, "api_pid"),
|
|
api_executable_name=_required_cli_text(args, "api_executable_name"),
|
|
api_executable_sha256=_required_cli_text(args, "api_executable_sha256"),
|
|
api_cwd=_required_cli_text(args, "api_cwd"),
|
|
api_listen_port=_required_cli_int(args, "api_listen_port"),
|
|
cloudflared_pid=_required_cli_int(args, "cloudflared_pid"),
|
|
cloudflared_executable_name=_required_cli_text(
|
|
args, "cloudflared_executable_name"
|
|
),
|
|
cloudflared_executable_sha256=_required_cli_text(
|
|
args, "cloudflared_executable_sha256"
|
|
),
|
|
cloudflared_cwd=_required_cli_text(args, "cloudflared_cwd"),
|
|
samples=args.samples,
|
|
interval_seconds=args.interval_seconds,
|
|
)
|
|
|
|
|
|
def _minimal_failure_evidence(
|
|
*,
|
|
topology_mode: str,
|
|
public_host: str,
|
|
) -> dict[str, object]:
|
|
observed_at = utc_now()
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"topology_mode": topology_mode,
|
|
"status": "failed",
|
|
"started_at_utc": observed_at,
|
|
"ended_at_utc": observed_at,
|
|
"scope": _scope(),
|
|
"requested": {"public_host": public_host},
|
|
"targets": {},
|
|
"samples_completed": 0,
|
|
"samples": [],
|
|
"summary": {},
|
|
"failure_type": None,
|
|
}
|
|
|
|
|
|
def _write_evidence(evidence: dict[str, object], output: Path | None) -> None:
|
|
payload = json.dumps(evidence, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
if output is not None:
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(payload, encoding="utf-8")
|
|
print(payload, end="")
|
|
|
|
|
|
def main() -> int:
|
|
args = parser().parse_args()
|
|
topology_mode = (
|
|
WINDOWS_HOST_MODE if args.topology_mode == "windows-host" else LINUX_COMPOSE_MODE
|
|
)
|
|
config: CaptureConfig | WindowsHostConfig | None = None
|
|
try:
|
|
if topology_mode == LINUX_COMPOSE_MODE:
|
|
config = _compose_config_from_args(args)
|
|
evidence = capture_evidence(
|
|
config, SubprocessCommandRunner(), LocalFileSource()
|
|
)
|
|
else:
|
|
config = _windows_config_from_args(args)
|
|
evidence = capture_windows_evidence(
|
|
config,
|
|
SubprocessCommandRunner(),
|
|
LocalWindowsSource(),
|
|
)
|
|
_write_evidence(evidence, args.evidence_output)
|
|
return 0
|
|
except EvidenceFailure as exc:
|
|
if isinstance(config, CaptureConfig):
|
|
evidence = base_evidence(config)
|
|
elif isinstance(config, WindowsHostConfig):
|
|
evidence = base_windows_evidence(config)
|
|
else:
|
|
evidence = _minimal_failure_evidence(
|
|
topology_mode=topology_mode,
|
|
public_host=args.public_host,
|
|
)
|
|
evidence["status"] = "failed"
|
|
evidence["failure_type"] = str(exc)
|
|
evidence["ended_at_utc"] = utc_now()
|
|
_write_evidence(evidence, args.evidence_output)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|