sediment-cli 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.
- sediment_cli/__init__.py +22 -0
- sediment_cli/attribution.py +3881 -0
- sediment_cli/cli.py +1922 -0
- sediment_cli/client.py +323 -0
- sediment_cli/delivery.py +1334 -0
- sediment_cli/local_postgres.py +337 -0
- sediment_cli/transcript.py +1764 -0
- sediment_cli/ui.py +123 -0
- sediment_cli-0.1.0.dist-info/METADATA +16 -0
- sediment_cli-0.1.0.dist-info/RECORD +13 -0
- sediment_cli-0.1.0.dist-info/WHEEL +4 -0
- sediment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sediment_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Private local PostgreSQL installation and foreground server ownership."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
VERSION = "17.11.0"
|
|
21
|
+
_PACKAGES = {
|
|
22
|
+
("Darwin", "arm64"): (
|
|
23
|
+
"aarch64-apple-darwin",
|
|
24
|
+
"fd4b62794b160e26973a768a1eef3248aef9d2ff23ebd6d884a4299485e28e57",
|
|
25
|
+
),
|
|
26
|
+
("Darwin", "x86_64"): (
|
|
27
|
+
"x86_64-apple-darwin",
|
|
28
|
+
"e43a81b15e1cfe7f9d8fd79c6d4d0366e9001a5f690e322224dca704656602f7",
|
|
29
|
+
),
|
|
30
|
+
("Linux", "aarch64"): (
|
|
31
|
+
"aarch64-unknown-linux-gnu",
|
|
32
|
+
"abffda09209280ec1502b73720dc4d254fb7fff9a072e324926c600a5b16c221",
|
|
33
|
+
),
|
|
34
|
+
("Linux", "x86_64"): (
|
|
35
|
+
"x86_64-unknown-linux-gnu",
|
|
36
|
+
"b7a1ba6bae6499d8296e3e81b0171eecfd1766ca9aaa0057e41ad3e844e5e2e0",
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
_EXTERNAL = "Set SEDIMENT_BOOTSTRAP_DATABASE_URL to use external PostgreSQL."
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def private_file(path: Path, flags: int):
|
|
44
|
+
"""Open an owned regular file without following links or waiting on a FIFO."""
|
|
45
|
+
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
46
|
+
try:
|
|
47
|
+
info = os.fstat(fd)
|
|
48
|
+
if (
|
|
49
|
+
not stat.S_ISREG(info.st_mode)
|
|
50
|
+
or info.st_nlink != 1
|
|
51
|
+
or info.st_uid != os.getuid()
|
|
52
|
+
):
|
|
53
|
+
raise ValueError(f"{path.name} must be a regular private file")
|
|
54
|
+
os.fchmod(fd, 0o600)
|
|
55
|
+
yield fd
|
|
56
|
+
finally:
|
|
57
|
+
os.close(fd)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def server_root(root: Path):
|
|
62
|
+
"""Hold one root for the entire API and PostgreSQL lifetime."""
|
|
63
|
+
if os.name != "posix":
|
|
64
|
+
raise ValueError("sediment server requires macOS or Linux")
|
|
65
|
+
import fcntl
|
|
66
|
+
|
|
67
|
+
if root.is_symlink():
|
|
68
|
+
raise ValueError("server root must be a regular private directory")
|
|
69
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
70
|
+
if root.stat().st_uid != os.getuid():
|
|
71
|
+
raise ValueError("server root must be a regular private directory")
|
|
72
|
+
root.chmod(0o700)
|
|
73
|
+
with private_file(root / "server.lock", os.O_CREAT | os.O_RDWR) as fd:
|
|
74
|
+
try:
|
|
75
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
76
|
+
except BlockingIOError:
|
|
77
|
+
raise ValueError(f"a server is already running under {root}") from None
|
|
78
|
+
# Uvicorn restores and re-raises signals after its own graceful shutdown.
|
|
79
|
+
# This handler also covers PostgreSQL initialization and provisioning.
|
|
80
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
81
|
+
signal.signal(signal.SIGTERM, _interrupt)
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
finally:
|
|
85
|
+
signal.signal(signal.SIGTERM, previous)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _interrupt(signum, frame):
|
|
89
|
+
raise KeyboardInterrupt
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _install_postgres(root: Path) -> Path:
|
|
93
|
+
package = _PACKAGES.get((platform.system(), platform.machine()))
|
|
94
|
+
if package is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"managed PostgreSQL isn't available on this platform. {_EXTERNAL}"
|
|
97
|
+
)
|
|
98
|
+
target, checksum = package
|
|
99
|
+
name = f"postgresql-{VERSION}-{target}"
|
|
100
|
+
destination = root / name
|
|
101
|
+
host_ssl = None
|
|
102
|
+
if platform.system() == "Darwin":
|
|
103
|
+
try:
|
|
104
|
+
prefix = subprocess.run(
|
|
105
|
+
["brew", "--prefix", "openssl@3"],
|
|
106
|
+
check=True,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
timeout=10,
|
|
110
|
+
).stdout.strip()
|
|
111
|
+
host_ssl = Path(prefix) / "lib"
|
|
112
|
+
if not host_ssl.is_absolute() or not all(
|
|
113
|
+
(host_ssl / name).is_file()
|
|
114
|
+
for name in ("libssl.3.dylib", "libcrypto.3.dylib")
|
|
115
|
+
):
|
|
116
|
+
raise ValueError
|
|
117
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"managed PostgreSQL requires Homebrew openssl@3; install the quickstart prerequisites, then retry"
|
|
120
|
+
) from None
|
|
121
|
+
if destination.is_symlink():
|
|
122
|
+
raise ValueError("PostgreSQL installation must be a regular private directory")
|
|
123
|
+
if destination.exists():
|
|
124
|
+
if not all(
|
|
125
|
+
(destination / "bin" / executable).is_file()
|
|
126
|
+
for executable in ("postgres", "initdb")
|
|
127
|
+
):
|
|
128
|
+
raise ValueError(f"incomplete PostgreSQL installation at {destination}")
|
|
129
|
+
return destination / "bin"
|
|
130
|
+
print(f"Downloading PostgreSQL {VERSION.removesuffix('.0')} for this machine...")
|
|
131
|
+
url = f"https://github.com/theseus-rs/postgresql-binaries/releases/download/{VERSION}/{name}.tar.gz"
|
|
132
|
+
with tempfile.TemporaryDirectory(
|
|
133
|
+
prefix=".postgres-download-", dir=root
|
|
134
|
+
) as temporary:
|
|
135
|
+
staging = Path(temporary)
|
|
136
|
+
archive = staging / "postgres.tar.gz"
|
|
137
|
+
digest = hashlib.sha256()
|
|
138
|
+
size = 0
|
|
139
|
+
try:
|
|
140
|
+
with (
|
|
141
|
+
urllib.request.urlopen(url, timeout=30) as response,
|
|
142
|
+
archive.open("wb") as stream,
|
|
143
|
+
):
|
|
144
|
+
while chunk := response.read(1024 * 1024):
|
|
145
|
+
size += len(chunk)
|
|
146
|
+
if size > 128 * 1024 * 1024:
|
|
147
|
+
raise ValueError("PostgreSQL download exceeds the size limit")
|
|
148
|
+
digest.update(chunk)
|
|
149
|
+
stream.write(chunk)
|
|
150
|
+
except OSError:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"PostgreSQL download failed; check your connection and retry. {_EXTERNAL}"
|
|
153
|
+
) from None
|
|
154
|
+
if digest.hexdigest() != checksum:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"PostgreSQL download checksum mismatch; no binaries were installed"
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
with tarfile.open(archive, mode="r:gz") as bundle:
|
|
160
|
+
|
|
161
|
+
def installed_file(member, path):
|
|
162
|
+
# Use maintained host OpenSSL; never install the archive's
|
|
163
|
+
# copied libraries. Ordinary loader paths follow the two
|
|
164
|
+
# links below, including initdb's shell-launched children.
|
|
165
|
+
if host_ssl is not None and member.name in {
|
|
166
|
+
f"{name}/lib/libssl.3.dylib",
|
|
167
|
+
f"{name}/lib/libcrypto.3.dylib",
|
|
168
|
+
}:
|
|
169
|
+
return None
|
|
170
|
+
return tarfile.data_filter(member, path)
|
|
171
|
+
|
|
172
|
+
bundle.extractall(staging, filter=installed_file)
|
|
173
|
+
except (tarfile.TarError, OSError):
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"PostgreSQL archive could not be extracted safely"
|
|
176
|
+
) from None
|
|
177
|
+
installed = staging / name
|
|
178
|
+
if not all(
|
|
179
|
+
(installed / "bin" / executable).is_file()
|
|
180
|
+
for executable in ("postgres", "initdb")
|
|
181
|
+
):
|
|
182
|
+
raise ValueError("PostgreSQL archive lacks the required executables")
|
|
183
|
+
if host_ssl is not None:
|
|
184
|
+
for library in ("libssl.3.dylib", "libcrypto.3.dylib"):
|
|
185
|
+
(installed / "lib" / library).symlink_to(host_ssl / library)
|
|
186
|
+
installed.rename(destination)
|
|
187
|
+
return destination / "bin"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _stop(process: subprocess.Popen) -> None:
|
|
191
|
+
"""Stop only the child this invocation owns; never act on a saved PID."""
|
|
192
|
+
if process.poll() is None:
|
|
193
|
+
os.killpg(process.pid, signal.SIGINT) # Fast, orderly PostgreSQL shutdown.
|
|
194
|
+
try:
|
|
195
|
+
process.wait(timeout=15)
|
|
196
|
+
except subprocess.TimeoutExpired:
|
|
197
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
198
|
+
process.wait()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@contextmanager
|
|
202
|
+
def managed_postgres(root: Path, password: str):
|
|
203
|
+
"""Yield a bootstrap URL while an owned loopback PostgreSQL process runs."""
|
|
204
|
+
from sqlalchemy.engine import URL
|
|
205
|
+
from sediment_core.postgres_engine import DatabaseOperationError, configure_libpq
|
|
206
|
+
|
|
207
|
+
if os.geteuid() == 0:
|
|
208
|
+
raise ValueError(f"managed PostgreSQL must run as a non-root user. {_EXTERNAL}")
|
|
209
|
+
# Keep the existing maintained host driver; fail before downloading or
|
|
210
|
+
# initializing data if it isn't installed.
|
|
211
|
+
try:
|
|
212
|
+
configure_libpq()
|
|
213
|
+
import psycopg
|
|
214
|
+
except ImportError:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
"PostgreSQL client library unavailable; install libpq (macOS) or libpq5 (Debian/Ubuntu), then retry"
|
|
217
|
+
) from None
|
|
218
|
+
binaries = _install_postgres(root)
|
|
219
|
+
data = root / "postgres"
|
|
220
|
+
if data.is_symlink():
|
|
221
|
+
raise ValueError("PostgreSQL data must be a regular private directory")
|
|
222
|
+
if data.exists() and (
|
|
223
|
+
not (data / "PG_VERSION").is_file()
|
|
224
|
+
or (data / "PG_VERSION").read_text().strip() != "17"
|
|
225
|
+
):
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"existing database at {data} isn't PostgreSQL 17; it was preserved"
|
|
228
|
+
)
|
|
229
|
+
environment = {
|
|
230
|
+
key: value
|
|
231
|
+
for key, value in os.environ.items()
|
|
232
|
+
if not key.startswith(("SEDIMENT_", "PG"))
|
|
233
|
+
}
|
|
234
|
+
environment["LC_ALL"] = "C"
|
|
235
|
+
log = root / "postgres.log"
|
|
236
|
+
with private_file(log, os.O_CREAT | os.O_WRONLY | os.O_APPEND) as log_fd:
|
|
237
|
+
if not data.exists():
|
|
238
|
+
print(f"Creating PostgreSQL database in {data}")
|
|
239
|
+
with tempfile.TemporaryDirectory(
|
|
240
|
+
prefix=".postgres-init-", dir=root
|
|
241
|
+
) as temporary:
|
|
242
|
+
staging = Path(temporary)
|
|
243
|
+
password_file = staging / "password"
|
|
244
|
+
with private_file(password_file, os.O_CREAT | os.O_WRONLY) as fd:
|
|
245
|
+
os.write(fd, password.encode() + b"\n")
|
|
246
|
+
initializer = subprocess.Popen(
|
|
247
|
+
[
|
|
248
|
+
str(binaries / "initdb"),
|
|
249
|
+
"-D",
|
|
250
|
+
str(staging / "data"),
|
|
251
|
+
"--username=sediment_bootstrap",
|
|
252
|
+
f"--pwfile={password_file}",
|
|
253
|
+
"--auth=scram-sha-256",
|
|
254
|
+
"--encoding=UTF8",
|
|
255
|
+
"--locale=C",
|
|
256
|
+
],
|
|
257
|
+
env=environment,
|
|
258
|
+
stdin=subprocess.DEVNULL,
|
|
259
|
+
stdout=log_fd,
|
|
260
|
+
stderr=log_fd,
|
|
261
|
+
start_new_session=True,
|
|
262
|
+
)
|
|
263
|
+
try:
|
|
264
|
+
if initializer.wait(timeout=60):
|
|
265
|
+
raise ValueError(f"PostgreSQL initialization failed; see {log}")
|
|
266
|
+
except subprocess.TimeoutExpired:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"PostgreSQL initialization timed out; see {log}"
|
|
269
|
+
) from None
|
|
270
|
+
finally:
|
|
271
|
+
_stop(initializer)
|
|
272
|
+
(staging / "data").rename(data)
|
|
273
|
+
with socket.socket() as listener:
|
|
274
|
+
listener.bind(("127.0.0.1", 0))
|
|
275
|
+
port = listener.getsockname()[1]
|
|
276
|
+
url = URL.create(
|
|
277
|
+
"postgresql+psycopg",
|
|
278
|
+
username="sediment_bootstrap",
|
|
279
|
+
password=password,
|
|
280
|
+
host="127.0.0.1",
|
|
281
|
+
port=port,
|
|
282
|
+
database="sediment",
|
|
283
|
+
)
|
|
284
|
+
process = subprocess.Popen(
|
|
285
|
+
[
|
|
286
|
+
str(binaries / "postgres"),
|
|
287
|
+
"-D",
|
|
288
|
+
str(data),
|
|
289
|
+
"-h",
|
|
290
|
+
"127.0.0.1",
|
|
291
|
+
"-p",
|
|
292
|
+
str(port),
|
|
293
|
+
"-c",
|
|
294
|
+
"unix_socket_directories=",
|
|
295
|
+
"-c",
|
|
296
|
+
"ssl=off",
|
|
297
|
+
],
|
|
298
|
+
env=environment,
|
|
299
|
+
stdin=subprocess.DEVNULL,
|
|
300
|
+
stdout=log_fd,
|
|
301
|
+
stderr=log_fd,
|
|
302
|
+
start_new_session=True,
|
|
303
|
+
)
|
|
304
|
+
try:
|
|
305
|
+
deadline = time.monotonic() + 30
|
|
306
|
+
while True:
|
|
307
|
+
if process.poll() is not None:
|
|
308
|
+
raise ValueError(f"PostgreSQL startup failed; see {log}")
|
|
309
|
+
try:
|
|
310
|
+
with psycopg.connect(
|
|
311
|
+
host="127.0.0.1",
|
|
312
|
+
port=port,
|
|
313
|
+
user="sediment_bootstrap",
|
|
314
|
+
password=password,
|
|
315
|
+
dbname="postgres",
|
|
316
|
+
connect_timeout=1,
|
|
317
|
+
sslmode="disable",
|
|
318
|
+
autocommit=True,
|
|
319
|
+
) as connection:
|
|
320
|
+
if not connection.execute(
|
|
321
|
+
"SELECT 1 FROM pg_database WHERE datname = 'sediment'"
|
|
322
|
+
).fetchone():
|
|
323
|
+
connection.execute("CREATE DATABASE sediment")
|
|
324
|
+
break
|
|
325
|
+
except psycopg.OperationalError:
|
|
326
|
+
if time.monotonic() >= deadline:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"PostgreSQL didn't become ready; see {log}"
|
|
329
|
+
) from None
|
|
330
|
+
time.sleep(0.1)
|
|
331
|
+
except psycopg.Error:
|
|
332
|
+
raise DatabaseOperationError(
|
|
333
|
+
f"create local database failed; see {log}"
|
|
334
|
+
) from None
|
|
335
|
+
yield url.render_as_string(hide_password=False)
|
|
336
|
+
finally:
|
|
337
|
+
_stop(process)
|