db-cutover 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
db_cutover/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Zero-downtime blue/green PostgreSQL cutovers on RDS: logical replication plus a PgBouncer switch."""
2
+
3
+ __version__ = "0.2.0"
db_cutover/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from db_cutover.cli import main
2
+
3
+ main()
db_cutover/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """db-cutover CONFIG COMMAND [options]. Run `db-cutover --help` for the commands, and see docs/procedure.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tomllib
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ import psycopg
15
+
16
+ from db_cutover import __version__, config
17
+ from db_cutover.commands import blue, inspect, replication, switch, terraform
18
+ from db_cutover.db import Context
19
+ from db_cutover.output import StepFailed, error_detail
20
+ from db_cutover.shell import CommandFailed
21
+
22
+ Command = Callable[[Context, argparse.Namespace], None]
23
+
24
+
25
+ def nonnegative_float(value: str) -> float:
26
+ number = float(value)
27
+ if not math.isfinite(number) or number < 0:
28
+ raise argparse.ArgumentTypeError("must be a finite number >= 0")
29
+ return number
30
+
31
+
32
+ def positive_float(value: str) -> float:
33
+ number = nonnegative_float(value)
34
+ if number == 0:
35
+ raise argparse.ArgumentTypeError("must be greater than 0")
36
+ return number
37
+
38
+
39
+ def positive_int(value: str) -> int:
40
+ number = int(value)
41
+ if number <= 0:
42
+ raise argparse.ArgumentTypeError("must be an integer greater than 0")
43
+ return number
44
+
45
+
46
+ def parser() -> argparse.ArgumentParser:
47
+ p = argparse.ArgumentParser(
48
+ prog="db-cutover",
49
+ description="Blue/green PostgreSQL cutover on RDS with logical replication and PgBouncer. Every command "
50
+ "that changes something is a dry run without --execute.",
51
+ epilog="`db-cutover skill` prints where the packaged agent skill is, and `db-cutover skill --install DIR` "
52
+ "copies it into an agent's skills directory.",
53
+ )
54
+ p.add_argument("--version", action="version", version=f"db-cutover {__version__}")
55
+ p.add_argument("config", help="path to the cutover's TOML config")
56
+ sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
57
+
58
+ def add(name: str, fn: Command, help: str) -> argparse.ArgumentParser:
59
+ sp = sub.add_parser(name, help=help, description=(fn.__doc__ or help).strip())
60
+ sp.set_defaults(fn=fn)
61
+ return sp
62
+
63
+ def execute(sp: argparse.ArgumentParser) -> None:
64
+ sp.add_argument("--execute", action="store_true", help="make the change (without it: a dry run)")
65
+
66
+ # Read-only
67
+ add("env", inspect.cmd_env, "identifiers, hosts and instance settings")
68
+ sp = add("preflight", inspect.cmd_preflight, "read-only checks before starting")
69
+ sp.add_argument("--json", action="store_true")
70
+ sp = add("status", inspect.cmd_status, "one-shot status of BLUE, GREEN, replication and PgBouncer")
71
+ sp.add_argument("--json", action="store_true")
72
+ sp = add("watch", inspect.cmd_watch, "long-running: a line per change, ALERT lines for problems")
73
+ sp.add_argument("--interval", type=positive_float, default=30, help="seconds between database checks (default 30)")
74
+ sp.add_argument(
75
+ "--health-interval", type=positive_float, default=5, help="seconds between health checks (default 5)"
76
+ )
77
+ sp.add_argument(
78
+ "--slow-ms", type=positive_float, default=3000, help="health check slower than this alerts (default 3000)"
79
+ )
80
+ sp.add_argument(
81
+ "--health-timeout", type=positive_float, default=30, help="seconds before a health check fails (default 30)"
82
+ )
83
+ sp.add_argument(
84
+ "--max-retained-gb", type=positive_float, default=20, help="alert when BLUE keeps more WAL (default 20)"
85
+ )
86
+ sp = add("probe", inspect.cmd_probe, "long-running: writes through PgBouncer and reports errors and pauses")
87
+ execute(sp)
88
+ sp.add_argument("--interval", type=positive_float, default=0.5, help="seconds between writes (default 0.5)")
89
+ sp.add_argument(
90
+ "--slow-ms", type=positive_float, default=1000, help="writes slower than this are reported (default 1000)"
91
+ )
92
+ sp = add("pgbouncer", inspect.cmd_pgbouncer, "SHOW ... on every PgBouncer pod, or resume them all")
93
+ execute(sp)
94
+ sp.add_argument("what", choices=["pools", "servers", "clients", "databases", "stats", "config", "resume"])
95
+
96
+ # Before replication
97
+ sp = add("reboot-blue", blue.cmd_reboot_blue, "apply BLUE's pending parameters by rebooting it, PgBouncer paused")
98
+ execute(sp)
99
+ sp.add_argument("--force", action="store_true", help="reboot even if nothing is pending-reboot")
100
+ sp.add_argument("--allow-direct", action="store_true", help="reboot even with direct writers connected to BLUE")
101
+ sp.add_argument(
102
+ "--failover",
103
+ action="store_true",
104
+ help="Multi-AZ: fail over to the standby instead of restarting in place (longer: adds PgBouncer's DNS wait)",
105
+ )
106
+ sp.add_argument(
107
+ "--max-pause", type=positive_float, default=90, help="resume after this many seconds regardless (default 90)"
108
+ )
109
+ sp.add_argument(
110
+ "--pause-timeout", type=positive_float, default=30, help="give up if PAUSE takes longer (default 30)"
111
+ )
112
+ execute(add("prepare-blue", blue.cmd_prepare_blue, "REPLICA IDENTITY FULL where needed, and the probe table"))
113
+
114
+ # Replication
115
+ execute(add("copy-schema", replication.cmd_copy_schema, "create GREEN's database and copy BLUE's schema"))
116
+ execute(add("start-replication", replication.cmd_start_replication, "publication on BLUE, subscription on GREEN"))
117
+ sp = add("wait-sync", replication.cmd_wait_sync, "block until the initial copy is done and GREEN has caught up")
118
+ execute(sp)
119
+ sp.add_argument("--interval", type=positive_float, default=30)
120
+ sp.add_argument("--timeout", type=nonnegative_float, default=0, help="minutes; 0 waits forever")
121
+ sp = add("verify", replication.cmd_verify, "compare BLUE and GREEN (read-only)")
122
+ execute(sp)
123
+ sp.add_argument("--json", action="store_true")
124
+ sp.add_argument("--exact-count-rows", type=positive_int, default=100_000, help="count(*) tables up to this size")
125
+ sp.add_argument(
126
+ "--window-rows", type=positive_int, default=2_000, help="rows per hashed id window on bigger tables"
127
+ )
128
+ sp.add_argument("--tables", nargs="*", help="only these tables")
129
+ execute(add("sequences", replication.cmd_sequences, "move GREEN's sequences above BLUE's"))
130
+
131
+ # The switch
132
+ sp = add("cutover", switch.cmd_cutover, "switch PgBouncer from BLUE to GREEN")
133
+ execute(sp)
134
+ sp.add_argument(
135
+ "--rehearse", action="store_true", help="with --execute: run the barrier, then resume on BLUE instead of RELOAD"
136
+ )
137
+ sp.add_argument("--pause-timeout", type=positive_float, default=15, help="abort if PAUSE takes longer (default 15)")
138
+ sp.add_argument(
139
+ "--direct-wait", type=positive_float, default=3, help="seconds for BLUE's sessions to close (default 3)"
140
+ )
141
+ sp.add_argument("--catchup-timeout", type=positive_float, default=15, help="seconds for the marker (default 15)")
142
+ sp.add_argument(
143
+ "--max-paused", type=positive_float, default=20, help="abort if paused longer before RELOAD (default 20)"
144
+ )
145
+ sp.add_argument(
146
+ "--max-transaction-seconds", type=positive_float, default=5, help="older BLUE transactions block (default 5)"
147
+ )
148
+ sp = add("finish", switch.cmd_finish, "drop replication and the probe table once BLUE is quiet")
149
+ execute(sp)
150
+ sp.add_argument("--quiet-seconds", type=positive_float, default=60, help="BLUE must see no writes for this long")
151
+
152
+ # Terraform
153
+ sp = add("green-tf", terraform.cmd_green_tf, "write GREEN's Terraform file from BLUE's")
154
+ execute(sp)
155
+ sp.add_argument("--engine-version", required=True)
156
+ sp.add_argument("--family", help="parameter group family (default: derived from the engine version)")
157
+ sp.add_argument("--storage-type", help="e.g. gp3 (default: BLUE's)")
158
+ sp.add_argument("--set", action="append", metavar="NAME=HCL", help="another instance argument, e.g. multi_az=false")
159
+ sp.add_argument("--force", action="store_true", help="overwrite an existing GREEN file")
160
+ sp = add("promote-tf", terraform.cmd_promote_tf, "after finish: GREEN takes BLUE's Terraform names")
161
+ execute(sp)
162
+ sp.add_argument("--force", action="store_true", help="overwrite an existing old file")
163
+ return p
164
+
165
+
166
+ SKILL_NAME = "db-cutover"
167
+
168
+
169
+ def skill_dir() -> Path:
170
+ """The agent skill: inside the installed package, or at the repository root in a source checkout."""
171
+ package = Path(__file__).resolve().parent
172
+ for candidate in (package / "skills" / SKILL_NAME, package.parents[1] / "skills" / SKILL_NAME):
173
+ if (candidate / "SKILL.md").is_file():
174
+ return candidate
175
+ raise FileNotFoundError(f"the {SKILL_NAME} skill is missing from this installation")
176
+
177
+
178
+ def skill_command(argv: list[str]) -> None:
179
+ """`db-cutover skill`: takes no config, so it is parsed apart from the cutover commands."""
180
+ p = argparse.ArgumentParser(
181
+ prog="db-cutover skill",
182
+ description="The agent skill that teaches a coding agent to plan, rehearse and run a cutover with an "
183
+ "operator. Without options, prints the skill's directory.",
184
+ )
185
+ p.add_argument(
186
+ "--install", metavar="DIR", help="copy the skill to DIR/db-cutover (e.g. .claude/skills or .agents/skills)"
187
+ )
188
+ p.add_argument("--force", action="store_true", help="with --install: replace a db-cutover skill already there")
189
+ args = p.parse_args(argv)
190
+ source = skill_dir()
191
+ if not args.install:
192
+ print(source)
193
+ return
194
+ target = Path(args.install).expanduser() / SKILL_NAME
195
+ if target.exists():
196
+ if not args.force:
197
+ print(f"{target} already exists: pass --force to replace it", file=sys.stderr)
198
+ sys.exit(1)
199
+ shutil.rmtree(target)
200
+ shutil.copytree(source, target)
201
+ print(f"installed the {SKILL_NAME} skill (version {__version__}) to {target}")
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> None:
205
+ argv = sys.argv[1:] if argv is None else argv
206
+ if argv[:1] == ["skill"]:
207
+ skill_command(argv[1:])
208
+ return
209
+ args = parser().parse_args(argv)
210
+ try:
211
+ ctx = Context(config.load(args.config))
212
+ args.fn(ctx, args)
213
+ except config.ConfigError as e:
214
+ print(f"config error: {e}", file=sys.stderr)
215
+ sys.exit(2)
216
+ except StepFailed as e:
217
+ print(f"\nFAILED: {e}", file=sys.stderr)
218
+ sys.exit(1)
219
+ except psycopg.Error as e:
220
+ print(f"\nFAILED: {error_detail(e)}", file=sys.stderr)
221
+ sys.exit(1)
222
+ except subprocess.TimeoutExpired:
223
+ print("\nFAILED: external command timed out (command and output withheld)", file=sys.stderr)
224
+ sys.exit(1)
225
+ except (CommandFailed, OSError, ValueError, tomllib.TOMLDecodeError) as e:
226
+ print(f"\nFAILED: {type(e).__name__}; inspect configuration and local tool setup privately", file=sys.stderr)
227
+ sys.exit(1)
228
+ except KeyboardInterrupt:
229
+ print("\ninterrupted", file=sys.stderr)
230
+ sys.exit(130)
File without changes
@@ -0,0 +1,189 @@
1
+ """Commands that change BLUE before replication starts: reboot-blue and prepare-blue."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+
8
+ import psycopg
9
+ from psycopg import sql
10
+
11
+ from db_cutover import db
12
+ from db_cutover.commands.inspect import direct_writer_replicas
13
+ from db_cutover.commands.switch import split_direct
14
+ from db_cutover.db import Context, q1
15
+ from db_cutover.output import fail, log
16
+ from db_cutover.pgbouncer import PgBouncer, Pod
17
+
18
+
19
+ def cmd_prepare_blue(ctx: Context, args: argparse.Namespace) -> None:
20
+ """REPLICA IDENTITY FULL on tables without a primary key (their UPDATEs and DELETEs fail while they're in a
21
+ publication otherwise), and the probe table that markers and `probe` write to. Idempotent."""
22
+ cfg = ctx.cfg
23
+ with ctx.blue() as blue:
24
+ missing = db.tables_without_replica_identity(blue)
25
+ has_probe = db.table_exists(blue, f"public.{cfg.probe_table}")
26
+ if not args.execute:
27
+ for table in missing:
28
+ print(f" would run: ALTER TABLE {table} REPLICA IDENTITY FULL")
29
+ if not has_probe:
30
+ print(f" would create table public.{cfg.probe_table} and grant {cfg.app_user} INSERT and SELECT")
31
+ log("dry run: nothing changed. Run again with --execute." if missing or not has_probe else "nothing to do")
32
+ return
33
+ for table in missing:
34
+ log(f"ALTER TABLE {table} REPLICA IDENTITY FULL")
35
+ db.ddl(blue, sql.SQL("ALTER TABLE {} REPLICA IDENTITY FULL").format(sql.SQL(table)))
36
+ if has_probe:
37
+ log(f"{cfg.probe_table} already exists on BLUE")
38
+ else:
39
+ log(f"creating public.{cfg.probe_table} on BLUE")
40
+ table = sql.Identifier(cfg.probe_table)
41
+ user = sql.Identifier(cfg.app_user)
42
+ with blue.transaction():
43
+ blue.execute(
44
+ sql.SQL(
45
+ """CREATE TABLE public.{} (
46
+ id bigserial PRIMARY KEY,
47
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
48
+ source text NOT NULL,
49
+ note text)"""
50
+ ).format(table)
51
+ )
52
+ blue.execute(sql.SQL("GRANT SELECT, INSERT ON public.{} TO {}").format(table, user))
53
+ blue.execute(
54
+ sql.SQL("GRANT USAGE ON SEQUENCE public.{} TO {}").format(
55
+ sql.Identifier(f"{cfg.probe_table}_id_seq"), user
56
+ )
57
+ )
58
+ log("BLUE prepared")
59
+
60
+
61
+ def _postmaster_start(ctx: Context) -> tuple[object, str] | None:
62
+ try:
63
+ with ctx.blue(timeout=5) as blue:
64
+ row = q1(blue, "SELECT pg_postmaster_start_time() AS t, current_setting('wal_level') AS w")
65
+ return (row["t"], row["w"]) if row else None
66
+ except psycopg.Error:
67
+ return None
68
+
69
+
70
+ def cmd_reboot_blue(ctx: Context, args: argparse.Namespace) -> None:
71
+ """Applies BLUE's pending static parameters (rds.logical_replication) by rebooting it with PgBouncer paused, so
72
+ clients wait instead of failing. BLUE restarts in place, Multi-AZ or not: the parameter group belongs to the
73
+ instance, so a standby that takes over later starts with it too. --failover fails over instead, which
74
+ takes longer (PgBouncer has to wait out its DNS cache for the new address). Traffic resumes after --max-pause
75
+ seconds even if BLUE isn't back. Direct connections drop."""
76
+ cfg = ctx.cfg
77
+ inst = ctx.aws.instance(ctx.blue_id, refresh=True)
78
+ assert inst is not None
79
+ groups = [(g["DBParameterGroupName"], g["ParameterApplyStatus"]) for g in inst["DBParameterGroups"]]
80
+ print(f"BLUE {ctx.blue_id}: {inst['DBInstanceStatus']}, multi-AZ {inst['MultiAZ']}, parameter groups {groups}")
81
+ if not any(status == "pending-reboot" for _, status in groups) and not args.force:
82
+ fail("no parameter group is pending-reboot: apply the parameter change first, or pass --force")
83
+ if inst["DBInstanceStatus"] != "available":
84
+ fail(f"BLUE is {inst['DBInstanceStatus']}, not available")
85
+ failover = bool(inst["MultiAZ"]) and args.failover
86
+ before = _postmaster_start(ctx)
87
+ if not before:
88
+ fail("can't connect to BLUE")
89
+ # PgBouncer holds its clients through the restart; direct connections fail instead (a job runner logs
90
+ # ConnectionNotEstablished until BLUE is back).
91
+ replicas = direct_writer_replicas(ctx)
92
+ with ctx.blue() as blue:
93
+ direct, _ = split_direct(ctx, db.client_sessions(blue))
94
+ if (direct or any(r not in ("0", "") for r in replicas.values())) and not args.allow_direct:
95
+ fail(
96
+ f"direct writers are running (replicas {replicas}, {len(direct)} sessions on BLUE): they'd get connection "
97
+ "errors during the restart. Scale them to 0 first, or pass --allow-direct."
98
+ )
99
+ if not args.execute:
100
+ log(
101
+ f"dry run: would pause PgBouncer, reboot {ctx.blue_id}{' with failover' if failover else ''}, and resume "
102
+ f"once BLUE accepts connections (or after {args.max_pause}s). Run again with --execute."
103
+ )
104
+ return
105
+
106
+ with PgBouncer(cfg) as bouncer:
107
+ configs = bouncer.on_all(lambda pod: pod.admin("SHOW CONFIG"), timeout=10)
108
+ settings = {
109
+ pod: {r["key"]: r["value"] for r in rows} for pod, rows in configs.items() if isinstance(rows, list)
110
+ }
111
+ if len(settings) != len(bouncer.pods):
112
+ fail(f"SHOW CONFIG failed: {configs}")
113
+ original_wait = {pod: s.get("query_wait_timeout") for pod, s in settings.items()}
114
+ dns_ttl = max(float(s.get("dns_max_ttl", 15)) for s in settings.values())
115
+ wait_timeout = int(args.max_pause + 60)
116
+
117
+ def set_wait(pod: Pod, value: object) -> None:
118
+ pod.admin(f"SET query_wait_timeout = {value}")
119
+
120
+ def resume() -> None:
121
+ results = bouncer.resume_all()
122
+ if any(isinstance(r, Exception) for r in results.values()):
123
+ fail(f"could not confirm RESUME on every pod: {results}; manual recovery required")
124
+
125
+ try:
126
+ results = bouncer.on_all(lambda pod: set_wait(pod, wait_timeout), timeout=10)
127
+ if any(isinstance(r, Exception) for r in results.values()):
128
+ fail(f"could not set query_wait_timeout on every pod: {results}")
129
+ log(f"query_wait_timeout raised to {wait_timeout}s (was {original_wait})")
130
+ started = time.monotonic()
131
+ results = bouncer.on_all(lambda pod: pod.admin("PAUSE"), timeout=args.pause_timeout)
132
+ errors = {k: v for k, v in results.items() if isinstance(v, Exception) and "already" not in str(v)}
133
+ if errors:
134
+ resume()
135
+ fail(f"PAUSE failed; resumed, nothing rebooted: {errors}")
136
+ log(f"PgBouncer paused in {(time.monotonic() - started) * 1000:.0f} ms; rebooting {ctx.blue_id}")
137
+ reboot = ["rds", "reboot-db-instance", "--db-instance-identifier", ctx.blue_id]
138
+ if failover:
139
+ reboot.append("--force-failover")
140
+ try:
141
+ ctx.aws.call(*reboot)
142
+ except Exception as e:
143
+ resume()
144
+ fail(f"the reboot call failed; resumed: {e}")
145
+
146
+ back = None
147
+ while True:
148
+ elapsed = time.monotonic() - started
149
+ if back is None:
150
+ now_start = _postmaster_start(ctx)
151
+ if now_start and now_start[0] != before[0]:
152
+ back = elapsed
153
+ log(f"{elapsed:.0f}s: BLUE is back (wal_level {now_start[1]})")
154
+ if failover:
155
+ log(f"waiting {dns_ttl:.0f}s for PgBouncer's DNS cache (dns_max_ttl) to see the failover")
156
+ else:
157
+ log(f"{elapsed:.0f}s: waiting for BLUE")
158
+ ready = back is not None and (not failover or elapsed - back >= dns_ttl)
159
+ if ready or elapsed > args.max_pause:
160
+ resume()
161
+ if ready:
162
+ log(f"RESUMED after {elapsed:.1f}s paused")
163
+ else:
164
+ fail(f"resumed after {elapsed:.0f}s without BLUE back: clients get errors until it is")
165
+ break
166
+ time.sleep(1)
167
+ except BaseException:
168
+ resume()
169
+ raise
170
+ finally:
171
+ restored = bouncer.on_all(lambda pod: set_wait(pod, original_wait[pod.name]), timeout=10)
172
+ if any(isinstance(r, Exception) for r in restored.values()):
173
+ fail(f"could not restore query_wait_timeout on every pod: {restored}; restore it manually")
174
+ log("query_wait_timeout restored")
175
+
176
+ # PostgreSQL accepts connections before RDS finishes its own post-reboot steps; traffic is already flowing.
177
+ log("waiting for RDS to report the instance available and its parameters in-sync")
178
+ deadline = time.monotonic() + 900
179
+ while time.monotonic() < deadline:
180
+ inst = ctx.aws.instance(ctx.blue_id, refresh=True) or {}
181
+ groups = [(g["DBParameterGroupName"], g["ParameterApplyStatus"]) for g in inst.get("DBParameterGroups", [])]
182
+ if inst.get("DBInstanceStatus") == "available" and all(status == "in-sync" for _, status in groups):
183
+ after = _postmaster_start(ctx)
184
+ log(f"BLUE available, parameter groups {groups}, wal_level {after[1] if after else 'unknown'}")
185
+ if not after or after[1] != "logical":
186
+ fail("wal_level is not logical after the reboot: check the parameter group")
187
+ return
188
+ time.sleep(10)
189
+ fail(f"RDS still reports {inst.get('DBInstanceStatus')} / {groups} after 15 minutes: check RDS events")