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/__init__.py +0 -0
- hox_cli/cli.py +791 -0
- hox_cli/config.py +205 -0
- hox_cli/deploy.py +95 -0
- hox_cli/dev.py +162 -0
- hox_cli/lock.py +198 -0
- hox_cli/master.py +371 -0
- hox_cli/node.py +302 -0
- hox_cli/panel.py +275 -0
- hox_cli/port.py +705 -0
- hox_cli/report.py +208 -0
- hox_cli/status.py +361 -0
- hox_cli/try_start.py +327 -0
- hox_cli-0.1.0.dist-info/METADATA +8 -0
- hox_cli-0.1.0.dist-info/RECORD +17 -0
- hox_cli-0.1.0.dist-info/WHEEL +4 -0
- hox_cli-0.1.0.dist-info/entry_points.txt +3 -0
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,,
|