#!/bin/bash

set -e
set -o pipefail

# NetBird — community combined → Enterprise combined migration
#
# Non-destructive migration: produces docker-compose.override.yml (auto-loaded
# by docker compose) and config.yaml.enterprise alongside the operator's
# existing files. Original docker-compose.yml and config.yaml are never
# modified.
#
# Steps (all optional, asked interactively):
#   1. Image swap         — replace community images with enterprise cloud images.
#   2. Postgres migration — add Postgres, migrate SQLite data via migrate-store.
#   3. Traffic flow       — add NATS + flow-enricher + flow-receiver.
#
# Step 2 is skipped when the deployment already runs on Postgres
# (server.store.engine: postgres in config.yaml). Nothing is provisioned or
# migrated in that case and the store config is left exactly as the operator
# wrote it — the enterprise image reads the same Postgres the community image
# did. Such a deployment gets the image swap, and can still opt into step 3.
#
# If any step fails once the stack has been touched, the script rolls itself
# back automatically: generated files are removed, the Postgres volume this run
# created is dropped, and the original deployment is started again.
#
# To revert a successful migration:
#   docker compose down
#   rm -f docker-compose.override.yml config.yaml.enterprise
#   # If Postgres migration was done, also restore the SQLite backup printed
#   # at the end of this script's run.
#   docker compose up -d

OVERRIDE_FILE="docker-compose.override.yml"
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"

# Rollback bookkeeping. ROLLBACK_STATE flips to "armed" the moment the script
# starts mutating the deployment, and back to "disarmed" once the migration has
# completed successfully.
ROLLBACK_STATE="disarmed"
ENV_EXISTED="unknown"
ENV_BACKUP=""
PG_VOLUME_NAME=""
BACKUP_DIR=""

# Store state. STORE_ENGINE is what the deployment runs on today; when it is
# already postgres, MIGRATE_POSTGRES stays "no" and nothing is provisioned.
# POSTGRES_SERVICE is empty when Postgres lives outside this compose project.
STORE_ENGINE=""
EXISTING_POSTGRES="no"
POSTGRES_DSN=""
POSTGRES_SERVICE=""
POSTGRES_DEPENDS_CONDITION="service_healthy"
# Whether this run needs to generate config.yaml.enterprise at all. A pure
# image swap does not.
ENTERPRISE_CONFIG="no"

NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"

check_docker_compose() {
  if command -v docker-compose &> /dev/null; then
    echo "docker-compose"
    return
  fi
  if docker compose --help &> /dev/null; then
    echo "docker compose"
    return
  fi
  echo "docker-compose is not installed or not in PATH." > /dev/stderr
  exit 1
}

check_yq() {
  if ! command -v yq &> /dev/null; then
    cat > /dev/stderr <<'EOF'
yq is required to parse and update YAML safely.

  macOS:   brew install yq
  Linux:   https://github.com/mikefarah/yq/releases (download binary into PATH)
  Debian:  apt-get install yq   (Note: must be the mikefarah Go yq, not the Python wrapper.)

EOF
    exit 1
  fi
  if ! yq --version 2>&1 | grep -q "mikefarah"; then
    echo "yq is present but appears to be the wrong implementation. The mikefarah Go-based yq is required (https://github.com/mikefarah/yq)." > /dev/stderr
    exit 1
  fi
}

check_openssl() {
  if ! command -v openssl &> /dev/null; then
    echo "openssl is not installed or not in PATH." > /dev/stderr
    exit 1
  fi
}

rand_password() {
  openssl rand -hex 32
}

read_required() {
  local prompt="$1"
  local value=""
  while [[ -z "$value" ]]; do
    echo -n "$prompt: " > /dev/stderr
    read -r value < /dev/tty
    if [[ -z "$value" ]]; then
      echo "Value cannot be empty." > /dev/stderr
    fi
  done
  echo "$value"
}

read_secret() {
  local prompt="$1"
  local value=""
  while [[ -z "$value" ]]; do
    echo -n "$prompt: " > /dev/stderr
    read -rs value < /dev/tty
    echo "" > /dev/stderr
    if [[ -z "$value" ]]; then
      echo "Value cannot be empty." > /dev/stderr
    fi
  done
  echo "$value"
}

read_yes_no() {
  local prompt="$1"
  local default="${2:-n}"
  local hint
  if [[ "$default" == "y" ]]; then
    hint="[Y/n]"
  else
    hint="[y/N]"
  fi
  echo -n "${prompt} ${hint}: " > /dev/stderr
  local ans=""
  read -r ans < /dev/tty
  if [[ -z "$ans" ]]; then
    ans="$default"
  fi
  case "$ans" in
    [Yy] | [Yy][Ee][Ss]) echo "yes" ;;
    *) echo "no" ;;
  esac
}

# Gate the migration on explicit acceptance of the NetBird On-Premise EULA.
require_eula_acceptance() {
  cat > /dev/stderr <<EOF

  ──────────────────────────────────────────────────────────────────────
   NetBird On-Premise End User License Agreement
  ──────────────────────────────────────────────────────────────────────
  NetBird's on-premise software is commercial software, licensed and not
  sold. Your installation, deployment and use are governed by the NetBird
  On-Premise End User License Agreement (the "EULA"). Please read the EULA
  in full before continuing:

      ${NETBIRD_EULA_URL}

  By typing "accept" and continuing the installation, you confirm that you
  have read and agree to the EULA, that you are authorized to accept it on
  behalf of your organization (the "Customer"), and that the Software is
  used for business purposes only.
  ──────────────────────────────────────────────────────────────────────
EOF

  if [[ "${NB_ACCEPT_EULA:-}" == "yes" ]]; then
    echo "EULA accepted via NB_ACCEPT_EULA=yes." > /dev/stderr
    return 0
  fi

  local ans=""
  echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr
  read -r ans < /dev/tty
  if [[ "$ans" != "accept" ]]; then
    echo "" > /dev/stderr
    echo "EULA not accepted. Aborting migration." > /dev/stderr
    exit 1
  fi
  echo "" > /dev/stderr
}

