feat(deploy): add fast code-only update script with guardrails and docs

This commit is contained in:
Christian 2026-05-17 23:05:22 +02:00
parent 08f40977f9
commit 468814ca8d
3 changed files with 569 additions and 0 deletions

View File

@ -258,6 +258,32 @@ crontab -e
## 🔄 Opdatering til Ny Version ## 🔄 Opdatering til Ny Version
### Valg Af Update-metode
| Situation | Brug metode | Hvorfor |
|---|---|---|
| Små kodeændringer i `app/*` eller `main.py` | `./update_fast.sh --ref <ref>` | Hurtig update uden ny release-tag/pakke |
| Ændringer i `migrations/*` | `./updateto.sh <version>` | Kræver kontrolleret release + migrations-flow |
| Ændringer i `requirements.txt` eller `Dockerfile` | `./updateto.sh <version>` | Kræver fuld image-build og versionsstyring |
| Ændringer i `docker-compose*.yml`, scripts eller `.env` | `./updateto.sh <version>` | Drift/infra-konfiguration skal deployes fuldt |
| Når du er i tvivl | `./updateto.sh <version>` | Sikreste og mest forudsigelige metode |
Hurtig start for fast mode:
```bash
# Tjek først scope
./update_fast.sh --ref main --dry-run --allow-prod
# Kør update
./update_fast.sh --ref <commit-eller-tag> --allow-prod
```
Rollback i fast mode:
```bash
./update_fast.sh --rollback <backup-id> --allow-prod
```
### På din Mac: ### På din Mac:
```bash ```bash

View File

