Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scripts/check_alert_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 19 additions & 11 deletions scripts/check_mounted_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -103,28 +103,29 @@ 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
# the container silently gets a directory where its config
# 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
distroless and has no shell at all.
"""
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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading