#!/usr/bin/env bash
#
# PRODUCTION DEPLOY — the schema first, then the stack (Cham's Q41 (a),
# NEEDS 101, round 915). `make prod-deploy` runs this.
#
#   1. The release's migrations run as a ONE-OFF SWARM JOB: the release's own
#      image, production's own settings (docker/prod/settings.env) and its
#      Docker secrets, on the network the app reaches its database through,
#      `php /app/yii migrate:up -n`, once, never restarted.
#   2. **Only if that job completed** is the stack deployed. A migration that
#      fails stops the release before any new container exists, so nothing
#      new takes traffic and the old release keeps serving the schema it
#      knows. Running it at container start was the other way, and two
#      replicas starting together would race over one schema.
#   3. `docker stack deploy`, waiting for the rolling update; a rollback or a
#      paused update fails the deploy and names the task that failed.
#
# A release that somehow starts without its migrations still never takes
# traffic: `/health/app` reports it not ready (INV-188, round 807).
#
# `--migrate-only` stops after step 1 (CI proves the job on a real swarm).
#
# The names come from docker/.env (IMAGE, IMAGE_TAG, STACK_NAME, PROD_SSH) and
# are refused when unset — a deploy that stops is better than one that
# guesses. PROD_SSH is a Docker endpoint (`ssh://…` or `unix://…`), never a
# credential. Nothing here holds or prints a secret: the settings file is
# handed to Docker by path, and the secrets by name.
#
# SECRETS ARE DOCKER SECRETS, NEVER SETTINGS (round 944; Cham's Q31 (a):
# "secret manager → Docker secrets; never persistent production `.env`").
# They are created on the swarm from the secret manager and mounted, in
# memory, at /run/secrets/<name>; the app reads them there (src/SecretFiles.php).
# So the deploy refuses, before Docker is asked anything:
#   - docker/prod/.env, the file production's secrets used to live in, until
#     it is gone — its settings belong in settings.env, its secrets in Docker;
#   - docker/prod/override.env: the stack format `docker stack deploy` reads
#     cannot make an env file optional, so production has one settings file,
#     and a second would be settings nobody applies;
#   - a settings file that names a secret, set or passed through (a bare
#     `NAME` line hands Docker the deploying shell's value);
# and then a swarm that lacks a secret every release needs. The app refuses a
# secret given as an environment variable too, so a release started any other
# way stops the same.
set -euo pipefail
cd "$(dirname "$0")/.."

missing=""
for name in IMAGE IMAGE_TAG STACK_NAME PROD_SSH; do
    [ -n "${!name:-}" ] || missing="$missing $name"
done
if [ -n "$missing" ]; then
    echo "Refusing: these are unset:$missing"
    echo "They come from docker/.env — run 'make init', then set them."
    exit 1
fi
if [ -f docker/prod/.env ]; then
    echo "Refusing: docker/prod/.env is still here. Production's secrets are Docker secrets now (Q31 (a)):"
    echo "move its settings to docker/prod/settings.env, give its secrets to the swarm, then remove it."
    exit 1
fi
if [ -f docker/prod/override.env ]; then
    echo "Refusing: docker/prod/override.env would not be applied — production has one settings file."
    echo "Move its lines into docker/prod/settings.env, then remove it."
    exit 1
fi
if [ ! -f docker/prod/settings.env ]; then
    echo "Refusing: docker/prod/settings.env is missing — production's settings, which the migration job needs."
    exit 1
fi

# Every secret the app reads (src/SecretFiles.php, NAMES — a test holds the two
# lists equal), and the two every release needs, as Docker names them.
secret_names=(NEXHIRE_SECRET_CIPHER_KEY DB_PASSWORD NEXHIRE_S3_ACCESS_KEY_ID NEXHIRE_S3_SECRET_ACCESS_KEY NEXHIRE_SMTP_PASSWORD)
cipher_secret="${NEXHIRE_SECRET_CIPHER_KEY_SECRET:-nexhire_secret_cipher_key}"
db_secret="${DB_PASSWORD_SECRET:-db_password}"

