S19: establish engineering repository
This commit is contained in:
204
resources/deploy/remote.sh
Normal file
204
resources/deploy/remote.sh
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/bin/sh
|
||||
# Invoked over a pinned SSH connection by ReleaseRemote. No secrets in arguments.
|
||||
# mkdir is the NAS-wide lock; a disconnected runner leaves it for explicit recovery.
|
||||
set -eu
|
||||
umask 077
|
||||
action=$1
|
||||
root=$2
|
||||
owner=$3
|
||||
shift 3
|
||||
die() { echo "Deployment: $*" >&2; exit 1; }
|
||||
case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac
|
||||
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
|
||||
test -d "$root" || die 'Provision the deployment directory first'
|
||||
cd "$root"
|
||||
lock="$root/deploy.lock"
|
||||
release="$root/releases/$owner"
|
||||
|
||||
if [ "$action" = lock ]; then
|
||||
test ! -e pending.json || die 'Unresolved deployment: follow manual recovery runbook'
|
||||
mkdir "$lock" 2>/dev/null || die 'Deployment locked: another run or manual recovery owns the NAS'
|
||||
printf '%s\n' "$owner" > "$lock/owner"
|
||||
# Check again after acquiring, covering races with another lock holder.
|
||||
if [ -e pending.json ]; then
|
||||
rm "$lock/owner"
|
||||
rmdir "$lock"
|
||||
die 'Unresolved deployment: follow manual recovery runbook'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
test -f "$lock/owner" && [ "$(cat "$lock/owner")" = "$owner" ] || die 'Deployment lock ownership mismatch'
|
||||
|
||||
case "$action" in
|
||||
unlock)
|
||||
test ! -e pending.json || die 'Pending deployment retained; manual recovery required'
|
||||
rm "$lock/owner"
|
||||
rmdir "$lock"
|
||||
exit 0;;
|
||||
state)
|
||||
if [ -f state.json ]; then cat state.json; else printf '{}\n'; fi
|
||||
exit 0;;
|
||||
pending)
|
||||
test ! -e pending.json || die 'Deployment already pending'
|
||||
cat > pending.json.tmp
|
||||
mv pending.json.tmp pending.json
|
||||
exit 0;;
|
||||
commit)
|
||||
test -f pending.json || die 'No pending deployment'
|
||||
cat > state.json.tmp
|
||||
mv state.json.tmp state.json
|
||||
exit 0;;
|
||||
finish)
|
||||
test -f pending.json || die 'No pending deployment'
|
||||
mkdir -p history
|
||||
mv pending.json "history/$owner.json"
|
||||
exit 0;;
|
||||
event)
|
||||
mkdir -p history
|
||||
cat > "history/$owner-outcome.json.tmp"
|
||||
mv "history/$owner-outcome.json.tmp" "history/$owner-outcome.json"
|
||||
exit 0;;
|
||||
esac
|
||||
|
||||
test -f production.env || die 'Missing production.env; follow provisioning runbook'
|
||||
# This account-owned file is trusted shell configuration, never a workflow input.
|
||||
. "$root/production.env"
|
||||
: "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}"
|
||||
export DOCKER_CONFIG
|
||||
docker_cmd() {
|
||||
if [ "${DOCKER_SUDO:-1}" = 1 ]; then
|
||||
sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||
else
|
||||
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
|
||||
fi
|
||||
}
|
||||
: "${COMPOSE_PROJECT:?}" "${DEPLOY_ROLES:?}" "${TARGET_PLATFORM:?}"
|
||||
case "$COMPOSE_PROJECT" in ''|*[!a-z0-9_-]*) die 'Invalid Compose project';; esac
|
||||
case "$DEPENDENCY_SERVICES" in *[!a-zA-Z0-9_\ -]*) die 'Invalid dependency services';; esac
|
||||
case "$DEPLOY_ROLES" in backend|frontend|'backend frontend') ;; *) die 'Invalid deployment roles';; esac
|
||||
case "$TARGET_PLATFORM" in linux/amd64|linux/arm64) ;; *) die 'Invalid target platform';; esac
|
||||
compose() {
|
||||
docker_cmd compose --project-name "$COMPOSE_PROJECT" --project-directory "$root" \
|
||||
--env-file "$root/production.env" --env-file "$release/release.env" -f "$release/compose.yaml" "$@"
|
||||
}
|
||||
identity() {
|
||||
image=$1
|
||||
sha=$2
|
||||
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|
||||
|| die 'Image revision does not match selected SHA'
|
||||
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = "$TARGET_PLATFORM" \
|
||||
|| die 'Image platform differs from target'
|
||||
}
|
||||
use_release() {
|
||||
case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac
|
||||
release="$root/releases/$1"
|
||||
test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration'
|
||||
}
|
||||
health() {
|
||||
# Compare image IDs, revision labels and the container release labels.
|
||||
for role in $DEPLOY_ROLES; do
|
||||
id=$(compose ps -q "$role")
|
||||
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
|
||||
test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1
|
||||
test "$(docker_cmd inspect --format '{{.Image}}' "$id")" = "$(docker_cmd image inspect --format '{{.Id}}' "$expected")" || return 1
|
||||
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1
|
||||
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
|
||||
done
|
||||
if [ -f "$release/health.sh" ]; then . "$release/health.sh"; fi
|
||||
application_health
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
preflight)
|
||||
for secret in ${REQUIRED_SECRETS:-}; do
|
||||
case "$secret" in ''|*[!A-Z0-9_]*) die 'Invalid required secret name';; esac
|
||||
eval 'value=${'"$secret"':-}'
|
||||
test -n "$value" || die "Missing $secret in production.env"
|
||||
done
|
||||
command -v timeout >/dev/null || die 'timeout is required for bounded remote operations'
|
||||
command -v curl >/dev/null || die 'curl is required'
|
||||
test -x "$DOCKER_BIN" || die 'Docker executable missing'
|
||||
docker_cmd compose version >/dev/null
|
||||
actual=$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')
|
||||
case "$actual" in linux/x86_64) actual=linux/amd64;; linux/aarch64) actual=linux/arm64;; esac
|
||||
test "$actual" = "$TARGET_PLATFORM" || die 'Docker platform differs from target'
|
||||
test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first'
|
||||
# Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication.
|
||||
case "$BIND_IP" in
|
||||
10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.30.*|172.31.*|127.*) ;;
|
||||
*) die 'Bind frontend to a private IPv4 address';;
|
||||
esac
|
||||
case "$FRONTEND_PORT" in ''|*[!0-9]*) die 'Invalid frontend port';; esac
|
||||
test "$FRONTEND_PORT" -ge 1024 && test "$FRONTEND_PORT" -le 65535 || die 'Use port 1024..65535'
|
||||
;;
|
||||
stage)
|
||||
mkdir -p "$release"
|
||||
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
|
||||
cat > "$release/compose.yaml"
|
||||
;;
|
||||
stage-health)
|
||||
test -f "$release/compose.yaml" || die 'Stage Compose first'
|
||||
test ! -e "$release/health.sh" || die 'Attempt health configuration already exists'
|
||||
cat > "$release/health.sh"
|
||||
;;
|
||||
prepare)
|
||||
version=$1; sha=$2; prefix=$3
|
||||
backend='-'; frontend='-'
|
||||
for role in $DEPLOY_ROLES; do
|
||||
repository="$prefix$role"
|
||||
image="$repository:$sha"
|
||||
docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2
|
||||
identity "$image" "$sha"
|
||||
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
|
||||
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
|
||||
test -n "$digest" || die 'Missing registry digest'
|
||||
if [ "$role" = backend ]; then backend=$digest; else frontend=$digest; fi
|
||||
done
|
||||
printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \
|
||||
"$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env"
|
||||
printf 'COMPOSE_PROJECT=%s\nDEPLOY_ROLES="%s"\nDEPENDENCY_SERVICES="%s"\nTARGET_PLATFORM=%s\n' \
|
||||
"$COMPOSE_PROJECT" "$DEPLOY_ROLES" "${DEPENDENCY_SERVICES:-}" "$TARGET_PLATFORM" >> "$release/release.env"
|
||||
compose config --quiet
|
||||
printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend"
|
||||
;;
|
||||
ensure)
|
||||
use_release "$1"
|
||||
. "$release/release.env"
|
||||
test "$RELEASE_VERSION" = "$2" && test "$RELEASE_SHA" = "$3" \
|
||||
&& test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \
|
||||
|| die 'Retained configuration differs from recorded successful release'
|
||||
for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do
|
||||
[ "$image" != - ] || continue
|
||||
docker_cmd pull --platform "$TARGET_PLATFORM" "$image" >&2
|
||||
identity "$image" "$RELEASE_SHA"
|
||||
done
|
||||
compose config --quiet
|
||||
;;
|
||||
apply)
|
||||
mkdir "$lock/operation" 2>/dev/null || die 'A remote replacement is still active; manual recovery required'
|
||||
trap 'rmdir "$lock/operation"' EXIT
|
||||
use_release "$1"
|
||||
. "$release/release.env"
|
||||
health_seconds=${2:-280}
|
||||
case "$health_seconds" in ''|*[!0-9]*) die 'Invalid readiness window';; esac
|
||||
test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window'
|
||||
end=$(( $(date +%s) + health_seconds ))
|
||||
# Always recreate the frontend too: nginx must resolve the new backend IP.
|
||||
# Recreate application containers only; the database and volume survive releases.
|
||||
if [ -n "${DEPENDENCY_SERVICES:-}" ]; then
|
||||
compose up -d --no-build --pull missing --timeout 20 $DEPENDENCY_SERVICES
|
||||
fi
|
||||
compose up -d --no-deps --no-build --pull never --force-recreate --timeout 20 $DEPLOY_ROLES
|
||||
while ! health; do
|
||||
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
|
||||
sleep 1
|
||||
done
|
||||
echo 'S6_HEALTHY'
|
||||
;;
|
||||
diagnose)
|
||||
use_release "$1"
|
||||
compose ps
|
||||
compose logs --no-color --tail 200
|
||||
;;
|
||||
*) die 'Unknown remote deployment operation';;
|
||||
esac
|
||||
Reference in New Issue
Block a user