refactor: remove deploy.cmd and update Dockerfile for health checks
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

- Deleted the deploy.cmd script as it was no longer needed.
- Updated Dockerfile to include curl for health checks and added a non-root user for improved security.
- Modified health check command to use curl for better reliability.
- Adjusted docker-compose.test.yml to reflect changes in health check configuration.
- Cleaned up appsettings.json and removed unused configuration properties.
- Removed Resource entity and related requests from the codebase as part of the architectural shift.
- Updated documentation to reflect the removal of hardware binding and related endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 08:47:21 +03:00
parent 43fe38e67d
commit c7b297de83
76 changed files with 4034 additions and 832 deletions
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# scripts/_lib.sh — shared helpers sourced by all deploy scripts.
#
# This file is sourced (not executed); do not set -e at the top — leave error
# handling to the caller. The helpers always check their own preconditions.
# ----- logging --------------------------------------------------------------
log_info() { printf '\033[32m[deploy]\033[0m %s\n' "$*" >&2; }
log_warn() { printf '\033[33m[deploy WARN]\033[0m %s\n' "$*" >&2; }
log_error() { printf '\033[31m[deploy ERROR]\033[0m %s\n' "$*" >&2; }
die() { log_error "$*"; exit 1; }
# ----- input validation -----------------------------------------------------
require_env() {
local var
for var in "$@"; do
if [[ -z "${!var:-}" ]]; then
die "Required environment variable not set: $var"
fi
done
}
require_cmd() {
local cmd
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
die "Required command not found on PATH: $cmd"
fi
done
}
# ----- env overlay ----------------------------------------------------------
# load_env_overlay <env>
# 1. Sources scripts/_defaults.env if present (developer-friendly defaults).
# 2. Sources secrets/<env>.public.env (committed plain-text).
# 3. Decrypts secrets/<env>.env via sops + age and sources the result.
# The decrypted intermediate is written to a mktemp file and removed on EXIT.
load_env_overlay() {
local env="$1"
local script_dir repo_root public_file enc_file decrypted
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
if [[ -f "$repo_root/.env" ]]; then
# Local dev convenience; harmless on a production host because the
# production host should not have a .env in REPO_ROOT.
log_info "Sourcing $repo_root/.env"
set -a; . "$repo_root/.env"; set +a
fi
public_file="$repo_root/secrets/${env}.public.env"
if [[ -f "$public_file" ]]; then
log_info "Sourcing $public_file"
set -a; . "$public_file"; set +a
else
log_warn "No $public_file — relying on environment / .env only"
fi
enc_file="$repo_root/secrets/${env}.env"
if [[ -f "$enc_file" ]]; then
require_cmd sops age
decrypted="$(mktemp -t azaion-env.XXXXXX)"
# shellcheck disable=SC2064
trap "rm -f '$decrypted'" EXIT INT TERM
if ! SOPS_AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-/etc/azaion/age.key}" \
sops -d "$enc_file" > "$decrypted" 2>/tmp/sops.err; then
log_error "sops decrypt failed for $enc_file"
cat /tmp/sops.err >&2
die "Cannot continue without secrets"
fi
chmod 600 "$decrypted"
log_info "Sourcing decrypted overlay (intermediate: $decrypted)"
set -a; . "$decrypted"; set +a
else
log_warn "No $enc_file — secret values must already be in the environment"
fi
}
# ----- container helpers ----------------------------------------------------
container_exists() {
docker container inspect "$1" >/dev/null 2>&1
}
container_running() {
[[ "$(docker container inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo false)" == "true" ]]
}
current_image_revision() {
# Returns the org.opencontainers.image.revision label of the running
# container, or empty if the container does not exist.
docker container inspect "$1" \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' 2>/dev/null || true
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# scripts/deploy.sh — Azaion Admin API deployment orchestrator.
#
# Usage:
# ENV=staging ./scripts/deploy.sh <sha-tag>
# ENV=production ./scripts/deploy.sh <sha-tag>
# ./scripts/deploy.sh --rollback # uses the SHA from previous_tags.env
# ./scripts/deploy.sh --help
#
# This is the single entry point; do not call the per-step scripts (pull/stop/
# start/health) directly except from this file.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=./_lib.sh
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage:
ENV=staging|production ./scripts/deploy.sh <sha-tag>
./scripts/deploy.sh --rollback
./scripts/deploy.sh --help
Environment:
ENV Required. "staging" or "production". Selects which
secrets/<env>.env (sops-encrypted) is decrypted.
REGISTRY_HOST,
REGISTRY_IMAGE Registry hostname and image path; loaded from
secrets/<env>.public.env unless already set.
DEPLOY_* See .env.example.
Notes:
- Run this on the deploy target host (it does not SSH for you in cycle 1).
- Requires: docker, sops, age, curl, jq.
EOF
}
ROLLBACK=0
SHA_TAG=""
for arg in "$@"; do
case "$arg" in
--help|-h) usage; exit 0 ;;
--rollback) ROLLBACK=1 ;;
-*) die "Unknown flag: $arg (use --help)" ;;
*) SHA_TAG="$arg" ;;
esac
done
require_env ENV
require_cmd docker sops age curl jq
load_env_overlay "$ENV"
if [[ "$ROLLBACK" -eq 1 ]]; then
PREV_FILE="$REPO_ROOT/scripts/.previous_tags.env"
[[ -f "$PREV_FILE" ]] || die "No $PREV_FILE — cannot determine rollback target"
# shellcheck disable=SC1090
. "$PREV_FILE"
[[ -n "${PREVIOUS_SHA_TAG:-}" ]] || die "PREVIOUS_SHA_TAG missing in $PREV_FILE"
SHA_TAG="$PREVIOUS_SHA_TAG"
log_warn "ROLLBACK requested → redeploying $SHA_TAG"
fi
[[ -n "$SHA_TAG" ]] || die "Missing <sha-tag>. Pass the immutable SHA-tag (e.g. a1b2c3d4e5f6-arm) or use --rollback."
export REGISTRY_TAG="$SHA_TAG"
log_info "Deploy plan"
log_info " ENV=$ENV"
log_info " REGISTRY_HOST=$REGISTRY_HOST"
log_info " REGISTRY_IMAGE=$REGISTRY_IMAGE"
log_info " REGISTRY_TAG=$REGISTRY_TAG"
log_info " DEPLOY_CONTAINER_NAME=$DEPLOY_CONTAINER_NAME"
log_info " DEPLOY_HOST_PORT=$DEPLOY_HOST_PORT"
"$SCRIPT_DIR/pull-images.sh"
"$SCRIPT_DIR/stop-services.sh"
"$SCRIPT_DIR/start-services.sh"
"$SCRIPT_DIR/health-check.sh"
log_info "Deploy succeeded — $REGISTRY_HOST/$REGISTRY_IMAGE:$REGISTRY_TAG is live as $DEPLOY_CONTAINER_NAME"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# scripts/health-check.sh — poll /health/ready until 200 or timeout. Used as
# the post-start gate by deploy.sh. Returns non-zero on any failure.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage: ./scripts/health-check.sh [--help]
Reads from the environment:
DEPLOY_HOST_PORT port the container is published on (default 4000)
HEALTH_BASE_URL full URL override; defaults to http://127.0.0.1:$DEPLOY_HOST_PORT
HEALTH_TIMEOUT seconds total to wait (default 60)
HEALTH_INTERVAL seconds between attempts (default 2)
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
require_cmd curl
PORT="${DEPLOY_HOST_PORT:-4000}"
BASE_URL="${HEALTH_BASE_URL:-http://127.0.0.1:$PORT}"
TIMEOUT="${HEALTH_TIMEOUT:-60}"
INTERVAL="${HEALTH_INTERVAL:-2}"
# Liveness first (cheap; fails only if the process is wedged).
log_info "Probing $BASE_URL/health/live"
if ! curl --fail --silent --show-error --max-time 3 "$BASE_URL/health/live" >/dev/null; then
die "/health/live did not return 200 — container is not responsive"
fi
log_info "Polling $BASE_URL/health/ready (timeout=${TIMEOUT}s, interval=${INTERVAL}s)"
DEADLINE=$(( $(date +%s) + TIMEOUT ))
ATTEMPT=0
while :; do
ATTEMPT=$((ATTEMPT + 1))
if BODY="$(curl --fail --silent --show-error --max-time 3 "$BASE_URL/health/ready" 2>/dev/null)"; then
log_info "/health/ready returned 200 on attempt $ATTEMPT: $BODY"
exit 0
fi
NOW=$(date +%s)
if (( NOW >= DEADLINE )); then
die "/health/ready did not return 200 within ${TIMEOUT}s (gave up after $ATTEMPT attempts)"
fi
sleep "$INTERVAL"
done
+128
View File
@@ -0,0 +1,128 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const ADMIN_EMAIL = __ENV.ADMIN_EMAIL || 'admin@azaion.com';
const ADMIN_PASSWORD = __ENV.ADMIN_PASSWORD || 'Admin1234';
export const options = {
scenarios: {
nft_perf_01_login: {
executor: 'constant-vus',
vus: 10,
duration: '30s',
exec: 'login',
tags: { scenario: 'nft_perf_01_login' },
},
nft_perf_04_user_list: {
executor: 'constant-vus',
vus: 10,
duration: '30s',
exec: 'userList',
tags: { scenario: 'nft_perf_04_user_list' },
startTime: '35s',
},
},
thresholds: {
'http_req_duration{scenario:nft_perf_01_login}': ['p(95)<500'],
'http_req_duration{scenario:nft_perf_04_user_list}': ['p(95)<1000'],
'http_req_failed{scenario:nft_perf_01_login}': ['rate<0.01'],
'http_req_failed{scenario:nft_perf_04_user_list}': ['rate<0.01'],
},
};
// setup() runs once before all VUs. Pre-fetch a JWT so the user-list scenario
// measures only the listing path, not login latency.
export function setup() {
const res = http.post(
`${BASE_URL}/login`,
JSON.stringify({ Email: ADMIN_EMAIL, Password: ADMIN_PASSWORD }),
{ headers: { 'Content-Type': 'application/json' } }
);
if (res.status !== 200) {
throw new Error(`setup: login failed (status ${res.status}): ${res.body}`);
}
const body = JSON.parse(res.body);
const token = body.token || body.Token;
if (!token) {
throw new Error(`setup: login response missing Token: ${res.body}`);
}
return { token };
}
export function login() {
const res = http.post(
`${BASE_URL}/login`,
JSON.stringify({ Email: ADMIN_EMAIL, Password: ADMIN_PASSWORD }),
{
headers: { 'Content-Type': 'application/json' },
tags: { scenario: 'nft_perf_01_login' },
}
);
check(res, {
'login status 200': (r) => r.status === 200,
'login returned token': (r) => {
try {
const body = JSON.parse(r.body);
return !!(body.token || body.Token);
} catch {
return false;
}
},
});
sleep(0.1);
}
export function userList(data) {
const res = http.get(`${BASE_URL}/users`, {
headers: { Authorization: `Bearer ${data.token}` },
tags: { scenario: 'nft_perf_04_user_list' },
});
check(res, {
'user list status 200': (r) => r.status === 200,
'user list returned >= 500 users': (r) => {
try {
const arr = JSON.parse(r.body);
return Array.isArray(arr) && arr.length >= 500;
} catch {
return false;
}
},
});
sleep(0.1);
}
export function handleSummary(data) {
return {
'e2e/test-results/perf-summary.json': JSON.stringify(data, null, 2),
stdout: textSummary(data),
};
}
function textSummary(data) {
const lines = [];
lines.push('');
lines.push('=== PERF SUMMARY ===');
for (const [name, metric] of Object.entries(data.metrics)) {
if (!name.startsWith('http_req_duration') && !name.startsWith('http_req_failed')) continue;
const vals = metric.values || {};
const parts = [];
if (vals['p(50)'] !== undefined) parts.push(`p50=${vals['p(50)'].toFixed(1)}ms`);
if (vals['p(95)'] !== undefined) parts.push(`p95=${vals['p(95)'].toFixed(1)}ms`);
if (vals['p(99)'] !== undefined) parts.push(`p99=${vals['p(99)'].toFixed(1)}ms`);
if (vals.rate !== undefined) parts.push(`rate=${(vals.rate * 100).toFixed(2)}%`);
if (vals.count !== undefined) parts.push(`count=${vals.count}`);
lines.push(` ${name}: ${parts.join(' · ')}`);
}
if (data.root_group && data.root_group.checks) {
const checks = data.root_group.checks;
if (checks.length > 0) {
lines.push('--- checks ---');
for (const c of checks) {
lines.push(` ${c.name}: ${c.passes} pass / ${c.fails} fail`);
}
}
}
lines.push('====================');
return lines.join('\n') + '\n';
}
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# scripts/pull-images.sh — login + pull the target image. Idempotent.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage: ./scripts/pull-images.sh [--help]
Reads from the environment (use scripts/deploy.sh which sources the overlays):
REGISTRY_HOST, REGISTRY_IMAGE, REGISTRY_TAG image coordinates
REGISTRY_USER, REGISTRY_TOKEN optional; if both set, docker login first
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
require_env REGISTRY_HOST REGISTRY_IMAGE REGISTRY_TAG
require_cmd docker
IMAGE="$REGISTRY_HOST/$REGISTRY_IMAGE:$REGISTRY_TAG"
if [[ -n "${REGISTRY_USER:-}" && -n "${REGISTRY_TOKEN:-}" ]]; then
log_info "Logging in to $REGISTRY_HOST as $REGISTRY_USER"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin >/dev/null
else
log_warn "No REGISTRY_USER / REGISTRY_TOKEN — assuming pre-authenticated docker"
fi
log_info "Pulling $IMAGE"
docker pull "$IMAGE"
# Surface the digest for the deploy log; the operator can reference it later.
DIGEST="$(docker image inspect "$IMAGE" --format '{{ index .RepoDigests 0 }}' 2>/dev/null || true)"
if [[ -n "$DIGEST" ]]; then
log_info "Pulled digest: $DIGEST"
else
log_warn "Could not resolve digest for $IMAGE (image may not have a registry digest yet)"
fi
+42 -19
View File
@@ -3,41 +3,64 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RESULTS_DIR="$PROJECT_ROOT/e2e/test-results"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
SCENARIOS_FILE="$SCRIPT_DIR/perf-scenarios.js"
BASE_URL="${BASE_URL:-http://localhost:8080}"
PERF_USER_COUNT="${PERF_USER_COUNT:-500}"
if ! command -v k6 >/dev/null 2>&1; then
echo "ERROR: k6 not found on PATH. Install with: brew install k6"
exit 1
fi
mkdir -p "$RESULTS_DIR"
cleanup() {
echo "Cleaning up..."
docker compose -f "$PROJECT_ROOT/docker-compose.test.yml" down -v --remove-orphans 2>/dev/null || true
echo "=== Tearing down ==="
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans || true
}
trap cleanup EXIT
echo "=== Starting system under test ==="
docker compose -f "$PROJECT_ROOT/docker-compose.test.yml" up -d system-under-test test-db
docker compose -f "$COMPOSE_FILE" up -d system-under-test test-db
echo "=== Waiting for system to be ready ==="
MAX_WAIT=30
MAX_WAIT=60
WAIT=0
until curl -sf http://localhost:8080/swagger/index.html > /dev/null 2>&1 || [ $WAIT -ge $MAX_WAIT ]; do
until curl -sf "$BASE_URL/swagger/index.html" > /dev/null 2>&1 || [ $WAIT -ge $MAX_WAIT ]; do
sleep 1
WAIT=$((WAIT + 1))
done
if [ $WAIT -ge $MAX_WAIT ]; then
echo "ERROR: System did not become ready within ${MAX_WAIT}s"
docker compose -f "$COMPOSE_FILE" logs --tail=80 system-under-test || true
exit 1
fi
echo "=== Running performance tests ==="
echo "Performance test runner not yet configured."
echo "Install k6, locust, or artillery and add load scenarios from:"
echo " _docs/02_document/tests/performance-tests.md"
echo ""
echo "Example with k6:"
echo " k6 run scripts/perf-scenarios.js"
echo ""
echo "Thresholds from test spec:"
echo " NFT-PERF-01: Login p95 < 500ms"
echo " NFT-PERF-02: Small file download p95 < 1000ms"
echo " NFT-PERF-03: Large file download p95 < 30000ms"
echo " NFT-PERF-04: User list p95 < 1000ms"
echo "=== Seeding $PERF_USER_COUNT perf users ==="
# Reuse the admin password hash so the rows satisfy NOT NULL on password_hash.
# These users are only used as listing volume, never for login.
docker compose -f "$COMPOSE_FILE" exec -T test-db psql -U postgres -d azaion -v ON_ERROR_STOP=1 <<SQL
INSERT INTO public.users (id, email, password_hash, role)
SELECT
gen_random_uuid(),
'perf-user-' || lpad(g::text, 5, '0') || '@perf.azaion.com',
'282wqVHZU0liTxphiGkKIaJtUA1W6rILdvfEOx8Ez350x0XLbgNtrSUYCK1r/ajq',
'ApiAdmin'
FROM generate_series(1, $PERF_USER_COUNT) AS g
ON CONFLICT (email) DO NOTHING;
SELECT count(*) AS users_total FROM public.users;
SQL
echo "=== Performance test scaffolding complete ==="
echo "=== Running k6 performance scenarios ==="
# handleSummary() in perf-scenarios.js writes the JSON summary to RESULTS_DIR.
set +e
BASE_URL="$BASE_URL" k6 run "$SCENARIOS_FILE"
K6_EXIT=$?
set -e
echo "=== Performance test run complete (k6 exit=$K6_EXIT) ==="
exit $K6_EXIT
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# scripts/smoke.sh — minimal post-deploy critical-path checks. Run from the
# operator's workstation against the public URL, NOT from the deploy host.
#
# Usage:
# BASE_URL=https://stage.admin.azaion.com SMOKE_ADMIN_EMAIL=... SMOKE_ADMIN_PASSWORD=... ./scripts/smoke.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage: ./scripts/smoke.sh [--help]
Required environment:
BASE_URL public base URL of the deploy target
SMOKE_ADMIN_EMAIL existing ApiAdmin user
SMOKE_ADMIN_PASSWORD matching password
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
require_env BASE_URL SMOKE_ADMIN_EMAIL SMOKE_ADMIN_PASSWORD
require_cmd curl jq
step() { log_info "smoke[$1] $2"; }
fail() { log_error "smoke[$1] FAILED: $2"; exit 1; }
# 1. Liveness (anonymous)
step 1 "GET /health/live"
curl --fail --silent --show-error --max-time 5 "$BASE_URL/health/live" >/dev/null \
|| fail 1 "/health/live did not return 200"
# 2. Readiness — public-facing nginx may not expose /health/ready (it's internal-
# only by design). Skip if not 200; the deploy script already checked it inside
# the host network.
step 2 "GET /health/ready (best-effort, may be unreachable from public URL)"
if curl --fail --silent --show-error --max-time 5 "$BASE_URL/health/ready" >/dev/null; then
log_info "/health/ready returned 200 (exposed publicly — verify this is intentional)"
fi
# 3. Login → JWT
step 3 "POST /login as $SMOKE_ADMIN_EMAIL"
TOKEN_JSON="$(curl --fail --silent --show-error --max-time 10 \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg e "$SMOKE_ADMIN_EMAIL" --arg p "$SMOKE_ADMIN_PASSWORD" '{email:$e, password:$p}')" \
"$BASE_URL/login")" \
|| fail 3 "login request failed"
TOKEN="$(echo "$TOKEN_JSON" | jq -r '.token // .Token // empty')"
[[ -n "$TOKEN" ]] || fail 3 "login returned no token: $TOKEN_JSON"
AUTH=(-H "Authorization: Bearer $TOKEN")
# 4. Authenticated GET /users/current
step 4 "GET /users/current"
curl --fail --silent --show-error --max-time 5 "${AUTH[@]}" "$BASE_URL/users/current" >/dev/null \
|| fail 4 "/users/current did not return 200"
# 5. Authenticated GET /users
step 5 "GET /users"
USERS_JSON="$(curl --fail --silent --show-error --max-time 10 "${AUTH[@]}" "$BASE_URL/users")" \
|| fail 5 "/users did not return 200"
USER_COUNT="$(echo "$USERS_JSON" | jq 'length')"
log_info "/users returned $USER_COUNT rows"
# 6. Authenticated GET /resources/list (default folder)
step 6 "GET /resources/list"
curl --fail --silent --show-error --max-time 5 "${AUTH[@]}" "$BASE_URL/resources/list" >/dev/null \
|| fail 6 "/resources/list did not return 200"
log_info "smoke OK — 6 checks passed"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# scripts/start-services.sh — `docker run` the API with the env overlay
# materialized into a temp file. Bind mounts come from DEPLOY_HOST_*.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage: ./scripts/start-services.sh [--help]
Reads from the environment (deploy.sh sets these):
REGISTRY_HOST, REGISTRY_IMAGE, REGISTRY_TAG
DEPLOY_CONTAINER_NAME, DEPLOY_HOST_PORT
DEPLOY_HOST_CONTENT_DIR, DEPLOY_HOST_LOGS_DIR
ASPNETCORE_ENVIRONMENT, ASPNETCORE_URLS
ASPNETCORE_ConnectionStrings__AzaionDb / __AzaionDbAdmin
ASPNETCORE_JwtConfig__Secret
ASPNETCORE_ResourcesConfig__* (defaults from appsettings.json if unset)
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
require_env \
REGISTRY_HOST REGISTRY_IMAGE REGISTRY_TAG \
DEPLOY_CONTAINER_NAME DEPLOY_HOST_PORT \
DEPLOY_HOST_CONTENT_DIR DEPLOY_HOST_LOGS_DIR \
ASPNETCORE_ConnectionStrings__AzaionDb \
ASPNETCORE_ConnectionStrings__AzaionDbAdmin \
ASPNETCORE_JwtConfig__Secret
require_cmd docker
IMAGE="$REGISTRY_HOST/$REGISTRY_IMAGE:$REGISTRY_TAG"
# Materialize an env file for `docker run --env-file`. We pass only the
# ASPNETCORE_* + AZAION_* variables — registry / deploy host vars stay on the
# host, never in the container.
ENV_FILE="$(mktemp -t azaion-runtime-env.XXXXXX)"
chmod 600 "$ENV_FILE"
trap 'rm -f "$ENV_FILE"' EXIT INT TERM
env | grep -E '^(ASPNETCORE_|AZAION_)' > "$ENV_FILE" || true
mkdir -p "$DEPLOY_HOST_CONTENT_DIR" "$DEPLOY_HOST_LOGS_DIR"
log_info "Starting $DEPLOY_CONTAINER_NAME from $IMAGE on host port $DEPLOY_HOST_PORT"
docker run --detach \
--name "$DEPLOY_CONTAINER_NAME" \
--restart unless-stopped \
--env-file "$ENV_FILE" \
--publish "$DEPLOY_HOST_PORT:8080" \
--volume "$DEPLOY_HOST_CONTENT_DIR:/app/Content" \
--volume "$DEPLOY_HOST_LOGS_DIR:/app/logs" \
"$IMAGE" >/dev/null
log_info "Container ID: $(docker container inspect -f '{{.Id}}' "$DEPLOY_CONTAINER_NAME" | cut -c1-12)"
log_info "Running revision label: $(current_image_revision "$DEPLOY_CONTAINER_NAME")"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# scripts/stop-services.sh — graceful stop + record the previous image SHA so
# `./scripts/deploy.sh --rollback` can find a target.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/_lib.sh"
usage() {
cat <<'EOF'
Usage: ./scripts/stop-services.sh [--help]
Reads from the environment:
DEPLOY_CONTAINER_NAME name of the container to stop
REGISTRY_TAG (optional, for logging only)
Side-effect: writes scripts/.previous_tags.env containing PREVIOUS_SHA_TAG so
the next deploy can roll back to whatever was running just before this stop.
EOF
}
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
require_env DEPLOY_CONTAINER_NAME
require_cmd docker
PREV_FILE="$SCRIPT_DIR/.previous_tags.env"
if container_exists "$DEPLOY_CONTAINER_NAME"; then
REVISION="$(current_image_revision "$DEPLOY_CONTAINER_NAME")"
if [[ -n "$REVISION" ]]; then
SHA12="$(echo "$REVISION" | cut -c1-12)"
SUFFIX="${REGISTRY_TAG##*-}" # arm / amd; falls back to whatever follows the last dash
PREV_TAG="${SHA12}-${SUFFIX:-arm}"
printf 'PREVIOUS_SHA_TAG=%s\nPREVIOUS_REVISION=%s\nRECORDED_AT=%s\n' \
"$PREV_TAG" "$REVISION" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PREV_FILE"
log_info "Recorded rollback target → $PREV_TAG (revision $REVISION) in $PREV_FILE"
else
log_warn "Could not read org.opencontainers.image.revision from $DEPLOY_CONTAINER_NAME — rollback target NOT recorded"
fi
if container_running "$DEPLOY_CONTAINER_NAME"; then
# 40s matches the grace period in deployment_procedures.md §1; the app
# itself shuts down after ShutdownTimeout=30s leaving 10s headroom.
log_info "Stopping $DEPLOY_CONTAINER_NAME (grace 40s)"
docker stop -t 40 "$DEPLOY_CONTAINER_NAME"
else
log_info "$DEPLOY_CONTAINER_NAME exists but is not running"
fi
log_info "Removing container $DEPLOY_CONTAINER_NAME"
docker rm -f "$DEPLOY_CONTAINER_NAME"
else
log_info "$DEPLOY_CONTAINER_NAME does not exist — nothing to stop"
fi