Files
multi-agent-mux/tests/test_deploy_freshness.py
T

696 lines
33 KiB
Python

"""Deploy freshness — the deploy/ scripts must actually ship the latest assets.
``deploy/install.sh`` treats only ``.agents/skills/**`` as framework-owned. Every
other shipped asset (``.agents/hooks.json``, ``.agents/hooks/*.sh``,
``.agents/MULTI_AGENT_RULES*.md``, ``.agents/INSTALL.md``) takes the
``elif [ ! -e "$dest" ]`` branch, so it is copied once and never refreshed.
``deploy/remove.sh``'s manifest-less fallback list is likewise frozen at the
pre-loop skill set, so it strands those same assets plus the whole
``multi-agent-mux-loop`` skill.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import pytest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# Framework assets that live outside .agents/skills/ and must still be
# refreshed. Mapped as {installed path: path in the source tree} because the
# user manual is installed from deploy/, not from .agents/.
NON_SKILL_ASSETS = {
".agents/hooks.json": ".agents/hooks.json",
".agents/hooks/loop_delegation_guard.sh": ".agents/hooks/loop_delegation_guard.sh",
".agents/MULTI_AGENT_RULES.md": ".agents/MULTI_AGENT_RULES.md",
".agents/MULTI_AGENT_RULES.ko.md": ".agents/MULTI_AGENT_RULES.ko.md",
".agents/INSTALL.md": "deploy/INSTALL.md",
}
MARK = "UPSTREAM-RELEASE-MARKER"
@pytest.fixture
def src_and_target():
"""A pristine copy of the working tree plus an empty install target.
Runtime directories are excluded so the fixture stays fast and so the
source can never be confused with an already-installed workspace.
"""
tmp = tempfile.mkdtemp(prefix="mam_deploy_fresh_")
src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt")
shutil.copytree(REPO_ROOT, src,
ignore=shutil.ignore_patterns(".git", ".venv", ".mam",
".mam_deploy", "__pycache__"),
symlinks=True)
os.makedirs(tgt)
try:
yield src, tgt
finally:
shutil.rmtree(tmp, ignore_errors=True)
def _install(src, tgt, **extra):
env = dict(os.environ)
env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1",
"MAM_SKIP_GITIGNORE": "1"})
env.update(extra)
return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt],
env=env, capture_output=True, text=True)
def _bump(src, rel):
"""Simulate an upstream release that changed ``rel``."""
path = os.path.join(src, rel)
assert os.path.exists(path), "no such source asset: %s" % rel
if rel.endswith(".json"):
with open(path) as f:
data = json.load(f)
data["_release"] = MARK
with open(path, "w") as f:
json.dump(data, f, indent=2)
else:
with open(path, "a") as f:
f.write("\n# %s\n" % MARK)
# --------------------------------------------------------------------------
# D-1 — a refresh install must deliver upstream changes to EVERY framework
# asset, not only to those under .agents/skills/.
# --------------------------------------------------------------------------
def test_d1_refresh_updates_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
for source_rel in NON_SKILL_ASSETS.values():
_bump(src, source_rel)
_bump(src, ".agents/skills/lib.sh") # control: this one is known to work
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(os.path.join(tgt, ".agents/skills/lib.sh")) as f:
assert MARK in f.read(), (
"control failed: even a skills/ file did not refresh, so this test "
"is not measuring what it claims")
stale = []
for rel in NON_SKILL_ASSETS:
with open(os.path.join(tgt, rel)) as f:
if MARK not in f.read():
stale.append(rel)
assert not stale, (
"install.sh refreshed .agents/skills/ but left these framework assets "
"at their originally-installed version: %s. The O-3 guard and the "
"MULTI_AGENT_RULES protocol therefore never reach an existing "
"workspace." % stale)
# --------------------------------------------------------------------------
# D-2 — remove.sh's manifest-less fallback must clear every asset the
# installers write, or the next install is blocked by the survivors
# (install.sh only copies a non-skill asset when it does not exist).
# --------------------------------------------------------------------------
def test_d2_manifestless_removal_strands_no_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
# An install_mam.sh deployment leaves no manifest; force that code path.
os.remove(os.path.join(tgt, ".mam", "install_manifest.txt"))
res = subprocess.run(["bash", os.path.join(tgt, ".mam_deploy", "remove.sh"),
"--force", tgt], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
survivors = []
for root, _, files in os.walk(os.path.join(tgt, ".agents")):
for f in files:
survivors.append(os.path.relpath(os.path.join(root, f), tgt))
assert not survivors, (
"the manifest-less fallback left framework assets behind: %s. Because "
"install.sh copies a non-skill asset only when it is absent, these "
"survivors permanently pin the workspace to the old version."
% sorted(survivors))
# --------------------------------------------------------------------------
# D-3 — install_mam.sh must record a manifest, so that remove.sh/update.sh
# take the exact-reversal path instead of the frozen fallback list.
# --------------------------------------------------------------------------
def test_d3_install_mam_records_a_manifest():
body = open(os.path.join(REPO_ROOT, "deploy", "install_mam.sh")).read()
assert "install_manifest.txt" in body, (
"deploy/install_mam.sh writes no .mam/install_manifest.txt, so every "
"workspace it deploys is uninstalled and updated through remove.sh's "
"frozen fallback list")
# --------------------------------------------------------------------------
# D-5 — the fetch-validation gate must cover the assets a broken fetch would
# plausibly drop, including the O-3 hook pair.
# --------------------------------------------------------------------------
def test_d5_asset_presence_gate_covers_the_hook_pair():
body = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start = body.index("check_assets_present()")
gate = body[start:body.index("}", body.index("return 0", start))]
for rel in (".agents/hooks.json", ".agents/hooks/loop_delegation_guard.sh"):
assert rel in gate, (
"check_assets_present() does not require %s, so a fetch that "
"silently dropped the O-3 guard still passes validation and "
"installs a workspace whose guard is inert" % rel)
# --------------------------------------------------------------------------
# D-6 — asset_hashes.txt drives both the refresh 3-way merge and remove.sh's
# "preserve modified files" backup. Framework files outside skills/ get
# neither today.
# --------------------------------------------------------------------------
def test_d6_hash_db_covers_non_skill_framework_assets(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".mam", "asset_hashes.txt")) as f:
db = f.read()
missing = [rel for rel in NON_SKILL_ASSETS if rel not in db]
assert not missing, (
"these framework assets are absent from .mam/asset_hashes.txt: %s. "
"Local edits to them are silently destroyed by remove.sh instead of "
"being backed up." % missing)
# --------------------------------------------------------------------------
# D-7 — every variable the installer writes into a fresh .mam.env must be
# documented in the committed template.
# --------------------------------------------------------------------------
def test_d7_env_template_documents_installer_applied_defaults():
installer = open(os.path.join(REPO_ROOT, "deploy", "install.sh")).read()
start_idx = installer.index("cat <<EOF > \"$MAM_ENV\"")
end_idx = installer.index("EOF", start_idx + 15)
block = installer[start_idx:end_idx]
written = [ln.split("=", 1)[0].strip() for ln in block.splitlines()
if "=" in ln and not ln.strip().startswith("#")]
template = open(os.path.join(REPO_ROOT, ".mam.env.example")).read()
undocumented = [v for v in written if v not in template]
assert not undocumented, (
"install.sh seeds %s into every new .mam.env, but .mam.env.example "
"never mentions them, so users cannot discover or correct them"
% undocumented)
# --------------------------------------------------------------------------
# D-8 — CI must lint the shipped hook and run the test suite that every
# recent feature commit claims as its evidence.
# --------------------------------------------------------------------------
def test_d8_ci_lints_the_hook_and_runs_the_tests():
ci = open(os.path.join(REPO_ROOT, "deploy", "gitea-ci.yml")).read()
assert ".agents/hooks/loop_delegation_guard.sh" in ci, (
"gitea-ci.yml shellchecks every other shipped bash entrypoint but not "
"the O-3 hook")
assert "pytest" in ci, (
"gitea-ci.yml runs no pytest job, so none of the 17 files under tests/ "
"ever gate a release")
# --------------------------------------------------------------------------
# D-9 — .agents/INSTALL.md is shipped from two different sources: the .agents/
# walk and the later `cp deploy/INSTALL.md`, whose `[ ! -e ]` guard can
# therefore never fire. One of the two must not exist, or the herdr
# migration's doc update silently loses to the tmux-era copy.
# --------------------------------------------------------------------------
def test_d9_installed_manual_is_not_the_stale_tmux_era_copy(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
with open(os.path.join(tgt, ".agents", "INSTALL.md")) as f:
shipped = f.read()
assert "tmux" not in shipped, (
"the installed .agents/INSTALL.md is the pre-herdr copy. install.sh "
"stages .agents/INSTALL.md from the repo first, so its later "
"`cp deploy/INSTALL.md` -- guarded by `[ ! -e ]` -- never runs and the "
"up-to-date manual is never delivered")
# --------------------------------------------------------------------------
# D-10 — a preserved customization must stay preserved across REPEATED
# refreshes. The hash DB was rebuilt from the working copy, so the
# second refresh saw target == db, classified the file as unmodified,
# and overwrote it with no notice.
# --------------------------------------------------------------------------
def test_d10_customization_survives_repeated_refresh(src_and_target):
src, tgt = src_and_target
assert _install(src, tgt).returncode == 0
skill = os.path.join(tgt, ".agents", "skills", "lib.sh")
with open(skill, "a") as f:
f.write("\n# MY_LOCAL_TWEAK\n")
for n in (2, 3, 4):
if n == 4: # upstream also moves on
_bump(src, ".agents/skills/lib.sh")
res = _install(src, tgt)
assert res.returncode == 0, res.stderr
with open(skill) as f:
body = f.read()
assert "MY_LOCAL_TWEAK" in body, (
"the local customization was destroyed on refresh #%d. install.sh "
"rebuilds .mam/asset_hashes.txt from the working copy, so after "
"the first PRESERVE the modified hash becomes the canonical one "
"and the next refresh silently overwrites it." % n)
assert "Local modification detected" in res.stderr, (
"refresh #%d overwrote nothing but also reported nothing; the "
"user gets no signal that their edit is diverging" % n)
# --------------------------------------------------------------------------
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
# variables that broker_config_from_env() actually parses.
# --------------------------------------------------------------------------
def test_d11_private_server_env_names_valid():
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
assert os.path.exists(doc_path), "PRIVATE_SERVER.md missing"
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
# Extract all code blocks
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
assert code_blocks, "No code blocks found in PRIVATE_SERVER.md"
# Known recognized MQTT env vars from broker_config_from_env() and deployment
valid_mqtt_vars = {
"MQTT_BROKER", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
"MQTT_CLIENT_ID_PREFIX", "MQTT_CA_CERTS", "MQTT_CERTFILE", "MQTT_KEYFILE",
"MQTT_KEEPALIVE", "MQTT_BIND"
}
for block in code_blocks:
found_vars = set(re.findall(r"\b(MQTT_[A-Z0-9_]+)\b", block))
invalid = found_vars - valid_mqtt_vars
assert not invalid, f"Invalid or unrecognized MQTT variables in PRIVATE_SERVER.md code blocks: {invalid}"
# --------------------------------------------------------------------------
# D-12 — (G-D2) PRIVATE_SERVER.md must not contain invalid MAM_MQTT_* in
# active configuration code blocks.
# --------------------------------------------------------------------------
def test_d12_private_server_no_mam_mqtt_in_code_fences():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
for i, block in enumerate(code_blocks):
assert "MAM_MQTT_" not in block, (
f"Code block #{i+1} in PRIVATE_SERVER.md contains deprecated/invalid 'MAM_MQTT_*' prefix"
)
# --------------------------------------------------------------------------
# D-13 — (G-D3) nats-server launch instructions in PRIVATE_SERVER.md must
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
# --------------------------------------------------------------------------
def test_d13_private_server_nats_config_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
assert "-m 1883" not in content, (
"PRIVATE_SERVER.md incorrectly contains '-m 1883' (which sets HTTP port, not MQTT port)"
)
assert "mqtt {" in content, "PRIVATE_SERVER.md must document 'mqtt {' configuration block for nats-server"
assert "-c " in content or "-c /" in content, "PRIVATE_SERVER.md must document '-c <config>' for nats-server"
# --------------------------------------------------------------------------
# D-14 — (G-D4) Verification commands in PRIVATE_SERVER.md must use valid
# CLI flags matching the actual scripts' argparse parsers.
# --------------------------------------------------------------------------
def test_d14_private_server_cli_args_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
# Extract all command invocations for registry.py and publish_event.py
code_blocks = "\n".join(re.findall(r"```(?:bash|)(.*?)```", content, re.DOTALL))
# Assert --job-id is not used with register command (register takes --prompt, not --job-id)
# and registry.py commands have correct flag formatting
assert "register --job-id" not in code_blocks, (
"PRIVATE_SERVER.md contains invalid 'register --job-id' (registry.py register auto-assigns ID and takes no --job-id flag)"
)
assert "status --job " in code_blocks, "PRIVATE_SERVER.md must include cleanup step with status --job"
# --------------------------------------------------------------------------
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
# and heredocs writing it must be unquoted (<<EOF).
# --------------------------------------------------------------------------
def test_d15_private_server_store_dir_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
for i, block in enumerate(code_blocks):
for match in re.finditer(r'store_dir:\s*["\']?([^"\'\n]+)["\']?', block):
val = match.group(1).strip()
assert val.startswith("/") or val.startswith("$HOME"), (
f"Block #{i+1} store_dir '{val}' must start with '/' or '$HOME' (no literal ~)"
)
if "store_dir:" in block and "cat <<" in block:
assert "<<'EOF'" not in block, (
f"Block #{i+1} writes store_dir with quoted heredoc <<'EOF', which prevents $HOME expansion"
)
# --------------------------------------------------------------------------
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
# --------------------------------------------------------------------------
def test_d16_private_server_nats_image_alpine_pinned():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
for i, block in enumerate(code_blocks):
nats_refs = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', block)
for tag in nats_refs:
assert tag != "latest", f"Block #{i+1} contains unpinned 'nats:latest'"
assert "alpine" in tag, f"Block #{i+1} nats image '{tag}' must use alpine variant"
# --------------------------------------------------------------------------
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
# --------------------------------------------------------------------------
def test_d17_private_server_monitoring_port_localhost_bound():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
for i, block in enumerate(code_blocks):
for match in re.finditer(r'["\']?([0-9a-zA-Z._$:-]*8222:8222)["\']?', block):
mapping = match.group(1).strip()
assert "127.0.0.1:8222:8222" in mapping, (
f"Block #{i+1} port 8222 must be bound to 127.0.0.1, got '{mapping}'"
)
# --------------------------------------------------------------------------
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
# --------------------------------------------------------------------------
def test_d18_private_server_tls_examples_use_domain_names():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
for i, block in enumerate(code_blocks):
if "MQTT_TLS=1" in block or "port: 8883" in block:
for match in re.finditer(r'MQTT_BROKER=["\']?([0-9.]+)', block):
ip = match.group(1)
assert False, f"Block #{i+1} uses IP literal '{ip}' with TLS (must use DNS domain name for SAN verification)"
# --------------------------------------------------------------------------
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
# --------------------------------------------------------------------------
def test_d19_private_server_subject_literals_match_default_topic_root():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common
expected_prefix = mqtt_common.DEFAULT_TOPIC_ROOT.replace("/", ".")
matches = re.findall(r'["\'](python\.mqtt\.jobs\.[>*\w.]+)["\']', content)
assert matches, "Expected subject literals matching DEFAULT_TOPIC_ROOT in PRIVATE_SERVER.md"
for sub in matches:
assert sub.startswith(expected_prefix), f"Subject '{sub}' does not start with expected prefix '{expected_prefix}'"
# --------------------------------------------------------------------------
# D-20 — (G-R1) run_loop.sh exports MAM_ENV_FILE
# --------------------------------------------------------------------------
def test_d20_run_loop_exports_mam_env_file():
run_loop_path = os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-loop", "scripts", "run_loop.sh")
with open(run_loop_path, "r", encoding="utf-8") as f:
content = f.read()
assert 'export MAM_ENV_FILE=' in content, "run_loop.sh must export MAM_ENV_FILE"
# --------------------------------------------------------------------------
# D-21 — (G-R2) MQTT_KEEPALIVE documented and no un-commented retry vars
# --------------------------------------------------------------------------
def test_d21_env_template_mqtt_var_coverage():
template_path = os.path.join(REPO_ROOT, ".mam.env.example")
with open(template_path, "r", encoding="utf-8") as f:
template = f.read()
assert "MQTT_KEEPALIVE" in template, ".mam.env.example must document MQTT_KEEPALIVE"
for line in template.splitlines():
line = line.strip()
if not line.startswith("#"):
assert "MQTT_RETRY_INTERVAL" not in line
assert "MQTT_MAX_RETRIES" not in line
# ==============================================================================
# Track 1R / M2b — docker/ Canonical Deployment Assets Guards (D-22 ~ D-30)
# ==============================================================================
DOCKER_DIR = os.path.join(REPO_ROOT, "docker")
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
SECRET_VARS = {"MAM_BROKER_PASS", "MAM_OBSERVER_PASS",
"HOME_BROKER_PASS", "SYS_BROKER_PASS"}
def _load_compose():
"""compose 파일을 dict 로. 파일이 비었거나 nats 서비스가 없으면 즉시 실패."""
import yaml
with open(COMPOSE_PATH, encoding="utf-8") as f:
doc = yaml.safe_load(f)
assert isinstance(doc, dict) and doc.get("services"), (
"docker/docker-compose.yaml is empty or has no services: — the docker/ "
"assets were never populated")
svc = doc["services"].get("nats")
assert svc, "compose file defines no 'nats' service"
return doc, svc
def _active_conf_lines():
"""nats.conf 에서 주석을 제외한 '활성' 라인만. 주석 템플릿을 오탐하지 않기 위함."""
with open(NATS_CONF_PATH, encoding="utf-8") as f:
lines = [ln.split("#", 1)[0].rstrip() for ln in f]
return [ln for ln in lines if ln.strip()]
# --------------------------------------------------------------------------
# D-22 — docker/ assets exist and are populated
# --------------------------------------------------------------------------
def test_d22_docker_assets_exist_and_are_populated():
for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]:
assert os.path.exists(p), f"Required asset {p} does not exist"
assert os.path.getsize(p) > 0, f"Required asset {p} is empty (0 bytes)"
_load_compose()
# --------------------------------------------------------------------------
# D-23 — compose image matches doc and is alpine
# --------------------------------------------------------------------------
def test_d23_compose_image_matches_doc_and_is_alpine():
_, svc = _load_compose()
compose_img = svc.get("image", "")
assert compose_img.startswith("nats:"), f"Expected nats image in compose, got {compose_img}"
compose_tag = compose_img.split(":", 1)[1]
assert compose_tag != "latest", "Compose image tag must not be 'latest'"
assert "alpine" in compose_tag, f"Compose image tag '{compose_tag}' must use alpine variant"
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
with open(doc_path, "r", encoding="utf-8") as f:
doc_content = f.read()
doc_tags = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', doc_content)
assert doc_tags, "No nats image tags found in PRIVATE_SERVER.md"
assert compose_tag in doc_tags, f"Compose image tag '{compose_tag}' not found in PRIVATE_SERVER.md"
# --------------------------------------------------------------------------
# D-24 — compose port exposure contract
# --------------------------------------------------------------------------
def test_d24_compose_port_exposure_contract():
_, svc = _load_compose()
ports = svc.get("ports", [])
assert ports, "No ports exposed in nats service"
ctr_ports = set()
for p in ports:
parts = str(p).rsplit(":", 2)
assert len(parts) == 3, f"Port mapping '{p}' must specify 3 parts (host:hostport:ctrport), bare mappings forbidden"
ctr_ports.add(int(parts[2]))
assert ctr_ports == {1883, 4222, 8222, 8080}, f"Expected container ports {1883, 4222, 8222, 8080}, got {ctr_ports}"
p8222 = [p for p in ports if ":8222:8222" in str(p)]
assert len(p8222) == 1, "Port 8222 mapping must exist"
assert p8222[0] == "127.0.0.1:8222:8222", f"Port 8222 must be hardcoded to 127.0.0.1:8222:8222, got '{p8222[0]}'"
# --------------------------------------------------------------------------
# D-25 — secrets are fail closed
# --------------------------------------------------------------------------
def test_d25_secrets_are_fail_closed():
_, svc = _load_compose()
env = svc.get("environment", {})
if isinstance(env, list):
env_dict = {}
for item in env:
k, v = item.split("=", 1)
env_dict[k.strip()] = v.strip()
env = env_dict
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
conf_text = f.read()
active_text = "\n".join(_active_conf_lines())
nats_vars = set(re.findall(r'\$([A-Z0-9_]+)', active_text))
assert nats_vars, "No $VAR references found in nats.conf"
assert nats_vars.issubset(set(env.keys())), f"nats.conf variables {nats_vars} not in compose environment {set(env.keys())}"
for k, v in env.items():
assert "${" in v and ":?" in v, f"Environment variable '{k}={v}' must use '${{VAR:?error}}' fail-closed syntax"
with open(ENV_EXAMPLE_PATH, "r", encoding="utf-8") as f:
example_text = f.read()
for svar in SECRET_VARS:
assert svar in example_text, f"Secret variable '{svar}' missing from .env.example"
for line in example_text.splitlines():
line = line.strip()
for svar in SECRET_VARS:
if line.startswith(f"{svar}="):
val = line.split("=", 1)[1].strip()
assert val == "", f"Secret variable '{svar}' in .env.example must have empty value, got '{val}'"
for match in re.finditer(r'password:\s*([^\s,}]+)', conf_text):
pw_val = match.group(1).strip()
assert pw_val.startswith("$"), f"nats.conf contains non-variable password value '{pw_val}'"
# --------------------------------------------------------------------------
# D-26 — nats_conf jetstream and mqtt contract
# --------------------------------------------------------------------------
def test_d26_nats_conf_jetstream_and_mqtt_contract():
_, svc = _load_compose()
volumes = svc.get("volumes", [])
target_data_vol = None
for vol in volumes:
parts = str(vol).split(":")
if len(parts) >= 2 and parts[1] == "/data":
target_data_vol = parts[1]
assert target_data_vol == "/data", "Compose volume must mount to /data"
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
conf_text = f.read()
store_dir_match = re.search(r'store_dir:\s*["\']?([^"\'\s,}]+)["\']?', conf_text)
assert store_dir_match, "store_dir not found in nats.conf"
assert store_dir_match.group(1).strip() == "/data", f"store_dir must be '/data', got '{store_dir_match.group(1)}'"
for key in ["max_file", "max_mem"]:
m = re.search(rf'{key}:\s*([^\s,}}]+)', conf_text)
assert m, f"{key} not found in nats.conf"
assert re.match(r'^\d+[KMGT]$', m.group(1).strip()), f"{key} '{m.group(1)}' must match regex '^\\d+[KMGT]$'"
assert "mqtt {" in conf_text, "mqtt { block missing in nats.conf"
assert "port: 1883" in conf_text or "port:1883" in conf_text, "mqtt port 1883 missing in nats.conf"
assert "MAM:" in conf_text and "jetstream: enabled" in conf_text, "Account MAM must have 'jetstream: enabled'"
# --------------------------------------------------------------------------
# D-27 — observer permissions match topic root
# --------------------------------------------------------------------------
def test_d27_observer_permissions_match_topic_root():
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common
expected_prefix = mqtt_common.DEFAULT_TOPIC_ROOT.replace("/", ".")
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
conf_text = f.read()
subjects = re.findall(r'["\']([a-zA-Z0-9_.-]+(?:\.>|\.\*)?)["\']', conf_text)
job_subjects = [s for s in subjects if "jobs" in s]
assert job_subjects, "No job subjects found in nats.conf"
for s in job_subjects:
assert s.startswith(expected_prefix), f"Subject '{s}' in nats.conf does not match prefix '{expected_prefix}'"
assert "mam_observer" in conf_text, "mam_observer user missing in nats.conf"
assert "deny:" in conf_text, "Publish deny permission missing for mam_observer in nats.conf"
# --------------------------------------------------------------------------
# D-28 — healthcheck contract and image coupling
# --------------------------------------------------------------------------
def test_d28_healthcheck_contract_and_image_coupling():
_, svc = _load_compose()
hc = svc.get("healthcheck")
assert hc, "healthcheck block missing from nats service"
test_cmd = hc.get("test", [])
test_str = " ".join(test_cmd) if isinstance(test_cmd, list) else str(test_cmd)
assert "wget" in test_str, f"Healthcheck test '{test_str}' must use wget"
assert "/healthz" in test_str, f"Healthcheck test '{test_str}' must target /healthz"
assert "127.0.0.1:8222" in test_str, f"Healthcheck test '{test_str}' must use 127.0.0.1:8222"
img = svc.get("image", "")
assert "alpine" in img, f"Image '{img}' must be alpine variant because healthcheck uses alpine wget"
# --------------------------------------------------------------------------
# D-29 — env secrets never tracked
# --------------------------------------------------------------------------
def test_d29_env_secrets_never_tracked():
res_env = subprocess.run(["git", "check-ignore", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT)
assert res_env.returncode == 0, "docker/.env must be ignored by .gitignore"
res_ex = subprocess.run(["git", "check-ignore", "docker/.env.example"], capture_output=True, text=True, cwd=REPO_ROOT)
assert res_ex.returncode != 0, "docker/.env.example must NOT be ignored by .gitignore"
res_ls = subprocess.run(["git", "ls-files", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT)
assert res_ls.stdout.strip() == "", "docker/.env must never be tracked in git"
# --------------------------------------------------------------------------
# D-30 — websocket origin policy is startable
# --------------------------------------------------------------------------
def test_d30_websocket_origin_policy_is_startable():
active_lines = _active_conf_lines()
active_text = "\n".join(active_lines)
assert "websocket {" in active_text, "websocket { block must exist in active nats.conf"
assert "no_tls: true" in active_text or "no_tls:true" in active_text, (
"Active nats.conf must specify 'no_tls: true' in websocket block (omission causes startup TLS error)"
)
for line in active_lines:
if "allowed_origins" in line:
assert '"*"' not in line and "'*'" not in line, (
f"allowed_origins must not contain '*' (NATS requires absolute URLs with http/https schemes, got '{line}')"
)
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
full_conf = f.read()
assert "/mqtt" in full_conf, "nats.conf must document /mqtt WebSocket MQTT path (N-7)"