Files
ukmesh/scripts/replace-container.sh

394 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
umask 077
usage() {
cat <<'EOF'
Usage:
replace-container.sh SERVICE --image=NAME@sha256:DIGEST \
--backend-image=NAME@sha256:DIGEST --source-revision=40_HEX
Required environment:
RESTORE_RECEIPT_PATH
RESTORE_RECEIPT_VERIFY_KEY
Signature trust (choose one):
COSIGN_PUBLIC_KEY
or both COSIGN_CERTIFICATE_IDENTITY_REGEXP and
COSIGN_CERTIFICATE_OIDC_ISSUER
The command verifies signed images and a fresh restore receipt, runs migrations,
tests the prior backend against the resulting schema, deploys without building,
waits for readiness, runs smoke/metric checks, and writes a signed release
receipt. It will only roll back automatically to a previously signed digest.
EOF
}
if [ "$#" -lt 1 ]; then
usage >&2
exit 64
fi
service="$1"
shift
desired_image=""
backend_image=""
source_revision=""
bootstrap_approval=""
for arg in "$@"; do
case "$arg" in
--image=*) desired_image="${arg#*=}" ;;
--backend-image=*) backend_image="${arg#*=}" ;;
--source-revision=*) source_revision="${arg#*=}" ;;
--bootstrap-immutable=*) bootstrap_approval="${arg#*=}" ;;
--help|-h) usage; exit 0 ;;
*) echo "unknown argument: $arg" >&2; usage >&2; exit 64 ;;
esac
done
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
project_dir="$(cd -- "${script_dir}/.." && pwd)"
project_name="${COMPOSE_PROJECT_NAME:-meshcore-analytics}"
release_dir="${RELEASE_STATUS_DIR:-/home/ben/meshcore-releases}"
cosign_key="${COSIGN_PUBLIC_KEY:-}"
cosign_identity_regexp="${COSIGN_CERTIFICATE_IDENTITY_REGEXP:-}"
cosign_oidc_issuer="${COSIGN_CERTIFICATE_OIDC_ISSUER:-}"
receipt_path="${RESTORE_RECEIPT_PATH:?RESTORE_RECEIPT_PATH is required}"
receipt_signature="${RESTORE_RECEIPT_SIGNATURE:-${receipt_path}.sig}"
receipt_verify_key="${RESTORE_RECEIPT_VERIFY_KEY:?RESTORE_RECEIPT_VERIFY_KEY is required}"
compatibility_timeout="${COMPATIBILITY_TIMEOUT_SECONDS:-90}"
if ! [[ "$compatibility_timeout" =~ ^[1-9][0-9]*$ ]] \
|| [ "$compatibility_timeout" -gt 600 ]; then
echo "COMPATIBILITY_TIMEOUT_SECONDS must be an integer from 1 to 600" >&2
exit 64
fi
declare -A image_variables=(
[backend]=BACKEND_IMAGE
[db-migrate]=BACKEND_IMAGE
[path-learning-worker]=BACKEND_IMAGE
[health-worker]=BACKEND_IMAGE
[synthetic-monitor]=BACKEND_IMAGE
[link-backfill-worker]=BACKEND_IMAGE
[alert-receiver]=BACKEND_IMAGE
[link-worker]=RF_WORKER_IMAGE
[viewshed-worker]=RF_WORKER_IMAGE
[app-ukmesh]=APP_IMAGE
[website-ukmesh]=WEBSITE_IMAGE
[website-dev]=WEBSITE_DEV_IMAGE
[mesh-health-check]=HEALTHCHECK_IMAGE
[mosquitto-reloader]=MOSQUITTO_RELOADER_IMAGE
)
image_variable="${image_variables[$service]:-}"
if [ -z "$image_variable" ]; then
echo "service is not deployable through the immutable release path: $service" >&2
exit 65
fi
digest_pattern='^[-a-zA-Z0-9._/:]+@sha256:[a-f0-9]{64}$'
if [[ ! "$desired_image" =~ $digest_pattern ]]; then
echo "--image must be an immutable registry digest" >&2
exit 64
fi
if [[ ! "$backend_image" =~ $digest_pattern ]]; then
echo "--backend-image must be an immutable registry digest" >&2
exit 64
fi
if [[ ! "$source_revision" =~ ^[a-f0-9]{40}$ ]]; then
echo "--source-revision must be a full Git commit" >&2
exit 64
fi
for command in docker cosign jq openssl sha256sum git curl; do
command -v "$command" >/dev/null || {
echo "required command is unavailable: $command" >&2
exit 69
}
done
if [ -n "$cosign_key" ]; then
test -r "$cosign_key" || {
echo "Cosign public key is not readable: $cosign_key" >&2
exit 66
}
elif [ -z "$cosign_identity_regexp" ] || [ -z "$cosign_oidc_issuer" ]; then
echo "configure either COSIGN_PUBLIC_KEY or exact keyless identity and issuer trust" >&2
exit 66
fi
for path in "$receipt_path" "$receipt_signature" "$receipt_verify_key"; do
test -r "$path" || {
echo "required verification input is not readable: $path" >&2
exit 66
}
done
cd "$project_dir"
docker compose --project-name "$project_name" config -q
if ! docker compose --project-name "$project_name" config --services | grep -Fxq -- "$service"; then
echo "unknown Compose service: $service" >&2
exit 65
fi
if [ "$(git rev-parse HEAD)" != "$source_revision" ]; then
echo "working checkout does not match the release source revision" >&2
exit 65
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "immutable releases require a clean tracked working tree" >&2
exit 65
fi
verify_cosign_signature() {
local image="$1"
if [ -n "$cosign_key" ]; then
cosign verify --key "$cosign_key" "$image" >/dev/null
else
cosign verify "$image" \
--certificate-identity-regexp "$cosign_identity_regexp" \
--certificate-oidc-issuer "$cosign_oidc_issuer" >/dev/null
fi
}
verify_image() {
local image="$1"
verify_cosign_signature "$image"
docker pull "$image" >/dev/null
local label
label="$(docker image inspect "$image" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$label" != "$source_revision" ]; then
echo "image revision label does not match release source: $image" >&2
return 1
fi
}
verify_image "$backend_image"
if [ "$desired_image" != "$backend_image" ]; then
verify_image "$desired_image"
fi
openssl dgst -sha256 -verify "$receipt_verify_key" \
-signature "$receipt_signature" "$receipt_path" >/dev/null
test "$(jq -r '.format' "$receipt_path")" = "meshcore-restore-receipt-v1"
test "$(jq -r '.status' "$receipt_path")" = "verified"
for dataset in analytics owner_auth mosquitto redis configuration; do
jq -e --arg dataset "$dataset" '.datasets | index($dataset) != null' \
"$receipt_path" >/dev/null
done
now_epoch="$(date +%s)"
backup_epoch="$(date -d "$(jq -r '.backup_completed_at' "$receipt_path")" +%s)"
restore_epoch="$(date -d "$(jq -r '.restore_verified_at' "$receipt_path")" +%s)"
maximum_age="$((7 * 24 * 60 * 60))"
if [ "$((now_epoch - backup_epoch))" -gt "$maximum_age" ] \
|| [ "$((now_epoch - restore_epoch))" -gt "$maximum_age" ] \
|| [ "$backup_epoch" -gt "$((now_epoch + 300))" ] \
|| [ "$restore_epoch" -gt "$((now_epoch + 300))" ]; then
echo "signed backup/restore evidence is stale or future-dated" >&2
exit 65
fi
current_service_id="$(docker compose --project-name "$project_name" ps -q "$service")"
current_backend_id="$(docker compose --project-name "$project_name" ps -q backend)"
test -n "$current_service_id" && test -n "$current_backend_id" || {
echo "current service and backend must be running before replacement" >&2
exit 69
}
prior_image="$(docker inspect "$current_service_id" --format '{{.Config.Image}}')"
prior_backend_image="$(docker inspect "$current_backend_id" --format '{{.Config.Image}}')"
prior_is_signed="false"
if [[ "$prior_image" =~ $digest_pattern ]] \
&& verify_cosign_signature "$prior_image" >/dev/null 2>&1; then
prior_is_signed="true"
elif [ "$bootstrap_approval" != "bootstrap-${service}-${source_revision}" ]; then
echo "the current service is not a signed digest; use the documented one-time bootstrap approval" >&2
exit 65
fi
release_id="release-$(date -u +%Y%m%dT%H%M%SZ)-${source_revision:0:12}-${service}"
mkdir -p "$release_dir"
release_path="$release_dir/${release_id}.json"
config_sha="$(
docker compose --project-name "$project_name" config \
| sha256sum | awk '{print $1}'
)"
restore_receipt_id="$(jq -r '.receipt_id' "$receipt_path")"
write_release_status() {
local status="$1"
local detail="${2:-}"
local schema_version="${3:-0}"
jq -n \
--arg release_id "$release_id" \
--arg service "$service" \
--arg source_revision "$source_revision" \
--arg image "$desired_image" \
--arg backend_image "$backend_image" \
--arg prior_image "$prior_image" \
--arg prior_backend_image "$prior_backend_image" \
--arg config_sha256 "$config_sha" \
--arg restore_receipt_id "$restore_receipt_id" \
--arg status "$status" \
--arg detail "$detail" \
--arg recorded_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson schema_version "$schema_version" \
'{
format:"meshcore-release-status-v1",
release_id:$release_id,
service:$service,
source_revision:$source_revision,
image:$image,
backend_image:$backend_image,
prior_image:$prior_image,
prior_backend_image:$prior_backend_image,
schema_version:$schema_version,
config_sha256:$config_sha256,
restore_receipt_id:$restore_receipt_id,
status:$status,
detail:$detail,
recorded_at:$recorded_at
}' >"${release_path}.partial"
mv "${release_path}.partial" "$release_path"
if [ -n "${RELEASE_RECEIPT_SIGNING_KEY:-}" ]; then
openssl dgst -sha256 -sign "$RELEASE_RECEIPT_SIGNING_KEY" \
-out "${release_path}.sig.partial" "$release_path"
mv "${release_path}.sig.partial" "${release_path}.sig"
fi
}
write_release_status preflight
echo "Running the required migration job with the signed backend image..."
BACKEND_IMAGE="$backend_image" \
docker compose --project-name "$project_name" run --rm db-migrate
schema_version="$(
docker compose --project-name "$project_name" exec -T timescaledb \
psql -U "${POSTGRES_USER:-meshcore}" -d "${POSTGRES_DB:-meshcore}" -Atc \
"SELECT COALESCE(MAX(((regexp_match(name, '^([0-9]+)_'))[1])::int), 0) FROM schema_migrations"
)"
compat_name="${project_name}-compat-${source_revision:0:12}-$$"
compat_env="$(mktemp)"
compat_cleanup() {
docker rm -f "$compat_name" >/dev/null 2>&1 || true
rm -f "$compat_env"
}
trap compat_cleanup EXIT
docker inspect "$current_backend_id" --format '{{range .Config.Env}}{{println .}}{{end}}' \
>"$compat_env"
network_name="$(
docker inspect "$current_backend_id" \
--format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' \
| head -n 1
)"
echo "Testing the prior backend image against the post-migration schema..."
docker run -d \
--name "$compat_name" \
--network "$network_name" \
--env-file "$compat_env" \
--volumes-from "$current_backend_id:ro" \
-e MQTT_INGEST_ENABLED=false \
-e OWNER_AUTHORIZATION_MODE=shadow \
-e OWNER_ACL_MODE=shadow \
-e PORT=3000 \
-e METRICS_PORT=9091 \
--read-only \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
"$prior_backend_image" >/dev/null
compat_ready="false"
for _ in $(seq 1 "$compatibility_timeout"); do
if docker exec "$compat_name" wget -qO- http://127.0.0.1:3000/readyz \
2>/dev/null | jq -e '.status == "ready"' >/dev/null 2>&1 \
&& docker exec "$compat_name" wget -qO- \
'http://127.0.0.1:3000/api/stats?network=ukmesh' \
2>/dev/null | jq -e '.totalNodes >= 0' >/dev/null 2>&1; then
compat_ready="true"
break
fi
sleep 1
done
if [ "$compat_ready" != "true" ]; then
docker logs "$compat_name" >&2 || true
write_release_status stopped "prior image failed post-migration compatibility test" "$schema_version"
exit 1
fi
docker rm -f "$compat_name" >/dev/null
smoke_service() {
local target_service="$1"
local container_id
container_id="$(docker compose --project-name "$project_name" ps -q "$target_service")"
test -n "$container_id"
test "$(docker inspect "$container_id" --format '{{.State.Running}}')" = "true"
local health
health="$(docker inspect "$container_id" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}')"
test "$health" = "healthy" || test "$health" = "none"
docker compose --project-name "$project_name" exec -T backend \
wget -qO- http://127.0.0.1:3000/readyz \
| jq -e '.status == "ready"' >/dev/null
metrics="$(docker compose --project-name "$project_name" exec -T backend \
wget -qO- http://127.0.0.1:9091/metrics)"
grep -q '^meshcore_process_' <<<"$metrics"
case "$target_service" in
backend)
curl --fail --silent http://127.0.0.1:3000/readyz \
| jq -e '.status == "ready"' >/dev/null
;;
app-ukmesh) curl --fail --silent http://127.0.0.1:3003/ >/dev/null ;;
website-ukmesh) curl --fail --silent http://127.0.0.1:3004/ >/dev/null ;;
mesh-health-check)
curl --fail --silent http://127.0.0.1:3090/api/bootstrap \
| jq -e '.mqtt.connected == true' >/dev/null
;;
esac
}
deploy_image() {
local image="$1"
if [ "$image_variable" = "BACKEND_IMAGE" ]; then
BACKEND_IMAGE="$image" \
docker compose --project-name "$project_name" up \
--detach --no-build --wait --wait-timeout 240 "$service"
else
env \
"$image_variable=$image" \
"BACKEND_IMAGE=$backend_image" \
docker compose --project-name "$project_name" up \
--detach --no-build --wait --wait-timeout 240 "$service"
fi
}
write_release_status deploying "" "$schema_version"
set +e
deploy_image "$desired_image"
deploy_status=$?
if [ "$deploy_status" -eq 0 ]; then
smoke_service "$service"
deploy_status=$?
fi
set -e
if [ "$deploy_status" -ne 0 ]; then
write_release_status failed "readiness, smoke, or metric check failed" "$schema_version"
if [ "$prior_is_signed" = "true" ]; then
echo "Release failed; restoring the prior signed digest..."
set +e
deploy_image "$prior_image"
rollback_status=$?
if [ "$rollback_status" -eq 0 ]; then
smoke_service "$service"
rollback_status=$?
fi
set -e
if [ "$rollback_status" -eq 0 ]; then
write_release_status rolled_back "automatic signed-digest rollback completed" "$schema_version"
else
write_release_status rollback_failed "manual recovery required" "$schema_version"
fi
else
write_release_status failed_no_rollback "prior image was not signed; manual recovery required" "$schema_version"
fi
exit 1
fi
write_release_status deployed "" "$schema_version"
ln -sfn "$(basename "$release_path")" "$release_dir/latest-${service}.json"
echo "Immutable release deployed:"
jq '{release_id,service,source_revision,image,schema_version,status,restore_receipt_id}' \
"$release_path"