feat(deploy): create production Docker assets in docker/ (compose, nats.conf, env template, README) with D-22~D-30 freshness guards
This commit is contained in:
@@ -383,7 +383,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
|
||||
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 or tag.startswith("2."), f"Block #{i+1} nats image '{tag}' must use alpine variant"
|
||||
assert "alpine" in tag, f"Block #{i+1} nats image '{tag}' must use alpine variant"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -461,3 +461,235 @@ def test_d21_env_template_mqtt_var_coverage():
|
||||
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)"
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user