diff --git a/DEPLOYMENT_CHECKLIST.md b/DEPLOYMENT_CHECKLIST.md index 948bb09..eb26925 100644 --- a/DEPLOYMENT_CHECKLIST.md +++ b/DEPLOYMENT_CHECKLIST.md @@ -258,6 +258,32 @@ crontab -e ## 🔄 Opdatering til Ny Version +### Valg Af Update-metode + +| Situation | Brug metode | Hvorfor | +|---|---|---| +| Små kodeændringer i `app/*` eller `main.py` | `./update_fast.sh --ref ` | Hurtig update uden ny release-tag/pakke | +| Ændringer i `migrations/*` | `./updateto.sh ` | Kræver kontrolleret release + migrations-flow | +| Ændringer i `requirements.txt` eller `Dockerfile` | `./updateto.sh ` | Kræver fuld image-build og versionsstyring | +| Ændringer i `docker-compose*.yml`, scripts eller `.env` | `./updateto.sh ` | Drift/infra-konfiguration skal deployes fuldt | +| Når du er i tvivl | `./updateto.sh ` | 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 --allow-prod +``` + +Rollback i fast mode: + +```bash +./update_fast.sh --rollback --allow-prod +``` + ### På din Mac: ```bash diff --git a/QUICK_UPDATE.md b/QUICK_UPDATE.md index 24ce862..547132b 100644 --- a/QUICK_UPDATE.md +++ b/QUICK_UPDATE.md @@ -22,6 +22,34 @@ cd /srv/podman/bmc_hub_v1.0 ./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) ```bash diff --git a/update_fast.sh b/update_fast.sh new file mode 100755 index 0000000..bfa2622 --- /dev/null +++ b/update_fast.sh @@ -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 [--dry-run] [--allow-prod] + ./update_fast.sh --rollback [--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 or --rollback ." + 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" < "$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" \ No newline at end of file