Generic host/repo/branch-aware CD on adnanh/webhook
Replaces the per-project deploy scripts and self-contained webhook receivers with one manifest-driven implementation that lives outside the application repositories and can be updated independently of them. First targets: domaindingo test and prod on s5.
This commit is contained in:
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Generic, host/repo/branch-aware deployment.
|
||||
#
|
||||
# One script for every project and every environment in the fleet. It is driven
|
||||
# entirely by targets.json, so onboarding a new environment never means writing
|
||||
# another copy of this logic inside a project repository.
|
||||
#
|
||||
# Invoked by the webhook daemon as:
|
||||
# cd-deploy --repo OWNER/NAME --ref refs/heads/BRANCH --sha FULLSHA [--event push]
|
||||
#
|
||||
# Deliberate properties, each one a lesson from the scripts this replaces:
|
||||
#
|
||||
# * It never touches git. The compose stacks live in uas-ng and are refreshed
|
||||
# by that repo's own updater timer. A deploy script that runs `git reset
|
||||
# --hard` owns two jobs badly instead of one job well.
|
||||
# * It deploys the immutable <branch>-sha-<short> tag, then re-pins to the
|
||||
# resolved digest. Mutable tags like :latest and :test have already drifted
|
||||
# once in this fleet and served a broken build.
|
||||
# * It verifies the image's org.opencontainers.image.revision label equals the
|
||||
# commit that triggered the deploy, before changing anything.
|
||||
# * It never runs `docker compose down`, and most emphatically never
|
||||
# `down -v` -- `up -d` recreates exactly the services whose image changed.
|
||||
# * A target belonging to another host is a clean no-op, logged as such, and
|
||||
# never reported as a success.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
readonly CD_TARGET="${SCRIPT_DIR}/cd-target"
|
||||
|
||||
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
|
||||
readonly NO_MATCH=3
|
||||
|
||||
REPO=""
|
||||
REF=""
|
||||
SHA=""
|
||||
EVENT="push"
|
||||
DRY_RUN=0
|
||||
|
||||
# ---------------------------------------------------------------- logging ---
|
||||
|
||||
log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||
warn() { log "WARN $*" >&2; }
|
||||
die() { log "ERROR $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ arg parsing ---
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
|
||||
--ref) REF="${2:?--ref needs a value}"; shift 2 ;;
|
||||
--sha) SHA="${2:?--sha needs a value}"; shift 2 ;;
|
||||
--event) EVENT="${2:?--event needs a value}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) die "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$REPO" ]] || die "--repo is required"
|
||||
[[ -n "$REF" ]] || die "--ref is required"
|
||||
[[ -n "$SHA" ]] || die "--sha is required"
|
||||
|
||||
if [[ "$EVENT" != "push" ]]; then
|
||||
log "ignoring event '${EVENT}' for ${REPO} (only 'push' deploys)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$REF" != refs/heads/* ]]; then
|
||||
log "ignoring non-branch ref '${REF}' for ${REPO} (tags and deletes never deploy)"
|
||||
exit 0
|
||||
fi
|
||||
BRANCH="${REF#refs/heads/}"
|
||||
|
||||
if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
die "--sha must be a full 40-character hex commit, got: ${SHA}"
|
||||
fi
|
||||
|
||||
# The all-zero SHA is how git spells "this ref was deleted".
|
||||
if [[ "$SHA" == "0000000000000000000000000000000000000000" ]]; then
|
||||
log "ignoring branch deletion of ${REPO}@${BRANCH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------ target resolution ---
|
||||
|
||||
set +e
|
||||
resolved="$("$CD_TARGET" resolve --repo "$REPO" --branch "$BRANCH")"
|
||||
resolve_rc=$?
|
||||
set -e
|
||||
|
||||
if [[ $resolve_rc -eq $NO_MATCH ]]; then
|
||||
log "no target on $(hostname) for ${REPO}@${BRANCH} -- nothing to do here"
|
||||
exit 0
|
||||
elif [[ $resolve_rc -ne 0 ]]; then
|
||||
die "target lookup failed for ${REPO}@${BRANCH} (exit ${resolve_rc})"
|
||||
fi
|
||||
|
||||
eval "$resolved"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
readonly LOG_FILE="${STATE_DIR}/${CD_NAME}.log"
|
||||
exec > >(stdbuf -oL tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
log "=============================================================="
|
||||
log "target ${CD_NAME} (${CD_ENV} on ${CD_RESOLVED_HOST})"
|
||||
log "repo ${REPO}@${BRANCH}"
|
||||
log "commit ${SHA}"
|
||||
log "stack ${CD_STACK_DIR}/${CD_COMPOSE_FILE} [project ${CD_COMPOSE_PROJECT}]"
|
||||
[[ $DRY_RUN -eq 1 ]] && log "mode DRY RUN -- nothing will be changed"
|
||||
|
||||
[[ -d "$CD_STACK_DIR" ]] || die "stack directory missing: ${CD_STACK_DIR}"
|
||||
[[ -f "${CD_STACK_DIR}/${CD_COMPOSE_FILE}" ]] \
|
||||
|| die "compose file missing: ${CD_STACK_DIR}/${CD_COMPOSE_FILE}"
|
||||
|
||||
# ----------------------------------------------------------------- notify ---
|
||||
|
||||
notify() {
|
||||
local status="$1" message="$2" icon topic
|
||||
case "$status" in
|
||||
ok) icon="✅" ;;
|
||||
fail) icon="❌" ;;
|
||||
*) icon="ℹ️" ;;
|
||||
esac
|
||||
topic="${REPO//\//-}"
|
||||
curl -fsS --max-time 10 \
|
||||
-H "Title: ${CD_NAME} deploy" \
|
||||
-d "${icon} ${CD_NAME} (${CD_ENV}): ${message}" \
|
||||
"${CD_NTFY_BASE_URL}/${topic}" >/dev/null 2>&1 \
|
||||
|| warn "ntfy notification failed (deploy outcome itself is unaffected)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- lock ---
|
||||
|
||||
# Serialise per target, not per host: a test deploy must not block a prod one.
|
||||
readonly LOCK_FILE="${STATE_DIR}/${CD_NAME}.lock"
|
||||
exec 9>"$LOCK_FILE"
|
||||
log "waiting for deploy lock (${CD_LOCK_WAIT_SECONDS}s max)"
|
||||
if ! flock -w "$CD_LOCK_WAIT_SECONDS" 9; then
|
||||
notify fail "timed out waiting for the deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
|
||||
die "timed out waiting for deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
|
||||
fi
|
||||
log "lock acquired"
|
||||
|
||||
# -------------------------------------------------------------- image tag ---
|
||||
|
||||
tag="$CD_IMAGE_TAG_TEMPLATE"
|
||||
tag="${tag//\{branch\}/$BRANCH}"
|
||||
tag="${tag//\{env\}/$CD_ENV}"
|
||||
tag="${tag//\{sha\}/$SHA}"
|
||||
tag="${tag//\{short7\}/${SHA:0:7}}"
|
||||
tag="${tag//\{short12\}/${SHA:0:12}}"
|
||||
readonly CANDIDATE="${CD_IMAGE_REPO}:${tag}"
|
||||
|
||||
log "candidate ${CANDIDATE}"
|
||||
|
||||
# ------------------------------------------------------- wait for the image --
|
||||
|
||||
# The push webhook fires the moment the commit lands, which is well before CI
|
||||
# has finished building. Poll rather than fail: the running container keeps
|
||||
# serving throughout.
|
||||
wait_for_image() {
|
||||
local elapsed=0
|
||||
while true; do
|
||||
if docker pull "$CANDIDATE" >/dev/null 2>&1; then
|
||||
log "image available after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
if (( elapsed >= CD_IMAGE_WAIT_SECONDS )); then
|
||||
return 1
|
||||
fi
|
||||
log "image not published yet, retrying in ${CD_IMAGE_POLL_INTERVAL}s (${elapsed}s elapsed)"
|
||||
sleep "$CD_IMAGE_POLL_INTERVAL"
|
||||
elapsed=$(( elapsed + CD_IMAGE_POLL_INTERVAL ))
|
||||
done
|
||||
}
|
||||
|
||||
log "waiting for CI to publish the image (up to ${CD_IMAGE_WAIT_SECONDS}s)"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "dry run: skipping image wait"
|
||||
elif ! wait_for_image; then
|
||||
notify fail "image ${CANDIDATE} never appeared (waited ${CD_IMAGE_WAIT_SECONDS}s) -- did CI fail?"
|
||||
die "image ${CANDIDATE} did not appear within ${CD_IMAGE_WAIT_SECONDS}s"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------- verify and pin digest ---
|
||||
|
||||
image_digest_ref() {
|
||||
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$1" 2>/dev/null \
|
||||
| grep "^${CD_IMAGE_REPO}@" | head -n1
|
||||
}
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
DEPLOY_IMAGE="$CANDIDATE"
|
||||
log "dry run: would deploy ${DEPLOY_IMAGE}"
|
||||
else
|
||||
revision="$(docker image inspect \
|
||||
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
|
||||
"$CANDIDATE" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$revision" || "$revision" == "<no value>" ]]; then
|
||||
warn "image carries no org.opencontainers.image.revision label; cannot prove provenance"
|
||||
elif [[ "$revision" != "$SHA" ]]; then
|
||||
notify fail "image ${CANDIDATE} is built from ${revision:0:12}, not ${SHA:0:12} -- refusing to deploy"
|
||||
die "provenance mismatch: ${CANDIDATE} declares revision ${revision}, expected ${SHA}"
|
||||
else
|
||||
log "provenance revision label matches ${SHA:0:12}"
|
||||
fi
|
||||
|
||||
DEPLOY_IMAGE="$(image_digest_ref "$CANDIDATE")"
|
||||
if [[ -z "$DEPLOY_IMAGE" ]]; then
|
||||
warn "could not resolve a digest for ${CANDIDATE}; deploying by tag instead"
|
||||
DEPLOY_IMAGE="$CANDIDATE"
|
||||
else
|
||||
log "pinned ${DEPLOY_IMAGE}"
|
||||
fi
|
||||
fi
|
||||
readonly DEPLOY_IMAGE
|
||||
|
||||
# --------------------------------------------- remember what is running now ---
|
||||
|
||||
PREVIOUS_IMAGE="$(
|
||||
container_image="$(docker inspect --format '{{.Image}}' "$CD_CONTAINER" 2>/dev/null || true)"
|
||||
[[ -n "$container_image" ]] && image_digest_ref "$container_image" || true
|
||||
)"
|
||||
readonly PREVIOUS_IMAGE
|
||||
|
||||
if [[ -n "$PREVIOUS_IMAGE" ]]; then
|
||||
log "current ${PREVIOUS_IMAGE}"
|
||||
else
|
||||
log "current (nothing running -- first deploy, or container absent)"
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------- deploy ---
|
||||
|
||||
compose_up() {
|
||||
local image="$1"
|
||||
# `up -d` recreates only what actually changed. `down` is deliberately not
|
||||
# used here: it causes avoidable downtime, and `down -v` would destroy the
|
||||
# data volumes these stacks depend on.
|
||||
( cd "$CD_STACK_DIR" \
|
||||
&& env "${CD_IMAGE_ENV_VAR}=${image}" \
|
||||
"${CD_PULL_POLICY_ENV_VAR}=missing" \
|
||||
timeout "$CD_COMPOSE_TIMEOUT_SECONDS" \
|
||||
docker compose -f "$CD_COMPOSE_FILE" -p "$CD_COMPOSE_PROJECT" up -d --no-build )
|
||||
}
|
||||
|
||||
check_health() {
|
||||
local attempt=1 body
|
||||
while (( attempt <= CD_HEALTH_RETRIES )); do
|
||||
body="$(curl -fsS --max-time 5 "$CD_HEALTH_URL" 2>/dev/null || true)"
|
||||
if [[ -n "$body" ]]; then
|
||||
if python3 -c '
|
||||
import json, sys
|
||||
key, want = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
data = json.loads(sys.stdin.read())
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(0 if str(data.get(key, "")).lower() == want.lower() else 1)
|
||||
' "$CD_HEALTH_EXPECT_KEY" "$CD_HEALTH_EXPECT_VALUE" <<<"$body"; then
|
||||
log "health ok after ${attempt} attempt(s): ${body}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
log "health not ready (attempt ${attempt}/${CD_HEALTH_RETRIES}), retrying in ${CD_HEALTH_INTERVAL}s"
|
||||
sleep "$CD_HEALTH_INTERVAL"
|
||||
(( attempt++ ))
|
||||
done
|
||||
warn "health check never passed: last response was '${body:-<no response>}'"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
log "dry run: would run docker compose up -d with ${CD_IMAGE_ENV_VAR}=${DEPLOY_IMAGE}"
|
||||
log "dry run: would health-check ${CD_HEALTH_URL}"
|
||||
log "dry run complete -- no changes made"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "deploying ${DEPLOY_IMAGE}"
|
||||
if ! compose_up "$DEPLOY_IMAGE"; then
|
||||
notify fail "docker compose up failed for ${SHA:0:12} -- stack left as-is"
|
||||
die "docker compose up failed"
|
||||
fi
|
||||
|
||||
if check_health; then
|
||||
log "=== deploy succeeded: ${CD_NAME} now runs ${SHA:0:12} ==="
|
||||
notify ok "deployed ${SHA:0:12} (${DEPLOY_IMAGE##*@})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- rollback ---
|
||||
|
||||
if [[ "$CD_ROLLBACK_ON_FAILURE" != "True" && "$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
|
||||
|
||||
if [[ -z "$PREVIOUS_IMAGE" ]]; then
|
||||
notify fail "health check failed for ${SHA:0:12} and there is no previous image to roll back to"
|
||||
die "health check failed; no previous image recorded, leaving the stack as it is"
|
||||
fi
|
||||
|
||||
warn "health check failed -- rolling back to ${PREVIOUS_IMAGE}"
|
||||
if compose_up "$PREVIOUS_IMAGE" && check_health; then
|
||||
notify fail "deploy of ${SHA:0:12} failed health check; rolled back to the previous image successfully"
|
||||
die "deploy failed health check; rolled back to ${PREVIOUS_IMAGE}"
|
||||
fi
|
||||
|
||||
notify fail "deploy of ${SHA:0:12} failed AND rollback failed -- ${CD_NAME} needs manual attention now"
|
||||
die "deploy failed and rollback also failed; manual intervention required"
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an adnanh/webhook hooks file for this host from targets.json.
|
||||
|
||||
One hook is emitted per repository that this host actually deploys, not one per
|
||||
environment. That keeps the Gitea side trivial -- a repository gets exactly one
|
||||
webhook, configured once -- while the branch/environment mapping stays in
|
||||
targets.json where it can change without anyone touching Gitea.
|
||||
|
||||
Secrets never live in this repository. They are read from a host-local file
|
||||
(default ~/.config/cd-webhook/secrets.env, mode 0600) as:
|
||||
|
||||
CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<the secret set in Gitea>
|
||||
|
||||
A repository with no secret is a hard error: a hook without HMAC validation
|
||||
would accept a deploy request from anyone who can reach the port.
|
||||
|
||||
Usage:
|
||||
cd-render-hooks [--host HOST] [--output PATH] [--redact]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BIN_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = BIN_DIR.parent
|
||||
|
||||
|
||||
def _load_cd_target():
|
||||
"""Import bin/cd-target, whose hyphenated name blocks a plain import.
|
||||
|
||||
Sharing the module rather than re-implementing the lookup keeps one
|
||||
definition of how a (repo, branch, host) triple is matched.
|
||||
"""
|
||||
spec = importlib.util.spec_from_loader(
|
||||
"cd_target",
|
||||
importlib.machinery.SourceFileLoader("cd_target", str(BIN_DIR / "cd-target")),
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_cd_target = _load_cd_target()
|
||||
load = _cd_target.load
|
||||
pick_host = _cd_target.pick_host
|
||||
host_matches = _cd_target.host_matches
|
||||
|
||||
DEFAULT_CONFIG_DIR = Path(
|
||||
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
|
||||
) / "cd-webhook"
|
||||
DEFAULT_SECRETS = DEFAULT_CONFIG_DIR / "secrets.env"
|
||||
DEFAULT_OUTPUT = DEFAULT_CONFIG_DIR / "hooks.json"
|
||||
|
||||
REDACTED = "<redacted>"
|
||||
|
||||
|
||||
def secret_var(repo: str) -> str:
|
||||
"""webdev/domaindingo -> CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO"""
|
||||
return "CD_WEBHOOK_SECRET_" + re.sub(r"[^A-Za-z0-9]+", "_", repo).upper()
|
||||
|
||||
|
||||
def hook_id(repo: str) -> str:
|
||||
"""webdev/domaindingo -> webdev-domaindingo (becomes the URL path)."""
|
||||
return re.sub(r"[^A-Za-z0-9]+", "-", repo).lower().strip("-")
|
||||
|
||||
|
||||
def load_secrets(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
sys.exit(
|
||||
f"cd-render-hooks: no secrets file at {path}\n"
|
||||
f" Create it (mode 0600) with one line per repository, e.g.\n"
|
||||
f" CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<secret from the Gitea webhook>"
|
||||
)
|
||||
|
||||
mode = path.stat().st_mode
|
||||
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
sys.exit(
|
||||
f"cd-render-hooks: {path} is group/world accessible.\n"
|
||||
f" Run: chmod 600 {path}"
|
||||
)
|
||||
|
||||
secrets: dict[str, str] = {}
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
secrets[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return secrets
|
||||
|
||||
|
||||
def build_hook(repo: str, branches: list[str], secret: str) -> dict:
|
||||
"""One hook: HMAC-validated, push-only, restricted to known branches."""
|
||||
branch_rules = [
|
||||
{
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": f"refs/heads/{branch}",
|
||||
"parameter": {"source": "payload", "name": "ref"},
|
||||
}
|
||||
}
|
||||
for branch in branches
|
||||
]
|
||||
|
||||
return {
|
||||
"id": hook_id(repo),
|
||||
"execute-command": str(REPO_ROOT / "bin" / "cd-deploy"),
|
||||
"command-working-directory": str(REPO_ROOT),
|
||||
"http-methods": ["POST"],
|
||||
"incoming-payload-content-type": "application/json",
|
||||
|
||||
# The deploy runs detached; Gitea gets an immediate answer and never
|
||||
# times out waiting for an image build plus a health check.
|
||||
"response-message": f"deploy request accepted for {repo}",
|
||||
"include-command-output-in-response": False,
|
||||
|
||||
# A push to a branch we do not deploy is a normal event, not a failure.
|
||||
# Returning 200 keeps Gitea's delivery history readable; the daemon log
|
||||
# records which rule did not match.
|
||||
"trigger-rule-mismatch-http-response-code": 200,
|
||||
|
||||
"pass-arguments-to-command": [
|
||||
{"source": "string", "name": "--repo"},
|
||||
{"source": "payload", "name": "repository.full_name"},
|
||||
{"source": "string", "name": "--ref"},
|
||||
{"source": "payload", "name": "ref"},
|
||||
{"source": "string", "name": "--sha"},
|
||||
{"source": "payload", "name": "after"},
|
||||
{"source": "string", "name": "--event"},
|
||||
{"source": "header", "name": "X-Gitea-Event"},
|
||||
],
|
||||
|
||||
"trigger-rule": {
|
||||
"and": [
|
||||
{
|
||||
"match": {
|
||||
"type": "payload-hmac-sha256",
|
||||
"secret": secret,
|
||||
"parameter": {
|
||||
"source": "header",
|
||||
"name": "X-Gitea-Signature",
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": "push",
|
||||
"parameter": {
|
||||
"source": "header",
|
||||
"name": "X-Gitea-Event",
|
||||
},
|
||||
}
|
||||
},
|
||||
{"or": branch_rules} if len(branch_rules) > 1 else branch_rules[0],
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--host")
|
||||
parser.add_argument("--secrets", type=Path, default=DEFAULT_SECRETS)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument(
|
||||
"--redact",
|
||||
action="store_true",
|
||||
help="print to stdout with secrets masked, and write nothing",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
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
|
||||
by_repo.setdefault(target["repo"], []).append(target["branch"])
|
||||
|
||||
if not by_repo:
|
||||
sys.exit(
|
||||
f"cd-render-hooks: no enabled targets for host {host!r}.\n"
|
||||
f" Either this host deploys nothing, or its name does not match "
|
||||
f"any 'host' field in targets.json."
|
||||
)
|
||||
|
||||
secrets = {} if args.redact else load_secrets(args.secrets)
|
||||
|
||||
hooks = []
|
||||
for repo, branches in by_repo.items():
|
||||
if args.redact:
|
||||
secret = REDACTED
|
||||
else:
|
||||
var = secret_var(repo)
|
||||
secret = secrets.get(var, "")
|
||||
if not secret:
|
||||
sys.exit(
|
||||
f"cd-render-hooks: {args.secrets} has no value for {var}\n"
|
||||
f" Every deployed repository needs its Gitea webhook secret here."
|
||||
)
|
||||
hooks.append(build_hook(repo, branches, secret))
|
||||
|
||||
rendered = json.dumps(hooks, indent=2) + "\n"
|
||||
|
||||
if args.redact:
|
||||
print(rendered, end="")
|
||||
return 0
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Create with restrictive permissions before any secret reaches the disk.
|
||||
fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
handle.write(rendered)
|
||||
|
||||
print(f"wrote {args.output} ({len(hooks)} hook(s) for {host})")
|
||||
for repo, branches in by_repo.items():
|
||||
print(f" /hooks/{hook_id(repo)} <- {repo} [{', '.join(branches)}]")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# What is this host deploying, and is the receiver actually healthy?
|
||||
#
|
||||
# Written because "the timer is active" was never proof of anything in this
|
||||
# fleet -- the same lesson the uas-ng updater documents. This shows the daemon,
|
||||
# the socket, and the outcome of the last deploy for each target.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
|
||||
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/cd-webhook"
|
||||
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
|
||||
printf ' %s, receiver on %s:%s\n' "$CD_HOST" "$CD_BIND" "$CD_PORT"
|
||||
else
|
||||
printf ' %s is not configured in targets.json\n' "$(hostname)"
|
||||
printf ' answers to: %s\n' "$("${REPO_ROOT}/bin/cd-target" hostnames)"
|
||||
fi
|
||||
|
||||
bold "Receiver"
|
||||
state="$(systemctl --user is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||
printf ' %-12s %s\n' "state" "${state:-not installed}"
|
||||
if [[ "$state" == "active" ]]; then
|
||||
printf ' %-12s %s\n' "since" \
|
||||
"$(systemctl --user show "$UNIT_NAME" -p ActiveEnterTimestamp --value)"
|
||||
if command -v ss >/dev/null 2>&1 && [[ -n "${CD_PORT:-}" ]]; then
|
||||
if ss -ltn 2>/dev/null | grep -q ":${CD_PORT}\b"; then
|
||||
printf ' %-12s listening on port %s\n' "socket" "$CD_PORT"
|
||||
else
|
||||
printf ' %-12s \033[31mNOT listening on port %s\033[0m\n' "socket" "$CD_PORT"
|
||||
fi
|
||||
fi
|
||||
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
|
||||
fi
|
||||
|
||||
bold "Targets on this host"
|
||||
mapfile -t targets < <("${REPO_ROOT}/bin/cd-target" list 2>/dev/null || true)
|
||||
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}"
|
||||
else
|
||||
printf ' %-22s %s\n' "$target" "never deployed from this host"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
bold "Recent receiver log"
|
||||
journalctl --user -u "$UNIT_NAME" -n 15 --no-pager 2>/dev/null \
|
||||
|| printf ' (no journal entries)\n'
|
||||
echo
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Query targets.json.
|
||||
|
||||
The manifest is the only place that knows how a (repo, branch) pair maps onto a
|
||||
host, a compose stack and an image. Everything else -- the deploy script, the
|
||||
hook generator, the installer -- asks this helper rather than parsing JSON
|
||||
itself, so there is exactly one implementation of the matching rules.
|
||||
|
||||
Subcommands:
|
||||
resolve --repo OWNER/NAME --branch BRANCH [--host HOST]
|
||||
Print shell-quoted KEY=VALUE lines for the matching target.
|
||||
Exit 3 when nothing matches (a normal, non-error outcome: it just
|
||||
means this push is not ours to act on).
|
||||
|
||||
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.
|
||||
|
||||
hostnames
|
||||
Print the names this machine answers to (diagnostics).
|
||||
|
||||
HOST defaults to the local machine in every subcommand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# CD_TARGETS_FILE lets a test run against a scratch manifest. It changes which
|
||||
# manifest is read, never which host this machine is allowed to act as, so it
|
||||
# cannot be used to make one host deploy another's targets.
|
||||
MANIFEST = Path(os.environ.get("CD_TARGETS_FILE", REPO_ROOT / "targets.json"))
|
||||
|
||||
NO_MATCH = 3
|
||||
|
||||
|
||||
def local_hostnames() -> set[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.
|
||||
"""
|
||||
names = {socket.gethostname()}
|
||||
try:
|
||||
names.add(socket.getfqdn())
|
||||
except OSError:
|
||||
pass
|
||||
for name in list(names):
|
||||
if "." in name:
|
||||
names.add(name.split(".", 1)[0])
|
||||
return {n.lower() for n in names if n}
|
||||
|
||||
|
||||
def host_matches(target_host: str, names: set[str]) -> bool:
|
||||
target_host = target_host.lower()
|
||||
return target_host in names or target_host.split(".", 1)[0] in names
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text())
|
||||
except FileNotFoundError:
|
||||
sys.exit(f"cd-target: manifest not found: {MANIFEST}")
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"cd-target: manifest is not valid JSON: {exc}")
|
||||
|
||||
|
||||
def pick_host(manifest: dict, explicit: str | None) -> str:
|
||||
"""Return the manifest's canonical name for the host we are acting as."""
|
||||
if explicit:
|
||||
return explicit
|
||||
names = local_hostnames()
|
||||
for host in manifest.get("hosts", {}):
|
||||
if host_matches(host, names):
|
||||
return host
|
||||
# Not a configured host: return the short local name so callers report
|
||||
# something truthful rather than silently adopting another host's identity.
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def emit(pairs: dict[str, object]) -> None:
|
||||
for key, value in pairs.items():
|
||||
print(f"{key}={shlex.quote(str(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
|
||||
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
|
||||
emit({f"CD_{k.upper()}": v for k, v in merged.items() if not k.startswith("_")})
|
||||
return 0
|
||||
|
||||
return NO_MATCH
|
||||
|
||||
|
||||
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))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_host_config(args, manifest: dict) -> int:
|
||||
host = pick_host(manifest, args.host)
|
||||
config = manifest.get("hosts", {}).get(host)
|
||||
if config is None:
|
||||
sys.exit(
|
||||
f"cd-target: host {host!r} has no entry in targets.json 'hosts'. "
|
||||
"Add one before installing here."
|
||||
)
|
||||
emit({"CD_HOST": host, "CD_BIND": config["bind"], "CD_PORT": config["port"]})
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_hostnames(_args, _manifest: dict) -> int:
|
||||
print(" ".join(sorted(local_hostnames())))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Query the CD target manifest")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("resolve")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--branch", required=True)
|
||||
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)):
|
||||
p = sub.add_parser(name)
|
||||
p.add_argument("--host")
|
||||
p.set_defaults(func=func)
|
||||
|
||||
p = sub.add_parser("hostnames")
|
||||
p.set_defaults(func=cmd_hostnames)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.func(args, load())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user