#!/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"