From 62c62baf05dfd3eac9919ba6d0252fe7b99b69fc Mon Sep 17 00:00:00 2001 From: fisher Date: Sun, 23 Aug 2026 08:50:45 +0000 Subject: [PATCH] Share the target filter, fix cd-status branch, record deploy status - enabled_targets/names_for: one implementation of 'a host acts only on its own targets'; --host s5 and --host s5.fisher.hu now select the same targets - shell_value: JSON booleans reach shell consumers as true/false - cd-status: capture before eval, so the unconfigured-host branch is reachable - cd-deploy writes a .status record; cd-status reads it instead of grepping the log's prose - cd-render-hooks --list-urls replaces two inline JSON readers --- bin/cd-deploy | 12 ++++++- bin/cd-render-hooks | 40 ++++++++++++++++++---- bin/cd-status | 24 ++++++++------ bin/cd-target | 81 ++++++++++++++++++++++++++++----------------- docs/OPERATIONS.md | 4 +-- docs/hints.md | 7 ++++ install/install.sh | 12 ++----- targets.json | 1 - 8 files changed, 119 insertions(+), 62 deletions(-) diff --git a/bin/cd-deploy b/bin/cd-deploy index dcde5b9..1e9ae50 100755 --- a/bin/cd-deploy +++ b/bin/cd-deploy @@ -107,6 +107,7 @@ eval "$resolved" mkdir -p "$STATE_DIR" readonly LOG_FILE="${STATE_DIR}/${CD_NAME}.log" +readonly STATUS_FILE="${STATE_DIR}/${CD_NAME}.status" exec > >(stdbuf -oL tee -a "$LOG_FILE") 2>&1 log "==============================================================" @@ -129,6 +130,15 @@ notify() { fail) icon="❌" ;; *) icon="ℹ️" ;; esac + + # Every terminal outcome notifies, so this is also where the machine-readable + # record of that outcome belongs. cd-status reads this file rather than + # grepping the log's prose, which quietly reported "no completed deploy" + # for a healthy target whenever a log line was reworded. + printf '%s\t%s\t%s\t%s\n' \ + "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$status" "${SHA:0:12}" "$message" \ + > "$STATUS_FILE" + topic="${REPO//\//-}" curl -fsS --max-time 10 \ -H "Title: ${CD_NAME} deploy" \ @@ -299,7 +309,7 @@ fi # ---------------------------------------------------------------- rollback --- -if [[ "$CD_ROLLBACK_ON_FAILURE" != "True" && "$CD_ROLLBACK_ON_FAILURE" != "true" ]]; then +if [[ "$CD_ROLLBACK_ON_FAILURE" != "true" ]]; then notify fail "health check failed for ${SHA:0:12}; rollback disabled, stack left on the new image" die "health check failed and rollback is disabled for this target" fi diff --git a/bin/cd-render-hooks b/bin/cd-render-hooks index 969564b..dc37f46 100755 --- a/bin/cd-render-hooks +++ b/bin/cd-render-hooks @@ -16,6 +16,7 @@ would accept a deploy request from anyone who can reach the port. Usage: cd-render-hooks [--host HOST] [--output PATH] [--redact] + cd-render-hooks --list-urls [--output PATH] [--base-url URL] """ from __future__ import annotations @@ -52,7 +53,8 @@ def _load_cd_target(): _cd_target = _load_cd_target() load = _cd_target.load pick_host = _cd_target.pick_host -host_matches = _cd_target.host_matches +names_for = _cd_target.names_for +enabled_targets = _cd_target.enabled_targets DEFAULT_CONFIG_DIR = Path( os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") @@ -167,6 +169,22 @@ def build_hook(repo: str, branches: list[str], secret: str) -> dict: } +def list_urls(path: Path, base_url: str) -> int: + """Print one endpoint URL per hook in an already-rendered hooks file. + + Reads the rendered file rather than the manifest so that what is printed is + what the daemon is actually serving. Both the installer and cd-status use + this instead of reaching into the file's schema themselves. + """ + try: + hooks = json.loads(path.read_text()) + except FileNotFoundError: + sys.exit(f"cd-render-hooks: no rendered hooks file at {path}") + for hook in hooks: + print(f"{base_url}/hooks/{hook['id']}") + return 0 + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--host") @@ -177,19 +195,27 @@ def main() -> int: action="store_true", help="print to stdout with secrets masked, and write nothing", ) + parser.add_argument( + "--list-urls", + action="store_true", + help="print the endpoint URL of each hook in an existing hooks file", + ) + parser.add_argument( + "--base-url", + default="", + help="prefix for --list-urls, e.g. http://10.255.255.1:21600", + ) args = parser.parse_args() + if args.list_urls: + return list_urls(args.output, args.base_url.rstrip("/")) + manifest = load() host = pick_host(manifest, args.host) - names = {host.lower(), host.split(".", 1)[0].lower()} # repo -> branches, preserving manifest order for a stable diff. by_repo: dict[str, list[str]] = {} - for target in manifest.get("targets", []): - if not target.get("enabled", True): - continue - if not host_matches(target["host"], names): - continue + for target in enabled_targets(manifest, names_for(host)): by_repo.setdefault(target["repo"], []).append(target["branch"]) if not by_repo: diff --git a/bin/cd-status b/bin/cd-status index 4ea600f..97f3a6f 100755 --- a/bin/cd-status +++ b/bin/cd-status @@ -17,7 +17,10 @@ readonly UNIT_NAME="cd-webhook.service" bold() { printf '\n\033[1m%s\033[0m\n' "$*"; } bold "Host" -if eval "$("${REPO_ROOT}/bin/cd-target" host-config 2>/dev/null)"; then +# Capture first: `eval ""` succeeds, so evaluating the failed call inline made +# the branch below unreachable and tripped `set -u` instead. +if host_config="$("${REPO_ROOT}/bin/cd-target" host-config 2>/dev/null)"; then + eval "$host_config" printf ' %s, receiver on %s:%s\n' "$CD_HOST" "$CD_BIND" "$CD_PORT" else printf ' %s is not configured in targets.json\n' "$(hostname)" @@ -41,12 +44,8 @@ fi if [[ -f "${CONFIG_DIR}/hooks.json" ]]; then bold "Hook endpoints" - python3 - "${CONFIG_DIR}/hooks.json" <<'PY' -import json, sys -with open(sys.argv[1]) as fh: - for hook in json.load(fh): - print(f" /hooks/{hook['id']}") -PY + "${REPO_ROOT}/bin/cd-render-hooks" --list-urls \ + --output "${CONFIG_DIR}/hooks.json" | sed 's/^/ /' fi bold "Targets on this host" @@ -55,10 +54,13 @@ if [[ ${#targets[@]} -eq 0 ]]; then printf ' none\n' else for target in "${targets[@]}"; do - log="${STATE_DIR}/${target}.log" - if [[ -f "$log" ]]; then - last="$(grep -E '=== deploy succeeded|ERROR|WARN health' "$log" | tail -n1 || true)" - printf ' %-22s %s\n' "$target" "${last:-no completed deploy recorded}" + # cd-deploy writes this on every terminal outcome. Reading a record + # beats grepping the log for phrases the log is free to reword. + status_file="${STATE_DIR}/${target}.status" + if [[ -f "$status_file" ]]; then + IFS=$'\t' read -r when result sha message < "$status_file" || true + printf ' %-22s %s %-4s %s %s\n' \ + "$target" "$when" "$result" "$sha" "$message" else printf ' %-22s %s\n' "$target" "never deployed from this host" fi diff --git a/bin/cd-target b/bin/cd-target index 07947a6..687b247 100755 --- a/bin/cd-target +++ b/bin/cd-target @@ -15,9 +15,6 @@ Subcommands: list [--host HOST] Print the name of every enabled target owned by HOST, one per line. - repos [--host HOST] - Print the distinct repositories HOST deploys, one per line. - host-config [--host HOST] Print shell-quoted BIND/PORT for HOST. @@ -30,11 +27,13 @@ HOST defaults to the local machine in every subcommand. from __future__ import annotations import argparse +import functools import json import os import shlex import socket import sys +from collections.abc import Iterator from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -47,13 +46,17 @@ MANIFEST = Path(os.environ.get("CD_TARGETS_FILE", REPO_ROOT / "targets.json")) NO_MATCH = 3 -def local_hostnames() -> set[str]: +@functools.cache +def local_hostnames() -> frozenset[str]: """Every name this machine plausibly answers to. Hosts in this fleet are referred to by FQDN in the manifest but report a short name from `hostname`, so both forms -- and the leading label of any FQDN -- have to count as a match. Getting this wrong is how the old per-project scripts ended up with a dead `hostname != s2` guard. + + Cached: `getfqdn` can block on a slow resolver, and this sits on the path of + every webhook delivery. The answer cannot change within one process. """ names = {socket.gethostname()} try: @@ -63,14 +66,39 @@ def local_hostnames() -> set[str]: for name in list(names): if "." in name: names.add(name.split(".", 1)[0]) - return {n.lower() for n in names if n} + return frozenset(n.lower() for n in names if n) -def host_matches(target_host: str, names: set[str]) -> bool: +def host_matches(target_host: str, names: frozenset[str]) -> bool: target_host = target_host.lower() return target_host in names or target_host.split(".", 1)[0] in names +def names_for(explicit: str | None) -> frozenset[str]: + """The names a target's `host` field is matched against. + + Defaults to this machine's own names. An explicit --host is expanded to both + its FQDN and short forms, the same way `local_hostnames` expands the local + ones, so `--host s5` and `--host s5.fisher.hu` select the same targets. + """ + if not explicit: + return local_hostnames() + name = explicit.lower() + return frozenset({name, name.split(".", 1)[0]}) + + +def enabled_targets(manifest: dict, names: frozenset[str]) -> Iterator[dict]: + """Every enabled target owned by `names`, in manifest order. + + The single implementation of "a host acts only on its own targets" -- the + rule the whole design rests on. cd-render-hooks imports this rather than + repeating the filter, so a new filtering dimension is one edit, not four. + """ + for target in manifest.get("targets", []): + if target.get("enabled", True) and host_matches(target["host"], names): + yield target + + def load() -> dict: try: return json.loads(MANIFEST.read_text()) @@ -93,23 +121,29 @@ def pick_host(manifest: dict, explicit: str | None) -> str: return socket.gethostname() +def shell_value(value: object) -> str: + """Render a manifest value the way a shell consumer expects it. + + JSON booleans would otherwise arrive as Python's `True`/`False`, forcing + every consumer to test for both spellings of every boolean field. + """ + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + def emit(pairs: dict[str, object]) -> None: for key, value in pairs.items(): - print(f"{key}={shlex.quote(str(value))}") + print(f"{key}={shlex.quote(shell_value(value))}") def cmd_resolve(args, manifest: dict) -> int: host = pick_host(manifest, args.host) - names = local_hostnames() if not args.host else {args.host.lower()} defaults = manifest.get("defaults", {}) - for target in manifest.get("targets", []): - if not target.get("enabled", True): - continue + for target in enabled_targets(manifest, names_for(args.host)): if target["repo"] != args.repo or target["branch"] != args.branch: continue - if not host_matches(target["host"], names): - continue merged = {**defaults, **target} merged["resolved_host"] = host @@ -120,22 +154,8 @@ def cmd_resolve(args, manifest: dict) -> int: def cmd_list(args, manifest: dict) -> int: - names = local_hostnames() if not args.host else {args.host.lower()} - for target in manifest.get("targets", []): - if target.get("enabled", True) and host_matches(target["host"], names): - print(target["name"]) - return 0 - - -def cmd_repos(args, manifest: dict) -> int: - names = local_hostnames() if not args.host else {args.host.lower()} - seen: list[str] = [] - for target in manifest.get("targets", []): - if not target.get("enabled", True): - continue - if host_matches(target["host"], names) and target["repo"] not in seen: - seen.append(target["repo"]) - print("\n".join(seen)) + for target in enabled_targets(manifest, names_for(args.host)): + print(target["name"]) return 0 @@ -166,8 +186,7 @@ def main() -> int: p.add_argument("--host") p.set_defaults(func=cmd_resolve) - for name, func in (("list", cmd_list), ("repos", cmd_repos), - ("host-config", cmd_host_config)): + for name, func in (("list", cmd_list), ("host-config", cmd_host_config)): p = sub.add_parser(name) p.add_argument("--host") p.set_defaults(func=func) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9927241..cb327e8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -21,7 +21,7 @@ deploy ended. | This repository | `~/S/my-cd-webhook` | | Secrets (0600) | `~/.config/cd-webhook/secrets.env` | | Rendered hooks (0600, generated) | `~/.config/cd-webhook/hooks.json` | -| Deploy logs and locks | `~/.local/state/cd-webhook/.log` | +| Deploy logs, locks, last-deploy records | `~/.local/state/cd-webhook/.{log,lock,status}` | | systemd unit | `~/.config/systemd/user/cd-webhook.service` | ```bash @@ -74,7 +74,7 @@ missed deploy, not a wrong one. ## Adding a host 1. Add the host to `"hosts"` in `targets.json` with its WireGuard address and - port `21600`. + the same port the other hosts there use. 2. Add its targets. 3. Commit and push this repository. 4. On the host: clone, `./install/install.sh`, fill in `secrets.env`, re-run. diff --git a/docs/hints.md b/docs/hints.md index 16ac692..65d2851 100644 --- a/docs/hints.md +++ b/docs/hints.md @@ -7,6 +7,13 @@ Short notes on things that were not obvious. Prune stale ones. `importlib.machinery.SourceFileLoader`. Importing `importlib.util` alone is not enough — `importlib.machinery` needs its own import. +- **`if eval "$(cmd)"` tests the wrong thing.** `eval` reports the status of the + string it evaluates, and an empty string is success — so a failing `cmd` makes + the `if` take the *true* branch with nothing assigned, and `set -u` then aborts + on the first variable the branch reads. Capture first + (`if out="$(cmd)"; then eval "$out"`). This had made `cd-status`'s "host is not + configured" message unreachable. + - **`docker/metadata-action`'s `type=sha,format=short` produces 7 characters.** That is why `image_tag_template` uses `{short7}`. domaindingo's `build.yml` prefixes it with the branch, giving `test-sha-abc1234` / `prod-sha-abc1234`. diff --git a/install/install.sh b/install/install.sh index 9d1a85f..af47316 100755 --- a/install/install.sh +++ b/install/install.sh @@ -119,15 +119,9 @@ printf ' %-28s %s\n' "Trigger On" "Push Events" printf ' %-28s %s\n' "Secret" "the matching value from ${SECRETS_FILE}" echo -"${REPO_ROOT}/bin/cd-render-hooks" --redact >/dev/null 2>&1 || true -python3 - "$HOOKS_FILE" "$CD_BIND" "$CD_PORT" <<'PY' -import json, sys -hooks_file, bind, port = sys.argv[1], sys.argv[2], sys.argv[3] -with open(hooks_file) as fh: - hooks = json.load(fh) -for hook in hooks: - print(f" http://{bind}:{port}/hooks/{hook['id']}") -PY +"${REPO_ROOT}/bin/cd-render-hooks" --list-urls \ + --output "$HOOKS_FILE" --base-url "http://${CD_BIND}:${CD_PORT}" \ + | sed 's/^/ /' echo echo " Logs: journalctl --user -u ${UNIT_NAME} -f" diff --git a/targets.json b/targets.json index f634b69..0244c85 100644 --- a/targets.json +++ b/targets.json @@ -12,7 +12,6 @@ ], "defaults": { - "registry": "gitea.fisher.hu", "ntfy_base_url": "https://ntfy.fisher.hu", "image_wait_seconds": 900,