hox-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.
hox_cli/try_start.py ADDED
@@ -0,0 +1,327 @@
1
+ """Start a test build's real processes and see whether they come up.
2
+
3
+ Runs on the node, as slapsoft, on what /srv/hox-dev/run-build.sh built: stdlib only,
4
+ for the node's Python, sent over ssh by `hox try-start`. It reads the instance profile
5
+ rendered by the build and runs its own process scripts, with its environment, behind
6
+ its haproxy and with a PostgreSQL of its own. What needs the master is replaced: no
7
+ frontend, no instance parameters, no monitoring, and supervisord's restarts are a
8
+ failure here. Everything lives in a temporary directory, removed at the end.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import secrets
16
+ import shlex
17
+ import shutil
18
+ import signal
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+ import textwrap
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ BUILDS = Path("/srv/hox-dev")
29
+ # ponytail: fixed ports, so one run at a time; no node has this partition.
30
+ PARTITION = "slappart99"
31
+ # Its own loopback address and the stack's port: some apps can't be told another.
32
+ DATABASE_HOST = "127.0.0.99"
33
+ FRONTEND = "try-start.invalid"
34
+ REFERENCE = re.compile(r"(?<!\$)\$\{([^:}]*):([^}]*)\}")
35
+
36
+
37
+ def parse(text: str) -> dict[str, dict[str, str]]:
38
+ """A buildout profile's sections, with the indentation of multi-line values."""
39
+ sections: dict[str, dict[str, str]] = {}
40
+ section = option = None
41
+ for line in text.splitlines():
42
+ if header := re.fullmatch(r"\[([^\]]+)\]\s*", line):
43
+ section, option = sections.setdefault(header[1], {}), None
44
+ elif section is None or line.startswith("#"):
45
+ continue
46
+ elif assignment := re.match(r"(<|[\w.+-]+)\s*=\s*(.*)", line):
47
+ option = assignment[1]
48
+ section[option] = assignment[2]
49
+ elif option and (not line.strip() or line[0].isspace()):
50
+ section[option] += "\n" + line
51
+ return {
52
+ name: {k: v.rstrip() for k, v in options.items()}
53
+ for name, options in sections.items()
54
+ }
55
+
56
+
57
+ class Options:
58
+ """What a recipe's Python gets as `options`."""
59
+
60
+ def __init__(self, profile: Profile, section: str):
61
+ self.profile, self.section = profile, section
62
+
63
+ def __getitem__(self, option: str) -> str:
64
+ return self.profile.get(self.section, option)
65
+
66
+ def __setitem__(self, option: str, value: str) -> None:
67
+ self.profile.sections[self.section][option] = value
68
+
69
+
70
+ class Profile:
71
+ """A rendered profile's values, computed when asked for, as buildout's are."""
72
+
73
+ def __init__(self, text: str, overrides: dict[tuple[str, str], str]):
74
+ self.sections = parse(text)
75
+ self.overrides = overrides
76
+ self.written: set[str] = set()
77
+ self.buildout = {"slap-connection": {"partition-id": PARTITION}}
78
+
79
+ def get(self, section: str, option: str) -> str:
80
+ if (section, option) in self.overrides:
81
+ return self.overrides[section, option]
82
+ options = self.sections.get(section, {})
83
+ if option not in options and "init" in options:
84
+ exec(
85
+ textwrap.dedent(options["init"]),
86
+ {"options": Options(self, section), "self": self},
87
+ ) # ken: S102 intentional, the profile's own recipe code, as buildout runs it
88
+ options.pop("init")
89
+ if (
90
+ option not in options
91
+ and options.get("recipe") == "slapos.cookbook:generate.password"
92
+ ):
93
+ options[option] = secrets.token_urlsafe(24)
94
+ if option not in options and "<" in options:
95
+ return self.get(options["<"], option)
96
+ if option not in options:
97
+ raise KeyError(f"${{{section}:{option}}}: not in the profile")
98
+ value = self.resolve(section, options[option])
99
+ if option == "output" and "inline" in options and section not in self.written:
100
+ self.written.add(section)
101
+ self.write(section, value, options)
102
+ return value
103
+
104
+ def resolve(self, section: str, value: str) -> str:
105
+ value = REFERENCE.sub(lambda m: self.get(m[1] or section, m[2]), value)
106
+ return value.replace("$${", "${")
107
+
108
+ def write(self, section: str, path: str, options: dict[str, str]) -> None:
109
+ inline = (
110
+ "" if "context" in options else self.resolve(section, options["inline"])
111
+ ) # no parameters
112
+ text = textwrap.dedent(inline).strip().replace('{{ "\\n" }}', "") + "\n"
113
+ Path(path).write_text(text)
114
+
115
+ def environment(self) -> dict[str, str]:
116
+ lines = (
117
+ textwrap
118
+ .dedent(self.get("process-base", "environment"))
119
+ .strip()
120
+ .splitlines()
121
+ )
122
+ return dict(line.split("=", 1) for line in lines)
123
+
124
+
125
+ class Run:
126
+ def __init__(self, build: Path, home: Path):
127
+ self.home = home
128
+ self.profile = Profile(
129
+ (build / "instance.cfg").read_text(),
130
+ {
131
+ ("buildout", "directory"): str(home),
132
+ ("slap-configuration", "ipv4-random"): DATABASE_HOST,
133
+ ("slap-configuration", "ipv6-random"): "::1",
134
+ ("request-frontend", "connection-secure_access"): f"https://{FRONTEND}",
135
+ ("request-frontend", "connection-domain"): FRONTEND,
136
+ ("release-key", "marker"): str(home / "var/run/release-try-start"),
137
+ },
138
+ )
139
+ self.env: dict[str, str] = {}
140
+ self.processes: dict[str, subprocess.Popen] = {}
141
+ self.postgres: list[str] | None = None
142
+
143
+ def log(self, name: str) -> Path:
144
+ return self.home / f"{name}.log"
145
+
146
+ def start(self, name: str, command: str) -> None:
147
+ with self.log(name).open("w") as log:
148
+ self.processes[name] = subprocess.Popen(
149
+ shlex.split(command),
150
+ env=self.env,
151
+ cwd=self.home,
152
+ stdin=subprocess.DEVNULL,
153
+ stdout=log,
154
+ stderr=subprocess.STDOUT,
155
+ start_new_session=True,
156
+ )
157
+
158
+ def prepare(self) -> None:
159
+ sections = self.profile.sections
160
+ for option in sections["directory"]:
161
+ if option != "recipe":
162
+ Path(self.profile.get("directory", option)).mkdir(
163
+ parents=True, exist_ok=True
164
+ )
165
+ if "workdir" in sections:
166
+ location = self.profile.get("workdir", "location")
167
+ code = textwrap.dedent(sections["workdir"]["install"])
168
+ exec(
169
+ code,
170
+ {"options": Options(self.profile, "workdir"), "location": location},
171
+ ) # ken: S102 intentional, as above
172
+ self.env = self.profile.environment()
173
+ if "postgresql" in sections:
174
+ self.start_postgres()
175
+ if "mariadb" in sections:
176
+ self.start("mariadb", self.profile.get("mariadb", "command-line"))
177
+ if "haproxy" in sections:
178
+ self.start("haproxy", self.profile.get("haproxy", "command-line"))
179
+
180
+ def start_postgres(self) -> None:
181
+ def get(option: str) -> str:
182
+ return self.profile.get("postgresql", option)
183
+
184
+ def tool(program: str) -> str:
185
+ return str(Path(get("bin")) / program)
186
+
187
+ pgdata, user, name, port = (
188
+ get("pgdata-directory"),
189
+ get("superuser"),
190
+ get("dbname"),
191
+ get("port"),
192
+ )
193
+ password = self.home / "postgres-password"
194
+ password.write_text(get("password"))
195
+ subprocess.run(
196
+ [
197
+ tool("initdb"),
198
+ "-D",
199
+ pgdata,
200
+ "-U",
201
+ user,
202
+ "-A",
203
+ "md5",
204
+ f"--pwfile={password}",
205
+ "-E",
206
+ "UTF8",
207
+ ],
208
+ check=True,
209
+ stdout=subprocess.DEVNULL,
210
+ )
211
+ options = f"-p {port} -k {self.home} -c listen_addresses={DATABASE_HOST}"
212
+ self.postgres = [tool("pg_ctl"), "-D", pgdata, "-m", "fast", "stop"]
213
+ subprocess.run(
214
+ [
215
+ tool("pg_ctl"),
216
+ "-D",
217
+ pgdata,
218
+ "-w",
219
+ "-l",
220
+ str(self.log("postgres")),
221
+ "-o",
222
+ options,
223
+ "start",
224
+ ],
225
+ check=True,
226
+ stdout=subprocess.DEVNULL,
227
+ )
228
+ subprocess.run(
229
+ [tool("createdb"), "-h", DATABASE_HOST, "-p", port, "-U", user, name],
230
+ check=True,
231
+ env={**os.environ, "PGPASSWORD": get("password")},
232
+ )
233
+
234
+ def url(self) -> str:
235
+ if "backend-health-promise" in self.profile.sections:
236
+ return self.profile.get("backend-health-promise", "config-url")
237
+ return self.profile.get("app-parameters", "url")
238
+
239
+ def status(self, url: str) -> int | str:
240
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
241
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
242
+ return None
243
+
244
+ try:
245
+ return urllib.request.build_opener(NoRedirect).open(url, timeout=5).status
246
+ except urllib.error.HTTPError as e:
247
+ return e.code
248
+ except OSError as e:
249
+ return str(e)
250
+
251
+ def wait(self, timeout: int) -> str | None:
252
+ """Why it failed, or None when the app answers and all that runs once has run."""
253
+ url, last = self.url(), None
254
+ print(f"waiting for {url}", flush=True)
255
+ deadline = time.monotonic() + timeout
256
+ while time.monotonic() < deadline:
257
+ done = {name: p.poll() for name, p in self.processes.items()}
258
+ for name, code in done.items():
259
+ if code not in (None, 0) or (
260
+ code == 0 and name not in ("release", "after-start")
261
+ ):
262
+ return f"{name} exited with {code}"
263
+ last = self.status(url)
264
+ answers = isinstance(last, int) and last < 400
265
+ if (
266
+ answers
267
+ and done.get("release", 0) == 0
268
+ and done.get("after-start", 0) == 0
269
+ ):
270
+ print(f"ok {url}: {last}")
271
+ return None
272
+ time.sleep(2)
273
+ pending = [
274
+ name
275
+ for name in ("release", "after-start")
276
+ if self.processes.get(name) and done[name] is None
277
+ ]
278
+ return f"after {timeout} s: {url} says {last}" + (
279
+ f", still running: {', '.join(pending)}" if pending else ""
280
+ )
281
+
282
+ def stop(self) -> None:
283
+ for process in self.processes.values():
284
+ if process.poll() is None:
285
+ os.killpg(process.pid, signal.SIGTERM)
286
+ for process in self.processes.values():
287
+ try:
288
+ process.wait(10)
289
+ except subprocess.TimeoutExpired:
290
+ os.killpg(process.pid, signal.SIGKILL)
291
+ if self.postgres:
292
+ subprocess.run(self.postgres, stdout=subprocess.DEVNULL, check=False)
293
+
294
+ def run(self, timeout: int) -> int:
295
+ self.prepare()
296
+ names = [
297
+ s[len("process-") : -len("-script")]
298
+ for s in self.profile.sections
299
+ if s.startswith("process-") and s.endswith("-script")
300
+ ]
301
+ print("processes:", " ".join(names), flush=True)
302
+ for name in names:
303
+ self.start(name, self.profile.get(f"process-{name}", "command-line"))
304
+ failure = self.wait(timeout)
305
+ if failure is None:
306
+ return 0
307
+ print(f"FAILED: {failure}")
308
+ for name in self.processes:
309
+ print(f"--- {name}")
310
+ print(
311
+ "\n".join(self.log(name).read_text(errors="replace").splitlines()[-25:])
312
+ )
313
+ return 1
314
+
315
+
316
+ def main(software: str, timeout: str) -> int:
317
+ home = Path(tempfile.mkdtemp(prefix=f"try-{software}-", dir=BUILDS))
318
+ run = Run(BUILDS / software, home)
319
+ try:
320
+ return run.run(int(timeout))
321
+ finally:
322
+ run.stop()
323
+ shutil.rmtree(home, ignore_errors=True)
324
+
325
+
326
+ if __name__ == "__main__":
327
+ sys.exit(main(*sys.argv[1:]))
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: hox-cli
3
+ Version: 0.1.0
4
+ Summary: Drive our SlapOS work on Rapid.Space (master, project, compute node) from the terminal
5
+ Requires-Dist: cyclopts>=5.3
6
+ Requires-Dist: httpx2>=2.16
7
+ Requires-Dist: packaging>=24
8
+ Requires-Python: >=3.12
@@ -0,0 +1,17 @@
1
+ hox_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ hox_cli/cli.py,sha256=IAR8L4LR3HlRgFoT-9V7er3-Q7g1zuamI9Ff9MTgzLY,26261
3
+ hox_cli/config.py,sha256=7KfXJx_eMj5VHByk3JGLWIoYDvlVRawQvuBkYIuU5os,6576
4
+ hox_cli/deploy.py,sha256=4qAXE8sz5limjeL73jq_khEkoNr764XbAZQrCwGJHnw,3191
5
+ hox_cli/dev.py,sha256=EoExY2p_jWCpMcjZkZrCxJKoQxYmaKjuJyP_hGm-QFg,5696
6
+ hox_cli/lock.py,sha256=j2kNF41h5gD9EhbPIAYNOdJwvdhjNnbOPcOy_3Gj44U,6900
7
+ hox_cli/master.py,sha256=3lqlLg8hctqvGJJq9qTbtUxegb5nZf9KYPki_yw-6Rg,13370
8
+ hox_cli/node.py,sha256=_1il6Zuy2rqI60888rgR116Do9iDtXu1RlpgAQDMBM4,10451
9
+ hox_cli/panel.py,sha256=og7uOnqkQ1x-E38VlaWG80ixgwcsyfrX7SYGATsRk0w,10221
10
+ hox_cli/port.py,sha256=7l5R3ZnBfzCjx7WfNPNqz7cSiPkURpWQH8Fjlg2iBIU,26028
11
+ hox_cli/report.py,sha256=1CYwYe_JwWZKWC7Xb6fVTiAL0X7C2xopHm3kzqp2pbE,8111
12
+ hox_cli/status.py,sha256=OlCk6g0hEgMR47dg6Pzqxh2seZIAaZu7ItAo_YlSSM0,12428
13
+ hox_cli/try_start.py,sha256=9q1tFJd2sucSOAJtth8_Omf8uizLQiayyoPEvXqmmkw,12036
14
+ hox_cli-0.1.0.dist-info/WHEEL,sha256=1dD_GoEqEIxQ7VnrUExgdj_eExY4b4UgfR0iBlGKVac,80
15
+ hox_cli-0.1.0.dist-info/entry_points.txt,sha256=7oyapiyK4ZplXQ2dcmZhlf2IRQ38NEeVaemEuyVn0Jk,41
16
+ hox_cli-0.1.0.dist-info/METADATA,sha256=gGISN2xkUAH3ly6m4MP1vcprY6pWwQx7kCxs4wdZ0vw,257
17
+ hox_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ hox = hox_cli.cli:run
3
+