# ---------------------------------------------------------------------------
# Detection — read the operator's existing compose to find service names and
# paths we need to override. Bail loudly if shape isn't recognised.
# ---------------------------------------------------------------------------

detect_combined_service() {
  yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/netbird-server([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
}

detect_dashboard_service() {
  yq eval '.services | to_entries | map(select(.value.image | test("^(ghcr\\.io/)?netbirdio/dashboard([:@]|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
}

detect_config_yaml_host_path() {
  yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/etc/netbird/config.yaml\")) | sub(\":/etc/netbird/config.yaml.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
}

detect_data_volume() {
  yq eval ".services[\"$COMBINED_SERVICE\"].volumes[] | select(. | test(\":/var/lib/netbird\")) | sub(\":/var/lib/netbird.*\"; \"\") // \"\"" "$COMPOSE_FILE" | head -1
}

detect_exposed_address() {
  yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST"
}

# The engine is a config.yaml-only setting — there is no env override for it
# (combined/cmd/root.go reads it from YAML and derives the env vars), so
# config.yaml is authoritative. Absent means the sqlite default.
detect_store_engine() {
  local engine
  engine=$(yq eval '.server.store.engine // ""' "$CONFIG_YAML_HOST")
  if [[ -z "$engine" ]] || [[ "$engine" == "null" ]]; then
    engine="sqlite"
  fi
  echo "$engine" | tr '[:upper:]' '[:lower:]'
}

detect_store_dsn() {
  yq eval '.server.store.dsn // ""' "$CONFIG_YAML_HOST"
}

# config.yaml is where a combined deployment carries its DSN; this only covers
# hand-rolled installs that keep it in the environment instead.
detect_store_dsn_from_compose() {
  # `compose config` re-escapes a literal $ as $$ on the way out, so undo that
  # to get the value the container actually receives.
  $DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval "
    .services[\"$COMBINED_SERVICE\"].environment.NB_STORE_ENGINE_POSTGRES_DSN //
    .services[\"$COMBINED_SERVICE\"].environment.NETBIRD_STORE_ENGINE_POSTGRES_DSN // \"\"
  " - 2>/dev/null | sed 's/\$\$/$/g'
}

# Reads either DSN form: "host=db ..." or "postgres://user:pass@db:5432/name".
dsn_host() {
  local dsn="$1"
  case "$dsn" in
    *://*) printf '%s' "$dsn" | sed -n 's,^[a-zA-Z+]*://\([^/?]*\).*,\1,p' | sed -e 's,.*@,,' -e 's,:.*,,' ;;
    *) printf '%s' "$dsn" | sed -n 's/.*[[:space:]]*host=\([^[:space:]]*\).*/\1/p' ;;
  esac
}

# flow-enricher is its own container, so a loopback host or a socket path would
# reach the enricher rather than Postgres. Only flag hosts we can positively
# identify — an unparseable DSN must not leave the operator with no way forward.
dsn_host_reachable() {
  local dsn="$1"
  case "$(dsn_host "$dsn")" in
    localhost | 127.* | ::1 | 0.0.0.0 | /*) return 1 ;;
    *) return 0 ;;
  esac
}

# Names the compose service running this deployment's Postgres, for depends_on.
# Empty means external — the DSN host matched no service. A DSN with no readable
# host falls back to matching on image.
detect_postgres_service() {
  local host
  host=$(dsn_host "$POSTGRES_DSN")
  if [[ -n "$host" ]]; then
    if [[ "$(host="$host" yq eval '.services | has(env(host))' "$COMPOSE_FILE" 2>/dev/null)" == "true" ]]; then
      echo "$host"
    fi
    return
  fi
  yq eval '.services | to_entries | map(select(.value.image // "" | test("(^|/)(postgres|postgis|pgvector|timescaledb)(:|@|$)"))) | .[0].key // ""' "$COMPOSE_FILE"
}

# depends_on: service_healthy is only legal if the service defines a healthcheck.
detect_postgres_depends_condition() {
  local tag
  tag=$(yq eval ".services[\"$POSTGRES_SERVICE\"].healthcheck | tag" "$COMPOSE_FILE" 2>/dev/null)
  if [[ "$tag" == "!!map" ]]; then
    echo "service_healthy"
  else
    echo "service_started"
  fi
}

env_value() {
  local value="$1"
  value=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\$/$$/g')
  printf '"%s"' "$value"
}

detect_compose_network() {
  local tag
  tag=$(yq eval ".services[\"$COMBINED_SERVICE\"].networks | tag" "$COMPOSE_FILE" 2>/dev/null)
  case "$tag" in
    "!!seq")
      yq eval ".services[\"$COMBINED_SERVICE\"].networks[0]" "$COMPOSE_FILE"
      ;;
    "!!map")
      yq eval ".services[\"$COMBINED_SERVICE\"].networks | keys | .[0]" "$COMPOSE_FILE"
      ;;
    *)
      echo "default"
      ;;
  esac
}

# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------

# Build docker-compose.override.yml from the steps the operator selected.
# Service names match what we detected on the operator's side.
render_override() {
  cat <<EOF
# Generated by migrate-to-enterprise.sh. Mode 644.
# Merged with docker-compose.yml automatically by Docker Compose.
# Remove this file (and config.yaml.enterprise if present) to revert.

services:
  ${COMBINED_SERVICE}:
    image: \${NETBIRD_SERVER_IMAGE:-ghcr.io/netbirdio/netbird-server-cloud:latest}
    environment:
      NB_LICENSE_KEY: \${NB_LICENSE_KEY}
      NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
EOF

  # An existing Postgres is already wired up by the operator's own compose file,
  # so only a Postgres this run creates needs a depends_on.
  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    cat <<EOF
    depends_on:
      ${POSTGRES_SERVICE}:
        condition: ${POSTGRES_DEPENDS_CONDITION}
EOF
  fi

  # The server is only pointed at a different config file when this run
  # generates one. A pure image swap leaves it on its original config.yaml.
  if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
    cat <<EOF
    volumes:
      - ./${ENTERPRISE_CONFIG_FILE}:/etc/netbird/config.yaml.enterprise:ro
    command: ["--config", "/etc/netbird/config.yaml.enterprise"]
EOF
  fi

  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    cat <<EOF

  ${POSTGRES_SERVICE}:
    image: postgres:17
    container_name: netbird-postgres
    restart: unless-stopped
    networks: [${COMPOSE_NETWORK}]
    environment:
      POSTGRES_USER: netbird
      POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
      POSTGRES_DB: netbird
    volumes:
      - netbird_postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U netbird -d netbird"]
      interval: 5s
      timeout: 5s
      retries: 20
EOF
  fi

  if [[ "$ENABLE_FLOW" == "yes" ]]; then
    # Nothing to wait on when Postgres is managed outside this compose project.
    local enricher_depends=""
    if [[ -n "$POSTGRES_SERVICE" ]]; then
      enricher_depends="
      ${POSTGRES_SERVICE}:
        condition: ${POSTGRES_DEPENDS_CONDITION}"
    fi

    cat <<EOF

  nats:
    image: nats:2
    container_name: netbird-nats
    restart: unless-stopped
    networks: [${COMPOSE_NETWORK}]
    command: ["-m", "8222", "--jetstream", "--store_dir", "/data"]
    volumes:
      - netbird_nats_data:/data

  flow-enricher:
    image: ghcr.io/netbirdio/flow-enricher-cloud:latest
    container_name: netbird-flow-enricher
    restart: unless-stopped
    networks: [${COMPOSE_NETWORK}]
    depends_on:${enricher_depends}
      nats:
        condition: service_started
    environment:
      NB_LICENSE_KEY: \${NB_LICENSE_KEY}
      NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
      NB_DATADIR: /var/lib/netbird
      NB_MANAGEMENT_STORE_ENGINE: postgres
      NB_MANAGEMENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
      NB_STORE_ENGINE_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
      NB_TRAFFIC_EVENT_STORE_ENGINE: postgres
      NB_TRAFFIC_EVENT_POSTGRES_DSN: \${NB_ENTERPRISE_POSTGRES_DSN}
      NB_MANAGEMENT_STORE_KEY: \${NETBIRD_ENCRYPTION_KEY}
      NB_FLOW_ADAPTER_TYPE: nats
      NB_FLOW_NATS_ENDPOINTS: nats://nats:4222
      NB_FLOW_NATS_STREAM: traffic-events
      NB_METRICS_PORT: 9091
      NB_PERSISTENCE_RETENTION_PERIOD: 168h

  flow-receiver:
    image: ghcr.io/netbirdio/flow-receiver-cloud:latest
    container_name: netbird-flow-receiver
    restart: unless-stopped
    networks: [${COMPOSE_NETWORK}]
    depends_on:
      nats:
        condition: service_started
    environment:
      NB_LICENSE_KEY: \${NB_LICENSE_KEY}
      NETBIRD_LICENSE_SERVER_BASE_URL: \${NETBIRD_LICENSE_SERVER_BASE_URL}
      NB_FLOW_LISTEN_PORT: 80
      NB_FLOW_ADAPTER_TYPE: nats
      NB_FLOW_NATS_ENDPOINTS: nats://nats:4222
      NB_FLOW_NATS_STREAM: traffic-events
      NB_FLOW_AUTH_SECRET: \${NB_FLOW_AUTH_SECRET}
    labels:
      - traefik.enable=true
      - traefik.http.routers.netbird-flow.rule=Host(\`${NETBIRD_HOSTNAME}\`) && PathPrefix(\`/flow.FlowService/\`)
      - traefik.http.routers.netbird-flow.entrypoints=websecure
      - traefik.http.routers.netbird-flow.tls=true
      - traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt
      - traefik.http.routers.netbird-flow.service=netbird-flow-h2c
      - traefik.http.routers.netbird-flow.priority=100
      - traefik.http.services.netbird-flow-h2c.loadbalancer.server.port=80
      - traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c
EOF
  fi

  # Volume declarations for anything new the override introduced
  local has_volumes="no"
  if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then
    has_volumes="yes"
  fi

  if [[ "$has_volumes" == "yes" ]]; then
    cat <<EOF

volumes:
EOF
    if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
      echo "  netbird_postgres:"
    fi
    if [[ "$ENABLE_FLOW" == "yes" ]]; then
      echo "  netbird_nats_data:"
    fi
  fi
}

# Build config.yaml.enterprise from the operator's existing config.yaml. We
# don't touch the original file. Values go through strenv() so a DSN carrying
# quotes, backslashes or $ cannot break out of the expression.
render_enterprise_config() {
  {
    echo "# Generated by migrate-to-enterprise.sh from ${CONFIG_YAML_HOST}."
    echo "# The enterprise server is started with --config pointing at this file,"
    echo "# so later edits to ${CONFIG_YAML_HOST} have no effect until copied here."
    cat "$CONFIG_YAML_HOST"
  } > "$ENTERPRISE_CONFIG_FILE"

  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    # Fresh Postgres: point every store section at it. migrate-store carries the
    # SQLite contents across.
    POSTGRES_DSN="$POSTGRES_DSN" yq eval -i '
      .server.store.engine = "postgres" |
      .server.store.dsn = strenv(POSTGRES_DSN) |
      .server.activityStore.engine = "postgres" |
      .server.activityStore.dsn = strenv(POSTGRES_DSN) |
      .server.authStore.engine = "postgres" |
      .server.authStore.dsn = strenv(POSTGRES_DSN)
    ' "$ENTERPRISE_CONFIG_FILE"
  fi
  # Otherwise the store config is the operator's and stays untouched.
  # activityStore and authStore do not inherit from server.store — each falls
  # back to its own SQLite file under dataDir — so repointing them at Postgres
  # here would silently strand the existing audit log and the embedded IdP's
  # users, with no migrate-store run to carry them over.

  if [[ "$ENABLE_FLOW" == "yes" ]]; then
    NETBIRD_DOMAIN="$NETBIRD_DOMAIN" yq eval -i '
      .server.trafficFlow.enabled = true |
      .server.trafficFlow.address = strenv(NETBIRD_DOMAIN) |
      .server.trafficFlow.interval = "60s"
    ' "$ENTERPRISE_CONFIG_FILE"
  fi
}

# ---------------------------------------------------------------------------
# Execution steps
# ---------------------------------------------------------------------------

combined_container_id() {
  $DOCKER_COMPOSE_COMMAND ps -aq "$COMBINED_SERVICE" 2>/dev/null | head -1
}

container_data_mount() {
  local container="$1"
  [[ -n "$container" ]] || return 0
  docker inspect "$container" --format \
    '{{range .Mounts}}{{if eq .Destination "/var/lib/netbird"}}{{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}}{{end}}{{end}}' 2>/dev/null
}

# The name comes from the container, so `-v` cannot invent an empty volume here.
# 0 = empty, 1 = holds data, 2 = could not determine. A failed listing must not
# be reported as empty: that would abort a healthy migration over a pull error
# or an unreadable bind mount.
data_dir_state() {
  local src="$1" out
  if [[ "$src" == /* ]]; then
    [[ -d "$src" ]] || return 2
    out=$(ls -A "$src" 2>/dev/null) || return 2
  else
    docker volume inspect "$src" &> /dev/null || return 0
    out=$(docker run --rm -v "${src}:/d:ro" busybox sh -c 'ls -A /d' 2>/dev/null) || return 2
  fi
  [[ -z "$out" ]] && return 0
  return 1
}

check_data_directory() {
  [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0

  local container
  container=$(combined_container_id)
  if [[ -z "$container" ]]; then
    echo "" > /dev/stderr
    echo "No container found for service '$COMBINED_SERVICE'." > /dev/stderr
    echo "The migration backs up the store by copying it out of that container," > /dev/stderr
    echo "so it has to exist. Start the deployment and re-run:" > /dev/stderr
    echo "  $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
    exit 1
  fi

  local src
  src=$(container_data_mount "$container")
  if [[ -z "$src" ]]; then
    echo "" > /dev/stderr
    echo "The '$COMBINED_SERVICE' container has nothing mounted at /var/lib/netbird." > /dev/stderr
    echo "Cannot locate the NetBird store to back it up." > /dev/stderr
    exit 1
  fi

  local state=0
  data_dir_state "$src" || state=$?
  if [[ $state -eq 0 ]]; then
    echo "" > /dev/stderr
    echo "The NetBird data directory is empty:" > /dev/stderr
    echo "  $src" > /dev/stderr
    echo "There is nothing to migrate. Check that you are running this from the" > /dev/stderr
    echo "deployment directory of the NetBird install you mean to migrate." > /dev/stderr
    exit 1
  fi
  if [[ $state -eq 2 ]]; then
    echo "  ⚠ Could not read $src to confirm it holds data — continuing." > /dev/stderr
    echo "    The backup step still fails loudly if it turns out to be empty." > /dev/stderr
  fi

  echo "  Data directory:   $src"
}

# Only for the Postgres volume, which has no container to read it off yet.
resolve_compose_volume() {
  local short="$1"
  local actual
  # Resolve project-prefixed volume name from Docker Compose config first.
  actual=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval ".volumes.\"$short\".name" - 2>/dev/null)
  if [[ -n "$actual" && "$actual" != "null" ]]; then
    echo "$actual"
    return
  fi
  # Relative bind mount: docker-compose resolves it against the compose
  # file's directory, but `docker run -v` resolves it against the current
  # working directory. Normalize to an absolute path so both interpretations
  # agree (and the printed revert command works from any CWD).
  if [[ "$short" == ./* || "$short" == ../* ]]; then
    local compose_dir
    compose_dir="$(cd "$(dirname "$COMPOSE_FILE")" && pwd)"
    (
      cd "$compose_dir"
      cd "$(dirname "$short")"
      printf '%s/%s\n' "$(pwd)" "$(basename "$short")"
    )
    return
  fi
  # Not a named volume (e.g. an absolute bind-mount path) — use it as-is.
  echo "$short"
}

backup_sqlite() {
  BACKUP_DIR="$(pwd)/backups/sqlite-pre-enterprise-$(date +%Y%m%d-%H%M%S)"
  mkdir -p "$BACKUP_DIR"

  local container
  container=$(combined_container_id)
  if [[ -z "$container" ]]; then
    echo "  ⚠ No container found for '$COMBINED_SERVICE' — cannot back up the store." > /dev/stderr
    exit 1
  fi

  echo "Backing up the NetBird store to $BACKUP_DIR ..."
  docker cp "${container}:/var/lib/netbird/." "$BACKUP_DIR/"

  local copied
  copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1)
  if [[ -z "$copied" ]]; then
    echo "  ⚠ Backup directory is empty — /var/lib/netbird held no data. Aborting." > /dev/stderr
    exit 1
  fi
  echo "  done"
}

run_migrate_store() {
  echo "Running migrate-store (SQLite → Postgres) ..."
  $DOCKER_COMPOSE_COMMAND run --rm "$COMBINED_SERVICE" migrate-store --config /etc/netbird/config.yaml.enterprise --verify
  echo "  done"
}

# ---------------------------------------------------------------------------
# Rollback — a failed run must not leave the operator with a stopped stack and
# half-written artifacts.
# ---------------------------------------------------------------------------

# Resolve the name Compose would give the Postgres volume before the override
# exists, so a leftover volume can be spotted up front.
compose_project_name() {
  local container project
  container=$($DOCKER_COMPOSE_COMMAND ps -aq 2>/dev/null | head -1)
  if [[ -n "$container" ]]; then
    project=$(docker inspect "$container" \
      --format '{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null)
    if [[ -n "$project" ]]; then
      echo "$project"
      return 0
    fi
  fi
  project=$($DOCKER_COMPOSE_COMMAND config 2>/dev/null | yq eval '.name // ""' - 2>/dev/null)
  if [[ -n "$project" ]] && [[ "$project" != "null" ]]; then
    echo "$project"
  fi
  return 0
}

postgres_volume_name() {
  local project
  project=$(compose_project_name)
  if [[ -n "$project" ]]; then
    echo "${project}_netbird_postgres"
  fi
  return 0
}

# Postgres skips initdb when its data directory is non-empty, so a volume left
# behind by an interrupted run would keep the old password and old contents,
# and migrate-store would fail against it.
check_stale_postgres_volume() {
  [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0

  PG_VOLUME_NAME=$(postgres_volume_name)
  if [[ -z "$PG_VOLUME_NAME" ]]; then
    echo ""
    echo "  ⚠ Could not determine the Compose project name, so a Postgres volume"
    echo "    left over from an earlier attempt cannot be checked for. If a"
    echo "    previous run failed, remove it before continuing:"
    echo "      docker volume ls | grep netbird_postgres"
    return 0
  fi
  docker volume inspect "$PG_VOLUME_NAME" &> /dev/null || return 0

  echo ""
  echo "  ⚠  A Postgres volume from an earlier attempt already exists:"
  echo "       $PG_VOLUME_NAME"
  echo "     Postgres does not re-initialise a non-empty data directory, so the"
  echo "     migration would run against stale credentials and stale data."
  local remove
  remove=$(read_yes_no "  Remove it and continue?" "y")
  if [[ "$remove" != "yes" ]]; then
    echo "" > /dev/stderr
    echo "Aborted. Remove it manually with: docker volume rm $PG_VOLUME_NAME" > /dev/stderr
    exit 1
  fi
  docker volume rm "$PG_VOLUME_NAME" > /dev/null
  echo "  Removed."
}

# Undo whatever this run changed and start the previous deployment again.
rollback() {
  ROLLBACK_STATE="done"

  echo ""
  echo "──────────────────────────────────────────────────────────────────────"
  echo " Migration failed — restoring the previous deployment"
  echo "──────────────────────────────────────────────────────────────────────"

  # Resolve while the override is still present; without it Compose no longer
  # knows about the Postgres volume.
  local pg_volume="$PG_VOLUME_NAME"
  if [[ -z "$pg_volume" ]] && [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    pg_volume=$(postgres_volume_name)
  fi

  echo ""
  echo "Stopping services ..."
  $DOCKER_COMPOSE_COMMAND down || true

  echo "Removing generated files ..."
  rm -f "$OVERRIDE_FILE" "$ENTERPRISE_CONFIG_FILE"

  # Restore .env to exactly what it was, or remove it if this run created it.
  if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
    mv -f "$ENV_BACKUP" .env || echo "  ⚠ Could not restore .env from $ENV_BACKUP." > /dev/stderr
  elif [[ "$ENV_EXISTED" == "no" ]]; then
    rm -f .env || true
  fi

  # Only ever the volume this run created — never the NetBird data volume.
  if [[ -n "$pg_volume" ]] && [[ "$pg_volume" != "null" ]]; then
    echo "Removing Postgres volume $pg_volume ..."
    docker volume rm "$pg_volume" &> /dev/null || true
  fi

  echo "Starting the previous deployment ..."
  if ! $DOCKER_COMPOSE_COMMAND up -d; then
    echo ""
    echo "  ⚠ Could not start the previous deployment automatically." > /dev/stderr
    echo "    Run: $DOCKER_COMPOSE_COMMAND up -d" > /dev/stderr
  fi

  echo ""
  echo "Rolled back. Your docker-compose.yml, config.yaml and the NetBird data"
  echo "volume were never modified."
  if [[ -n "$BACKUP_DIR" ]] && [[ -d "$BACKUP_DIR" ]]; then
    echo "The SQLite backup taken during this run is kept at:"
    echo "  $BACKUP_DIR"
  fi
  echo "──────────────────────────────────────────────────────────────────────"
}

on_exit() {
  local code=$?
  trap - EXIT
  if [[ $code -ne 0 ]] && [[ "$ROLLBACK_STATE" == "armed" ]]; then
    rollback
  fi
  exit $code
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

# Already on Postgres: there is nothing to provision and nothing to migrate.
# The enterprise image reads the very same store config the community image
# did, so step 2 collapses to a no-op and the run is a plain image swap.
configure_existing_postgres() {
  EXISTING_POSTGRES="yes"
  MIGRATE_POSTGRES="no"

  # DSN first — detect_postgres_service prefers the host it names.
  POSTGRES_DSN=$(detect_store_dsn)
  if [[ -z "$POSTGRES_DSN" ]] || [[ "$POSTGRES_DSN" == "null" ]]; then
    POSTGRES_DSN=$(detect_store_dsn_from_compose)
  fi
  if [[ "$POSTGRES_DSN" == "null" ]]; then
    POSTGRES_DSN=""
  fi

  POSTGRES_SERVICE=$(detect_postgres_service)
  if [[ -n "$POSTGRES_SERVICE" ]]; then
    POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
  fi

  echo "Step 2: Postgres migration not needed — this deployment already runs on"
  echo "        Postgres. Its store configuration is reused as-is and left"
  echo "        untouched; no database is created and no data is moved."
  if [[ -n "$POSTGRES_SERVICE" ]]; then
    echo "        Postgres service: $POSTGRES_SERVICE (in $COMPOSE_FILE)"
  else
    echo "        Postgres service: managed outside $COMPOSE_FILE"
  fi
}

configure_sqlite_store() {
  MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n")
  [[ "$MIGRATE_POSTGRES" == "yes" ]] || return 0

  # The override would otherwise merge into a service of the same name and
  # quietly rewrite its image and credentials.
  local existing
  existing=$(yq eval '.services | has("postgres")' "$COMPOSE_FILE")
  if [[ "$existing" == "true" ]]; then
    echo "" > /dev/stderr
    echo "$COMPOSE_FILE already defines a service named 'postgres', but config.yaml" > /dev/stderr
    echo "still has server.store.engine: sqlite. This script would add its own" > /dev/stderr
    echo "'postgres' service and Compose would merge the two." > /dev/stderr
    echo "" > /dev/stderr
    echo "Point server.store.engine at that Postgres yourself, or rename the service," > /dev/stderr
    echo "then re-run." > /dev/stderr
    exit 1
  fi

  echo ""
  echo "  ⚠  Data will be migrated from SQLite to Postgres. The SQLite store"
  echo "     will be backed up automatically. To fully revert later, restore"
  echo "     that backup and delete docker-compose.override.yml +"
  echo "     config.yaml.enterprise."
  local confirm
  confirm=$(read_yes_no "  Continue?" "y")
  if [[ "$confirm" != "yes" ]]; then
    MIGRATE_POSTGRES="no"
    echo "  Skipping Postgres migration."
    return 0
  fi

  POSTGRES_PASSWORD=$(rand_password)
  POSTGRES_SERVICE="postgres"
  POSTGRES_DEPENDS_CONDITION="service_healthy"
  POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable"
}

# mysql, or something this script has never seen. Swapping the images is still
# valid; touching the store is not.
configure_unsupported_store() {
  MIGRATE_POSTGRES="no"
  echo "  ⚠  server.store.engine is '$STORE_ENGINE'. This script only migrates"
  echo "     SQLite to Postgres, and traffic flow requires Postgres, so both are"
  echo "     unavailable here. The store configuration will be left untouched."
  echo ""
  local proceed
  proceed=$(read_yes_no "Step 2 skipped. Continue with the image swap only?" "n")
  if [[ "$proceed" != "yes" ]]; then
    echo "Aborted."
    exit 0
  fi
}

init_migration() {
  DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
  check_yq
  check_openssl

  COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"

  if [[ ! -f "$COMPOSE_FILE" ]]; then
    echo "$COMPOSE_FILE not found in $(pwd)." > /dev/stderr
    exit 1
  fi
  if [[ -f "$OVERRIDE_FILE" ]] || [[ -f "$ENTERPRISE_CONFIG_FILE" ]]; then
    echo "Migration artifacts already exist in $(pwd):"
    [[ -f "$OVERRIDE_FILE" ]] && echo "  $OVERRIDE_FILE"
    [[ -f "$ENTERPRISE_CONFIG_FILE" ]] && echo "  $ENTERPRISE_CONFIG_FILE"
    echo ""
    echo "Either you've already migrated, or a previous run was interrupted."
    echo "To re-run cleanly: rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
    exit 1
  fi

  COMBINED_SERVICE=$(detect_combined_service)
  DASHBOARD_SERVICE=$(detect_dashboard_service)
  CONFIG_YAML_HOST=$(detect_config_yaml_host_path)
  DATA_VOLUME=$(detect_data_volume)
  COMPOSE_NETWORK=$(detect_compose_network)

  if [[ -z "$COMBINED_SERVICE" ]]; then
    echo "Could not find a service running netbirdio/netbird-server or ghcr.io/netbirdio/netbird-server in $COMPOSE_FILE." > /dev/stderr
    echo "This script targets the community combined-server deployment." > /dev/stderr
    exit 1
  fi
  if [[ -z "$DASHBOARD_SERVICE" ]]; then
    echo "Could not find a service running netbirdio/dashboard or ghcr.io/netbirdio/dashboard in $COMPOSE_FILE." > /dev/stderr
    exit 1
  fi
  if [[ -z "$CONFIG_YAML_HOST" ]]; then
    echo "Could not find a config.yaml mount on $COMBINED_SERVICE (expected to bind-mount to /etc/netbird/config.yaml)." > /dev/stderr
    exit 1
  fi
  if [[ ! -f "$CONFIG_YAML_HOST" ]]; then
    echo "config.yaml host file not found at $CONFIG_YAML_HOST." > /dev/stderr
    exit 1
  fi
  if [[ -z "$DATA_VOLUME" ]]; then
    echo "Could not find a volume mounted at /var/lib/netbird on $COMBINED_SERVICE." > /dev/stderr
    exit 1
  fi

  STORE_ENGINE=$(detect_store_engine)

  echo "Detected existing deployment:"
  echo "  Combined service: $COMBINED_SERVICE"
  echo "  Dashboard:        $DASHBOARD_SERVICE"
  echo "  config.yaml:      $CONFIG_YAML_HOST"
  echo "  Data volume:      $DATA_VOLUME"
  echo "  Network:          $COMPOSE_NETWORK"
  echo "  Store engine:     $STORE_ENGINE"
  echo ""

  require_eula_acceptance
  NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  local proceed
  proceed=$(read_yes_no "Proceed with migration?" "y")
  if [[ "$proceed" != "yes" ]]; then
    echo "Aborted."
    exit 0
  fi

  # Step 1 — always (this is the point of the script)
  MIGRATE_IMAGES="yes"
  echo ""
  echo "Step 1: Image swap (community → Enterprise). License key required."
  NB_LICENSE_KEY=$(read_secret "  License key")

  # Step 2 — what this does depends on what the deployment already stores in.
  echo ""
  case "$STORE_ENGINE" in
    postgres) configure_existing_postgres ;;
    sqlite) configure_sqlite_store ;;
    *) configure_unsupported_store ;;
  esac

  # Step 3 — optional, only if Postgres is on (flow requires Postgres)
  echo ""
  if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then
    ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n")
    if [[ "$ENABLE_FLOW" == "yes" ]]; then
      # Auth secret MUST match server.authSecret from config.yaml
      NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST")
      if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then
        echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr
        echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr
        exit 1
      fi

      NETBIRD_DOMAIN=$(detect_exposed_address)
      if [[ -z "$NETBIRD_DOMAIN" ]] || [[ "$NETBIRD_DOMAIN" == "null" ]]; then
        NETBIRD_DOMAIN=$(read_required "  Public NetBird URL (e.g. https://netbird.example.com)")
      fi
      # Strip protocol + port to leave just the hostname for the Traefik Host() rule.
      NETBIRD_HOSTNAME=$(echo "$NETBIRD_DOMAIN" | sed -E 's,^https?://,,' | sed 's,:.*,,' | sed 's,/.*,,')

      # We need the encryption key from the existing config.yaml for the enricher
      NETBIRD_ENCRYPTION_KEY=$(yq eval '.server.store.encryptionKey // ""' "$CONFIG_YAML_HOST")
      if [[ -z "$NETBIRD_ENCRYPTION_KEY" ]] || [[ "$NETBIRD_ENCRYPTION_KEY" == "null" ]]; then
        echo "Could not read server.store.encryptionKey from $CONFIG_YAML_HOST." > /dev/stderr
        exit 1
      fi

      # flow-enricher talks to Postgres directly, so this is the one place an
      # existing deployment's DSN is actually needed — and the one place a host
      # that only works from inside the server container shows up.
      while :; do
        local dsn_problem=""
        if [[ -z "$POSTGRES_DSN" ]]; then
          dsn_problem="No DSN could be read from $CONFIG_YAML_HOST or from the $COMBINED_SERVICE environment."
        elif ! dsn_host_reachable "$POSTGRES_DSN"; then
          dsn_problem="Its host '$(dsn_host "$POSTGRES_DSN")' only resolves inside the server container."
        fi
        [[ -n "$dsn_problem" ]] || break

        echo ""
        echo "  The flow enricher reaches Postgres from a container of its own."
        echo "  $dsn_problem"
        echo "  Enter a DSN reachable from other containers, or press Ctrl-C to abort."
        POSTGRES_DSN=$(read_required "  Postgres DSN (host=… user=… password=… dbname=… port=5432 sslmode=disable)")
      done

      # Only where the operator owns Postgres: a DSN entered above may name a
      # different host. The sqlite path creates its own service, nothing to find.
      if [[ "$EXISTING_POSTGRES" == "yes" ]]; then
        POSTGRES_SERVICE=$(detect_postgres_service)
        if [[ -n "$POSTGRES_SERVICE" ]]; then
          POSTGRES_DEPENDS_CONDITION=$(detect_postgres_depends_condition)
        fi
      fi
    fi
  else
    ENABLE_FLOW="no"
    echo "Step 3 (traffic flow) skipped — requires Postgres."
  fi

  # config.yaml.enterprise only exists to hold changes; without any there is
  # nothing to generate and the server keeps running on its own config.yaml.
  if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$ENABLE_FLOW" == "yes" ]]; then
    ENTERPRISE_CONFIG="yes"
  fi

  check_data_directory
  check_stale_postgres_volume
}

apply_changes() {
  # From here on a failure must roll the deployment back.
  ROLLBACK_STATE="armed"

  echo ""
  echo "Writing $OVERRIDE_FILE ..."
  install -m 644 /dev/null "$OVERRIDE_FILE"
  render_override > "$OVERRIDE_FILE"

  if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
    sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' "$OVERRIDE_FILE" && rm -f "$OVERRIDE_FILE.bak"
  fi

  if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
    echo "Writing $ENTERPRISE_CONFIG_FILE ..."
    install -m 600 /dev/null "$ENTERPRISE_CONFIG_FILE"
    render_enterprise_config
  fi

  # Persist secrets that the override file references via env interpolation.
  # We write them to a .env file in the current directory; docker compose
  # picks it up automatically.
  echo "Writing .env additions (mode 600) ..."
  local ENV_FILE=".env"
  # Snapshot the operator's .env so a rollback can restore it byte for byte.
  if [[ -f "$ENV_FILE" ]]; then
    ENV_EXISTED="yes"
    ENV_BACKUP="${ENV_FILE}.pre-enterprise-$(date +%Y%m%d-%H%M%S)"
    cp -p "$ENV_FILE" "$ENV_BACKUP"
  else
    ENV_EXISTED="no"
  fi
  touch "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  {
    echo ""
    echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "# NetBird On-Premise EULA accepted at install time"
    echo "NETBIRD_EULA_ACCEPTED=yes"
    echo "NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}"
    echo "NETBIRD_EULA_URL=${NETBIRD_EULA_URL}"
    echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}"
    if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
      echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}"
    fi
    if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
      echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
    fi
    if [[ "$ENABLE_FLOW" == "yes" ]]; then
      # Own variable name rather than NB_STORE_ENGINE_POSTGRES_DSN so that a
      # deployment already setting that one keeps its own value.
      echo "NB_ENTERPRISE_POSTGRES_DSN=$(env_value "$POSTGRES_DSN")"
      echo "NB_FLOW_AUTH_SECRET=${NB_FLOW_AUTH_SECRET}"
      echo "NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY}"
    fi
  } >> "$ENV_FILE"

  echo ""
  echo "Pulling enterprise images ..."
  $DOCKER_COMPOSE_COMMAND pull

  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    echo ""
    # Stop, but keep the containers: the backup reads the store out of one.
    echo "Stopping services so the store is quiescent ..."
    $DOCKER_COMPOSE_COMMAND stop

    backup_sqlite

    echo ""
    echo "Removing stopped containers (volumes preserved) ..."
    $DOCKER_COMPOSE_COMMAND down

    echo ""
    echo "Starting Postgres ..."
    $DOCKER_COMPOSE_COMMAND up -d postgres

    # Wait for healthy
    local counter=0
    echo -n "Waiting for Postgres to become ready"
    while ! $DOCKER_COMPOSE_COMMAND exec -T postgres pg_isready -U netbird -d netbird &> /dev/null; do
      echo -n " ."
      sleep 2
      counter=$((counter + 1))
      if [[ $counter -ge 60 ]]; then
        echo ""
        echo "Postgres did not become ready in 120s. Recent logs:"
        $DOCKER_COMPOSE_COMMAND logs --tail=20 postgres
        exit 1
      fi
    done
    echo " done"

    run_migrate_store
  fi

  echo ""
  echo "Bringing up all services ..."
  $DOCKER_COMPOSE_COMMAND up -d

  echo ""
  echo "Migration complete."

  # Nothing left to undo.
  ROLLBACK_STATE="disarmed"
}

print_summary() {
  echo ""
  echo "──────────────────────────────────────────────────────────────────────"
  echo " Summary"
  echo "──────────────────────────────────────────────────────────────────────"
  echo "  Images:           swapped to enterprise"
  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    echo "  Storage:          Postgres (data migrated from SQLite)"
  elif [[ "$EXISTING_POSTGRES" == "yes" ]]; then
    echo "  Storage:          Postgres (pre-existing, configuration unchanged)"
  else
    echo "  Storage:          $STORE_ENGINE (unchanged)"
  fi
  [[ "$ENABLE_FLOW" == "yes" ]] && echo "  Traffic flow:     enabled"
  [[ "$ENABLE_FLOW" != "yes" ]] && echo "  Traffic flow:     disabled"
  echo ""
  echo "  Generated files (next to your docker-compose.yml):"
  echo "    $OVERRIDE_FILE"
  [[ "$ENTERPRISE_CONFIG" == "yes" ]] && echo "    $ENTERPRISE_CONFIG_FILE"
  echo "    .env  (license key + secrets, mode 600)"
  [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]] && echo "    $ENV_BACKUP  (.env as it was before this run)"
  [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo "    backups/sqlite-pre-enterprise-*/  (SQLite backup)"
  echo ""
  echo " Tail logs:"
  echo "   $DOCKER_COMPOSE_COMMAND logs -f $COMBINED_SERVICE"
  echo ""
  echo "──────────────────────────────────────────────────────────────────────"
  echo " To revert"
  echo "──────────────────────────────────────────────────────────────────────"
  if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then
    # Resolve the project-prefixed volume name now, before the override is gone.
    local pg_volume
    pg_volume=$(resolve_compose_volume "netbird_postgres")
    echo "  # Stop, but keep the containers so the store can be copied back in:"
    echo "  $DOCKER_COMPOSE_COMMAND stop"
    echo "  # Restore SQLite from the backup created during this run:"
    echo "  docker cp ${BACKUP_DIR}/. \$($DOCKER_COMPOSE_COMMAND ps -aq $COMBINED_SERVICE):/var/lib/netbird/"
    echo "  $DOCKER_COMPOSE_COMMAND down"
    echo "  docker volume rm $pg_volume"
  else
    echo "  $DOCKER_COMPOSE_COMMAND down"
  fi
  if [[ "$ENTERPRISE_CONFIG" == "yes" ]]; then
    echo "  rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE"
  else
    echo "  rm -f $OVERRIDE_FILE"
  fi
  if [[ "$ENV_EXISTED" == "yes" ]] && [[ -f "$ENV_BACKUP" ]]; then
    echo "  mv $ENV_BACKUP .env   # restores .env as it was before this run"
  elif [[ "$ENV_EXISTED" == "no" ]]; then
    echo "  rm -f .env   # created by this run"
  else
    echo "  # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)"
  fi
  echo "  $DOCKER_COMPOSE_COMMAND up -d"
  echo "──────────────────────────────────────────────────────────────────────"
}

# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------

trap on_exit EXIT
# Turn signals into a normal exit so the EXIT trap can roll back.
trap 'exit 130' INT TERM

init_migration
apply_changes
print_summary
