pg-stand 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.
pg_stand/.dockerignore ADDED
@@ -0,0 +1,5 @@
1
+ *
2
+ !docker/
3
+ !docker/Dockerfile.*
4
+ !docker/pg-stand-entrypoint.sh
5
+ !docker/pg-stand-pgbouncer.sh
pg_stand/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Declarative PostgreSQL stands managed through the Docker SDK."""
2
+
3
+ __version__ = "0.1.0"
pg_stand/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pg_stand.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
pg_stand/assets.py ADDED
@@ -0,0 +1,108 @@
1
+ """Bundled profiles, schema, and managed-image build assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from pg_stand.errors import PgStandError
10
+
11
+ PACKAGE_ROOT = Path(__file__).resolve().parent
12
+ PROFILE_FILES = (
13
+ "single.yaml",
14
+ "primary-standby-async.yaml",
15
+ "primary-standby-sync.yaml",
16
+ "primary-standby-sync-logical.yaml",
17
+ )
18
+ SCHEMA_FILES = ("pg_stand-v1.schema.json",)
19
+ DOCKER_FILES = (
20
+ "Dockerfile.pg10",
21
+ "Dockerfile.pg11-pg12",
22
+ "Dockerfile.pg13-pg17",
23
+ "Dockerfile.pg18",
24
+ "pg-stand-entrypoint.sh",
25
+ "pg-stand-pgbouncer.sh",
26
+ )
27
+
28
+
29
+ def _ensure_directory(path: Path, *, label: str) -> None:
30
+ if path.is_symlink():
31
+ raise PgStandError(f"{label} must not be a symbolic link: {path}")
32
+ try:
33
+ path.mkdir(parents=True, exist_ok=True, mode=0o755)
34
+ except OSError as exc:
35
+ raise PgStandError(f"cannot create {label} {path}: {exc}") from exc
36
+ if not path.is_dir():
37
+ raise PgStandError(f"{label} is not a directory: {path}")
38
+
39
+
40
+ def docker_build_context() -> Path:
41
+ """Return the installed package directory used as the managed-image build context."""
42
+ docker_directory = PACKAGE_ROOT / "docker"
43
+ missing = [name for name in DOCKER_FILES if not (docker_directory / name).is_file()]
44
+ dockerignore = PACKAGE_ROOT / ".dockerignore"
45
+ if not dockerignore.is_file():
46
+ missing.append("../.dockerignore")
47
+ if missing:
48
+ raise PgStandError("installed pg-stand package lacks Docker assets: " + ", ".join(missing))
49
+ return PACKAGE_ROOT
50
+
51
+
52
+ def _write_asset(source: Path, target: Path, *, force: bool) -> str:
53
+ payload = source.read_bytes()
54
+ existed = target.exists()
55
+ if target.is_symlink():
56
+ raise PgStandError(f"refusing to replace a symbolic link: {target}")
57
+ if target.exists():
58
+ if not target.is_file():
59
+ raise PgStandError(f"project asset target is not a regular file: {target}")
60
+ if target.read_bytes() == payload:
61
+ return "unchanged"
62
+ if not force:
63
+ raise PgStandError(
64
+ f"project asset already exists with different content: {target}; "
65
+ "use init --force to replace it"
66
+ )
67
+ descriptor, temporary_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
68
+ temporary = Path(temporary_name)
69
+ try:
70
+ os.fchmod(descriptor, 0o644)
71
+ with os.fdopen(descriptor, "wb") as stream:
72
+ descriptor = -1
73
+ stream.write(payload)
74
+ temporary.replace(target)
75
+ except BaseException:
76
+ if descriptor >= 0:
77
+ os.close(descriptor)
78
+ temporary.unlink(missing_ok=True)
79
+ raise
80
+ return "replaced" if existed else "created"
81
+
82
+
83
+ def initialize_project(directory: str | Path, *, force: bool = False) -> dict[str, object]:
84
+ """Materialize editable example profiles and the public schema in a project directory."""
85
+ project_directory = Path(directory).expanduser().resolve()
86
+ _ensure_directory(project_directory, label="project directory")
87
+ files: list[dict[str, str]] = []
88
+ for subdirectory, names in (("configs", PROFILE_FILES), ("schema", SCHEMA_FILES)):
89
+ target_directory = project_directory / subdirectory
90
+ _ensure_directory(target_directory, label="project asset directory")
91
+ resolved_target_directory = target_directory.resolve()
92
+ if project_directory not in resolved_target_directory.parents:
93
+ raise PgStandError(f"project asset directory escaped the project: {target_directory}")
94
+ for name in names:
95
+ source = PACKAGE_ROOT / subdirectory / name
96
+ if not source.is_file():
97
+ raise PgStandError(f"installed pg-stand package lacks asset: {source}")
98
+ target = target_directory / name
99
+ try:
100
+ state = _write_asset(source, target, force=force)
101
+ except OSError as exc:
102
+ raise PgStandError(f"cannot write project asset {target}: {exc}") from exc
103
+ files.append({"path": str(target), "state": state})
104
+ return {
105
+ "project_directory": str(project_directory),
106
+ "force": force,
107
+ "files": files,
108
+ }
pg_stand/cli.py ADDED
@@ -0,0 +1,334 @@
1
+ """Command-line interface for pg-stand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ import time
10
+ from collections.abc import Sequence
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ import docker
17
+ from pg_stand.assets import initialize_project
18
+ from pg_stand.cli_parser import _parser
19
+ from pg_stand.config import StandConfig, load_config
20
+ from pg_stand.errors import DockerRuntimeError, PgStandError
21
+ from pg_stand.runtime import StandManager, discover_active_stand
22
+
23
+ _DURATION_RE = re.compile(r"^(?P<value>[0-9]+(?:\.[0-9]+)?)(?P<unit>[smhd])$")
24
+
25
+
26
+ def _emit(value: Any, *, as_json: bool) -> None:
27
+ if as_json:
28
+ print(json.dumps(value, indent=2, sort_keys=True))
29
+ else:
30
+ print(yaml.safe_dump(value, sort_keys=False, allow_unicode=True).rstrip())
31
+
32
+
33
+ def _emit_stream(value: Any, *, as_json: bool) -> None:
34
+ if as_json:
35
+ print(json.dumps(value, sort_keys=True), flush=True)
36
+ else:
37
+ print("---")
38
+ print(yaml.safe_dump(value, sort_keys=False, allow_unicode=True).rstrip(), flush=True)
39
+
40
+
41
+ def _moment(value: str, *, now: datetime | None = None) -> datetime:
42
+ reference = now or datetime.now(timezone.utc)
43
+ match = _DURATION_RE.fullmatch(value.strip().lower())
44
+ if match:
45
+ factors = {"s": 1, "m": 60, "h": 3600, "d": 86400}
46
+ seconds = float(match.group("value")) * factors[match.group("unit")]
47
+ return reference - timedelta(seconds=seconds)
48
+ try:
49
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
50
+ except ValueError as exc:
51
+ raise DockerRuntimeError(
52
+ f"invalid time {value!r}; use an ISO-8601 timestamp or duration such as 30m"
53
+ ) from exc
54
+ if parsed.tzinfo is None:
55
+ parsed = parsed.replace(tzinfo=timezone.utc)
56
+ return parsed.astimezone(timezone.utc)
57
+
58
+
59
+ def _health_options(args: argparse.Namespace) -> dict[str, Any]:
60
+ return {
61
+ "max_physical_lag_bytes": args.max_physical_lag_bytes,
62
+ "max_physical_lag_seconds": args.max_physical_lag_seconds,
63
+ "max_logical_lag_seconds": args.max_logical_lag_seconds,
64
+ "min_free_bytes": args.min_free_bytes,
65
+ }
66
+
67
+
68
+ def _manager(config: StandConfig) -> StandManager:
69
+ return StandManager(config, output=lambda message: print(message, file=sys.stderr))
70
+
71
+
72
+ def _watch(manager: StandManager, args: argparse.Namespace, *, replication_only: bool) -> int:
73
+ samples = 0
74
+ while args.count == 0 or samples < args.count:
75
+ value = manager.replication_status() if replication_only else manager.overview()
76
+ _emit_stream(value, as_json=args.json or args.json_lines)
77
+ samples += 1
78
+ if args.count == 0 or samples < args.count:
79
+ time.sleep(args.interval)
80
+ return 0
81
+
82
+
83
+ def _emit_pretty_logs(records: list[dict[str, Any]]) -> None:
84
+ for record in records:
85
+ backend = f" {record['backend_type']}" if record["backend_type"] else ""
86
+ print(
87
+ f"{record['log_time']} [{record['node']}] {record['severity']}{backend}: "
88
+ f"{record['message']}"
89
+ )
90
+ for key in ("detail", "hint", "context", "query"):
91
+ if record[key]:
92
+ print(f" {key}: {record[key]}")
93
+
94
+
95
+ def _execute(args: argparse.Namespace, config: StandConfig) -> int:
96
+ if args.command == "validate":
97
+ result = {
98
+ "valid": True,
99
+ "config": str(config.source),
100
+ "project": config.metadata.name,
101
+ "postgres_version": config.postgres.version,
102
+ "image": config.postgres.image_name,
103
+ "topology": config.topology.mode,
104
+ "tls": config.postgres.tls.enabled,
105
+ }
106
+ _emit(result, as_json=args.json)
107
+ return 0
108
+ if args.command == "show":
109
+ _emit(config.public_document(), as_json=args.json)
110
+ return 0
111
+
112
+ manager = _manager(config)
113
+ if args.command == "up":
114
+ _emit(manager.up(timeout_seconds=args.timeout), as_json=args.json)
115
+ elif args.command == "status":
116
+ _emit(manager.status(), as_json=args.json)
117
+ elif args.command == "plan":
118
+ result = manager.plan()
119
+ _emit(result, as_json=args.json)
120
+ return 2 if result["required_action"] == "blocked" else 0
121
+ elif args.command == "resources":
122
+ _emit(manager.resources(args.node), as_json=args.json)
123
+ elif args.command == "health":
124
+ result = manager.health(**_health_options(args))
125
+ _emit(result, as_json=args.json)
126
+ return int(result["exit_code"])
127
+ elif args.command == "wait":
128
+ result = manager.wait_until_healthy(
129
+ timeout_seconds=args.timeout,
130
+ interval_seconds=args.interval,
131
+ **_health_options(args),
132
+ )
133
+ _emit(result, as_json=args.json)
134
+ return int(result["exit_code"])
135
+ elif args.command == "watch":
136
+ return _watch(manager, args, replication_only=False)
137
+ elif args.command == "replication":
138
+ if args.replication_command == "watch":
139
+ return _watch(manager, args, replication_only=True)
140
+ _emit(manager.replication_status(), as_json=args.json)
141
+ elif args.command == "apply":
142
+ requested_mode = "restart" if args.restart else ("reload" if args.reload else None)
143
+ if args.dry_run:
144
+ result = manager.plan(requested_mode=requested_mode)
145
+ _emit(result, as_json=args.json)
146
+ if result["required_action"] == "blocked":
147
+ return 2
148
+ return 0 if result["can_apply"] else 1
149
+ if requested_mode is None:
150
+ raise DockerRuntimeError(
151
+ "apply requires --reload or --restart unless --dry-run is used"
152
+ )
153
+ _emit(
154
+ manager.apply(
155
+ mode=requested_mode,
156
+ timeout_seconds=args.timeout,
157
+ stop_timeout_seconds=args.stop_timeout,
158
+ ),
159
+ as_json=args.json,
160
+ )
161
+ elif args.command == "stop":
162
+ manager.stop(timeout_seconds=args.timeout)
163
+ _emit({"project": config.metadata.name, "state": "stopped"}, as_json=args.json)
164
+ elif args.command == "restart":
165
+ manager.stop(timeout_seconds=args.stop_timeout)
166
+ _emit(manager.up(timeout_seconds=args.timeout), as_json=args.json)
167
+ elif args.command == "recreate":
168
+ _emit(
169
+ manager.recreate(
170
+ clear_data=args.clear_data,
171
+ timeout_seconds=args.timeout,
172
+ stop_timeout_seconds=args.stop_timeout,
173
+ ),
174
+ as_json=args.json,
175
+ )
176
+ elif args.command == "down":
177
+ manager.down(clear_data=args.clear_data, timeout_seconds=args.timeout)
178
+ _emit(
179
+ {"project": config.metadata.name, "state": "removed", "data_cleared": args.clear_data},
180
+ as_json=args.json,
181
+ )
182
+ elif args.command == "storage":
183
+ if args.storage_command == "init":
184
+ result = manager.storage_init()
185
+ elif args.storage_command == "status":
186
+ result = manager.storage_status()
187
+ elif args.storage_command == "usage":
188
+ result = manager.storage_usage()
189
+ elif args.storage_command == "clean":
190
+ result = manager.storage_clean()
191
+ else: # pragma: no cover
192
+ raise AssertionError(f"unhandled storage command: {args.storage_command}")
193
+ _emit(result, as_json=args.json)
194
+ elif args.command == "logical":
195
+ _emit(
196
+ manager.logical_sync(
197
+ full=args.full,
198
+ timeout_seconds=args.timeout,
199
+ stop_timeout_seconds=args.stop_timeout,
200
+ ),
201
+ as_json=args.json,
202
+ )
203
+ elif args.command == "standby":
204
+ _emit(
205
+ manager.standby_reinit(
206
+ timeout_seconds=args.timeout,
207
+ stop_timeout_seconds=args.stop_timeout,
208
+ ),
209
+ as_json=args.json,
210
+ )
211
+ elif args.command == "node":
212
+ if args.node_command == "inspect":
213
+ result = manager.node_inspect(args.node)
214
+ else:
215
+ result = manager.node_restart(
216
+ args.node,
217
+ timeout_seconds=args.timeout,
218
+ stop_timeout_seconds=args.stop_timeout,
219
+ )
220
+ _emit(result, as_json=args.json)
221
+ elif args.command == "logs":
222
+ has_filters = any((args.since, args.until, args.severity, args.backend_type, args.contains))
223
+ if args.follow:
224
+ if args.node == "all" or has_filters or args.format != "raw":
225
+ raise DockerRuntimeError("--follow supports one node in raw format without filters")
226
+ manager.logs(args.node, follow=True, tail=args.tail)
227
+ elif args.format == "raw" and args.node != "all" and not has_filters and not args.json:
228
+ manager.logs(args.node, tail=args.tail)
229
+ else:
230
+ now = datetime.now(timezone.utc)
231
+ records = manager.log_records(
232
+ args.node,
233
+ tail=args.tail,
234
+ since=_moment(args.since, now=now) if args.since else None,
235
+ until=_moment(args.until, now=now) if args.until else None,
236
+ severity=args.severity,
237
+ backend_type=args.backend_type,
238
+ contains=args.contains,
239
+ )
240
+ if args.json or args.format == "json":
241
+ print(json.dumps(records, indent=2, sort_keys=True))
242
+ else:
243
+ _emit_pretty_logs(records)
244
+ elif args.command == "events":
245
+ if args.follow and args.until:
246
+ raise DockerRuntimeError("events --follow cannot be combined with --until")
247
+ now = datetime.now(timezone.utc)
248
+ since = int(_moment(args.since, now=now).timestamp())
249
+ until = (
250
+ None
251
+ if args.follow
252
+ else int((_moment(args.until, now=now) if args.until else now).timestamp())
253
+ )
254
+ for event in manager.docker_events(since_epoch=since, until_epoch=until, count=args.count):
255
+ _emit_stream(event, as_json=args.json or args.json_lines)
256
+ elif args.command == "doctor":
257
+ result = manager.doctor()
258
+ _emit(result, as_json=args.json)
259
+ return int(result["exit_code"])
260
+ elif args.command == "capabilities":
261
+ _emit(manager.capabilities(), as_json=args.json)
262
+ elif args.command == "dsn":
263
+ _emit(
264
+ manager.connection_info(
265
+ args.node,
266
+ database=args.database,
267
+ show_password=args.show_password,
268
+ ),
269
+ as_json=args.json,
270
+ )
271
+ elif args.command == "psql":
272
+ return manager.run_psql(args.node, database=args.database)
273
+ elif args.command == "ssh":
274
+ return manager.run_ssh(args.node)
275
+ elif args.command == "image":
276
+ if args.image_command == "list":
277
+ result = manager.image_list()
278
+ elif args.image_command == "inspect":
279
+ result = manager.image_inspect()
280
+ else:
281
+ result = manager.image_build()
282
+ _emit(result, as_json=args.json)
283
+ elif args.command == "cleanup":
284
+ if args.cleanup_command == "status":
285
+ result = manager.cleanup_status()
286
+ else:
287
+ result = manager.cleanup(
288
+ remove_containers=args.containers or args.all,
289
+ remove_storage=args.storage or args.all,
290
+ remove_images=args.images or args.all,
291
+ remove_cache=args.cache or args.all,
292
+ remove_credentials=args.all,
293
+ stop_timeout_seconds=args.stop_timeout,
294
+ )
295
+ _emit(result, as_json=args.json)
296
+ elif args.command == "sql":
297
+ print(manager.sql(args.node, args.statement, database=args.database))
298
+ else: # pragma: no cover
299
+ raise AssertionError(f"unhandled command: {args.command}")
300
+ return 0
301
+
302
+
303
+ def main(argv: Sequence[str] | None = None) -> int:
304
+ parser = _parser()
305
+ args = parser.parse_args(argv)
306
+ try:
307
+ if args.command == "active":
308
+ result = discover_active_stand()
309
+ _emit(result, as_json=args.json)
310
+ return 0 if result["active"] else 1
311
+ if args.command == "init":
312
+ _emit(
313
+ initialize_project(args.directory, force=args.force),
314
+ as_json=args.json,
315
+ )
316
+ return 0
317
+ if args.config is None:
318
+ raise DockerRuntimeError(f"--config is required for {args.command}")
319
+ config = load_config(
320
+ args.config,
321
+ postgres_version=args.postgres_version,
322
+ tls_enabled=args.tls,
323
+ )
324
+ return _execute(args, config)
325
+ except (PgStandError, docker.errors.DockerException) as exc:
326
+ print(f"ERROR: {exc}", file=sys.stderr)
327
+ return 2
328
+ except KeyboardInterrupt:
329
+ print("ERROR: interrupted", file=sys.stderr)
330
+ return 130
331
+
332
+
333
+ if __name__ == "__main__": # pragma: no cover
334
+ raise SystemExit(main())