S19: establish engineering repository

This commit is contained in:
2026-11-08 15:15:50 +01:00
commit c25ce11a1b
84 changed files with 6599 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
#!/bin/sh
# One-time DSM administrator action, run with sudo; does not restart any service.
set -eu
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
account=${1:?Supply deployment account}
docker=${2:?Supply absolute Docker executable}
case "$account" in ''|*[!a-zA-Z0-9_-]*) exit 1;; esac
case "$docker" in /*) ;; *) exit 1;; esac
target=/etc/sudoers.d/"$account"
rule="$account ALL=(root) NOPASSWD: $docker"
test -x "$docker" && id "$account" >/dev/null
test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; }
if [ -e "$target" ]; then
test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; }
else
umask 077
temporary=$(mktemp /etc/sudoers.d/"$account".XXXXXX)
trap 'rm -f "$temporary"' EXIT
printf '%s\n' "$rule" > "$temporary"
chmod 440 "$temporary"
if command -v visudo >/dev/null 2>&1; then visudo -cf "$temporary"; fi
# A dot in the temporary name keeps sudo from loading the uncommitted file.
mv "$temporary" "$target"
if ! sudo -n -u "$account" sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'; then
rm "$target"
echo 'Docker permission check failed; new sudoers entry removed' >&2
exit 1
fi
fi
echo 'Dedicated Docker sudo permission is configured; no services were restarted.'

View File

@@ -0,0 +1,38 @@
#!/bin/sh
# Account-level provisioning. Docker sudo must already be enabled separately.
set -eu
umask 077
root=$1
registry_user=$2
registry=$3
bind=$4
port=$5
docker=${6:?Supply the Docker executable path}
test -x "$docker"
sudo -n "$docker" compose version
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'
test ! -L "$root"
mkdir -p "$root" "$root/docker-auth"
chmod 700 "$root" "$root/docker-auth"
if [ ! -f "$root/production.env" ]; then
# First provisioning must not take a port already occupied by another service.
command -v netstat >/dev/null
if netstat -lnt | awk -v port=":$port" '$4 ~ (port "$") { found=1 } END { exit !found }'; then
echo 'Frontend port is already occupied; production configuration unchanged' >&2
exit 1
fi
printf 'BIND_IP=%s\nFRONTEND_PORT=%s\nDOCKER_BIN=%s\nDOCKER_SUDO=1\nDOCKER_CONFIG=%s/docker-auth\n' \
"$bind" "$port" "$docker" "$root" > "$root/production.env"
chmod 600 "$root/production.env"
else
# Preserve existing config; refuse to silently rebind a configured deployment.
. "$root/production.env"
test "$BIND_IP" = "$bind" && test "$FRONTEND_PORT" = "$port" \
&& test "$DOCKER_CONFIG" = "$root/docker-auth" || {
echo 'Existing production.env differs; review it before provisioning' >&2
exit 1
}
fi
# Credential enters only via stdin. No password, environment or auth config is logged.
sudo -n "$docker" --config "$root/docker-auth" login "$registry" --username "$registry_user" --password-stdin
echo 'Production directory and dedicated registry login prepared; no containers were changed.'

204
resources/deploy/remote.sh Normal file
View 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