@ -22,6 +22,34 @@ cd /srv/podman/bmc_hub_v1.0
./updateto.sh v1.3.16 ./updateto.sh v1.3.16
``` ```
## Fast small update (kode-only, uden ny release tag)
Brug denne metode til meget små ændringer i `app/*` eller `main.py`, hvor du ikke vil lave en fuld release-pakke.
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Download/refresh fast update script
curl -O https://g.bmcnetworks.dk/ct/bmc_hub/raw/branch/main/update_fast.sh
chmod +x update_fast.sh
# Tjek først hvad der ændres (anbefalet)
./update_fast.sh --ref main --dry-run --allow-prod
# Kør fast update (eksempel: specifik commit)
./update_fast.sh --ref 08f4097 --allow-prod
```
Vigtigt:
- `update_fast.sh` er kun til kode/templates/static ændringer i fast scope.
- Hvis der er ændringer i migrationer, dependencies, docker/compose eller env: brug `./updateto.sh`.
- Rollback kan køres med backup-id:
```bash
./update_fast.sh --rollback 20260517-142155 --allow-prod
```
## Manuel deployment (hvis scriptet ikke virker) ## Manuel deployment (hvis scriptet ikke virker)
```bash ```bash

515
update_fast.sh Executable file
View File

@ -0,0 +1,515 @@
#!/bin/bash
# Fast code-only update for BMC Hub (DEV/PROD)
#
# Purpose:
# - Deploy small app code/template/static changes without creating a new tag/package.
# - Keep full release workflow in updateto.sh for dependency/runtime/migration changes.
#
# Safety model:
# - Allowed paths: app/* and main.py
# - Disallowed: migrations, Docker/compose, requirements, env files, deploy scripts.
set -euo pipefail
PODMAN_COMPOSE_FILE="docker-compose.prod.yml"
STATE_DIR=".fast-update"
STATE_FILE="${STATE_DIR}/current_ref"
LAST_BACKUP_FILE="${STATE_DIR}/last_backup"
TARGET_REF=""
DRY_RUN=false
ALLOW_PROD=false
ROLLBACK_ID=""
usage() {
cat <<'EOF'
Usage:
./update_fast.sh --ref <git-ref> [--dry-run] [--allow-prod]
./update_fast.sh --rollback <backup-id> [--allow-prod]
Examples:
./update_fast.sh --ref main --dry-run
./update_fast.sh --ref 08f4097
./update_fast.sh --rollback 20260517-142155
Notes:
- Fast mode is ONLY for code/template/static updates in app/* and main.py.
- For migrations, dependencies, Docker/compose, or env changes: use ./updateto.sh.
- On production hosts, --allow-prod is required.
EOF
}
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "Error: do not run as root. Use the normal rootless podman user."
exit 1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--ref)
TARGET_REF="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--allow-prod)
ALLOW_PROD=true
shift
;;
--rollback)
ROLLBACK_ID="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1"
usage
exit 1
;;
esac
done
if [ -n "$ROLLBACK_ID" ] && [ -n "$TARGET_REF" ]; then
echo "Error: use either --ref or --rollback, not both."
exit 1
fi
if [ -z "$ROLLBACK_ID" ] && [ -z "$TARGET_REF" ]; then
echo "Error: missing required option --ref <git-ref> or --rollback <backup-id>."
usage
exit 1
fi
if [ ! -f ".env" ]; then
echo "Error: .env not found in $(pwd)"
exit 1
fi
if [ ! -f "$PODMAN_COMPOSE_FILE" ]; then
echo "Error: $PODMAN_COMPOSE_FILE not found in $(pwd)"
exit 1
fi
load_env_file() {
local env_file="$1"
local line=""
local trimmed=""
local key=""
local value=""
local first_char=""
local last_char=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
trimmed="${line#"${line%%[![:space:]]*}"}"
if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]]; then
continue
fi
if [[ "$line" != *=* ]]; then
echo "Error: invalid line in .env: $line"
exit 1
fi
key="${line%%=*}"
value="${line#*=}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Error: invalid .env key: $key"
exit 1
fi
if [ "${#value}" -ge 2 ]; then
first_char="${value:0:1}"
last_char="${value: -1}"
if { [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; } || { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then
value="${value:1:${#value}-2}"
fi
fi
export "$key=$value"
done < "$env_file"
}
load_env_file .env
get_current_ip() {
local ip=""
if ip=$(hostname -I 2>/dev/null | awk '{print $1}'); then
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
fi
if command -v ipconfig >/dev/null 2>&1; then
ip=$(ipconfig getifaddr en0 2>/dev/null || true)
if [ -z "$ip" ]; then
ip=$(ipconfig getifaddr en1 2>/dev/null || true)
fi
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
fi
echo "unknown"
}
CURRENT_IP="$(get_current_ip)"
CURRENT_DIR=$(pwd)
IS_PROD=false
if [[ "$CURRENT_IP" == "172.16.31.183" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v2" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v1.0" ]]; then
IS_PROD=true
fi
if [ "$IS_PROD" = true ] && [ "$ALLOW_PROD" != true ]; then
echo "Error: production host detected. Re-run with --allow-prod to continue."
exit 1
fi
if [ "$IS_PROD" = true ] && [ "$DRY_RUN" != true ]; then
echo "Production safety check:"
echo " host ip: $CURRENT_IP"
echo " cwd: $CURRENT_DIR"
read -r -p "Type FAST-UPDATE to continue: " CONFIRM
if [ "$CONFIRM" != "FAST-UPDATE" ]; then
echo "Aborted."
exit 1
fi
fi
mkdir -p "$STATE_DIR/backups"
GITEA_BASE="${GITEA_URL:-https://g.bmcnetworks.dk}"
REPO="${GITHUB_REPO:-ct/bmc_hub}"
API_PORT="${API_PORT:-8000}"
url_encode() {
python3 - "$1" <<'PY'
import sys
from urllib.parse import quote
print(quote(sys.argv[1], safe=""))
PY
}
fetch_url_to_file() {
local url="$1"
local out_file="$2"
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" "$url" -o "$out_file"
else
curl -fsSL "$url" -o "$out_file"
fi
}
build_changed_from_compare_json() {
local compare_file="$1"
local out_file="$2"
python3 - "$compare_file" > "$out_file" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
data = json.load(f)
files = data.get("files") or []
for item in files:
status = str(item.get("status") or "")
filename = str(item.get("filename") or "")
prev = str(item.get("previous_filename") or "")
print(f"{status}\t{filename}\t{prev}")
PY
}
build_changed_from_local_git() {
local base_ref="$1"
local target_ref="$2"
local out_file="$3"
if ! git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
return 1
fi
if ! git rev-parse --verify "$target_ref" >/dev/null 2>&1; then
return 1
fi
git diff --name-status "$base_ref".."$target_ref" \
| awk 'BEGIN{OFS="\t"}
/^A\t/ {print "added", $2, ""}
/^M\t/ {print "modified", $2, ""}
/^D\t/ {print "deleted", $2, ""}
/^R[0-9]*\t/ {print "renamed", $3, $2}
/^C[0-9]*\t/ {print "copied", $3, $2}
/^T\t/ {print "type_changed", $2, ""}
/^U\t/ {print "unmerged", $2, ""}
/^X\t/ {print "unknown", $2, ""}
/^B\t/ {print "broken", $2, ""}' > "$out_file"
}
is_disallowed_path() {
local path="$1"
case "$path" in
migrations/*|Dockerfile|requirements.txt|docker-compose.yml|docker-compose.prod.yml|.env|.env.*|updateto.sh|update_fast.sh|scripts/*)
return 0
;;
*)
return 1
;;
esac
}
is_allowed_path() {
local path="$1"
case "$path" in
app/*|main.py)
return 0
;;
*)
return 1
;;
esac
}
health_check() {
local attempts=30
local i=1
while [ "$i" -le "$attempts" ]; do
if curl -fsS "http://localhost:${API_PORT}/health" >/dev/null 2>&1; then
return 0
fi
echo "Waiting for API health (${i}/${attempts})"
sleep 2
i=$((i + 1))
done
return 1
}
rebuild_api_latest() {
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" build api
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" up -d --no-deps api
}
restore_backup() {
local backup_id="$1"
local backup_dir="${STATE_DIR}/backups/${backup_id}"
local manifest="${backup_dir}/manifest.tsv"
if [ ! -d "$backup_dir" ]; then
echo "Error: backup id not found: $backup_id"
exit 1
fi
if [ ! -f "$manifest" ]; then
echo "Error: manifest missing: $manifest"
exit 1
fi
echo "Restoring backup: ${backup_id}"
while IFS=$'\t' read -r status path existed; do
[ -z "$path" ] && continue
mkdir -p "$(dirname "$path")"
if [ "$existed" = "1" ]; then
cp "${backup_dir}/original/${path}" "$path"
else
rm -f "$path"
fi
done < "$manifest"
rebuild_api_latest
if ! health_check; then
echo "Error: health check failed after rollback."
exit 1
fi
if [ -f "${backup_dir}/meta.env" ]; then
# shellcheck disable=SC1090
source "${backup_dir}/meta.env"
if [ -n "${BASE_REF:-}" ]; then
echo "$BASE_REF" > "$STATE_FILE"
fi
fi
echo "Rollback completed: ${backup_id}"
}
if [ -n "$ROLLBACK_ID" ]; then
restore_backup "$ROLLBACK_ID"
exit 0
fi
BASE_REF=""
if [ -f "$STATE_FILE" ]; then
BASE_REF="$(cat "$STATE_FILE")"
elif [ -n "${RELEASE_VERSION:-}" ] && [ "${RELEASE_VERSION}" != "latest" ]; then
BASE_REF="$RELEASE_VERSION"
elif [ -n "${FAST_BASE_REF:-}" ]; then
BASE_REF="$FAST_BASE_REF"
else
echo "Error: cannot determine base ref."
echo "Set RELEASE_VERSION in .env, or create ${STATE_FILE}, or provide FAST_BASE_REF env var."
exit 1
fi
echo "Fast update"
echo " base ref: $BASE_REF"
echo " target ref: $TARGET_REF"
BASE_ENC="$(url_encode "$BASE_REF")"
TARGET_ENC="$(url_encode "$TARGET_REF")"
COMPARE_URL="${GITEA_BASE}/api/v1/repos/${REPO}/compare/${BASE_ENC}...${TARGET_ENC}"
TMP_COMPARE="$(mktemp)"
TMP_CHANGED="$(mktemp)"
trap 'rm -f "$TMP_COMPARE" "$TMP_CHANGED"' EXIT
COMPARE_SOURCE=""
if fetch_url_to_file "$COMPARE_URL" "$TMP_COMPARE"; then
build_changed_from_compare_json "$TMP_COMPARE" "$TMP_CHANGED"
COMPARE_SOURCE="gitea-api"
else
if [ -d ".git" ] && build_changed_from_local_git "$BASE_REF" "$TARGET_REF" "$TMP_CHANGED"; then
COMPARE_SOURCE="local-git"
echo "Warning: compare API unavailable, using local git diff fallback."
else
echo "Error: could not fetch compare data from: $COMPARE_URL"
echo "Hint: ensure GITHUB_TOKEN is set, refs exist, or run in a git clone where both refs are available."
exit 1
fi
fi
echo "Compare source: ${COMPARE_SOURCE}"
if [ ! -s "$TMP_CHANGED" ]; then
echo "No changed files between ${BASE_REF} and ${TARGET_REF}."
exit 0
fi
declare -a DISALLOWED_FILES=()
declare -a NOT_ALLOWED_FILES=()
declare -a UNSUPPORTED_STATUS=()
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
case "$status" in
added|modified)
;;
*)
UNSUPPORTED_STATUS+=("${status}: ${file_path}")
continue
;;
esac
if is_disallowed_path "$file_path"; then
DISALLOWED_FILES+=("$file_path")
continue
fi
if ! is_allowed_path "$file_path"; then
NOT_ALLOWED_FILES+=("$file_path")
continue
fi
done < "$TMP_CHANGED"
if [ "${#UNSUPPORTED_STATUS[@]}" -gt 0 ]; then
echo "Error: unsupported change types in fast mode:"
for item in "${UNSUPPORTED_STATUS[@]}"; do
echo " - $item"
done
echo "Use ./updateto.sh for these changes."
exit 1
fi
if [ "${#DISALLOWED_FILES[@]}" -gt 0 ]; then
echo "Error: disallowed files changed for fast mode:"
for file_path in "${DISALLOWED_FILES[@]}"; do
echo " - $file_path"
done
echo "Use ./updateto.sh for this update."
exit 1
fi
if [ "${#NOT_ALLOWED_FILES[@]}" -gt 0 ]; then
echo "Error: files outside fast-mode scope changed:"
for file_path in "${NOT_ALLOWED_FILES[@]}"; do
echo " - $file_path"
done
echo "Fast mode only allows app/* and main.py. Use ./updateto.sh instead."
exit 1
fi
echo "Changed files in fast scope:"
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
echo " - ${status}: ${file_path}"
done < "$TMP_CHANGED"
if [ "$DRY_RUN" = true ]; then
echo "Dry-run complete. No files updated."
exit 0
fi
BACKUP_ID="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="${STATE_DIR}/backups/${BACKUP_ID}"
MANIFEST="${BACKUP_DIR}/manifest.tsv"
mkdir -p "${BACKUP_DIR}/original"
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
mkdir -p "$(dirname "$file_path")"
existed=0
if [ -f "$file_path" ]; then
mkdir -p "${BACKUP_DIR}/original/$(dirname "$file_path")"
cp "$file_path" "${BACKUP_DIR}/original/${file_path}"
existed=1
fi
printf "%s\t%s\t%s\n" "$status" "$file_path" "$existed" >> "$MANIFEST"
file_enc="$(url_encode "$file_path")"
raw_url="${GITEA_BASE}/api/v1/repos/${REPO}/raw/${file_enc}?ref=${TARGET_ENC}"
tmp_file="${file_path}.tmp.fast"
fetch_url_to_file "$raw_url" "$tmp_file"
mv "$tmp_file" "$file_path"
done < "$TMP_CHANGED"
cat > "${BACKUP_DIR}/meta.env" <<EOF
BASE_REF=${BASE_REF}
TARGET_REF=${TARGET_REF}
BACKUP_ID=${BACKUP_ID}
EOF
echo "Building and restarting API container only"
rebuild_api_latest
if ! health_check; then
echo "Error: health check failed after fast update. Starting automatic rollback."
restore_backup "$BACKUP_ID"
echo "Rollback completed. Fast update aborted."
exit 1
fi
echo "$TARGET_REF" > "$STATE_FILE"
echo "$BACKUP_ID" > "$LAST_BACKUP_FILE"
echo "Fast update complete"
echo " backup id: $BACKUP_ID"
echo " deployed: $TARGET_REF"
echo " state file: $STATE_FILE"