#!/usr/bin/env bash set -euo pipefail APPFACTORY_ROOT="/opt/appfactory" BACKUP_FILE_INPUT="${1:-}" TARGET_USER="${2:-${SUDO_USER:-$USER}}" log(){ echo "[restore] $*"; } ok(){ echo "[restore][OK] $*"; } warn(){ echo "[restore][WARN] $*"; } fail(){ echo "[restore][FAIL] $*" >&2; exit 1; } usage() { cat <> "$file" fi } set_env_if_missing_or_empty() { local file="$1" local key="$2" local value="$3" if env_has_non_empty_key "$file" "$key"; then return 0 fi set_env_value "$file" "$key" "$value" } merge_env_missing_keys() { local source_file="$1" local target_file="$2" [ -f "$source_file" ] || return 0 if [ ! -f "$target_file" ]; then cp "$source_file" "$target_file" return 0 fi while IFS= read -r line; do case "$line" in ""|\#*) continue ;; *=*) key="${line%%=*}" value="${line#*=}" if env_has_non_empty_key "$target_file" "$key"; then continue fi set_env_value "$target_file" "$key" "$value" ;; esac done < "$source_file" } resolve_runtime_uid_gid() { local user="$1" local uid local gid uid="$(id -u "$user" 2>/dev/null || echo 0)" gid="$(id -g "$user" 2>/dev/null || echo 0)" if [ "$uid" = "0" ]; then uid="1000" fi if [ "$gid" = "0" ]; then gid="1000" fi echo "$uid:$gid" } copy_dir_if_exists() { local src="$1" local dst="$2" if [ -d "$src" ]; then mkdir -p "$(dirname "$dst")" rm -rf "$dst" cp -a "$src" "$dst" fi } preserve_app_envs() { local source_apps_dir="$1" local target_dir="$2" [ -d "$source_apps_dir" ] || return 0 mkdir -p "$target_dir" while IFS= read -r env_file; do [ -f "$env_file" ] || continue app_id="$(basename "$(dirname "$env_file")")" mkdir -p "$target_dir/$app_id" cp "$env_file" "$target_dir/$app_id/.env" ok "Preserved app runtime env: $app_id" done < <(find "$source_apps_dir" -mindepth 2 -maxdepth 2 -type f -name ".env" 2>/dev/null || true) } restore_preserved_app_envs() { local preserved_dir="$1" local target_apps_dir="$2" [ -d "$preserved_dir" ] || return 0 while IFS= read -r preserved_env; do [ -f "$preserved_env" ] || continue app_id="$(basename "$(dirname "$preserved_env")")" target_env="$target_apps_dir/$app_id/.env" mkdir -p "$(dirname "$target_env")" if [ -f "$target_env" ]; then backup_env_copy="$(mktemp)" cp "$target_env" "$backup_env_copy" cp "$preserved_env" "$target_env" merge_env_missing_keys "$backup_env_copy" "$target_env" rm -f "$backup_env_copy" ok "Restored and merged app runtime env: $app_id" else cp "$preserved_env" "$target_env" ok "Restored app runtime env: $app_id" fi done < <(find "$preserved_dir" -mindepth 2 -maxdepth 2 -type f -name ".env" 2>/dev/null || true) } [ -n "$BACKUP_FILE_INPUT" ] || { usage; fail "Backup file argument is required"; } [ "$(id -u)" -eq 0 ] || fail "Run as root via sudo" [ -f "$BACKUP_FILE_INPUT" ] || fail "Backup file not found: $BACKUP_FILE_INPUT" command -v realpath >/dev/null 2>&1 || fail "realpath missing" command -v tar >/dev/null 2>&1 || fail "tar missing" command -v docker >/dev/null 2>&1 || fail "docker missing" BACKUP_FILE="$(realpath "$BACKUP_FILE_INPUT")" if ! id "$TARGET_USER" >/dev/null 2>&1; then warn "Target user does not exist: $TARGET_USER" warn "Using numeric fallback 1000:1000 for runtime ownership" fi RUNTIME_IDS="$(resolve_runtime_uid_gid "$TARGET_USER")" APPFACTORY_UID="${RUNTIME_IDS%%:*}" APPFACTORY_GID="${RUNTIME_IDS##*:}" [ -S /var/run/docker.sock ] || fail "Docker socket missing: /var/run/docker.sock" APPFACTORY_DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)" docker ps >/dev/null 2>&1 || fail "docker ps failed" log "Backup: $BACKUP_FILE" log "Target user: $TARGET_USER" log "Calculated runtime APPFACTORY_UID=$APPFACTORY_UID" log "Calculated runtime APPFACTORY_GID=$APPFACTORY_GID" log "Calculated runtime APPFACTORY_DOCKER_GID=$APPFACTORY_DOCKER_GID" PRESERVE_ROOT="$(mktemp -d)" PRESERVED_ENV="" PRESERVED_CADDY="$PRESERVE_ROOT/caddy" PRESERVED_CADDY_CONFIG="$PRESERVE_ROOT/caddy-config" PRESERVED_SECRETS="$PRESERVE_ROOT/secrets" PRESERVED_APP_ENVS="$PRESERVE_ROOT/app-envs" EXISTING_ENV="$APPFACTORY_ROOT/config/appfactory.env" if [ -f "$EXISTING_ENV" ]; then PRESERVED_ENV="$PRESERVE_ROOT/appfactory.env" cp "$EXISTING_ENV" "$PRESERVED_ENV" ok "Preserved existing env before restore: $EXISTING_ENV" fi copy_dir_if_exists "$APPFACTORY_ROOT/data/caddy" "$PRESERVED_CADDY" copy_dir_if_exists "$APPFACTORY_ROOT/data/caddy-config" "$PRESERVED_CADDY_CONFIG" copy_dir_if_exists "$APPFACTORY_ROOT/data/secrets" "$PRESERVED_SECRETS" preserve_app_envs "$APPFACTORY_ROOT/data/apps" "$PRESERVED_APP_ENVS" if [ -d "$PRESERVED_CADDY" ]; then ok "Preserved Caddy TLS state: $APPFACTORY_ROOT/data/caddy" fi if [ -d "$PRESERVED_CADDY_CONFIG" ]; then ok "Preserved Caddy config state: $APPFACTORY_ROOT/data/caddy-config" fi if [ -d "$PRESERVED_SECRETS" ]; then ok "Preserved secrets directory: $APPFACTORY_ROOT/data/secrets" fi log "Removing existing AppFactory containers if present" mapfile -t APPFACTORY_CONTAINERS < <( docker ps -a --format '{{.Names}}' \ | grep -E '^(appfactory-|.*-service$|test-|microsoft-365-service)' \ || true ) for container in "${APPFACTORY_CONTAINERS[@]}"; do [ -n "$container" ] || continue docker rm -f "$container" >/dev/null 2>&1 || true ok "Removed container: $container" done log "Removing existing AppFactory root" rm -rf "$APPFACTORY_ROOT" log "Extracting backup" cd / tar -xzf "$BACKUP_FILE" [ -d "$APPFACTORY_ROOT" ] || fail "Backup did not create $APPFACTORY_ROOT" log "Ensuring required directories" mkdir -p \ "$APPFACTORY_ROOT/apps" \ "$APPFACTORY_ROOT/backups" \ "$APPFACTORY_ROOT/config" \ "$APPFACTORY_ROOT/data" \ "$APPFACTORY_ROOT/deploy" \ "$APPFACTORY_ROOT/gateway" \ "$APPFACTORY_ROOT/services" \ "$APPFACTORY_ROOT/templates" \ "$APPFACTORY_ROOT/tools" \ "$APPFACTORY_ROOT/workspace" ENV_FILE="$APPFACTORY_ROOT/config/appfactory.env" BACKUP_ENV_COPY="" if [ -f "$ENV_FILE" ]; then BACKUP_ENV_COPY="$(mktemp)" cp "$ENV_FILE" "$BACKUP_ENV_COPY" fi if [ -n "$PRESERVED_ENV" ] && [ -f "$PRESERVED_ENV" ]; then log "Restoring target env and merging missing keys from backup env" cp "$PRESERVED_ENV" "$ENV_FILE" if [ -n "$BACKUP_ENV_COPY" ] && [ -f "$BACKUP_ENV_COPY" ]; then merge_env_missing_keys "$BACKUP_ENV_COPY" "$ENV_FILE" fi ENV_SNAPSHOT="$APPFACTORY_ROOT/config/appfactory.env.before-restore-merge-$(date +%Y%m%d-%H%M%S)" cp "$PRESERVED_ENV" "$ENV_SNAPSHOT" ok "Saved pre-restore env snapshot: $ENV_SNAPSHOT" else [ -f "$ENV_FILE" ] || fail "Missing env file after restore: $ENV_FILE" log "No existing target env found; using backup env and applying target runtime defaults" set_env_value "$ENV_FILE" "APPFACTORY_UID" "$APPFACTORY_UID" set_env_value "$ENV_FILE" "APPFACTORY_GID" "$APPFACTORY_GID" set_env_value "$ENV_FILE" "APPFACTORY_DOCKER_GID" "$APPFACTORY_DOCKER_GID" fi if [ -d "$PRESERVED_CADDY" ]; then log "Restoring preserved Caddy TLS state" rm -rf "$APPFACTORY_ROOT/data/caddy" cp -a "$PRESERVED_CADDY" "$APPFACTORY_ROOT/data/caddy" ok "Restored Caddy TLS state" fi if [ -d "$PRESERVED_CADDY_CONFIG" ]; then log "Restoring preserved Caddy config state" rm -rf "$APPFACTORY_ROOT/data/caddy-config" cp -a "$PRESERVED_CADDY_CONFIG" "$APPFACTORY_ROOT/data/caddy-config" ok "Restored Caddy config state" fi if [ -d "$PRESERVED_SECRETS" ]; then log "Restoring preserved secrets directory" rm -rf "$APPFACTORY_ROOT/data/secrets" cp -a "$PRESERVED_SECRETS" "$APPFACTORY_ROOT/data/secrets" ok "Restored secrets directory" fi restore_preserved_app_envs "$PRESERVED_APP_ENVS" "$APPFACTORY_ROOT/data/apps" log "Ensuring mandatory env keys without overwriting existing non-empty values" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_DIR" "$APPFACTORY_ROOT" set_env_if_missing_or_empty "$ENV_FILE" "WORKSPACE_DIR" "$APPFACTORY_ROOT/workspace" set_env_if_missing_or_empty "$ENV_FILE" "BACKUP_DIR" "$APPFACTORY_ROOT/backups" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_UID" "$APPFACTORY_UID" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_GID" "$APPFACTORY_GID" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_DOCKER_GID" "$APPFACTORY_DOCKER_GID" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_ENABLE_HTTPS" "false" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_PORTAL_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_GITEA_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_REGISTRY_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "APPFACTORY_PORTAL_PUBLIC_URL" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_ROOT_URL" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_URL" "" set_env_if_missing_or_empty "$ENV_FILE" "WEBHOOK_URL" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_OAUTH_REDIRECT_URI" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_SSH_PORT" "2222" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_ADMIN_TOKEN" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_API_TOKEN" "" set_env_if_missing_or_empty "$ENV_FILE" "GITEA_TOKEN" "" set_env_if_missing_or_empty "$ENV_FILE" "GOOGLE_OAUTH_ENABLED" "false" set_env_if_missing_or_empty "$ENV_FILE" "GOOGLE_CLIENT_ID" "" set_env_if_missing_or_empty "$ENV_FILE" "GOOGLE_CLIENT_SECRET" "" set_env_if_missing_or_empty "$ENV_FILE" "GOOGLE_REDIRECT_URI" "" set_env_if_missing_or_empty "$ENV_FILE" "GOOGLE_ALLOWED_DOMAIN" "" set_env_if_missing_or_empty "$ENV_FILE" "AUTH_MODE" "google" log "Effective runtime env:" grep -E '^(APPFACTORY_UID|APPFACTORY_GID|APPFACTORY_DOCKER_GID|APPFACTORY_ENABLE_HTTPS|APPFACTORY_DOMAIN|APPFACTORY_PORTAL_DOMAIN|APPFACTORY_GITEA_DOMAIN|APPFACTORY_REGISTRY_DOMAIN|APPFACTORY_PORTAL_PUBLIC_URL|GITEA_DOMAIN|GITEA_ROOT_URL|GITEA_URL|WEBHOOK_URL|GITEA_OAUTH_REDIRECT_URI|AUTH_MODE)=' "$ENV_FILE" || true DB_FILE="$APPFACTORY_ROOT/data/appfactory/appfactory.db" [ -f "$DB_FILE" ] || fail "Missing AppFactory DB after restore: $DB_FILE" COMPOSE_FILE="$APPFACTORY_ROOT/deploy/docker-compose.yml" [ -f "$COMPOSE_FILE" ] || fail "Missing compose file: $COMPOSE_FILE" APPFACTORY_UID_EFFECTIVE="$(env_get_raw "$ENV_FILE" "APPFACTORY_UID" || echo "$APPFACTORY_UID")" APPFACTORY_GID_EFFECTIVE="$(env_get_raw "$ENV_FILE" "APPFACTORY_GID" || echo "$APPFACTORY_GID")" APPFACTORY_UID_EFFECTIVE="${APPFACTORY_UID_EFFECTIVE%\"}" APPFACTORY_UID_EFFECTIVE="${APPFACTORY_UID_EFFECTIVE#\"}" APPFACTORY_GID_EFFECTIVE="${APPFACTORY_GID_EFFECTIVE%\"}" APPFACTORY_GID_EFFECTIVE="${APPFACTORY_GID_EFFECTIVE#\"}" if [ "$APPFACTORY_UID_EFFECTIVE" = "0" ]; then warn "APPFACTORY_UID is 0; changing to 1000 because Gitea cannot run as root" set_env_value "$ENV_FILE" "APPFACTORY_UID" "1000" APPFACTORY_UID_EFFECTIVE="1000" fi if [ "$APPFACTORY_GID_EFFECTIVE" = "0" ]; then warn "APPFACTORY_GID is 0; changing to 1000 because Gitea cannot run as root" set_env_value "$ENV_FILE" "APPFACTORY_GID" "1000" APPFACTORY_GID_EFFECTIVE="1000" fi log "Fixing ownership" chown -R "$APPFACTORY_UID_EFFECTIVE:$APPFACTORY_GID_EFFECTIVE" "$APPFACTORY_ROOT" log "Fixing script permissions" find "$APPFACTORY_ROOT/workspace/appfactory-tools" -type f -name '*.sh' -exec chmod +x {} \; 2>/dev/null || true build_image() { local image="$1" local dir="$2" [ -d "$dir" ] || fail "Missing build directory: $dir" [ -f "$dir/Dockerfile" ] || fail "Missing Dockerfile: $dir/Dockerfile" log "Building image: $image from $dir" docker build -t "$image:latest" "$dir" } GENERATE_CADDY="$APPFACTORY_ROOT/workspace/appfactory-tools/scripts/generate-caddyfile.sh" if [ -f "$GENERATE_CADDY" ]; then log "Generating Caddyfile before starting Caddy" "$GENERATE_CADDY" || true fi build_image "appfactory-portal" "$APPFACTORY_ROOT/workspace/appfactory-portal" build_image "appfactory-worker" "$APPFACTORY_ROOT/workspace/appfactory-worker" build_image "appfactory-monitor" "$APPFACTORY_ROOT/workspace/appfactory-monitor" build_image "appfactory-webhook" "$APPFACTORY_ROOT/workspace/appfactory-webhook" log "Deploying core compose stack" docker compose \ --env-file "$ENV_FILE" \ -f "$COMPOSE_FILE" \ up -d --force-recreate log "Waiting for core containers" CORE_CONTAINERS=( appfactory-caddy appfactory-gitea appfactory-registry appfactory-portal appfactory-worker appfactory-monitor appfactory-webhook ) deadline=$((SECONDS + 180)) for container in "${CORE_CONTAINERS[@]}"; do while true; do if docker inspect "$container" >/dev/null 2>&1; then state="$(docker inspect -f '{{.State.Status}}' "$container" 2>/dev/null || true)" health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container" 2>/dev/null || true)" if [ "$state" = "running" ] && [ "$health" != "unhealthy" ]; then ok "Container ready: $container health=$health" break fi fi if [ "$SECONDS" -ge "$deadline" ]; then docker ps -a fail "Timeout waiting for container: $container" fi sleep 2 done done if [ -f "$GENERATE_CADDY" ]; then log "Regenerating/reloading Caddyfile" "$GENERATE_CADDY" fi REDEPLOY_APPS="$APPFACTORY_ROOT/workspace/appfactory-tools/scripts/redeploy-enabled-apps.sh" if [ -f "$REDEPLOY_APPS" ]; then chmod +x "$REDEPLOY_APPS" log "Redeploying enabled user applications" "$REDEPLOY_APPS" || warn "redeploy-enabled-apps.sh failed; check app logs" else warn "redeploy-enabled-apps.sh not found, skipping user app redeploy" fi PREFLIGHT="$APPFACTORY_ROOT/workspace/appfactory-tools/maintenance/preflight-check.sh" [ -f "$PREFLIGHT" ] || fail "Missing preflight script: $PREFLIGHT" chmod +x "$PREFLIGHT" log "Running final preflight" "$PREFLIGHT" rm -rf "$PRESERVE_ROOT" echo echo "APPFACTORY RESTORE: READY" EOF