diff --git a/scripts/check_alert_channels.py b/scripts/check_alert_channels.py index 564f05b4..0d154a70 100755 --- a/scripts/check_alert_channels.py +++ b/scripts/check_alert_channels.py @@ -221,6 +221,22 @@ def main() -> int: ) args = ap.parse_args() + # Only the estate's stack runs an Alertmanager (ADR-0020 gives the lab none, + # and soc, media and sensitive follow it). `make up` runs this for every + # stack, and until 2026-09-20 a stack without one failed here on its last + # step — after compose, the reload and the health check had all succeeded — + # so `make up STACK=lab` exited 1 over a file it was right not to have. + # A stack with no Alertmanager service has no notification path to check. + compose = REPO / "stacks" / args.stack / "compose.yaml" + if not compose.is_file(): + sys.exit(f"no compose.yaml for stack {args.stack!r} at {compose}") + if not re.search(r"^ alertmanager:\s*$", compose.read_text(encoding="utf-8"), re.M): + print( + f" NOTE stack {args.stack!r} declares no alertmanager service — " + f"no notification path to check" + ) + return 0 + wanted, rendered = declared(args.stack) if not wanted: sys.exit( diff --git a/scripts/check_mounted_config.py b/scripts/check_mounted_config.py index a91ca965..4b023247 100755 --- a/scripts/check_mounted_config.py +++ b/scripts/check_mounted_config.py @@ -81,8 +81,8 @@ RESET = "\033[0m" -def single_file_mounts(stack: str) -> list[tuple[str, pathlib.Path | None, str]]: - """(service, host path, container path) for every single-FILE bind mount. +def single_file_mounts(stack: str) -> list[tuple[str, str, pathlib.Path | None, str]]: + """(service, container name, host path, container path) per single-FILE bind mount. Directory mounts are excluded and do not need to be here: a file replaced inside a mounted directory is visible to the container at once, which is why @@ -103,8 +103,9 @@ def single_file_mounts(stack: str) -> list[tuple[str, pathlib.Path | None, str]] continue source, target = parts[0], parts[1] host = (stack_dir / source).resolve() + container = spec.get("container_name") or service if host.is_file(): - found.append((service, host, target)) + found.append((service, container, host, target)) elif not host.exists() and not host.is_dir(): # A declared mount whose source does not exist is not a skip. # Docker CREATES a directory at a missing bind-mount source, so @@ -112,11 +113,11 @@ def single_file_mounts(stack: str) -> list[tuple[str, pathlib.Path | None, str]] # should be — which is the #69 failure, and reporting nothing # here would be the same silence in a new place. Returned with # host=None so the caller can say so. - found.append((service, None, target)) + found.append((service, container, None, target)) return found -def container_copy(service: str, target: str) -> bytes | None: +def container_copy(container: str, target: str) -> bytes | None: """What the container has at that path, or None if it cannot be read. `docker cp` rather than `docker exec cat`, because loki's image is @@ -124,7 +125,7 @@ def container_copy(service: str, target: str) -> bytes | None: """ with tempfile.NamedTemporaryFile() as tmp: result = subprocess.run( - ["docker", "cp", f"{service}:{target}", tmp.name], + ["docker", "cp", f"{container}:{target}", tmp.name], capture_output=True, text=True, ) if result.returncode != 0: @@ -155,8 +156,15 @@ def main() -> int: stale: list[str] = [] checked = 0 - for service, host, target in mounts: - if service not in alive: + # `docker ps` prints container names, and a stack that sets `container_name` + # (stacks/lab prefixes every service with `lab-`) names its containers + # differently from its services. Matching the service name against that + # list skipped every lab container as "not running" from the day the + # stack landed, and reported "0 mount(s) match" as OK — the #355 guard + # was never guarding the lab. The service name is still what compose + # wants for --force-recreate below. + for service, container, host, target in mounts: + if container not in alive: print(f"{YELLOW} SKIP{RESET} {service} is not running") continue if host is None: @@ -169,7 +177,7 @@ def main() -> int: f"gitignored, or missing from this checkout" ) continue - inside = container_copy(service, target) + inside = container_copy(container, target) if inside is None: print(f"{RED} FAIL{RESET} {service}: cannot read {target} from the container") stale.append(service) @@ -221,10 +229,10 @@ def main() -> int: # rebound nothing would otherwise leave this reporting success for the exact # failure it exists to catch. still: list[str] = [] - for service, host, target in mounts: + for service, container, host, target in mounts: if host is None or service not in set(stale): continue - inside = container_copy(service, target) + inside = container_copy(container, target) if inside != host.read_bytes(): still.append(service) if still: