#!/usr/bin/env bash # Migrate one installation DB: YC MDB → CNPG or Selectel Managed (no cutover). # Usage: # migrate-one.sh [cnpg|selectel] [postgres://dsn] # migrate-one.sh --scale-0 # TARGET=selectel migrate-one.sh # TRANSFER_MODE=file DUMP_PVC_SIZE=80Gi migrate-one.sh loop-ff # # Selectel: create user/password/database in UI with the same values as the MM pod secret. # --scale-0 / MIGRATE_SCALE_0=1: scale MM to 0 before transfer, restore after (or keep 0 if Job still running). set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "${DIR}/lib.sh" [[ $# -ge 2 ]] || die "usage: $0 [cnpg|selectel|loop-cnpg-*] [dsn] [--scale-0]" CTX="$1" INSTALLATION_RAW="$2" shift 2 SCALE_0="${MIGRATE_SCALE_0:-0}" ARGS=() for a in "$@"; do case "$a" in --scale-0|scale-0) SCALE_0=1 ;; *) ARGS+=("$a") ;; esac done set -- "${ARGS[@]+"${ARGS[@]}"}" parse_target_dsn_args "$@" OVERRIDE_DSN="${PARSE_DSN}" if [[ -n "${PARSE_TARGET}" ]]; then TARGET="${PARSE_TARGET}" fi if [[ -n "${PARSE_CNPG_PROFILE:-}" ]]; then CNPG_CLUSTER_PROFILE="${PARSE_CNPG_PROFILE}" fi TARGET="${TARGET:-cnpg}" TRANSFER_MODE="${TRANSFER_MODE:-stream}" DUMP_VOLUME="${DUMP_VOLUME:-pvc}" case "$TRANSFER_MODE" in stream|file) ;; *) die "TRANSFER_MODE must be stream|file (got: ${TRANSFER_MODE})" ;; esac case "$DUMP_VOLUME" in pvc|emptydir) ;; *) die "DUMP_VOLUME must be pvc|emptydir (got: ${DUMP_VOLUME})" ;; esac case "${DUMP_COMPRESS:-zstd}" in zstd|gzip|none) ;; *) die "DUMP_COMPRESS must be zstd|gzip|none (got: ${DUMP_COMPRESS})" ;; esac # ponytail: file-режим отключён — оборванный pg_dump мог считаться полным дампом (ревью 27.10.2026). Чинить test_archive/маркер, потом включать. [[ "$TRANSFER_MODE" != "file" ]] || die "TRANSFER_MODE=file отключён до исправления, используй stream" INSTALLATION="$(resolve_installation "$CTX" "$INSTALLATION_RAW")" LOG_FILE="${DIR}/${LOG_DIR}/${INSTALLATION}.log" exec > >(tee -a "$LOG_FILE") 2>&1 # MIGRATE_FORCE=1 (loopctl db move): статус в registry от прошлого переезда не повод пропускать копию на новый кластер if [[ "${MIGRATE_FORCE:-0}" != "1" ]] && is_done "$INSTALLATION"; then cur="$(registry_get "$INSTALLATION" status 2>/dev/null || true)" log "SKIP ${INSTALLATION} (registry status=${cur:-done}; re-run: registry_lib.py set ${INSTALLATION} status=sized)" exit 0 fi resolve_cnpg_cluster_profile "$INSTALLATION" resolve_target "$TARGET" registry_set "$INSTALLATION" "status=migrating" "kube_context=${CTX}" || true if [[ "${TARGET_KIND}" == "cnpg" && -n "${CNPG_CLUSTER_PROFILE:-}" ]]; then registry_set "$INSTALLATION" "target_cluster=${CNPG_CLUSTER_PROFILE}" || true fi log "=== migrate-one ${CTX} ${INSTALLATION} target=${TARGET_KIND} cnpg=${CNPG_CLUSTER_PROFILE:-${CNPG_CLUSTER:-?}} endpoint=${TARGET_HOST}:${TARGET_PORT} mode=${TRANSFER_MODE} scale-0=${SCALE_0} ===" SCALE_0_ACTIVE=0 SCALE_0_PREV="" DSN="${OVERRIDE_DSN}" if [[ -z "$DSN" ]]; then DSN="$(get_secret_dsn "$CTX" "$INSTALLATION")" fi parse_dsn "$DSN" [[ -n "$SRC_USER" ]] || die "empty user in DSN" [[ -n "$SRC_DB" ]] || die "empty db in DSN" log "Source host=${SRC_HOST}:${SRC_PORT} db=${SRC_DB} user=${SRC_USER}" printf '%s\n' "$DSN" >"${DIR}/${LOG_DIR}/${INSTALLATION}.source.dsn" chmod 600 "${DIR}/${LOG_DIR}/${INSTALLATION}.source.dsn" save_installation_target "$INSTALLATION" "$TARGET_KIND" log "Target ${TARGET_KIND} ${TARGET_HOST}:${TARGET_PORT} sslmode=${TARGET_SSLMODE}" SUPERUSER_CLEARED=1 # selectel: never toggles superuser if [[ "$TARGET_KIND" == "cnpg" ]]; then ensure_role_and_db "$SRC_USER" "$SRC_PASS" "$SRC_DB" set_role_superuser "$SRC_USER" ON SUPERUSER_CLEARED=0 else log "Selectel: using pre-created DB/user/password from UI (must match pod secret)" fi JOB_NAME="pgxfer-$(echo "$INSTALLATION" | tr '[:upper:]' '[:lower:]' | cut -c1-40)-$(date +%s | tail -c 5)" SECRET_NAME="${JOB_NAME}-env" PVC_NAME="pgxfer-dump-$(echo "$INSTALLATION" | tr '[:upper:]' '[:lower:]' | cut -c1-48)" JOB_FILE="$(mktemp)" KEEP_JOB=0 cleanup() { if [[ "$SUPERUSER_CLEARED" != "1" && "${TARGET_KIND:-}" == "cnpg" ]]; then set_role_superuser "$SRC_USER" OFF 2>/dev/null || true fi # Never delete a still-running Job (timeout, Ctrl-C, or bash crash after editing this file mid-wait) local active=0 if [[ -n "${JOB_NAME:-}" ]]; then active="$(kubectl --context "$CTX" -n "$JOB_NS" get job "$JOB_NAME" -o jsonpath='{.status.active}' 2>/dev/null || echo 0)" fi if [[ "$KEEP_JOB" == "1" || "${active:-0}" -ge 1 ]]; then log "Keeping Job ${JOB_NAME} and secret ${SECRET_NAME} (active=${active:-0})" if [[ "${TRANSFER_MODE}" == "file" && "${DUMP_VOLUME}" == "pvc" ]]; then log " dump PVC: ${PVC_NAME} (kept)" elif [[ "${TRANSFER_MODE}" == "file" ]]; then log " dump volume: emptyDir (node disk, lost if pod is rescheduled)" fi log " kubectl --context ${CTX} -n ${JOB_NS} logs -f job/${JOB_NAME}" if [[ "${MIGRATE_JOB_TIMEOUT}" == "0" ]]; then log " kubectl --context ${CTX} -n ${JOB_NS} wait --for=condition=complete job/${JOB_NAME} --timeout=-1s" else log " kubectl --context ${CTX} -n ${JOB_NS} wait --for=condition=complete job/${JOB_NAME} --timeout=${MIGRATE_JOB_TIMEOUT}s" fi else kubectl --context "$CTX" -n "$JOB_NS" delete job "$JOB_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true kubectl --context "$CTX" -n "$JOB_NS" delete secret "$SECRET_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true if [[ "${TRANSFER_MODE}" == "file" && "${DUMP_VOLUME}" == "pvc" && "${DUMP_KEEP_PVC}" != "1" ]]; then log "Deleting dump PVC ${PVC_NAME}" kubectl --context "$CTX" -n "$JOB_NS" delete pvc "$PVC_NAME" --ignore-not-found --wait=false >/dev/null 2>&1 || true elif [[ "${TRANSFER_MODE}" == "file" && "${DUMP_VOLUME}" == "pvc" ]]; then log "Keeping dump PVC ${PVC_NAME} (DUMP_KEEP_PVC=1)" fi fi # Restore MM replicas unless transfer Job is still running restore_mm_app_replicas "$CTX" "$INSTALLATION" || true rm -f "$JOB_FILE" } trap cleanup EXIT if [[ "$TRANSFER_MODE" == "file" && "$DUMP_VOLUME" == "pvc" ]]; then log "Ensuring dump PVC ${PVC_NAME} (${DUMP_PVC_SIZE}, sc=${DUMP_STORAGE_CLASS}, compress=${DUMP_COMPRESS})" cat </dev/null 2>&1; then PULL_SECRETS='[{"name":"docker.wilix.dev"}]' fi JOB_NAME="$JOB_NAME" JOB_NS="$JOB_NS" INSTALLATION="$INSTALLATION" \ JOB_TTL_SECONDS="$JOB_TTL_SECONDS" PG_IMAGE="$PG_IMAGE" \ SECRET_NAME="$SECRET_NAME" PULL_SECRETS="$PULL_SECRETS" \ TARGET_KIND="$TARGET_KIND" TRANSFER_MODE="$TRANSFER_MODE" \ PVC_NAME="$PVC_NAME" DUMP_VOLUME="$DUMP_VOLUME" \ DUMP_EMPTYDIR_SIZE_LIMIT="${DUMP_EMPTYDIR_SIZE_LIMIT}" \ XFER_CPU_REQUEST="${XFER_CPU_REQUEST}" XFER_CPU_LIMIT="${XFER_CPU_LIMIT}" \ XFER_MEMORY_REQUEST="${XFER_MEMORY_REQUEST}" XFER_MEMORY_LIMIT="${XFER_MEMORY_LIMIT}" \ python3 - "$JOB_FILE" <<'PY' import json, os, sys path = sys.argv[1] kind = os.environ.get("TARGET_KIND", "cnpg") mode = os.environ.get("TRANSFER_MODE", "stream") pvc_name = os.environ.get("PVC_NAME", "") dump_volume = os.environ.get("DUMP_VOLUME", "pvc") emptydir_limit = os.environ.get("DUMP_EMPTYDIR_SIZE_LIMIT", "50Gi") xfer_cpu_req = os.environ.get("XFER_CPU_REQUEST", "200m") xfer_cpu_lim = os.environ.get("XFER_CPU_LIMIT", "2") xfer_mem_req = os.environ.get("XFER_MEMORY_REQUEST", "512Mi") xfer_mem_lim = os.environ.get("XFER_MEMORY_LIMIT", "2Gi") if kind == "cnpg": pre_sql = r""" DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION CURRENT_USER; GRANT ALL ON SCHEMA public TO CURRENT_USER; CREATE OR REPLACE FUNCTION public.user_search(uname text) RETURNS TABLE(usename name, passwd text) LANGUAGE sql SECURITY DEFINER AS $f$SELECT usename, passwd FROM pg_catalog.pg_shadow WHERE usename=$1;$f$; REVOKE ALL ON FUNCTION public.user_search(text) FROM PUBLIC; GRANT USAGE ON SCHEMA public TO cnpg_pooler_pgbouncer; GRANT EXECUTE ON FUNCTION public.user_search(text) TO cnpg_pooler_pgbouncer; """ else: pre_sql = r""" DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION CURRENT_USER; GRANT ALL ON SCHEMA public TO CURRENT_USER; """ common = r"""set -uo pipefail echo "dump ${SRC_HOST}:${SRC_PORT}/${SRC_DB} -> ${DST_HOST}:${DST_PORT}/${SRC_DB} (target=${TARGET_KIND} mode=${TRANSFER_MODE:-stream})" export PGCONNECT_TIMEOUT=60 export PGOPTIONS="-c statement_timeout=0 -c idle_in_transaction_session_timeout=0" MAX_ATTEMPTS="${TRANSFER_MAX_ATTEMPTS:-30}" SLEEP_SEC="${TRANSFER_RETRY_SLEEP_SEC:-60}" KA_IDLE="${PG_KEEPALIVES_IDLE:-30}" KA_INT="${PG_KEEPALIVES_INTERVAL:-10}" KA_CNT="${PG_KEEPALIVES_COUNT:-9}" src_conn() { printf 'host=%s port=%s dbname=%s user=%s sslmode=%s keepalives=1 keepalives_idle=%s keepalives_interval=%s keepalives_count=%s connect_timeout=60' \ "${SRC_HOST}" "${SRC_PORT}" "${SRC_DB}" "${SRC_USER}" "${SRC_SSLMODE}" "${KA_IDLE}" "${KA_INT}" "${KA_CNT}" } dst_conn() { printf 'host=%s port=%s dbname=%s user=%s sslmode=%s keepalives=1 keepalives_idle=%s keepalives_interval=%s keepalives_count=%s connect_timeout=60' \ "${DST_HOST}" "${DST_PORT}" "${SRC_DB}" "${SRC_USER}" "${DST_SSLMODE}" "${KA_IDLE}" "${KA_INT}" "${KA_CNT}" } reset_dst() { PGPASSWORD="${SRC_PASS}" psql "$(dst_conn)" -v ON_ERROR_STOP=1 <<'SQL' """ + pre_sql + r""" SQL } rewrite_ext() { sed -E '/^CREATE EXTENSION/s/pgvector/vector/g' } """ if mode == "file": script = common + r""" DUMP_DIR="${DUMP_DIR:-/dump}" COMPRESS="${DUMP_COMPRESS:-zstd}" LEVEL="${DUMP_COMPRESS_LEVEL:-3}" case "${COMPRESS}" in zstd) DUMP_FILE="${DUMP_DIR}/dump.sql.zst" ;; gzip) DUMP_FILE="${DUMP_DIR}/dump.sql.gz" ;; none) DUMP_FILE="${DUMP_DIR}/dump.sql" ;; *) echo "bad DUMP_COMPRESS=${COMPRESS}"; exit 2 ;; esac MARKER="${DUMP_DIR}/dump.complete" PARTIAL="${DUMP_FILE}.partial" mkdir -p "${DUMP_DIR}" compress_cmd() { case "${COMPRESS}" in zstd) zstd -T0 -"${LEVEL}" -c ;; gzip) gzip -"${LEVEL}" -c ;; none) cat ;; esac } decompress_to_stdout() { case "${COMPRESS}" in zstd) zstd -dc "${DUMP_FILE}" ;; gzip) gzip -dc "${DUMP_FILE}" ;; none) cat "${DUMP_FILE}" ;; esac } test_archive() { local f="$1" case "${COMPRESS}" in zstd) zstd -t "$f" ;; gzip) gzip -t "$f" ;; none) [[ -s "$f" ]] ;; esac } dump_file_ready() { [[ -f "${MARKER}" && -s "${DUMP_FILE}" ]] && test_archive "${DUMP_FILE}" } # Dump from MDB once: write compressed file on PVC and stream to dst. # If psql dies mid-way, drain the pipe so pg_dump+compress can finish the file. dump_and_stream() { echo "=== dump+stream -> ${DUMP_FILE} ===" reset_dst rm -f "${MARKER}" "${PARTIAL}" local dst_cs pipe_rc=0 rc=0 dst_cs="$(dst_conn)" set +e # tee writes compressed file; if psql dies, cat drains so pg_dump can finish the archive PGPASSWORD="${SRC_PASS}" pg_dump "$(src_conn)" \ --no-owner --no-acl --no-comments \ | rewrite_ext \ | tee >(compress_cmd > "${PARTIAL}") \ | { PGPASSWORD="${SRC_PASS}" psql "${dst_cs}" -v ON_ERROR_STOP=1 rc=$? cat >/dev/null exit "${rc}" } pipe_rc=$? set -e if [[ ! -s "${PARTIAL}" ]]; then echo "ERROR: dump partial empty (pipeline rc=${pipe_rc})" return 1 fi if ! test_archive "${PARTIAL}"; then echo "ERROR: dump archive failed integrity check" rm -f "${PARTIAL}" return 1 fi mv -f "${PARTIAL}" "${DUMP_FILE}" sync touch "${MARKER}" echo "dump file ready: $(du -h "${DUMP_FILE}" | awk '{print $1}') (${DUMP_FILE}) pipeline_rc=${pipe_rc}" return "${pipe_rc}" } restore_from_file() { echo "=== restore from file ${DUMP_FILE} ($(du -h "${DUMP_FILE}" | awk '{print $1}')) ===" reset_dst decompress_to_stdout | PGPASSWORD="${SRC_PASS}" psql "$(dst_conn)" -v ON_ERROR_STOP=1 } attempt=1 while [[ "${attempt}" -le "${MAX_ATTEMPTS}" ]]; do echo "=== transfer attempt ${attempt}/${MAX_ATTEMPTS} at $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" ok=0 if dump_file_ready; then if restore_from_file; then ok=1; fi else if dump_and_stream; then ok=1 elif dump_file_ready; then echo "stream failed but dump file is complete — will retry restore from PVC" fi fi if [[ "${ok}" -eq 1 ]]; then echo "transfer OK (attempt ${attempt})" exit 0 fi echo "transfer FAILED attempt ${attempt}/${MAX_ATTEMPTS}; sleeping ${SLEEP_SEC}s before retry..." sleep "${SLEEP_SEC}" attempt=$((attempt + 1)) done echo "transfer gave up after ${MAX_ATTEMPTS} attempts" exit 1 """ else: script = common + r""" do_transfer() { set -euo pipefail reset_dst PGPASSWORD="${SRC_PASS}" pg_dump "$(src_conn)" \ --no-owner --no-acl --no-comments \ | rewrite_ext \ | PGPASSWORD="${SRC_PASS}" psql "$(dst_conn)" -v ON_ERROR_STOP=1 } attempt=1 while [[ "${attempt}" -le "${MAX_ATTEMPTS}" ]]; do echo "=== transfer attempt ${attempt}/${MAX_ATTEMPTS} at $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" if do_transfer; then echo "transfer OK (attempt ${attempt})" exit 0 fi rc=$? echo "transfer FAILED attempt ${attempt}/${MAX_ATTEMPTS} rc=${rc}; sleeping ${SLEEP_SEC}s before retry..." sleep "${SLEEP_SEC}" attempt=$((attempt + 1)) done echo "transfer gave up after ${MAX_ATTEMPTS} attempts" exit 1 """ container = { "name": "transfer", "image": os.environ["PG_IMAGE"], "imagePullPolicy": "IfNotPresent", "envFrom": [{"secretRef": {"name": os.environ["SECRET_NAME"]}}], "command": ["bash", "-c"], "args": [script], "resources": { "requests": {"cpu": xfer_cpu_req, "memory": xfer_mem_req}, "limits": {"cpu": xfer_cpu_lim, "memory": xfer_mem_lim}, }, } pod_spec = { "restartPolicy": "Never", "imagePullSecrets": json.loads(os.environ["PULL_SECRETS"]), "containers": [container], } if mode == "file": container["volumeMounts"] = [{"name": "dump", "mountPath": "/dump"}] # postgres image has zstd/gzip as root; keep root for compress tools + volume perms pod_spec["securityContext"] = {"runAsUser": 0, "fsGroup": 0} if dump_volume == "emptydir": pod_spec["volumes"] = [{ "name": "dump", "emptyDir": {"sizeLimit": emptydir_limit}, }] else: pod_spec["volumes"] = [{ "name": "dump", "persistentVolumeClaim": {"claimName": pvc_name}, }] job = { "apiVersion": "batch/v1", "kind": "Job", "metadata": { "name": os.environ["JOB_NAME"], "namespace": os.environ["JOB_NS"], "labels": { "app": "pgxfer", "installation": os.environ["INSTALLATION"], "migrate-target": kind, "transfer-mode": mode, }, }, "spec": { "ttlSecondsAfterFinished": int(os.environ.get("JOB_TTL_SECONDS", "86400")), "backoffLimit": 2, "template": { "metadata": { "labels": { "app": "pgxfer", "installation": os.environ["INSTALLATION"], "migrate-target": kind, "transfer-mode": mode, }, }, "spec": pod_spec, }, }, } with open(path, "w") as f: json.dump(job, f) PY kubectl --context "$CTX" -n "$JOB_NS" apply -f "$JOB_FILE" if [[ "${MIGRATE_JOB_TIMEOUT}" == "0" ]]; then log "Waiting for Job ${JOB_NAME} (no timeout) ..." else log "Waiting for Job ${JOB_NAME} (timeout=${MIGRATE_JOB_TIMEOUT}s) ..." fi wait_rc=0 wait_job "$CTX" "$JOB_NS" "$JOB_NAME" "${MIGRATE_JOB_TIMEOUT}" || wait_rc=$? if [[ "$wait_rc" -eq 0 ]]; then kubectl --context "$CTX" -n "$JOB_NS" logs "job/${JOB_NAME}" --tail=80 || true if [[ "$TARGET_KIND" == "cnpg" ]]; then log "Reinstalling CNPG pooler auth hooks on ${SRC_DB}" install_pooler_auth "$SRC_DB" set_role_superuser "$SRC_USER" OFF SUPERUSER_CLEARED=1 fi mark_done "$INSTALLATION" log "SUCCESS ${INSTALLATION} → ${TARGET_KIND} (mode=${TRANSFER_MODE})" exit 0 fi if [[ "$wait_rc" -eq 2 && "${MIGRATE_KEEP_JOB_ON_TIMEOUT}" == "1" ]]; then KEEP_JOB=1 kubectl --context "$CTX" -n "$JOB_NS" logs "job/${JOB_NAME}" --tail=40 || true log "Transfer still running after ${MIGRATE_JOB_TIMEOUT}s — Job left alive." if [[ "${MIGRATE_JOB_TIMEOUT}" == "0" ]]; then log "Resume wait: kubectl --context ${CTX} -n ${JOB_NS} wait --for=condition=complete job/${JOB_NAME} --timeout=-1s" else log "Resume wait: kubectl --context ${CTX} -n ${JOB_NS} wait --for=condition=complete job/${JOB_NAME} --timeout=${MIGRATE_JOB_TIMEOUT}s" fi exit 2 fi kubectl --context "$CTX" -n "$JOB_NS" logs "job/${JOB_NAME}" --tail=120 || true mark_failed "$INSTALLATION" die "Job ${JOB_NAME} failed for ${INSTALLATION}"