712 lines
33 KiB
Python
712 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)
|
|
# ==============================================================================
|
|
def _resolve_docker_dir() -> str:
|
|
for candidate in [
|
|
os.path.join(REPO_ROOT, "nats-docker", "docker"),
|
|
os.path.join(REPO_ROOT, "nats-docker"),
|
|
os.path.join(REPO_ROOT, "docker"),
|
|
]:
|
|
if os.path.exists(os.path.join(candidate, "docker-compose.yaml")):
|
|
return candidate
|
|
return os.path.join(REPO_ROOT, "nats-docker", "docker")
|
|
|
|
|
|
DOCKER_DIR = _resolve_docker_dir()
|
|
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 str(p).endswith(":8222")]
|
|
assert len(p8222) == 1, "Port 8222 mapping must exist"
|
|
assert "127.0.0.1" in str(p8222[0]), f"Port 8222 must default or be fixed to loopback 127.0.0.1, 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():
|
|
is_submodule = os.path.exists(os.path.join(REPO_ROOT, ".gitmodules")) and "nats-docker" in DOCKER_DIR
|
|
target_repo = os.path.join(REPO_ROOT, "nats-docker") if is_submodule else REPO_ROOT
|
|
rel_env = os.path.relpath(os.path.join(DOCKER_DIR, ".env"), target_repo)
|
|
rel_ex = os.path.relpath(ENV_EXAMPLE_PATH, target_repo)
|
|
|
|
res_env = subprocess.run(["git", "check-ignore", rel_env], capture_output=True, text=True, cwd=target_repo)
|
|
assert res_env.returncode == 0, f"{rel_env} must be ignored by .gitignore"
|
|
|
|
res_ex = subprocess.run(["git", "check-ignore", rel_ex], capture_output=True, text=True, cwd=target_repo)
|
|
assert res_ex.returncode != 0, f"{rel_ex} must NOT be ignored by .gitignore"
|
|
|
|
res_ls = subprocess.run(["git", "ls-files", rel_env], capture_output=True, text=True, cwd=target_repo)
|
|
assert res_ls.stdout.strip() == "", f"{rel_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)"
|
|
|
|
|
|
|