Fix health endpoint, align with the documented manual runbook

The health router is mounted at prefix="/api" in domaindingo's main.py, so
/health 404s. Checking it would have failed every deploy and rolled back a
perfectly good image.

Also adopts three conventions from the vault's DomainDingo Development note:
recreate a single named service (--no-deps --force-recreate), validate with
compose config --quiet before touching the running container, and write the
rollback record in the documented format and location.
This commit is contained in:
fisher
2026-08-23 08:53:26 +00:00
parent 62c62baf05
commit 952de1ea8e
3 changed files with 89 additions and 39 deletions
+61 -18
View File
@@ -236,14 +236,19 @@ readonly DEPLOY_IMAGE
# --------------------------------------------- remember what is running now --- # --------------------------------------------- remember what is running now ---
PREVIOUS_IMAGE="$( RunningImageId="$(docker inspect --format '{{.Image}}' "${CD_CONTAINER}" 2>/dev/null || true)"
container_image="$(docker inspect --format '{{.Image}}' "$CD_CONTAINER" 2>/dev/null || true)" PREVIOUS_IMAGE=""
[[ -n "$container_image" ]] && image_digest_ref "$container_image" || true PREVIOUS_REVISION=""
)" if [[ -n "${RunningImageId}" ]]; then
readonly PREVIOUS_IMAGE PREVIOUS_IMAGE="$(image_digest_ref "${RunningImageId}")"
PREVIOUS_REVISION="$(docker image inspect \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
"${RunningImageId}" 2>/dev/null || true)"
fi
readonly PREVIOUS_IMAGE PREVIOUS_REVISION
if [[ -n "$PREVIOUS_IMAGE" ]]; then if [[ -n "${PREVIOUS_IMAGE}" ]]; then
log "current ${PREVIOUS_IMAGE}" log "current ${PREVIOUS_IMAGE} (revision ${PREVIOUS_REVISION:0:12})"
else else
log "current (nothing running -- first deploy, or container absent)" log "current (nothing running -- first deploy, or container absent)"
fi fi
@@ -251,15 +256,44 @@ fi
# ----------------------------------------------------------------- deploy --- # ----------------------------------------------------------------- deploy ---
compose_up() { compose_up() {
local image="$1" local Image="$1"
# `up -d` recreates only what actually changed. `down` is deliberately not # Matches the manual runbook in the vault's "DomainDingo Development" note:
# used here: it causes avoidable downtime, and `down -v` would destroy the # recreate exactly one service, leave its dependencies alone, and never run
# data volumes these stacks depend on. # `docker compose down` -- that causes avoidable downtime, and `down -v`
( cd "$CD_STACK_DIR" \ # would destroy the bind-mounted data these stacks depend on.
&& env "${CD_IMAGE_ENV_VAR}=${image}" \ #
"${CD_PULL_POLICY_ENV_VAR}=missing" \ # `--pull always` is deliberately absent: the image is already pinned to a
timeout "$CD_COMPOSE_TIMEOUT_SECONDS" \ # digest and pulled, so there is nothing left to resolve.
docker compose -f "$CD_COMPOSE_FILE" -p "$CD_COMPOSE_PROJECT" up -d --no-build ) ( cd "${CD_STACK_DIR}" \
&& env "${CD_IMAGE_ENV_VAR}=${Image}" \
"${CD_PULL_POLICY_ENV_VAR}=never" \
timeout "${CD_COMPOSE_TIMEOUT_SECONDS}" \
docker compose -f "${CD_COMPOSE_FILE}" -p "${CD_COMPOSE_PROJECT}" \
up -d --no-build --no-deps --force-recreate "${CD_COMPOSE_SERVICE}" )
}
compose_validate() {
local Image="$1"
( cd "${CD_STACK_DIR}" \
&& env "${CD_IMAGE_ENV_VAR}=${Image}" \
"${CD_PULL_POLICY_ENV_VAR}=never" \
docker compose -f "${CD_COMPOSE_FILE}" -p "${CD_COMPOSE_PROJECT}" \
config --quiet )
}
# Write the rollback record where the manual runbook looks for it, in the same
# format, so an operator following that note can recover from an automated
# deploy without knowing this tool exists.
write_rollback_record() {
local Image="$1" Revision="$2" Dir="${CD_STACK_DIR}/${CD_ROLLBACK_RECORD_DIR}"
local Record="${Dir}/${CD_COMPOSE_SERVICE}-rollback-$(date -u +%Y%m%d-%H%M%S).env"
[[ -d "${Dir}" ]] || { warn "no rollback record directory at ${Dir}"; return 0; }
( umask 077
printf 'DD_ROLLBACK_IMAGE=%s\nDD_ROLLBACK_REVISION=%s\nDD_ROLLBACK_CAPTURED_AT=%s\n' \
"${Image}" "${Revision}" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "${Record}" )
log "rollback recorded at ${Record}"
} }
check_health() { check_health() {
@@ -289,14 +323,23 @@ sys.exit(0 if str(data.get(key, "")).lower() == want.lower() else 1)
} }
if [[ $DRY_RUN -eq 1 ]]; then 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 recreate service ${CD_COMPOSE_SERVICE} with ${CD_IMAGE_ENV_VAR}=${DEPLOY_IMAGE}"
log "dry run: would health-check ${CD_HEALTH_URL}" log "dry run: would health-check ${CD_HEALTH_URL}"
log "dry run complete -- no changes made" log "dry run complete -- no changes made"
exit 0 exit 0
fi fi
# Catch a broken compose file or a bad interpolation before the running
# container is touched.
if ! compose_validate "${DEPLOY_IMAGE}"; then
notify fail "compose config is invalid for ${SHA:0:12} -- nothing was changed"
die "docker compose config failed; the running stack was not touched"
fi
[[ -n "${PREVIOUS_IMAGE}" ]] && write_rollback_record "${PREVIOUS_IMAGE}" "${PREVIOUS_REVISION}"
log "deploying ${DEPLOY_IMAGE}" log "deploying ${DEPLOY_IMAGE}"
if ! compose_up "$DEPLOY_IMAGE"; then if ! compose_up "${DEPLOY_IMAGE}"; then
notify fail "docker compose up failed for ${SHA:0:12} -- stack left as-is" notify fail "docker compose up failed for ${SHA:0:12} -- stack left as-is"
die "docker compose up failed" die "docker compose up failed"
fi fi
+18
View File
@@ -37,3 +37,21 @@ Short notes on things that were not obvious. Prune stale ones.
- **`docker compose down -v` destroys the data volumes** for these stacks. The - **`docker compose down -v` destroys the data volumes** for these stacks. The
retired paradicsomleves test script used it. `cd-deploy` only ever runs retired paradicsomleves test script used it. `cd-deploy` only ever runs
`up -d`. `up -d`.
- **domaindingo's health endpoint is `/api/health`, not `/health`.** The route is
declared as `/health` in `src/domaindingo/api/status.py`, but `main.py` mounts
the router with `prefix="/api"`. Checking `/health` gets a 404, which reads as
a failed deploy and triggers a rollback. The vault's
"DomainDingo Development" note is the authority here.
- **The manual runbook recreates one service, not the project.**
`docker compose up -d --pull always --no-deps --force-recreate <service>`.
`cd-deploy` matches it (minus `--pull always`, since it has already pulled a
pinned digest), so an automated deploy and a hand-run one converge on the same
state.
- **Rollback records are a documented convention.**
`<service>-rollback-<UTC timestamp>.env` in `backup-test`/`backup-prod`,
holding `DD_ROLLBACK_IMAGE`, `DD_ROLLBACK_REVISION`, `DD_ROLLBACK_CAPTURED_AT`.
`cd-deploy` writes the same format in the same place, so an operator following
the manual note can recover from an automated deploy.
+10 -21
View File
@@ -1,6 +1,5 @@
{ {
"version": 1, "version": 1,
"_comment": [ "_comment": [
"Single source of truth for the fleet's continuous-deployment targets.", "Single source of truth for the fleet's continuous-deployment targets.",
"A target is uniquely identified by (repo, branch). The 'host' field decides", "A target is uniquely identified by (repo, branch). The 'host' field decides",
@@ -10,20 +9,16 @@
"Adding an environment is an edit here plus a re-run of install/install.sh on", "Adding an environment is an edit here plus a re-run of install/install.sh on",
"the owning host. It is never a change to the project repository." "the owning host. It is never a change to the project repository."
], ],
"defaults": { "defaults": {
"ntfy_base_url": "https://ntfy.fisher.hu", "ntfy_base_url": "https://ntfy.fisher.hu",
"image_wait_seconds": 900, "image_wait_seconds": 900,
"image_poll_interval": 15, "image_poll_interval": 15,
"health_retries": 30, "health_retries": 30,
"health_interval": 5, "health_interval": 5,
"lock_wait_seconds": 600, "lock_wait_seconds": 600,
"compose_timeout_seconds": 300 "compose_timeout_seconds": 300,
"rollback_record_dir": "."
}, },
"hosts": { "hosts": {
"s5.fisher.hu": { "s5.fisher.hu": {
"bind": "10.255.255.1", "bind": "10.255.255.1",
@@ -34,7 +29,6 @@
"port": 21600 "port": 21600
} }
}, },
"targets": [ "targets": [
{ {
"name": "domaindingo-test", "name": "domaindingo-test",
@@ -43,24 +37,21 @@
"branch": "test", "branch": "test",
"env": "test", "env": "test",
"host": "s5.fisher.hu", "host": "s5.fisher.hu",
"stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu", "stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu",
"compose_file": "docker-compose.yml", "compose_file": "docker-compose.yml",
"compose_project": "domaindingo-test", "compose_project": "domaindingo-test",
"container": "domaindingo-test", "container": "domaindingo-test",
"compose_service": "domaindingo-test",
"image_repo": "gitea.fisher.hu/webdev/domaindingo", "image_repo": "gitea.fisher.hu/webdev/domaindingo",
"image_tag_template": "{branch}-sha-{short7}", "image_tag_template": "{branch}-sha-{short7}",
"image_env_var": "DOMAINDINGO_TEST_IMAGE", "image_env_var": "DOMAINDINGO_TEST_IMAGE",
"pull_policy_env_var": "DOMAINDINGO_TEST_PULL_POLICY", "pull_policy_env_var": "DOMAINDINGO_TEST_PULL_POLICY",
"health_url": "http://127.0.0.1:9101/api/health",
"health_url": "http://127.0.0.1:9101/health",
"health_expect_key": "db", "health_expect_key": "db",
"health_expect_value": "ok", "health_expect_value": "ok",
"rollback_on_failure": true,
"rollback_on_failure": true "rollback_record_dir": "backup-test"
}, },
{ {
"name": "domaindingo-prod", "name": "domaindingo-prod",
"enabled": true, "enabled": true,
@@ -68,22 +59,20 @@
"branch": "prod", "branch": "prod",
"env": "prod", "env": "prod",
"host": "s5.fisher.hu", "host": "s5.fisher.hu",
"stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu", "stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu",
"compose_file": "docker-compose-prod.yml", "compose_file": "docker-compose-prod.yml",
"compose_project": "domaindingo-prod", "compose_project": "domaindingo-prod",
"container": "domaindingo-prod", "container": "domaindingo-prod",
"compose_service": "domaindingo-prod",
"image_repo": "gitea.fisher.hu/webdev/domaindingo", "image_repo": "gitea.fisher.hu/webdev/domaindingo",
"image_tag_template": "{branch}-sha-{short7}", "image_tag_template": "{branch}-sha-{short7}",
"image_env_var": "DOMAINDINGO_PROD_IMAGE", "image_env_var": "DOMAINDINGO_PROD_IMAGE",
"pull_policy_env_var": "DOMAINDINGO_PROD_PULL_POLICY", "pull_policy_env_var": "DOMAINDINGO_PROD_PULL_POLICY",
"health_url": "http://127.0.0.1:9103/api/health",
"health_url": "http://127.0.0.1:9103/health",
"health_expect_key": "db", "health_expect_key": "db",
"health_expect_value": "ok", "health_expect_value": "ok",
"rollback_on_failure": true,
"rollback_on_failure": true "rollback_record_dir": "backup-prod"
} }
] ]
} }