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
This commit is contained in:
fisher
2026-08-23 08:50:45 +00:00
parent b48e112ed7
commit 62c62baf05
8 changed files with 119 additions and 62 deletions
+11 -1
View File
@@ -107,6 +107,7 @@ eval "$resolved"
mkdir -p "$STATE_DIR" mkdir -p "$STATE_DIR"
readonly LOG_FILE="${STATE_DIR}/${CD_NAME}.log" 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 exec > >(stdbuf -oL tee -a "$LOG_FILE") 2>&1
log "==============================================================" log "=============================================================="
@@ -129,6 +130,15 @@ notify() {
fail) icon="❌" ;; fail) icon="❌" ;;
*) icon="️" ;; *) icon="️" ;;
esac 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//\//-}" topic="${REPO//\//-}"
curl -fsS --max-time 10 \ curl -fsS --max-time 10 \
-H "Title: ${CD_NAME} deploy" \ -H "Title: ${CD_NAME} deploy" \
@@ -299,7 +309,7 @@ fi
# ---------------------------------------------------------------- rollback --- # ---------------------------------------------------------------- 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" 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" die "health check failed and rollback is disabled for this target"
fi fi
+33 -7
View File
@@ -16,6 +16,7 @@ would accept a deploy request from anyone who can reach the port.
Usage: Usage:
cd-render-hooks [--host HOST] [--output PATH] [--redact] cd-render-hooks [--host HOST] [--output PATH] [--redact]
cd-render-hooks --list-urls [--output PATH] [--base-url URL]
""" """
from __future__ import annotations from __future__ import annotations
@@ -52,7 +53,8 @@ def _load_cd_target():
_cd_target = _load_cd_target() _cd_target = _load_cd_target()
load = _cd_target.load load = _cd_target.load
pick_host = _cd_target.pick_host 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( DEFAULT_CONFIG_DIR = Path(
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") 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: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--host") parser.add_argument("--host")
@@ -177,19 +195,27 @@ def main() -> int:
action="store_true", action="store_true",
help="print to stdout with secrets masked, and write nothing", 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() args = parser.parse_args()
if args.list_urls:
return list_urls(args.output, args.base_url.rstrip("/"))
manifest = load() manifest = load()
host = pick_host(manifest, args.host) host = pick_host(manifest, args.host)
names = {host.lower(), host.split(".", 1)[0].lower()}
# repo -> branches, preserving manifest order for a stable diff. # repo -> branches, preserving manifest order for a stable diff.
by_repo: dict[str, list[str]] = {} by_repo: dict[str, list[str]] = {}
for target in manifest.get("targets", []): for target in enabled_targets(manifest, names_for(host)):
if not target.get("enabled", True):
continue
if not host_matches(target["host"], names):
continue
by_repo.setdefault(target["repo"], []).append(target["branch"]) by_repo.setdefault(target["repo"], []).append(target["branch"])
if not by_repo: if not by_repo:
+13 -11
View File
@@ -17,7 +17,10 @@ readonly UNIT_NAME="cd-webhook.service"
bold() { printf '\n\033[1m%s\033[0m\n' "$*"; } bold() { printf '\n\033[1m%s\033[0m\n' "$*"; }
bold "Host" 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" printf ' %s, receiver on %s:%s\n' "$CD_HOST" "$CD_BIND" "$CD_PORT"
else else
printf ' %s is not configured in targets.json\n' "$(hostname)" printf ' %s is not configured in targets.json\n' "$(hostname)"
@@ -41,12 +44,8 @@ fi
if [[ -f "${CONFIG_DIR}/hooks.json" ]]; then if [[ -f "${CONFIG_DIR}/hooks.json" ]]; then
bold "Hook endpoints" bold "Hook endpoints"
python3 - "${CONFIG_DIR}/hooks.json" <<'PY' "${REPO_ROOT}/bin/cd-render-hooks" --list-urls \
import json, sys --output "${CONFIG_DIR}/hooks.json" | sed 's/^/ /'
with open(sys.argv[1]) as fh:
for hook in json.load(fh):
print(f" /hooks/{hook['id']}")
PY
fi fi
bold "Targets on this host" bold "Targets on this host"
@@ -55,10 +54,13 @@ if [[ ${#targets[@]} -eq 0 ]]; then
printf ' none\n' printf ' none\n'
else else
for target in "${targets[@]}"; do for target in "${targets[@]}"; do
log="${STATE_DIR}/${target}.log" # cd-deploy writes this on every terminal outcome. Reading a record
if [[ -f "$log" ]]; then # beats grepping the log for phrases the log is free to reword.
last="$(grep -E '=== deploy succeeded|ERROR|WARN health' "$log" | tail -n1 || true)" status_file="${STATE_DIR}/${target}.status"
printf ' %-22s %s\n' "$target" "${last:-no completed deploy recorded}" 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 else
printf ' %-22s %s\n' "$target" "never deployed from this host" printf ' %-22s %s\n' "$target" "never deployed from this host"
fi fi
+49 -30
View File
@@ -15,9 +15,6 @@ Subcommands:
list [--host HOST] list [--host HOST]
Print the name of every enabled target owned by HOST, one per line. 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] host-config [--host HOST]
Print shell-quoted BIND/PORT for 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 from __future__ import annotations
import argparse import argparse
import functools
import json import json
import os import os
import shlex import shlex
import socket import socket
import sys import sys
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent 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 NO_MATCH = 3
def local_hostnames() -> set[str]: @functools.cache
def local_hostnames() -> frozenset[str]:
"""Every name this machine plausibly answers to. """Every name this machine plausibly answers to.
Hosts in this fleet are referred to by FQDN in the manifest but report a 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 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 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. 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()} names = {socket.gethostname()}
try: try:
@@ -63,14 +66,39 @@ def local_hostnames() -> set[str]:
for name in list(names): for name in list(names):
if "." in name: if "." in name:
names.add(name.split(".", 1)[0]) 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() target_host = target_host.lower()
return target_host in names or target_host.split(".", 1)[0] in names 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: def load() -> dict:
try: try:
return json.loads(MANIFEST.read_text()) return json.loads(MANIFEST.read_text())
@@ -93,23 +121,29 @@ def pick_host(manifest: dict, explicit: str | None) -> str:
return socket.gethostname() 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: def emit(pairs: dict[str, object]) -> None:
for key, value in pairs.items(): 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: def cmd_resolve(args, manifest: dict) -> int:
host = pick_host(manifest, args.host) host = pick_host(manifest, args.host)
names = local_hostnames() if not args.host else {args.host.lower()}
defaults = manifest.get("defaults", {}) defaults = manifest.get("defaults", {})
for target in manifest.get("targets", []): for target in enabled_targets(manifest, names_for(args.host)):
if not target.get("enabled", True):
continue
if target["repo"] != args.repo or target["branch"] != args.branch: if target["repo"] != args.repo or target["branch"] != args.branch:
continue continue
if not host_matches(target["host"], names):
continue
merged = {**defaults, **target} merged = {**defaults, **target}
merged["resolved_host"] = host merged["resolved_host"] = host
@@ -120,25 +154,11 @@ def cmd_resolve(args, manifest: dict) -> int:
def cmd_list(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 enabled_targets(manifest, names_for(args.host)):
for target in manifest.get("targets", []):
if target.get("enabled", True) and host_matches(target["host"], names):
print(target["name"]) print(target["name"])
return 0 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))
return 0
def cmd_host_config(args, manifest: dict) -> int: def cmd_host_config(args, manifest: dict) -> int:
host = pick_host(manifest, args.host) host = pick_host(manifest, args.host)
config = manifest.get("hosts", {}).get(host) config = manifest.get("hosts", {}).get(host)
@@ -166,8 +186,7 @@ def main() -> int:
p.add_argument("--host") p.add_argument("--host")
p.set_defaults(func=cmd_resolve) p.set_defaults(func=cmd_resolve)
for name, func in (("list", cmd_list), ("repos", cmd_repos), for name, func in (("list", cmd_list), ("host-config", cmd_host_config)):
("host-config", cmd_host_config)):
p = sub.add_parser(name) p = sub.add_parser(name)
p.add_argument("--host") p.add_argument("--host")
p.set_defaults(func=func) p.set_defaults(func=func)
+2 -2
View File
@@ -21,7 +21,7 @@ deploy ended.
| This repository | `~/S/my-cd-webhook` | | This repository | `~/S/my-cd-webhook` |
| Secrets (0600) | `~/.config/cd-webhook/secrets.env` | | Secrets (0600) | `~/.config/cd-webhook/secrets.env` |
| Rendered hooks (0600, generated) | `~/.config/cd-webhook/hooks.json` | | Rendered hooks (0600, generated) | `~/.config/cd-webhook/hooks.json` |
| Deploy logs and locks | `~/.local/state/cd-webhook/<target>.log` | | Deploy logs, locks, last-deploy records | `~/.local/state/cd-webhook/<target>.{log,lock,status}` |
| systemd unit | `~/.config/systemd/user/cd-webhook.service` | | systemd unit | `~/.config/systemd/user/cd-webhook.service` |
```bash ```bash
@@ -74,7 +74,7 @@ missed deploy, not a wrong one.
## Adding a host ## Adding a host
1. Add the host to `"hosts"` in `targets.json` with its WireGuard address and 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. 2. Add its targets.
3. Commit and push this repository. 3. Commit and push this repository.
4. On the host: clone, `./install/install.sh`, fill in `secrets.env`, re-run. 4. On the host: clone, `./install/install.sh`, fill in `secrets.env`, re-run.
+7
View File
@@ -7,6 +7,13 @@ Short notes on things that were not obvious. Prune stale ones.
`importlib.machinery.SourceFileLoader`. Importing `importlib.util` alone is `importlib.machinery.SourceFileLoader`. Importing `importlib.util` alone is
not enough — `importlib.machinery` needs its own import. 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.** - **`docker/metadata-action`'s `type=sha,format=short` produces 7 characters.**
That is why `image_tag_template` uses `{short7}`. domaindingo's `build.yml` That is why `image_tag_template` uses `{short7}`. domaindingo's `build.yml`
prefixes it with the branch, giving `test-sha-abc1234` / `prod-sha-abc1234`. prefixes it with the branch, giving `test-sha-abc1234` / `prod-sha-abc1234`.
+3 -9
View File
@@ -119,15 +119,9 @@ printf ' %-28s %s\n' "Trigger On" "Push Events"
printf ' %-28s %s\n' "Secret" "the matching value from ${SECRETS_FILE}" printf ' %-28s %s\n' "Secret" "the matching value from ${SECRETS_FILE}"
echo echo
"${REPO_ROOT}/bin/cd-render-hooks" --redact >/dev/null 2>&1 || true "${REPO_ROOT}/bin/cd-render-hooks" --list-urls \
python3 - "$HOOKS_FILE" "$CD_BIND" "$CD_PORT" <<'PY' --output "$HOOKS_FILE" --base-url "http://${CD_BIND}:${CD_PORT}" \
import json, sys | sed 's/^/ /'
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
echo echo
echo " Logs: journalctl --user -u ${UNIT_NAME} -f" echo " Logs: journalctl --user -u ${UNIT_NAME} -f"
-1
View File
@@ -12,7 +12,6 @@
], ],
"defaults": { "defaults": {
"registry": "gitea.fisher.hu",
"ntfy_base_url": "https://ntfy.fisher.hu", "ntfy_base_url": "https://ntfy.fisher.hu",
"image_wait_seconds": 900, "image_wait_seconds": 900,