517 lines
18 KiB
Bash
Executable File
517 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Migrate one installation DB: YC MDB → CNPG or Selectel Managed (no cutover).
|
|
# Usage:
|
|
# migrate-one.sh <kube_context> <installation_id|uuid> [cnpg|selectel] [postgres://dsn]
|
|
# migrate-one.sh <kube_context> <installation_id> --scale-0
|
|
# TARGET=selectel migrate-one.sh <kube_context> <installation_id>
|
|
# TRANSFER_MODE=file DUMP_PVC_SIZE=80Gi migrate-one.sh loop-ff <installation_id>
|
|
#
|
|
# 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 <kube_context> <installation_id|uuid> [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 <<PVC | kubectl --context "$CTX" -n "$JOB_NS" apply -f -
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: ${PVC_NAME}
|
|
namespace: ${JOB_NS}
|
|
labels:
|
|
app: pgxfer
|
|
installation: ${INSTALLATION}
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: ${DUMP_STORAGE_CLASS}
|
|
resources:
|
|
requests:
|
|
storage: ${DUMP_PVC_SIZE}
|
|
PVC
|
|
elif [[ "$TRANSFER_MODE" == "file" ]]; then
|
|
log "Dump volume=emptyDir sizeLimit=${DUMP_EMPTYDIR_SIZE_LIMIT} compress=${DUMP_COMPRESS}"
|
|
fi
|
|
|
|
if [[ "$SCALE_0" == "1" ]]; then
|
|
scale_mm_app_to_zero "$CTX" "$INSTALLATION"
|
|
fi
|
|
|
|
log "Creating transfer Job ${JOB_NAME} in ${CTX}/${JOB_NS}"
|
|
|
|
kubectl --context "$CTX" -n "$JOB_NS" create secret generic "$SECRET_NAME" \
|
|
--from-literal=SRC_HOST="$SRC_HOST" \
|
|
--from-literal=SRC_PORT="$SRC_PORT" \
|
|
--from-literal=SRC_USER="$SRC_USER" \
|
|
--from-literal=SRC_PASS="$SRC_PASS" \
|
|
--from-literal=SRC_DB="$SRC_DB" \
|
|
--from-literal=SRC_SSLMODE="${SRC_SSLMODE}" \
|
|
--from-literal=DST_HOST="$TARGET_HOST" \
|
|
--from-literal=DST_PORT="$TARGET_PORT" \
|
|
--from-literal=DST_SSLMODE="$TARGET_SSLMODE" \
|
|
--from-literal=TARGET_KIND="$TARGET_KIND" \
|
|
--from-literal=TRANSFER_MODE="${TRANSFER_MODE}" \
|
|
--from-literal=TRANSFER_MAX_ATTEMPTS="${TRANSFER_MAX_ATTEMPTS}" \
|
|
--from-literal=TRANSFER_RETRY_SLEEP_SEC="${TRANSFER_RETRY_SLEEP_SEC}" \
|
|
--from-literal=PG_KEEPALIVES_IDLE="${PG_KEEPALIVES_IDLE}" \
|
|
--from-literal=PG_KEEPALIVES_INTERVAL="${PG_KEEPALIVES_INTERVAL}" \
|
|
--from-literal=PG_KEEPALIVES_COUNT="${PG_KEEPALIVES_COUNT}" \
|
|
--from-literal=DUMP_COMPRESS="${DUMP_COMPRESS}" \
|
|
--from-literal=DUMP_COMPRESS_LEVEL="${DUMP_COMPRESS_LEVEL}" \
|
|
--dry-run=client -o yaml | kubectl --context "$CTX" -n "$JOB_NS" apply -f -
|
|
|
|
PULL_SECRETS='[]'
|
|
if kubectl --context "$CTX" -n "$JOB_NS" get secret docker.wilix.dev >/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}"
|