settings=(--env-file docker/prod/settings.env)
pattern="$(IFS='|'; echo "${secret_names[*]}")"
named="$(grep -oE "^[[:space:]]*(export[[:space:]]+)?(${pattern})[[:space:]]*(=|\$)" docker/prod/settings.env | grep -oE "${pattern}" | sort -u | tr '\n' ' ' || true)"
if [ -n "$named" ]; then
    echo "Refusing: docker/prod/settings.env names ${named% }. Secrets are Docker secrets, never settings (Q31 (a)):"
    echo "take the line out and give the value to the swarm with 'docker secret create'."
    exit 1
fi

docker_() { docker -H "$PROD_SSH" "$@"; }

missing_secrets=""
for secret in "$cipher_secret" "$db_secret"; do
    docker_ secret inspect "$secret" >/dev/null 2>&1 || missing_secrets="$missing_secrets $secret"
done
if [ -n "$missing_secrets" ]; then
    echo "Refusing: the swarm has no secret named:$missing_secrets"
    echo "Create each from the secret manager (docs/runbooks/production-environment.md). Nothing was started."
    exit 1
fi
secrets=(--secret "source=$cipher_secret,target=nexhire_secret_cipher_key" --secret "source=$db_secret,target=db_password")

network="${PROD_NETWORK:-caddy_public}"
job="${STACK_NAME}_migrate"

migrate() {
    # A job left behind by a deploy that stopped half-way would refuse the name.
    docker_ service rm "$job" >/dev/null 2>&1 || true
    echo "Applying migrations from ${IMAGE}:${IMAGE_TAG} as a one-off job ($job)."
    created=0
    docker_ service create --name "$job" --mode replicated-job --restart-condition none \
        --network "$network" "${settings[@]}" "${secrets[@]}" --with-registry-auth --detach=false --quiet \
        "${IMAGE}:${IMAGE_TAG}" php /app/yii migrate:up -n || created=$?
    state="$(docker_ service ps "$job" --no-trunc --format '{{.CurrentState}}' 2>/dev/null | head -n 1 || true)"
    logs="$(docker_ service logs --raw "$job" 2>&1 || true)"
    printf '%s\n' "$logs" | tail -n 40
    docker_ service rm "$job" >/dev/null 2>&1 || true

    if [ "$created" -ne 0 ] || [ "${state%% *}" != "Complete" ]; then
        echo "Refusing to deploy: the migration job ended '${state:-unknown}' (exit $created). Nothing new was started."
        return 1
    fi
    # Judged by what it printed as well, as CI judges the image's own run.
    if printf '%s\n' "$logs" | grep -qE 'SQLSTATE\[|\[ERROR\]'; then
        echo "Refusing to deploy: the migration job reported an error. Nothing new was started."
        return 1
    fi
    echo "Migrations applied."
}

deploy() {
    # Read back below, then gone: a deploy leaves nothing in the working tree.
    log="$(mktemp)"
    trap 'rm -f "$log"' EXIT
    docker_ stack deploy --prune --detach=false --with-registry-auth \
        -c docker/compose.yml -c docker/prod/compose.yml "$STACK_NAME" 2>&1 | tee "$log"
    if grep -qiE 'rollback:|update rolled back|service update paused' "$log"; then
        failed_task="$(grep -oiE 'task[[:space:]]+[a-z0-9]+' "$log" | head -n 1 | awk '{print $2}' || true)"
        if [ -n "$failed_task" ]; then
            echo "Docker Swarm update failed. Failed task ID: $failed_task"
            echo "--- docker service logs ($failed_task) ---"
            docker_ service logs --timestamps --tail 500 "$failed_task" || true
        else
            echo "Docker Swarm update failed. Failed task ID: not found in deploy output."
        fi
        return 1
    fi
}

migrate
[ "${1:-}" = "--migrate-only" ] && exit 0
deploy
