toolframe-sql-postgres 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.
@@ -0,0 +1 @@
1
+ """PostgreSQL persistence for plugin-owned stores, independent of Core state."""
@@ -0,0 +1,163 @@
1
+ """Private database dumps, with bounded subprocesses and no credentials in argv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import time
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ import psycopg
15
+ from psycopg.conninfo import conninfo_to_dict, make_conninfo
16
+ from toolframe_sdk import PluginError
17
+
18
+
19
+ def command_environment(dsn):
20
+ values = conninfo_to_dict(dsn)
21
+ environment = {
22
+ key: value
23
+ for key, value in os.environ.items()
24
+ if not key.startswith("PG") and not key.startswith("TOOLFRAME_POSTGRES")
25
+ }
26
+ password = values.pop("password", None)
27
+ if password is not None:
28
+ environment["PGPASSWORD"] = password
29
+ return make_conninfo(**values), environment
30
+
31
+
32
+ async def run_tool(program, arguments, dsn, *, timeout):
33
+ public_dsn, environment = command_environment(dsn)
34
+ process = None
35
+ try:
36
+ process = await asyncio.create_subprocess_exec(
37
+ program,
38
+ *arguments,
39
+ "--no-password",
40
+ "--dbname",
41
+ public_dsn,
42
+ env=environment,
43
+ stdout=asyncio.subprocess.PIPE,
44
+ stderr=asyncio.subprocess.PIPE,
45
+ )
46
+ async with asyncio.timeout(max(0.01, timeout)):
47
+ output, _ = await process.communicate()
48
+ if process.returncode:
49
+ raise PluginError(
50
+ "postgres_tool_failed",
51
+ "PostgreSQL client failed; check client version and database access",
52
+ )
53
+ return output
54
+ except OSError, TimeoutError:
55
+ raise PluginError(
56
+ "postgres_tool_failed",
57
+ "PostgreSQL client is unavailable or exceeded its deadline",
58
+ ) from None
59
+ finally:
60
+ if process and process.returncode is None:
61
+ process.kill()
62
+ await process.wait()
63
+
64
+
65
+ async def client_major(program):
66
+ process = None
67
+ try:
68
+ process = await asyncio.create_subprocess_exec(
69
+ program,
70
+ "--version",
71
+ stdout=asyncio.subprocess.PIPE,
72
+ stderr=asyncio.subprocess.PIPE,
73
+ )
74
+ async with asyncio.timeout(5):
75
+ output, _ = await process.communicate()
76
+ match = re.search(rb"\(PostgreSQL\) (\d+)", output)
77
+ if process.returncode or not match:
78
+ raise ValueError
79
+ return int(match[1])
80
+ except OSError, TimeoutError, ValueError:
81
+ raise PluginError(
82
+ "postgres_tool_failed", "PostgreSQL client version is unavailable"
83
+ ) from None
84
+ finally:
85
+ if process and process.returncode is None:
86
+ process.kill()
87
+ await process.wait()
88
+
89
+
90
+ async def require_matching_client(program, dsn, expected=None):
91
+ async with await psycopg.AsyncConnection.connect(
92
+ dsn, connect_timeout=5
93
+ ) as connection:
94
+ major = connection.info.server_version // 10000
95
+ if await client_major(program) != major or (
96
+ expected is not None and expected != major
97
+ ):
98
+ raise PluginError(
99
+ "postgres_version_mismatch",
100
+ "Use PostgreSQL client tools and restore target with the same major version as the source database",
101
+ )
102
+ return major
103
+
104
+
105
+ async def create_backup(settings, directory, plan, deadline):
106
+ major = await require_matching_client(settings.pg_dump, settings.dsn)
107
+ directory = Path(directory) / "backups"
108
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
109
+ identifier = f"{int(time.time())}-{uuid.uuid4().hex}"
110
+ path = directory / f"{identifier}.dump"
111
+ temporary = directory / f".{identifier}.partial"
112
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
113
+ os.close(descriptor)
114
+ try:
115
+ await run_tool(
116
+ settings.pg_dump,
117
+ ["--format=custom", "--file", str(temporary)],
118
+ settings.dsn,
119
+ timeout=deadline - time.time(),
120
+ )
121
+ with temporary.open("rb") as source:
122
+ checksum = hashlib.file_digest(source, "sha256").hexdigest()
123
+ metadata = {
124
+ "format": 1,
125
+ "application_id": settings.application_id,
126
+ "owners": sorted(settings.stores),
127
+ "sha256": checksum,
128
+ "plan": plan,
129
+ "created": time.time(),
130
+ "core_state_included": False,
131
+ "postgres_major": major,
132
+ }
133
+ temporary.replace(path)
134
+ manifest = path.with_suffix(".json")
135
+ descriptor = os.open(manifest, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
136
+ with os.fdopen(descriptor, "w") as stream:
137
+ json.dump(metadata, stream, sort_keys=True)
138
+ return path
139
+ except BaseException:
140
+ temporary.unlink(missing_ok=True)
141
+ raise
142
+
143
+
144
+ def verify_backup(path):
145
+ path = Path(path)
146
+ metadata = json.loads(path.with_suffix(".json").read_text())
147
+ with path.open("rb") as source:
148
+ checksum = hashlib.file_digest(source, "sha256").hexdigest()
149
+ if (
150
+ metadata.get("format") != 1
151
+ or checksum != metadata.get("sha256")
152
+ or not isinstance(metadata.get("application_id"), str)
153
+ or not isinstance(metadata.get("postgres_major"), int)
154
+ or not isinstance(metadata.get("owners"), list)
155
+ or not metadata["owners"]
156
+ or any(
157
+ not isinstance(owner, str)
158
+ or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner)
159
+ for owner in metadata["owners"]
160
+ )
161
+ ):
162
+ raise PluginError("backup_invalid", "Backup checksum or format does not match")
163
+ return metadata
@@ -0,0 +1,221 @@
1
+ """Provisioning and restore utilities, separate from the runtime entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import psycopg
14
+ from psycopg import sql
15
+ from psycopg.conninfo import make_conninfo
16
+ from toolframe_sdk import PluginError
17
+
18
+ from .backup import require_matching_client, run_tool, verify_backup
19
+ from .config import names
20
+ from .provision import provision
21
+
22
+
23
+ def read_environment(path):
24
+ path = Path(path)
25
+ mode = path.lstat().st_mode
26
+ if not stat.S_ISREG(mode) or mode & 0o077:
27
+ raise PluginError(
28
+ "credentials_permissions",
29
+ "Environment file must be a private regular file (0600)",
30
+ )
31
+ values = json.loads(path.read_text())
32
+ if not isinstance(values, dict) or any(
33
+ not isinstance(k, str)
34
+ or not isinstance(v, str)
35
+ or not k.startswith("TOOLFRAME_POSTGRES_")
36
+ for k, v in values.items()
37
+ ):
38
+ raise PluginError(
39
+ "credentials_invalid", "Invalid PostgreSQL environment document"
40
+ )
41
+ if (
42
+ "TOOLFRAME_POSTGRES_SETUP_DSN" in values
43
+ or "TOOLFRAME_POSTGRES_TEST_ADMIN_DSN" in values
44
+ ):
45
+ raise PluginError(
46
+ "credentials_invalid",
47
+ "Provisioning credentials must not be stored in runtime output",
48
+ )
49
+ return values
50
+
51
+
52
+ def save_environment(path, values):
53
+ path = Path(path)
54
+ if path.is_symlink():
55
+ raise PluginError(
56
+ "credentials_invalid", "Environment output cannot be a symbolic link"
57
+ )
58
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
59
+ temporary = path.with_name(path.name + ".tmp")
60
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
61
+ try:
62
+ with os.fdopen(descriptor, "w") as stream:
63
+ json.dump(values, stream, indent=2)
64
+ stream.write("\n")
65
+ stream.flush()
66
+ os.fsync(stream.fileno())
67
+ temporary.replace(path)
68
+ finally:
69
+ temporary.unlink(missing_ok=True)
70
+
71
+
72
+ async def restore(admin_dsn, database, backup, *, pg_restore="pg_restore"):
73
+ """Restore only into a newly provisioned database. Failure leaves it for diagnosis."""
74
+ metadata = verify_backup(backup)
75
+ await require_matching_client(pg_restore, admin_dsn, metadata["postgres_major"])
76
+ application = metadata["application_id"]
77
+ environment = provision(admin_dsn, database, application, metadata["owners"])
78
+ target = make_conninfo(admin_dsn, dbname=database)
79
+ await run_tool(
80
+ pg_restore,
81
+ [
82
+ "--clean",
83
+ "--if-exists",
84
+ "--no-owner",
85
+ "--no-privileges",
86
+ "--exit-on-error",
87
+ str(Path(backup).resolve()),
88
+ ],
89
+ target,
90
+ timeout=300,
91
+ )
92
+ manager = names(application, database=database)["manager"]
93
+ with psycopg.connect(target) as connection:
94
+ schemas = {"_toolframe_platform": manager}
95
+ for owner in metadata["owners"]:
96
+ ns = names(application, owner, database=database)
97
+ schemas.update(
98
+ {ns["schema"]: ns["controller"], ns["metadata"]: ns["controller"]}
99
+ )
100
+ actual = {
101
+ row[0]
102
+ for row in connection.execute(
103
+ "SELECT nspname FROM pg_namespace WHERE nspname NOT LIKE 'pg_%' AND nspname NOT IN ('information_schema','public')"
104
+ ).fetchall()
105
+ }
106
+ if actual != set(schemas):
107
+ raise PluginError(
108
+ "backup_inventory_mismatch",
109
+ "Restored schema inventory differs from the backup owners; target remains isolated for inspection",
110
+ )
111
+ for schema, owner_role in schemas.items():
112
+ connection.execute(
113
+ sql.SQL("ALTER SCHEMA {} OWNER TO {}").format(
114
+ sql.Identifier(schema), sql.Identifier(owner_role)
115
+ )
116
+ )
117
+ tables = connection.execute(
118
+ "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relkind IN ('r','p')",
119
+ (schema,),
120
+ ).fetchall()
121
+ for (table,) in tables:
122
+ connection.execute(
123
+ sql.SQL("ALTER TABLE {}.{} OWNER TO {}").format(
124
+ sql.Identifier(schema),
125
+ sql.Identifier(table),
126
+ sql.Identifier(owner_role),
127
+ )
128
+ )
129
+ # Reapply grants and defaults to both restored and subsequently created objects.
130
+ provision(
131
+ admin_dsn, database, application, metadata["owners"], previous=environment
132
+ )
133
+ return environment
134
+
135
+
136
+ def parser():
137
+ result = argparse.ArgumentParser(
138
+ description="Provision and restore Toolframe PostgreSQL stores"
139
+ )
140
+ commands = result.add_subparsers(dest="command", required=True)
141
+ setup = commands.add_parser("setup")
142
+ setup.add_argument("--application-id", required=True)
143
+ setup.add_argument("--database", required=True)
144
+ setup.add_argument("--owner", action="append", required=True)
145
+ setup.add_argument("--output", required=True, type=Path)
146
+ recovery = commands.add_parser("restore")
147
+ recovery.add_argument("--database", required=True)
148
+ recovery.add_argument("--backup", required=True, type=Path)
149
+ recovery.add_argument("--output", required=True, type=Path)
150
+ run = commands.add_parser("run")
151
+ run.add_argument("--env-file", required=True, type=Path)
152
+ run.add_argument("argv", nargs=argparse.REMAINDER)
153
+ return result
154
+
155
+
156
+ def main(argv=None):
157
+ args = parser().parse_args(argv)
158
+ try:
159
+ if args.command == "run":
160
+ values = read_environment(args.env_file)
161
+ command = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
162
+ if not command:
163
+ raise PluginError("command_missing", "Supply a command after --")
164
+ environment = dict(os.environ)
165
+ for key in (
166
+ "TOOLFRAME_POSTGRES_SETUP_DSN",
167
+ "TOOLFRAME_POSTGRES_TEST_ADMIN_DSN",
168
+ ):
169
+ environment.pop(key, None)
170
+ environment.update(values)
171
+ os.execvpe(command[0], command, environment)
172
+ admin = os.environ.get("TOOLFRAME_POSTGRES_SETUP_DSN")
173
+ if not admin:
174
+ raise PluginError(
175
+ "setup_credentials_missing",
176
+ "Set TOOLFRAME_POSTGRES_SETUP_DSN for provisioning",
177
+ )
178
+ if args.command == "setup":
179
+ previous = read_environment(args.output) if args.output.exists() else None
180
+ values = provision(
181
+ admin, args.database, args.application_id, args.owner, previous=previous
182
+ )
183
+ else:
184
+ if args.output.exists():
185
+ raise PluginError(
186
+ "output_exists",
187
+ "Restore output must be a new private environment file",
188
+ )
189
+ values = asyncio.run(
190
+ restore(
191
+ admin,
192
+ args.database,
193
+ args.backup,
194
+ pg_restore=os.environ.get(
195
+ "TOOLFRAME_POSTGRES_PG_RESTORE", "pg_restore"
196
+ ),
197
+ )
198
+ )
199
+ save_environment(args.output, values)
200
+ print(
201
+ json.dumps(
202
+ {
203
+ "database": args.database,
204
+ "environment_file": str(args.output),
205
+ "credentials_printed": False,
206
+ }
207
+ )
208
+ )
209
+ except PluginError as error:
210
+ print(f"{error.code}: {error}", file=sys.stderr)
211
+ raise SystemExit(1) from None
212
+ except psycopg.Error, ValueError, OSError:
213
+ print(
214
+ "PostgreSQL setup/restore failed; inspect connectivity, client tools and resource permissions. No credentials were printed.",
215
+ file=sys.stderr,
216
+ )
217
+ raise SystemExit(1) from None
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -0,0 +1,104 @@
1
+ """Runtime credentials come exclusively from the process environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass
10
+
11
+ from psycopg.conninfo import conninfo_to_dict, make_conninfo
12
+ from toolframe_sdk import PluginError
13
+
14
+
15
+ def names(
16
+ application_id: str, owner: str | None = None, *, database: str
17
+ ) -> dict[str, str]:
18
+ application = hashlib.sha256(
19
+ (application_id + "\0" + database).encode()
20
+ ).hexdigest()[:20]
21
+ if owner is None:
22
+ return {"manager": f"tf_{application}_manager"}
23
+ suffix = hashlib.sha256(owner.encode()).hexdigest()[:20]
24
+ return {
25
+ "schema": f"tf_{suffix}",
26
+ "metadata": f"tf_{suffix}_meta",
27
+ "controller": f"tf_{application}_{suffix}_ctl",
28
+ "runtime": f"tf_{application}_{suffix}_run",
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True, repr=False)
33
+ class Settings:
34
+ application_id: str
35
+ dsn: str
36
+ stores: dict[str, dict[str, str]]
37
+ pg_dump: str
38
+ pg_restore: str
39
+ connection_limit: int
40
+
41
+ @classmethod
42
+ def from_env(cls):
43
+ try:
44
+ application = os.environ["TOOLFRAME_POSTGRES_APPLICATION_ID"]
45
+ dsn = os.environ["TOOLFRAME_POSTGRES_DSN"]
46
+ stores = json.loads(os.environ["TOOLFRAME_POSTGRES_STORES"])
47
+ connection = conninfo_to_dict(dsn)
48
+ database = connection["dbname"]
49
+ if (
50
+ connection.get("user")
51
+ != names(application, database=database)["manager"]
52
+ ):
53
+ raise ValueError("Use the application-scoped manager from setup")
54
+ if (
55
+ not application
56
+ or len(application) > 256
57
+ or not isinstance(stores, dict)
58
+ ):
59
+ raise ValueError
60
+ for owner, credentials in stores.items():
61
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner):
62
+ raise ValueError
63
+ if set(credentials) != {"user", "password"}:
64
+ raise ValueError
65
+ if (
66
+ credentials["user"]
67
+ != names(application, owner, database=database)["controller"]
68
+ ):
69
+ raise ValueError
70
+ if (
71
+ not isinstance(credentials["password"], str)
72
+ or not credentials["password"]
73
+ ):
74
+ raise ValueError
75
+ limit = int(os.environ.get("TOOLFRAME_POSTGRES_CONNECTION_LIMIT", "16"))
76
+ if not 1 <= limit <= 128:
77
+ raise ValueError
78
+ return cls(
79
+ application,
80
+ dsn,
81
+ stores,
82
+ os.environ.get("TOOLFRAME_POSTGRES_PG_DUMP", "pg_dump"),
83
+ os.environ.get("TOOLFRAME_POSTGRES_PG_RESTORE", "pg_restore"),
84
+ limit,
85
+ )
86
+ except Exception: # noqa: BLE001 - malformed secrets must never appear in diagnostics
87
+ raise PluginError(
88
+ "postgres_config_invalid",
89
+ "Set TOOLFRAME_POSTGRES_APPLICATION_ID, TOOLFRAME_POSTGRES_DSN and "
90
+ "TOOLFRAME_POSTGRES_STORES in the runtime environment using setup output",
91
+ ) from None
92
+
93
+ def store_dsn(self, owner):
94
+ if owner not in self.stores:
95
+ raise PluginError(
96
+ "store_not_provisioned",
97
+ "Provision the plugin store with toolframe-postgres setup",
98
+ )
99
+ return make_conninfo(self.dsn, **self.stores[owner])
100
+
101
+ def store_names(self, owner):
102
+ return names(
103
+ self.application_id, owner, database=conninfo_to_dict(self.dsn)["dbname"]
104
+ )