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.
- toolframe_sql_postgres/__init__.py +1 -0
- toolframe_sql_postgres/backup.py +163 -0
- toolframe_sql_postgres/cli.py +221 -0
- toolframe_sql_postgres/config.py +104 -0
- toolframe_sql_postgres/plugin.py +590 -0
- toolframe_sql_postgres/provision.py +348 -0
- toolframe_sql_postgres/sql.py +203 -0
- toolframe_sql_postgres-0.2.0.dist-info/METADATA +104 -0
- toolframe_sql_postgres-0.2.0.dist-info/RECORD +13 -0
- toolframe_sql_postgres-0.2.0.dist-info/WHEEL +4 -0
- toolframe_sql_postgres-0.2.0.dist-info/entry_points.txt +5 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/LICENSE +202 -0
- toolframe_sql_postgres-0.2.0.dist-info/licenses/NOTICE +2 -0
|
@@ -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
|
+
)
|