db-git 0.1.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_git-0.1.0.dist-info/METADATA +363 -0
- db_git-0.1.0.dist-info/RECORD +30 -0
- db_git-0.1.0.dist-info/WHEEL +4 -0
- db_git-0.1.0.dist-info/entry_points.txt +2 -0
- db_git-0.1.0.dist-info/licenses/LICENSE +21 -0
- git_db/__init__.py +1 -0
- git_db/backends/__init__.py +173 -0
- git_db/backends/postgresql/__init__.py +0 -0
- git_db/backends/postgresql/backend.py +176 -0
- git_db/backends/postgresql/branch_db.py +203 -0
- git_db/backends/postgresql/connections.py +82 -0
- git_db/backends/postgresql/pgdump.py +212 -0
- git_db/backends/postgresql/template.py +160 -0
- git_db/cli/__init__.py +8 -0
- git_db/cli/_common.py +41 -0
- git_db/cli/_console.py +18 -0
- git_db/cli/_format.py +56 -0
- git_db/cli/_prompts.py +121 -0
- git_db/cli/branch.py +168 -0
- git_db/cli/hook.py +102 -0
- git_db/cli/init.py +267 -0
- git_db/cli/inspect.py +414 -0
- git_db/cli/snapshot.py +115 -0
- git_db/config.py +243 -0
- git_db/db.py +17 -0
- git_db/errors.py +46 -0
- git_db/git.py +349 -0
- git_db/hook_script.py +49 -0
- git_db/state.py +105 -0
- git_db/storage.py +203 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
|
|
9
|
+
from git_db.backends import (
|
|
10
|
+
BranchDbManager,
|
|
11
|
+
DbConnection,
|
|
12
|
+
SnapshotStrategy,
|
|
13
|
+
register_backend,
|
|
14
|
+
)
|
|
15
|
+
from git_db.backends.postgresql.branch_db import PostgresBranchDbManager
|
|
16
|
+
from git_db.backends.postgresql.pgdump import PgDumpStrategy
|
|
17
|
+
from git_db.backends.postgresql.template import TemplateStrategy
|
|
18
|
+
from git_db.db import parse_database_url
|
|
19
|
+
from git_db.errors import ConfigError, DatabaseError
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from git_db.config import GitDbConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PgPermissions:
|
|
27
|
+
"""
|
|
28
|
+
PostgreSQL role permissions relevant to git-db operations.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
can_createdb: bool
|
|
32
|
+
is_superuser: bool
|
|
33
|
+
has_pg_signal_backend: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PostgresqlBackend:
|
|
37
|
+
"""
|
|
38
|
+
PostgreSQL backend implementing the DatabaseBackend protocol.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
engine = "postgresql"
|
|
42
|
+
max_identifier_length = 63
|
|
43
|
+
_VALID_STRATEGIES: ClassVar[set[str]] = {"template", "pgdump"}
|
|
44
|
+
|
|
45
|
+
def apply_url_defaults(
|
|
46
|
+
self, params: dict[str, str | int | None]
|
|
47
|
+
) -> dict[str, str | int]:
|
|
48
|
+
"""
|
|
49
|
+
Apply PostgreSQL-specific defaults to parsed URL parameters.
|
|
50
|
+
"""
|
|
51
|
+
dbname = params.get("dbname")
|
|
52
|
+
if not dbname:
|
|
53
|
+
raise ConfigError("Database name is required in the connection URL.")
|
|
54
|
+
|
|
55
|
+
port = params.get("port")
|
|
56
|
+
return {
|
|
57
|
+
"user": params.get("user") or "postgres",
|
|
58
|
+
"password": params.get("password") or "",
|
|
59
|
+
"host": params.get("host") or "localhost",
|
|
60
|
+
"port": port if port is not None else 5432,
|
|
61
|
+
"dbname": dbname,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def get_engine_version(self, url: str) -> int:
|
|
65
|
+
"""
|
|
66
|
+
Return the major PostgreSQL version as an integer.
|
|
67
|
+
"""
|
|
68
|
+
params = self.apply_url_defaults(parse_database_url(url))
|
|
69
|
+
conn = self.connect_maintenance(params)
|
|
70
|
+
try:
|
|
71
|
+
cur = conn.execute("SHOW server_version_num")
|
|
72
|
+
row = cur.fetchone()
|
|
73
|
+
if not row:
|
|
74
|
+
raise DatabaseError("SHOW server_version_num returned no rows")
|
|
75
|
+
try:
|
|
76
|
+
return int(row[0]) // 10000
|
|
77
|
+
except (TypeError, ValueError) as e:
|
|
78
|
+
raise DatabaseError(
|
|
79
|
+
f"Could not parse server_version_num: {row[0]!r}"
|
|
80
|
+
) from e
|
|
81
|
+
finally:
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
def connect_maintenance(self, params: dict[str, str | int]) -> DbConnection:
|
|
85
|
+
"""
|
|
86
|
+
Connect to the 'postgres' maintenance database for admin operations.
|
|
87
|
+
"""
|
|
88
|
+
conninfo = (
|
|
89
|
+
f"host={params['host']} port={params['port']} "
|
|
90
|
+
f"user={params['user']} dbname=postgres"
|
|
91
|
+
)
|
|
92
|
+
password = str(params.get("password", ""))
|
|
93
|
+
try:
|
|
94
|
+
return psycopg.connect(conninfo, autocommit=True, password=password or None)
|
|
95
|
+
except psycopg.Error as e:
|
|
96
|
+
raise DatabaseError(
|
|
97
|
+
f"Could not connect to PostgreSQL at "
|
|
98
|
+
f"{params['host']}:{params['port']}: {e}"
|
|
99
|
+
) from e
|
|
100
|
+
|
|
101
|
+
def build_subprocess_env(self, params: dict[str, str | int]) -> dict[str, str]:
|
|
102
|
+
"""
|
|
103
|
+
Build environment dict with PGPASSWORD set for subprocess calls.
|
|
104
|
+
"""
|
|
105
|
+
env = os.environ.copy()
|
|
106
|
+
password = str(params.get("password", ""))
|
|
107
|
+
if password:
|
|
108
|
+
env["PGPASSWORD"] = password
|
|
109
|
+
return env
|
|
110
|
+
|
|
111
|
+
def check_permissions(self, url: str) -> PgPermissions:
|
|
112
|
+
"""
|
|
113
|
+
Check the current user's PostgreSQL permissions.
|
|
114
|
+
"""
|
|
115
|
+
params = self.apply_url_defaults(parse_database_url(url))
|
|
116
|
+
try:
|
|
117
|
+
conn = self.connect_maintenance(params)
|
|
118
|
+
except DatabaseError:
|
|
119
|
+
return PgPermissions(False, False, False)
|
|
120
|
+
try:
|
|
121
|
+
cur = conn.execute(
|
|
122
|
+
"SELECT rolcreatedb, rolsuper, "
|
|
123
|
+
"pg_has_role(current_user, 'pg_signal_backend', 'MEMBER') "
|
|
124
|
+
"FROM pg_roles WHERE rolname = current_user"
|
|
125
|
+
)
|
|
126
|
+
row = cur.fetchone()
|
|
127
|
+
if not row:
|
|
128
|
+
return PgPermissions(False, False, False)
|
|
129
|
+
return PgPermissions(
|
|
130
|
+
can_createdb=bool(row[0]),
|
|
131
|
+
is_superuser=bool(row[1]),
|
|
132
|
+
has_pg_signal_backend=bool(row[2]),
|
|
133
|
+
)
|
|
134
|
+
except psycopg.Error:
|
|
135
|
+
return PgPermissions(False, False, False)
|
|
136
|
+
finally:
|
|
137
|
+
conn.close()
|
|
138
|
+
|
|
139
|
+
def database_exists(self, url: str, name: str) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Return whether a PostgreSQL database exists.
|
|
142
|
+
"""
|
|
143
|
+
params = self.apply_url_defaults(parse_database_url(url))
|
|
144
|
+
conn = self.connect_maintenance(params)
|
|
145
|
+
try:
|
|
146
|
+
cur = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,))
|
|
147
|
+
return cur.fetchone() is not None
|
|
148
|
+
except psycopg.Error as e:
|
|
149
|
+
raise DatabaseError(f"Could not inspect database '{name}': {e}") from e
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
def detect_strategy(self, config: GitDbConfig) -> SnapshotStrategy:
|
|
154
|
+
"""
|
|
155
|
+
Return the configured snapshot strategy for this PostgreSQL instance.
|
|
156
|
+
"""
|
|
157
|
+
if config.strategy not in self._VALID_STRATEGIES:
|
|
158
|
+
raise ConfigError(
|
|
159
|
+
f"Unknown strategy '{config.strategy}'. "
|
|
160
|
+
f"Valid strategies for PostgreSQL: "
|
|
161
|
+
f"{', '.join(sorted(self._VALID_STRATEGIES))}."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if config.strategy == "pgdump":
|
|
165
|
+
pg_version = self.get_engine_version(config.database_url)
|
|
166
|
+
return PgDumpStrategy(backend=self, pg_version=pg_version)
|
|
167
|
+
return TemplateStrategy(backend=self)
|
|
168
|
+
|
|
169
|
+
def branch_db_manager(self, config: GitDbConfig) -> BranchDbManager:
|
|
170
|
+
"""
|
|
171
|
+
Return the per-branch database manager for this PostgreSQL instance.
|
|
172
|
+
"""
|
|
173
|
+
return PostgresBranchDbManager(backend=self, config=config)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
register_backend("postgresql", PostgresqlBackend)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from git_db.backends import DatabaseBackend
|
|
12
|
+
from git_db.backends.postgresql.connections import handle_active_connections
|
|
13
|
+
from git_db.backends.postgresql.template import _create_from_template
|
|
14
|
+
from git_db.db import parse_database_url
|
|
15
|
+
from git_db.errors import SnapshotError, ToolNotFoundError
|
|
16
|
+
from git_db.state import BranchDbEntry, load_state, record_branch_db, remove_branch_db
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from git_db.config import GitDbConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PostgresBranchDbManager:
|
|
23
|
+
"""
|
|
24
|
+
Per-branch database operations for PostgreSQL.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, backend: DatabaseBackend, config: GitDbConfig) -> None:
|
|
28
|
+
self._backend = backend
|
|
29
|
+
self._config = config
|
|
30
|
+
self._params = backend.apply_url_defaults(
|
|
31
|
+
parse_database_url(config.database_url)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def exists(self, name: str) -> bool:
|
|
35
|
+
conn = self._backend.connect_maintenance(self._params)
|
|
36
|
+
try:
|
|
37
|
+
cur = conn.execute(
|
|
38
|
+
"SELECT 1 FROM pg_database WHERE datname = %s",
|
|
39
|
+
(name,),
|
|
40
|
+
)
|
|
41
|
+
return cur.fetchone() is not None
|
|
42
|
+
finally:
|
|
43
|
+
conn.close()
|
|
44
|
+
|
|
45
|
+
def create(
|
|
46
|
+
self,
|
|
47
|
+
target: str,
|
|
48
|
+
source: str,
|
|
49
|
+
branch: str,
|
|
50
|
+
created_from: str,
|
|
51
|
+
git_dir: Path,
|
|
52
|
+
) -> None:
|
|
53
|
+
strategy = self._backend.detect_strategy(self._config)
|
|
54
|
+
|
|
55
|
+
if strategy.name == "template":
|
|
56
|
+
_create_via_template(
|
|
57
|
+
self._backend, self._params, target, source, self._config
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
_create_via_pgdump(
|
|
61
|
+
self._backend,
|
|
62
|
+
self._params,
|
|
63
|
+
self._backend.build_subprocess_env(self._params),
|
|
64
|
+
target,
|
|
65
|
+
source,
|
|
66
|
+
self._config,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
record_branch_db(git_dir, branch, target, created_from)
|
|
70
|
+
|
|
71
|
+
def drop(self, name: str, branch: str, git_dir: Path) -> None:
|
|
72
|
+
conn = self._backend.connect_maintenance(self._params)
|
|
73
|
+
try:
|
|
74
|
+
handle_active_connections(conn, name, self._config)
|
|
75
|
+
conn.execute(
|
|
76
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
|
77
|
+
sql.Identifier(name)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
remove_branch_db(git_dir, branch)
|
|
84
|
+
|
|
85
|
+
def list(self, git_dir: Path) -> list[tuple[str, BranchDbEntry, bool]]:
|
|
86
|
+
state = load_state(git_dir)
|
|
87
|
+
return [
|
|
88
|
+
(branch, entry, self.exists(entry.db_name))
|
|
89
|
+
for branch, entry in state.databases.items()
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _create_via_template(
|
|
94
|
+
backend: DatabaseBackend,
|
|
95
|
+
params: dict[str, str | int],
|
|
96
|
+
target: str,
|
|
97
|
+
source: str,
|
|
98
|
+
config: GitDbConfig,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Create a database using CREATE DATABASE ... TEMPLATE.
|
|
102
|
+
"""
|
|
103
|
+
conn = backend.connect_maintenance(params)
|
|
104
|
+
try:
|
|
105
|
+
handle_active_connections(conn, source, config)
|
|
106
|
+
handle_active_connections(conn, target, config)
|
|
107
|
+
conn.execute(
|
|
108
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
|
|
109
|
+
sql.Identifier(target)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_create_from_template(conn, target, source)
|
|
114
|
+
except psycopg.Error as e:
|
|
115
|
+
raise SnapshotError(f"Template clone failed: {e}") from e
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _create_via_pgdump(
|
|
121
|
+
backend: DatabaseBackend,
|
|
122
|
+
params: dict[str, str | int],
|
|
123
|
+
env: dict[str, str],
|
|
124
|
+
target: str,
|
|
125
|
+
source: str,
|
|
126
|
+
config: GitDbConfig,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Create a database by piping pg_dump to pg_restore.
|
|
130
|
+
"""
|
|
131
|
+
pg_dump = shutil.which("pg_dump")
|
|
132
|
+
pg_restore = shutil.which("pg_restore")
|
|
133
|
+
|
|
134
|
+
if not pg_dump:
|
|
135
|
+
raise ToolNotFoundError("pg_dump not found in PATH.")
|
|
136
|
+
if not pg_restore:
|
|
137
|
+
raise ToolNotFoundError("pg_restore not found in PATH.")
|
|
138
|
+
|
|
139
|
+
common_args = [
|
|
140
|
+
"-h",
|
|
141
|
+
str(params["host"]),
|
|
142
|
+
"-p",
|
|
143
|
+
str(params["port"]),
|
|
144
|
+
"-U",
|
|
145
|
+
str(params["user"]),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
conn = backend.connect_maintenance(params)
|
|
149
|
+
try:
|
|
150
|
+
handle_active_connections(conn, target, config)
|
|
151
|
+
target_ident = sql.Identifier(target)
|
|
152
|
+
conn.execute(
|
|
153
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(target_ident)
|
|
154
|
+
)
|
|
155
|
+
conn.execute(
|
|
156
|
+
sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(target_ident)
|
|
157
|
+
)
|
|
158
|
+
except psycopg.Error as e:
|
|
159
|
+
raise SnapshotError(f"Create branch database failed: {e}") from e
|
|
160
|
+
finally:
|
|
161
|
+
conn.close()
|
|
162
|
+
|
|
163
|
+
dump_cmd = [
|
|
164
|
+
pg_dump,
|
|
165
|
+
"-Fc",
|
|
166
|
+
"--no-owner",
|
|
167
|
+
"--no-privileges",
|
|
168
|
+
*common_args,
|
|
169
|
+
source,
|
|
170
|
+
]
|
|
171
|
+
restore_cmd = [
|
|
172
|
+
pg_restore,
|
|
173
|
+
"--no-owner",
|
|
174
|
+
"--no-privileges",
|
|
175
|
+
*common_args,
|
|
176
|
+
"-d",
|
|
177
|
+
target,
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
dump_proc = subprocess.Popen(
|
|
181
|
+
dump_cmd,
|
|
182
|
+
stdout=subprocess.PIPE,
|
|
183
|
+
stderr=subprocess.PIPE,
|
|
184
|
+
env=env,
|
|
185
|
+
)
|
|
186
|
+
restore_result = subprocess.run(
|
|
187
|
+
restore_cmd,
|
|
188
|
+
stdin=dump_proc.stdout,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
text=True,
|
|
191
|
+
env=env,
|
|
192
|
+
timeout=300,
|
|
193
|
+
)
|
|
194
|
+
if dump_proc.stdout is not None:
|
|
195
|
+
dump_proc.stdout.close()
|
|
196
|
+
dump_proc.wait(timeout=300)
|
|
197
|
+
|
|
198
|
+
if dump_proc.returncode != 0:
|
|
199
|
+
stderr = dump_proc.stderr.read().decode() if dump_proc.stderr else ""
|
|
200
|
+
raise SnapshotError(f"pg_dump failed: {stderr.strip()}")
|
|
201
|
+
|
|
202
|
+
if restore_result.returncode != 0 and "ERROR" in restore_result.stderr:
|
|
203
|
+
raise SnapshotError(f"pg_restore failed: {restore_result.stderr.strip()}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
6
|
+
|
|
7
|
+
from psycopg import sql
|
|
8
|
+
|
|
9
|
+
from git_db.backends import DbConnection
|
|
10
|
+
from git_db.errors import ActiveConnectionsError, TerminationTimeout
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from git_db.config import GitDbConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionInfo(NamedTuple):
|
|
17
|
+
pid: int
|
|
18
|
+
application_name: str
|
|
19
|
+
state: str
|
|
20
|
+
query_start: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_connections(conn: DbConnection, dbname: str) -> list[ConnectionInfo]:
|
|
24
|
+
"""
|
|
25
|
+
Return list of active connections to the given database,
|
|
26
|
+
excluding our own connection.
|
|
27
|
+
"""
|
|
28
|
+
cur = conn.execute(
|
|
29
|
+
"SELECT pid, application_name, state, query_start::text "
|
|
30
|
+
"FROM pg_stat_activity "
|
|
31
|
+
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
|
32
|
+
(dbname,),
|
|
33
|
+
)
|
|
34
|
+
return [ConnectionInfo(*row) for row in cur.fetchall()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle_active_connections(
|
|
38
|
+
conn: DbConnection,
|
|
39
|
+
dbname: str,
|
|
40
|
+
config: GitDbConfig,
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Handle active connections based on config policy.
|
|
44
|
+
"""
|
|
45
|
+
active = check_connections(conn, dbname)
|
|
46
|
+
if not active:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
if config.on_active_connections == "fail":
|
|
50
|
+
raise ActiveConnectionsError(
|
|
51
|
+
f"{len(active)} active connection(s) to '{dbname}'. "
|
|
52
|
+
"Stop your dev server or set on_active_connections = 'terminate'."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_terminate_all(conn, dbname, config.force_terminate_timeout_ms)
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _terminate_all(conn: DbConnection, dbname: str, timeout_ms: int = 5000) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Block new connections, terminate existing ones, then restore access.
|
|
62
|
+
"""
|
|
63
|
+
db_ident = sql.Identifier(dbname)
|
|
64
|
+
conn.execute(sql.SQL("ALTER DATABASE {} ALLOW_CONNECTIONS false").format(db_ident))
|
|
65
|
+
try:
|
|
66
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
67
|
+
while time.monotonic() < deadline:
|
|
68
|
+
remaining = check_connections(conn, dbname)
|
|
69
|
+
if not remaining:
|
|
70
|
+
break
|
|
71
|
+
for c in remaining:
|
|
72
|
+
conn.execute("SELECT pg_terminate_backend(%s)", (c.pid,))
|
|
73
|
+
time.sleep(0.1)
|
|
74
|
+
else:
|
|
75
|
+
raise TerminationTimeout(
|
|
76
|
+
f"Could not terminate all connections within {timeout_ms}ms"
|
|
77
|
+
)
|
|
78
|
+
finally:
|
|
79
|
+
with contextlib.suppress(Exception):
|
|
80
|
+
conn.execute(
|
|
81
|
+
sql.SQL("ALTER DATABASE {} ALLOW_CONNECTIONS true").format(db_ident)
|
|
82
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from git_db.backends import DatabaseBackend
|
|
12
|
+
from git_db.backends.postgresql.connections import handle_active_connections
|
|
13
|
+
from git_db.db import parse_database_url
|
|
14
|
+
from git_db.errors import DatabaseError, SnapshotError, ToolNotFoundError
|
|
15
|
+
from git_db.storage import (
|
|
16
|
+
ensure_snapshot_dir,
|
|
17
|
+
make_metadata,
|
|
18
|
+
metadata_path,
|
|
19
|
+
snapshot_dump_path,
|
|
20
|
+
write_metadata,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from git_db.config import GitDbConfig
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PgDumpStrategy:
|
|
28
|
+
"""
|
|
29
|
+
Snapshot strategy using pg_dump/pg_restore.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name = "pgdump"
|
|
33
|
+
|
|
34
|
+
def __init__(self, backend: DatabaseBackend, pg_version: int) -> None:
|
|
35
|
+
self._backend = backend
|
|
36
|
+
self._pg_version = pg_version
|
|
37
|
+
|
|
38
|
+
def save(
|
|
39
|
+
self,
|
|
40
|
+
db_url: str,
|
|
41
|
+
branch: str,
|
|
42
|
+
snapshot_dir: Path,
|
|
43
|
+
config: GitDbConfig,
|
|
44
|
+
) -> None:
|
|
45
|
+
pg_dump = shutil.which("pg_dump")
|
|
46
|
+
if not pg_dump:
|
|
47
|
+
raise ToolNotFoundError(
|
|
48
|
+
"pg_dump not found in PATH. Install PostgreSQL client tools."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
params = self._backend.apply_url_defaults(parse_database_url(db_url))
|
|
52
|
+
env = self._backend.build_subprocess_env(params)
|
|
53
|
+
|
|
54
|
+
ensure_snapshot_dir(snapshot_dir)
|
|
55
|
+
dump_file = snapshot_dump_path(snapshot_dir, branch)
|
|
56
|
+
|
|
57
|
+
cmd = _build_pg_dump_cmd(
|
|
58
|
+
pg_dump,
|
|
59
|
+
params,
|
|
60
|
+
str(dump_file),
|
|
61
|
+
self._pg_version,
|
|
62
|
+
)
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
cmd,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
env=env,
|
|
68
|
+
timeout=300,
|
|
69
|
+
)
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
raise SnapshotError(f"pg_dump failed: {result.stderr.strip()}")
|
|
72
|
+
|
|
73
|
+
write_metadata(
|
|
74
|
+
snapshot_dir,
|
|
75
|
+
make_metadata(
|
|
76
|
+
branch=branch,
|
|
77
|
+
database=str(params["dbname"]),
|
|
78
|
+
strategy=self.name,
|
|
79
|
+
engine=self._backend.engine,
|
|
80
|
+
engine_version=str(self._pg_version),
|
|
81
|
+
file_size_bytes=dump_file.stat().st_size,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def restore(
|
|
86
|
+
self,
|
|
87
|
+
db_url: str,
|
|
88
|
+
branch: str,
|
|
89
|
+
snapshot_dir: Path,
|
|
90
|
+
config: GitDbConfig,
|
|
91
|
+
) -> None:
|
|
92
|
+
pg_restore = shutil.which("pg_restore")
|
|
93
|
+
if not pg_restore:
|
|
94
|
+
raise ToolNotFoundError(
|
|
95
|
+
"pg_restore not found in PATH. Install PostgreSQL client tools."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
params = self._backend.apply_url_defaults(parse_database_url(db_url))
|
|
99
|
+
env = self._backend.build_subprocess_env(params)
|
|
100
|
+
dump_file = snapshot_dump_path(snapshot_dir, branch)
|
|
101
|
+
|
|
102
|
+
if not dump_file.exists():
|
|
103
|
+
raise SnapshotError(
|
|
104
|
+
f"No dump file found for branch '{branch}' at {dump_file}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self._drop_and_create_db(params, config)
|
|
108
|
+
|
|
109
|
+
cmd = _build_pg_restore_cmd(pg_restore, params, str(dump_file))
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
cmd,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
env=env,
|
|
115
|
+
timeout=300,
|
|
116
|
+
)
|
|
117
|
+
if result.returncode != 0 and "ERROR" in result.stderr:
|
|
118
|
+
raise SnapshotError(f"pg_restore failed: {result.stderr.strip()}")
|
|
119
|
+
|
|
120
|
+
def cleanup(
|
|
121
|
+
self,
|
|
122
|
+
branch: str,
|
|
123
|
+
snapshot_dir: Path,
|
|
124
|
+
config: GitDbConfig,
|
|
125
|
+
) -> None:
|
|
126
|
+
dump = snapshot_dump_path(snapshot_dir, branch)
|
|
127
|
+
meta = metadata_path(snapshot_dir, branch)
|
|
128
|
+
if dump.exists():
|
|
129
|
+
dump.unlink()
|
|
130
|
+
if meta.exists():
|
|
131
|
+
meta.unlink()
|
|
132
|
+
|
|
133
|
+
def _drop_and_create_db(
|
|
134
|
+
self,
|
|
135
|
+
params: dict[str, str | int],
|
|
136
|
+
config: GitDbConfig,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Drop and recreate the target database via the maintenance connection.
|
|
140
|
+
"""
|
|
141
|
+
dbname = str(params["dbname"])
|
|
142
|
+
conn = self._backend.connect_maintenance(params)
|
|
143
|
+
try:
|
|
144
|
+
handle_active_connections(conn, dbname, config)
|
|
145
|
+
db_ident = sql.Identifier(dbname)
|
|
146
|
+
conn.execute(
|
|
147
|
+
sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(db_ident)
|
|
148
|
+
)
|
|
149
|
+
conn.execute(
|
|
150
|
+
sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(db_ident)
|
|
151
|
+
)
|
|
152
|
+
except (psycopg.Error, DatabaseError) as e:
|
|
153
|
+
raise SnapshotError(f"Drop/create database failed: {e}") from e
|
|
154
|
+
finally:
|
|
155
|
+
conn.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_pg_dump_cmd(
|
|
159
|
+
pg_dump: str,
|
|
160
|
+
params: dict[str, str | int],
|
|
161
|
+
dump_path: str,
|
|
162
|
+
pg_version: int,
|
|
163
|
+
) -> list[str]:
|
|
164
|
+
"""
|
|
165
|
+
Build the pg_dump command list.
|
|
166
|
+
"""
|
|
167
|
+
cmd = [
|
|
168
|
+
pg_dump,
|
|
169
|
+
"-Fc",
|
|
170
|
+
"--no-owner",
|
|
171
|
+
"--no-privileges",
|
|
172
|
+
"-h",
|
|
173
|
+
str(params["host"]),
|
|
174
|
+
"-p",
|
|
175
|
+
str(params["port"]),
|
|
176
|
+
"-U",
|
|
177
|
+
str(params["user"]),
|
|
178
|
+
"-f",
|
|
179
|
+
dump_path,
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if pg_version >= 16:
|
|
183
|
+
cmd.extend(["--compress", "zstd:3"])
|
|
184
|
+
else:
|
|
185
|
+
cmd.extend(["-Z", "1"])
|
|
186
|
+
|
|
187
|
+
cmd.append(str(params["dbname"]))
|
|
188
|
+
return cmd
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_pg_restore_cmd(
|
|
192
|
+
pg_restore: str,
|
|
193
|
+
params: dict[str, str | int],
|
|
194
|
+
dump_path: str,
|
|
195
|
+
) -> list[str]:
|
|
196
|
+
"""
|
|
197
|
+
Build the pg_restore command list.
|
|
198
|
+
"""
|
|
199
|
+
return [
|
|
200
|
+
pg_restore,
|
|
201
|
+
"--no-owner",
|
|
202
|
+
"--no-privileges",
|
|
203
|
+
"-h",
|
|
204
|
+
str(params["host"]),
|
|
205
|
+
"-p",
|
|
206
|
+
str(params["port"]),
|
|
207
|
+
"-U",
|
|
208
|
+
str(params["user"]),
|
|
209
|
+
"-d",
|
|
210
|
+
str(params["dbname"]),
|
|
211
|
+
dump_path,
|
|
212
|
+
]
|