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/cli.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""The `hox` command."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
from cyclopts import App, Parameter
|
|
14
|
+
|
|
15
|
+
from . import config as config_module
|
|
16
|
+
from . import deploy as deploy_module
|
|
17
|
+
from . import dev, lock, panel
|
|
18
|
+
from . import port as port_module
|
|
19
|
+
from . import report as report_module
|
|
20
|
+
from . import status as status_module
|
|
21
|
+
from . import try_start as try_start_module
|
|
22
|
+
from .config import Config, ConfigError, Software
|
|
23
|
+
from .dev import DevError
|
|
24
|
+
from .lock import LockError
|
|
25
|
+
from .master import (
|
|
26
|
+
Api,
|
|
27
|
+
ComputeNode,
|
|
28
|
+
MasterError,
|
|
29
|
+
Request,
|
|
30
|
+
Slap,
|
|
31
|
+
find_node,
|
|
32
|
+
next_name,
|
|
33
|
+
stored_request,
|
|
34
|
+
)
|
|
35
|
+
from .node import (
|
|
36
|
+
INSTANCE_LOG,
|
|
37
|
+
SLAPGRID_PROMISE_FAIL,
|
|
38
|
+
SOFTWARE_LOG,
|
|
39
|
+
Node,
|
|
40
|
+
NodeError,
|
|
41
|
+
)
|
|
42
|
+
from .port import PortError
|
|
43
|
+
|
|
44
|
+
app = App(name="hox", help="Drive our SlapOS work on Rapid.Space from the terminal.")
|
|
45
|
+
api_app = App(name="api", help="Raw, read-only access to the master's API.")
|
|
46
|
+
app.command(api_app)
|
|
47
|
+
|
|
48
|
+
_config_path: Path | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _config() -> Config:
|
|
52
|
+
return config_module.load(_config_path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _api(config: Config) -> Api:
|
|
56
|
+
return Api.connect(config.master)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _node(config: Config, api: Api) -> ComputeNode:
|
|
60
|
+
return find_node(api, config.project.reference, config.node.title)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_json(data) -> None:
|
|
64
|
+
print(json.dumps(data, indent=1, default=str))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@api_app.command
|
|
68
|
+
def get(relative_url: str, *, view: str | None = None):
|
|
69
|
+
"""Print a document (ERP5 traverse), e.g. instance_tree_module/20261029.012-427166F0.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
relative_url
|
|
74
|
+
The document's relative URL.
|
|
75
|
+
view
|
|
76
|
+
A view name from the document's links (view, slapos_panel_view, …).
|
|
77
|
+
"""
|
|
78
|
+
_print_json(_api(_config()).get(relative_url, view))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@api_app.command
|
|
82
|
+
def search(
|
|
83
|
+
query: str, *, select: tuple[str, ...] = ("title", "relative_url"), limit: int = 100
|
|
84
|
+
):
|
|
85
|
+
"""Print catalog search results, e.g. 'portal_type:"Instance Tree" AND title:="mynij-proxy-3"'.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
query
|
|
90
|
+
An ERP5 catalog query.
|
|
91
|
+
select
|
|
92
|
+
Columns to return (repeat the option for several).
|
|
93
|
+
limit
|
|
94
|
+
Maximum number of rows.
|
|
95
|
+
"""
|
|
96
|
+
_print_json(_api(_config()).search(query, list(select), limit))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command
|
|
100
|
+
def versions(lock_file: Path = Path("uv.lock"), *, python: str = "3.12"):
|
|
101
|
+
"""Print a buildout [versions] section from a project's uv.lock.
|
|
102
|
+
|
|
103
|
+
The root project's run-time dependencies (no dev groups), pinned as uv
|
|
104
|
+
resolved them, with the packages buildout must build from source, or can't
|
|
105
|
+
install, listed in comments. Needs no hox.toml.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
lock_file
|
|
110
|
+
The project's uv.lock.
|
|
111
|
+
python
|
|
112
|
+
The release's Python, for the dependencies' markers.
|
|
113
|
+
"""
|
|
114
|
+
print(lock.versions_cfg(lock_file, python), end="")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command
|
|
118
|
+
def port(
|
|
119
|
+
recipe: Path,
|
|
120
|
+
slapos: Path,
|
|
121
|
+
*,
|
|
122
|
+
python: str = "3.13",
|
|
123
|
+
source: Path | None = None,
|
|
124
|
+
venv: bool = False,
|
|
125
|
+
):
|
|
126
|
+
"""Write a SlapOS software release from a Hop3 recipe, on the hop3 stack.
|
|
127
|
+
|
|
128
|
+
The release goes to <slapos>/software/<app id>/: a software.cfg, a
|
|
129
|
+
versions.cfg from the recipe's requirements.txt (with PyPI's wheels), and
|
|
130
|
+
the recipe's files in hop3/. What couldn't be mapped is listed. Needs no
|
|
131
|
+
hox.toml.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
recipe
|
|
136
|
+
The recipe's directory, with its hop3.toml (a catalog app's directory).
|
|
137
|
+
slapos
|
|
138
|
+
A checkout of slapos_abilian_edge with stack/hop3/.
|
|
139
|
+
python
|
|
140
|
+
The release's Python.
|
|
141
|
+
source
|
|
142
|
+
For an app installed from its source (no requirements.txt): an unpacked
|
|
143
|
+
copy of the release its recipe fetches. Its dependencies are resolved with uv.
|
|
144
|
+
venv
|
|
145
|
+
Install the app with pip in a venv of its own, compiled packages from their
|
|
146
|
+
sdists, in place of buildout's eggs: goes to <slapos>/software/<app id>-venv/,
|
|
147
|
+
with a requirements.txt in place of the versions.cfg.
|
|
148
|
+
"""
|
|
149
|
+
if not (slapos / "stack" / "hop3").is_dir():
|
|
150
|
+
raise PortError(f"{slapos}: no stack/hop3/ (branch hop3-experiment?)")
|
|
151
|
+
release = port_module.port(recipe, python, source=source, venv=venv)
|
|
152
|
+
out = slapos / "software" / (f"{release.name}-venv" if venv else release.name)
|
|
153
|
+
port_module.write(release, out)
|
|
154
|
+
pins = "requirements.txt, " if venv else "versions.cfg, " if release.pins else ""
|
|
155
|
+
print(f"{out}: software.cfg, {pins}hop3/ ({len(release.files)} files)")
|
|
156
|
+
for note in release.notes:
|
|
157
|
+
print(f" {note}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command
|
|
161
|
+
def build_report(software: str | None = None):
|
|
162
|
+
"""How long the software's builds took on the node, and the disk they use, as Markdown.
|
|
163
|
+
|
|
164
|
+
From the node's software logs (14 days are kept): each build run of each
|
|
165
|
+
of its release URLs, the longest parts, then the release and shared part
|
|
166
|
+
sizes.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
software
|
|
171
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
172
|
+
"""
|
|
173
|
+
config = _config()
|
|
174
|
+
sw = config.software_named(software)
|
|
175
|
+
runs, usage = report_module.gather(Node(config.node.ssh))
|
|
176
|
+
print(report_module.markdown(config, sw, runs, usage))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _overview(config: Config) -> status_module.Overview:
|
|
180
|
+
api = _api(config)
|
|
181
|
+
return status_module.overview(
|
|
182
|
+
config, api, Node(config.node.ssh), _node(config, api).reference
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command
|
|
187
|
+
def instances(
|
|
188
|
+
*,
|
|
189
|
+
long: Annotated[bool, Parameter(name=["--long", "-l"])] = False,
|
|
190
|
+
json_: Annotated[bool, Parameter(name="--json")] = False,
|
|
191
|
+
):
|
|
192
|
+
"""List the project's instances, and whether their services run on the node.
|
|
193
|
+
|
|
194
|
+
`up` means their processes run: `hox check` tests that the app answers.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
long
|
|
199
|
+
Add each one's software in hox.toml, release and partition.
|
|
200
|
+
json_
|
|
201
|
+
Print the list as JSON, with everything.
|
|
202
|
+
"""
|
|
203
|
+
config = _config()
|
|
204
|
+
result = _overview(config)
|
|
205
|
+
if json_:
|
|
206
|
+
_print_json([dataclasses.asdict(row) for row in result.instances])
|
|
207
|
+
else:
|
|
208
|
+
print(format_instances(result, long=long))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command
|
|
212
|
+
def status(
|
|
213
|
+
software: str | None = None,
|
|
214
|
+
*,
|
|
215
|
+
json_: Annotated[bool, Parameter(name="--json")] = False,
|
|
216
|
+
):
|
|
217
|
+
"""The whole system, or one software in detail.
|
|
218
|
+
|
|
219
|
+
Without a name: the node's uptime, partitions, memory and disk, the builds that
|
|
220
|
+
aren't complete, the failing promises, the services down, the instances. With one:
|
|
221
|
+
its instance, services, frontends, builds on the node, promises and what GitHub serves.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
software
|
|
226
|
+
A software of hox.toml or an instance's name, to see it in detail.
|
|
227
|
+
json_
|
|
228
|
+
Print the report as JSON.
|
|
229
|
+
"""
|
|
230
|
+
config = _config()
|
|
231
|
+
if software is None:
|
|
232
|
+
result = _overview(config)
|
|
233
|
+
if json_:
|
|
234
|
+
_print_json(dataclasses.asdict(result))
|
|
235
|
+
else:
|
|
236
|
+
print(format_overview(result, config))
|
|
237
|
+
return
|
|
238
|
+
sw = config.software_named(software)
|
|
239
|
+
with httpx2.Client(timeout=30) as http:
|
|
240
|
+
api = _api(config)
|
|
241
|
+
report = status_module.gather(
|
|
242
|
+
config, sw, api, Node(config.node.ssh), http, _node(config, api).reference
|
|
243
|
+
)
|
|
244
|
+
if json_:
|
|
245
|
+
_print_json(dataclasses.asdict(report))
|
|
246
|
+
else:
|
|
247
|
+
print(format_report(report, config))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def format_instances(o: status_module.Overview, *, long: bool = False) -> str:
|
|
251
|
+
def row(r: status_module.Deployed) -> tuple[str, ...]:
|
|
252
|
+
extra = (r.software or "-", r.release, r.partition or "-") if long else ()
|
|
253
|
+
memory = "-" if r.memory_mb is None else str(r.memory_mb)
|
|
254
|
+
return (r.instance, *extra, r.status, memory, r.frontend or "-")
|
|
255
|
+
|
|
256
|
+
header = (
|
|
257
|
+
"INSTANCE",
|
|
258
|
+
*(("SOFTWARE", "RELEASE", "PARTITION") if long else ()),
|
|
259
|
+
"STATUS",
|
|
260
|
+
"MB",
|
|
261
|
+
"FRONTEND",
|
|
262
|
+
)
|
|
263
|
+
rows = [header, *map(row, o.instances)]
|
|
264
|
+
widths = [max(map(len, column)) for column in zip(*rows)]
|
|
265
|
+
lines = [
|
|
266
|
+
" ".join(cell.ljust(width) for cell, width in zip(r, widths)).rstrip()
|
|
267
|
+
for r in rows
|
|
268
|
+
]
|
|
269
|
+
if o.node_error:
|
|
270
|
+
lines.insert(0, f"node unreachable, so what runs is unknown: {o.node_error}\n")
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _duration(seconds: int) -> str:
|
|
275
|
+
days, hours, minutes = seconds // 86400, seconds // 3600 % 24, seconds // 60 % 60
|
|
276
|
+
return f"{days} d {hours} h" if days else f"{hours} h {minutes} min"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def format_overview(o: status_module.Overview, config: Config) -> str:
|
|
280
|
+
out = []
|
|
281
|
+
if s := o.system:
|
|
282
|
+
used = {r.partition for r in o.instances}
|
|
283
|
+
free = [p.name for p in s.partitions if p.name not in used]
|
|
284
|
+
out.append(
|
|
285
|
+
f"node {config.node.title}: up {_duration(s.uptime_seconds)}"
|
|
286
|
+
)
|
|
287
|
+
out.append(
|
|
288
|
+
f"partitions {len(s.partitions) - len(free)} of {len(s.partitions)} in use"
|
|
289
|
+
+ (f", free: {', '.join(free)}" if free else "")
|
|
290
|
+
)
|
|
291
|
+
out.append(
|
|
292
|
+
f"memory {s.memory_available_mb} MB available of {s.memory_total_mb} MB"
|
|
293
|
+
)
|
|
294
|
+
out.append(
|
|
295
|
+
f"disk {s.disk_free_mb // 1024} GB free of {s.disk_total_mb // 1024} GB"
|
|
296
|
+
)
|
|
297
|
+
if f := o.facts:
|
|
298
|
+
built = sum(b.state == "completed" for b in f.software)
|
|
299
|
+
out.append(f"builds {built} of {len(f.software)} complete")
|
|
300
|
+
for b in f.software:
|
|
301
|
+
if b.state != "completed":
|
|
302
|
+
out.append(
|
|
303
|
+
f"build {config.url_name(b.url)}: {b.state}"
|
|
304
|
+
+ (f" (last error: {b.last_error})" if b.last_error else "")
|
|
305
|
+
)
|
|
306
|
+
# Their output is for `hox status NAME`.
|
|
307
|
+
out += [
|
|
308
|
+
f"promise {re.sub(r"Promise '([^']+)' failed.*", r'\1', p)}"
|
|
309
|
+
for p in f.failing_promises
|
|
310
|
+
]
|
|
311
|
+
if not f.failing_promises:
|
|
312
|
+
out.append("promises all OK in the last instance run")
|
|
313
|
+
# All of a partition's services are down while the node boots: its status says enough.
|
|
314
|
+
out += [
|
|
315
|
+
f"down {r.instance}: {', '.join(r.down)}"
|
|
316
|
+
for r in o.instances
|
|
317
|
+
if r.down and r.status != "not running"
|
|
318
|
+
]
|
|
319
|
+
if o.undeployed:
|
|
320
|
+
out.append(f"not deployed {', '.join(o.undeployed)}")
|
|
321
|
+
return "\n".join([*out, "", format_instances(o)] if out else [format_instances(o)])
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def format_report(r: status_module.Report, config: Config) -> str:
|
|
325
|
+
out = []
|
|
326
|
+
i = r.instance
|
|
327
|
+
if i is None:
|
|
328
|
+
out.append("instance none (`hox request` to create it)")
|
|
329
|
+
else:
|
|
330
|
+
out.append(
|
|
331
|
+
f"instance {i.title}: {i.state}, {config.url_name(i.url)}, type {i.software_type}"
|
|
332
|
+
)
|
|
333
|
+
out.append(f"partition {i.partition or 'not allocated'}")
|
|
334
|
+
if s := r.services:
|
|
335
|
+
out.append(
|
|
336
|
+
f"services {s.running} running, {s.memory_mb} MB"
|
|
337
|
+
+ (f", down: {', '.join(s.down)}" if s.down else "")
|
|
338
|
+
)
|
|
339
|
+
out += [f"diagnosis {d}" for d in r.diagnosis]
|
|
340
|
+
if i.news:
|
|
341
|
+
out.append(f"news {i.news}")
|
|
342
|
+
for key, value in sorted(i.connection.items()):
|
|
343
|
+
out.append(f"connection {key} = {value}")
|
|
344
|
+
for f in i.frontends:
|
|
345
|
+
out.append(
|
|
346
|
+
f"frontend {f.title}: {'allocated' if f.allocated else 'not allocated'}"
|
|
347
|
+
)
|
|
348
|
+
states = {s.url: s for s in (r.node.software if r.node else [])}
|
|
349
|
+
for inst in r.installations:
|
|
350
|
+
node_state = states.get(inst.url)
|
|
351
|
+
line = f"software {config.url_name(inst.url)}: master {inst.state}"
|
|
352
|
+
if node_state:
|
|
353
|
+
line += f", node {node_state.state}"
|
|
354
|
+
if node_state.last_error:
|
|
355
|
+
line += f" (last error: {node_state.last_error})"
|
|
356
|
+
out.append(line)
|
|
357
|
+
if r.node_error:
|
|
358
|
+
out.append(f"node unreachable: {r.node_error}")
|
|
359
|
+
elif r.node is not None:
|
|
360
|
+
if r.node.failing_promises:
|
|
361
|
+
out += [f"promise {p}" for p in r.node.failing_promises]
|
|
362
|
+
else:
|
|
363
|
+
out.append("promises all OK in the last instance run")
|
|
364
|
+
if r.github:
|
|
365
|
+
g = r.github
|
|
366
|
+
served = {
|
|
367
|
+
True: "serves it",
|
|
368
|
+
False: "serves an older file (cache: up to 5 min)",
|
|
369
|
+
None: "could not be fetched",
|
|
370
|
+
}
|
|
371
|
+
out.append(
|
|
372
|
+
f"github local head {g.local_head[:9]}, {'pushed' if g.pushed else 'NOT pushed'},"
|
|
373
|
+
f" raw.githubusercontent.com {served[g.served]}"
|
|
374
|
+
)
|
|
375
|
+
return "\n".join(out)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@app.command
|
|
379
|
+
def check(software: str | None = None) -> int:
|
|
380
|
+
"""Run the software's checks (hox.toml) with curl on the node.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
software
|
|
385
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
386
|
+
"""
|
|
387
|
+
config = _config()
|
|
388
|
+
sw = config.software_named(software)
|
|
389
|
+
inst = status_module.require_instance(_api(config), sw)
|
|
390
|
+
return _report_checks(
|
|
391
|
+
dev.run_checks(sw.checks, inst.connection, Node(config.node.ssh))
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _report_checks(results: list[dev.CheckResult]) -> int:
|
|
396
|
+
for r in results:
|
|
397
|
+
print(
|
|
398
|
+
f"{'ok ' if r.ok else 'FAIL'} {r.name}: {r.detail}"
|
|
399
|
+
+ (f" ({r.url})" if r.url else "")
|
|
400
|
+
)
|
|
401
|
+
return 0 if all(r.ok for r in results) else 1
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _require_published(sw: Software, *, wait: bool = True) -> None:
|
|
405
|
+
"""Refuse an unpushed branch: the node would get a 404, or an older release."""
|
|
406
|
+
head = dev.require_pushed(sw)
|
|
407
|
+
print(f"{sw.branch} is pushed at {head[:9]}")
|
|
408
|
+
if wait:
|
|
409
|
+
with httpx2.Client(timeout=30) as http:
|
|
410
|
+
dev.wait_for_github(sw, head, http)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@app.command
|
|
414
|
+
def rebuild(
|
|
415
|
+
software: str | None = None, *, software_only: bool = False, no_wait: bool = False
|
|
416
|
+
) -> int:
|
|
417
|
+
"""The dev loop: rebuild the branch URL in place on the node, redeploy, check.
|
|
418
|
+
|
|
419
|
+
Refuses if the local branch isn't pushed. Waits until raw.githubusercontent.com
|
|
420
|
+
serves the pushed files (its cache keeps them up to 5 minutes).
|
|
421
|
+
|
|
422
|
+
Parameters
|
|
423
|
+
----------
|
|
424
|
+
software
|
|
425
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
426
|
+
software_only
|
|
427
|
+
Rebuild the software release only: no instance run, no checks.
|
|
428
|
+
no_wait
|
|
429
|
+
Don't wait for raw.githubusercontent.com to serve the pushed files.
|
|
430
|
+
"""
|
|
431
|
+
config = _config()
|
|
432
|
+
sw = config.software_named(software)
|
|
433
|
+
_require_published(sw, wait=not no_wait)
|
|
434
|
+
node = Node(config.node.ssh)
|
|
435
|
+
node.slapgrid(f"software --force --only-sr {sw.branch_url}")
|
|
436
|
+
if software_only:
|
|
437
|
+
return 0
|
|
438
|
+
inst = status_module.require_instance(_api(config), sw)
|
|
439
|
+
if inst.partition is None:
|
|
440
|
+
raise DevError(f"{sw.instance} is not allocated yet: `hox status` says why")
|
|
441
|
+
# Only our partition: another one's failure (a release still building) isn't ours.
|
|
442
|
+
if (
|
|
443
|
+
node.slapgrid(f"instance --force --only-cp {inst.partition}")
|
|
444
|
+
== SLAPGRID_PROMISE_FAIL
|
|
445
|
+
):
|
|
446
|
+
print("some promises fail (see above)")
|
|
447
|
+
inst = status_module.require_instance(
|
|
448
|
+
_api(config), sw
|
|
449
|
+
) # connection parameters, as published by this run
|
|
450
|
+
return _report_checks(dev.run_checks(sw.checks, inst.connection, node))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.command
|
|
454
|
+
def try_start(software: str, *, timeout: int = 300) -> int:
|
|
455
|
+
"""Start a test build's real processes on the node, and see whether they come up.
|
|
456
|
+
|
|
457
|
+
What `/srv/hox-dev/run-build.sh` built: the release's own scripts, in a temporary
|
|
458
|
+
directory, with its haproxy and a PostgreSQL of their own. Stops at the first
|
|
459
|
+
failure, with the logs. No frontend, nothing asked of the master.
|
|
460
|
+
|
|
461
|
+
Parameters
|
|
462
|
+
----------
|
|
463
|
+
software
|
|
464
|
+
The directory of the build in /srv/hox-dev: a release's name.
|
|
465
|
+
timeout
|
|
466
|
+
Seconds to wait for the app to answer.
|
|
467
|
+
"""
|
|
468
|
+
source = Path(try_start_module.__file__).read_bytes()
|
|
469
|
+
python = f"cd {try_start_module.BUILDS} && /opt/slapos/parts/python3/bin/python3 -u - {shlex.quote(software)} {timeout}"
|
|
470
|
+
command = f"su -s /bin/sh slapsoft -c {shlex.quote(python)}"
|
|
471
|
+
return subprocess.run(
|
|
472
|
+
[*_config().node.ssh, command], input=source, check=False
|
|
473
|
+
).returncode
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command
|
|
477
|
+
def logs(
|
|
478
|
+
kind: Literal["software", "instance", "app"],
|
|
479
|
+
*,
|
|
480
|
+
software: str | None = None,
|
|
481
|
+
follow: Annotated[bool, Parameter(name=["--follow", "-f"])] = False,
|
|
482
|
+
lines: Annotated[int, Parameter(name=["--lines", "-n"])] = 50,
|
|
483
|
+
) -> int:
|
|
484
|
+
"""Tail a log on the node: slapgrid's software or instance log, or the app's service logs.
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
kind
|
|
489
|
+
software, instance, or app (the instance's service logs).
|
|
490
|
+
software
|
|
491
|
+
A [software.<name>] of hox.toml, for app logs; optional when there is only one.
|
|
492
|
+
follow
|
|
493
|
+
Keep following (Ctrl-C to stop).
|
|
494
|
+
lines
|
|
495
|
+
Number of lines to show.
|
|
496
|
+
"""
|
|
497
|
+
config = _config()
|
|
498
|
+
if kind == "app":
|
|
499
|
+
sw = config.software_named(software)
|
|
500
|
+
partition = status_module.require_instance(_api(config), sw).partition
|
|
501
|
+
if partition is None:
|
|
502
|
+
raise DevError(f"{sw.instance} has no partition yet")
|
|
503
|
+
target = f"/srv/slapgrid/{partition}/.{partition}_*.log"
|
|
504
|
+
else:
|
|
505
|
+
target = SOFTWARE_LOG if kind == "software" else INSTANCE_LOG
|
|
506
|
+
return Node(config.node.ssh).interactive(
|
|
507
|
+
f"tail -n {lines}{' -F' if follow else ''} {target}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _url(sw: Software, pinned: str | None) -> str:
|
|
512
|
+
return sw.pinned_url(dev.pushed_commit(sw, pinned)) if pinned else sw.branch_url
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _confirm(question: str, yes: bool) -> None:
|
|
516
|
+
if not yes and input(f"{question} [y/N] ").strip().lower() not in ("y", "yes"):
|
|
517
|
+
raise DevError("cancelled")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@app.command
|
|
521
|
+
def supply(
|
|
522
|
+
software: str | None = None, *, pinned: str | None = None, remove: bool = False
|
|
523
|
+
):
|
|
524
|
+
"""Supply the software release on our node (or remove it).
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
software
|
|
529
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
530
|
+
pinned
|
|
531
|
+
A commit: use its pinned URL instead of the branch URL.
|
|
532
|
+
remove
|
|
533
|
+
Remove the release from the node instead.
|
|
534
|
+
"""
|
|
535
|
+
config = _config()
|
|
536
|
+
url = _url(config.software_named(software), pinned)
|
|
537
|
+
node = _node(config, _api(config))
|
|
538
|
+
Slap.connect(config.master).supply(
|
|
539
|
+
url, node.reference, "destroyed" if remove else "available"
|
|
540
|
+
)
|
|
541
|
+
print(f"{'removal' if remove else 'supply'} of {url} on {node.reference} requested")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@app.command
|
|
545
|
+
def request(
|
|
546
|
+
software: str | None = None,
|
|
547
|
+
*,
|
|
548
|
+
name: str | None = None,
|
|
549
|
+
pinned: str | None = None,
|
|
550
|
+
replace: bool = False,
|
|
551
|
+
yes: bool = False,
|
|
552
|
+
):
|
|
553
|
+
"""Request the instance on our node, with the branch URL or a pinned one.
|
|
554
|
+
|
|
555
|
+
Re-requesting an existing instance re-sends its original request. The master
|
|
556
|
+
refuses to change an instance's URL (HTTP 500): --replace destroys it and
|
|
557
|
+
requests the next name instead (mynij-proxy-3 -> mynij-proxy-4).
|
|
558
|
+
|
|
559
|
+
Parameters
|
|
560
|
+
----------
|
|
561
|
+
software
|
|
562
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
563
|
+
name
|
|
564
|
+
Instance name (default: `instance` in hox.toml).
|
|
565
|
+
pinned
|
|
566
|
+
A commit: use its pinned URL instead of the branch URL.
|
|
567
|
+
replace
|
|
568
|
+
If the instance runs another URL, destroy it and request the next name.
|
|
569
|
+
yes
|
|
570
|
+
Don't ask before destroying.
|
|
571
|
+
"""
|
|
572
|
+
config = _config()
|
|
573
|
+
sw = config.software_named(software)
|
|
574
|
+
url = _url(sw, pinned)
|
|
575
|
+
name = name or sw.instance
|
|
576
|
+
api, slap = _api(config), Slap.connect(config.master)
|
|
577
|
+
existing = stored_request(api, name)
|
|
578
|
+
if existing and existing.software_release != url:
|
|
579
|
+
if not replace:
|
|
580
|
+
raise DevError(
|
|
581
|
+
f"{name} runs {existing.software_release}; the master refuses a URL change: use --replace"
|
|
582
|
+
)
|
|
583
|
+
_confirm(f"Destroy {name} and request {next_name(name)}?", yes)
|
|
584
|
+
slap.request(dataclasses.replace(existing, state="destroyed"))
|
|
585
|
+
print(f"{name}: destruction requested")
|
|
586
|
+
name, existing = next_name(name), None
|
|
587
|
+
while stored_request(api, name):
|
|
588
|
+
name = next_name(name)
|
|
589
|
+
req = existing or Request(
|
|
590
|
+
url, name, sw.software_type, {"computer_guid": _node(config, api).reference}
|
|
591
|
+
)
|
|
592
|
+
if sw.parameters is not None:
|
|
593
|
+
req = dataclasses.replace(req, parameters=sw.parameters)
|
|
594
|
+
ready = slap.request(dataclasses.replace(req, state="started"))
|
|
595
|
+
print(
|
|
596
|
+
f"{name}: {'requested' if ready else 'requested, the master is provisioning it'} ({url})"
|
|
597
|
+
)
|
|
598
|
+
if name != sw.instance:
|
|
599
|
+
print(
|
|
600
|
+
f"set instance = {name!r} in [software.{sw.name}] of {config.root / config_module.FILENAME}"
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@app.command
|
|
605
|
+
def destroy(name: str, *, yes: bool = False):
|
|
606
|
+
"""Destroy an instance, by re-sending its original request with state destroyed.
|
|
607
|
+
|
|
608
|
+
Parameters
|
|
609
|
+
----------
|
|
610
|
+
name
|
|
611
|
+
The instance name.
|
|
612
|
+
yes
|
|
613
|
+
Don't ask for confirmation.
|
|
614
|
+
"""
|
|
615
|
+
config = _config()
|
|
616
|
+
existing = stored_request(_api(config), name)
|
|
617
|
+
if existing is None:
|
|
618
|
+
raise DevError(f"no instance {name!r} on the master")
|
|
619
|
+
_confirm(f"Destroy {name} ({existing.software_release})?", yes)
|
|
620
|
+
Slap.connect(config.master).request(
|
|
621
|
+
dataclasses.replace(existing, state="destroyed")
|
|
622
|
+
)
|
|
623
|
+
print(f"{name}: destruction requested")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _set_state(name: str, state: str) -> None:
|
|
627
|
+
config = _config()
|
|
628
|
+
existing = stored_request(_api(config), name)
|
|
629
|
+
if existing is None:
|
|
630
|
+
raise DevError(f"no instance {name!r} on the master")
|
|
631
|
+
if existing.state == "destroyed":
|
|
632
|
+
raise DevError(f"{name} is destroyed")
|
|
633
|
+
Slap.connect(config.master).request(dataclasses.replace(existing, state=state))
|
|
634
|
+
print(
|
|
635
|
+
f"{name}: requested state {state}; the node acts within a minute or two (hox instances)"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@app.command
|
|
640
|
+
def stop(name: str):
|
|
641
|
+
"""Stop an instance's processes and keep its data, by re-sending its original request.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
name
|
|
646
|
+
The instance name.
|
|
647
|
+
"""
|
|
648
|
+
_set_state(name, "stopped")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@app.command
|
|
652
|
+
def start(name: str):
|
|
653
|
+
"""Start a stopped instance again, by re-sending its original request.
|
|
654
|
+
|
|
655
|
+
Parameters
|
|
656
|
+
----------
|
|
657
|
+
name
|
|
658
|
+
The instance name.
|
|
659
|
+
"""
|
|
660
|
+
_set_state(name, "started")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _make_allocatable(software: str | None, pinned: str | None) -> None:
|
|
664
|
+
config = _config()
|
|
665
|
+
sw = config.software_named(software)
|
|
666
|
+
url = _url(sw, pinned)
|
|
667
|
+
api = _api(config)
|
|
668
|
+
node = _node(config, api)
|
|
669
|
+
panel.make_allocatable(api, config, node, sw, url)
|
|
670
|
+
Slap.connect(config.master).supply(url, node.reference)
|
|
671
|
+
print(f"supply of {url} on {node.reference}: requested")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@app.command
|
|
675
|
+
def setup(software: str | None = None):
|
|
676
|
+
"""One-time project setup: what we did in the panel, for the branch URL.
|
|
677
|
+
|
|
678
|
+
Software Product with the software type, branch URL as a release, Allocation
|
|
679
|
+
Supply on our node with the (release, type) cell ticked and validated, branch
|
|
680
|
+
URL supplied. Each step is skipped when already done.
|
|
681
|
+
|
|
682
|
+
Parameters
|
|
683
|
+
----------
|
|
684
|
+
software
|
|
685
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
686
|
+
"""
|
|
687
|
+
_make_allocatable(software, None)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@app.command
|
|
691
|
+
def release(commit: str, *, software: str | None = None):
|
|
692
|
+
"""Make a pinned version allocatable: `hox setup`'s steps for the commit's URL.
|
|
693
|
+
|
|
694
|
+
Doesn't move the instance: `hox request --pinned COMMIT --replace` does.
|
|
695
|
+
|
|
696
|
+
Parameters
|
|
697
|
+
----------
|
|
698
|
+
commit
|
|
699
|
+
The commit of the pinned URL (pushed: the node fetches it from GitHub).
|
|
700
|
+
software
|
|
701
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
702
|
+
"""
|
|
703
|
+
_make_allocatable(software, commit)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@app.command
|
|
707
|
+
def deploy(software: str | None = None) -> int:
|
|
708
|
+
"""From nothing on the master to an instance that passes its checks, for the branch URL.
|
|
709
|
+
|
|
710
|
+
`hox setup`, `hox request`, then waits: for the allocation, for the node's
|
|
711
|
+
build (hours for a big release without cache), for the checks (frontends
|
|
712
|
+
take a while). Run it again after a failure: what is done is skipped.
|
|
713
|
+
Refuses if the local branch isn't pushed, and waits until GitHub serves it.
|
|
714
|
+
|
|
715
|
+
Parameters
|
|
716
|
+
----------
|
|
717
|
+
software
|
|
718
|
+
A [software.<name>] of hox.toml; optional when there is only one.
|
|
719
|
+
"""
|
|
720
|
+
_require_published(_config().software_named(software))
|
|
721
|
+
_make_allocatable(software, None)
|
|
722
|
+
request(software)
|
|
723
|
+
config = _config()
|
|
724
|
+
sw = config.software_named(software)
|
|
725
|
+
results = deploy_module.deploy(
|
|
726
|
+
_api(config), sw, sw.branch_url, Node(config.node.ssh)
|
|
727
|
+
)
|
|
728
|
+
return _report_checks(results)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@app.command
|
|
732
|
+
def node_certificate():
|
|
733
|
+
"""Print our compute node's reference, certificate and key as JSON, creating the node if needed.
|
|
734
|
+
|
|
735
|
+
The node's title is `[node] title` of hox.toml. node-deployer's deploy.py
|
|
736
|
+
configures the node with it (SLAPOS_NODE=<the JSON file>). If the node
|
|
737
|
+
exists, its certificate is revoked and a new one issued (the master gives
|
|
738
|
+
a certificate only once): a node configured with the old one loses access.
|
|
739
|
+
"""
|
|
740
|
+
config = _config()
|
|
741
|
+
_print_json(panel.node_certificate(_api(config), config))
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@app.command
|
|
745
|
+
def re6st_token():
|
|
746
|
+
"""Print the node's re6st token, for node-deployer (RE6ST_TOKEN).
|
|
747
|
+
|
|
748
|
+
It comes from a shared instance of the re6st registry ("re6st token for
|
|
749
|
+
<node title>"), so asking again gives the same token. The registry takes
|
|
750
|
+
10+ minutes to publish a new one. Only the token goes to stdout.
|
|
751
|
+
"""
|
|
752
|
+
config = _config()
|
|
753
|
+
print(
|
|
754
|
+
panel.re6st_token(
|
|
755
|
+
_api(config), Slap.connect(config.master), config, out=_stderr
|
|
756
|
+
)
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _stderr(line: str) -> None:
|
|
761
|
+
print(line, file=sys.stderr)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@app.meta.default
|
|
765
|
+
def main(
|
|
766
|
+
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
767
|
+
config: Path | None = None,
|
|
768
|
+
debug: bool = False,
|
|
769
|
+
):
|
|
770
|
+
"""
|
|
771
|
+
Parameters
|
|
772
|
+
----------
|
|
773
|
+
config
|
|
774
|
+
Path to hox.toml (default: the first one in the current directory or its parents).
|
|
775
|
+
debug
|
|
776
|
+
Show tracebacks.
|
|
777
|
+
"""
|
|
778
|
+
global _config_path
|
|
779
|
+
_config_path = config
|
|
780
|
+
try:
|
|
781
|
+
code = app(tokens)
|
|
782
|
+
except (ConfigError, DevError, LockError, MasterError, NodeError, PortError) as e:
|
|
783
|
+
if debug:
|
|
784
|
+
raise
|
|
785
|
+
print(f"hox: {e}", file=sys.stderr)
|
|
786
|
+
sys.exit(1)
|
|
787
|
+
sys.exit(code or 0)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def run():
|
|
791
|
+
app.meta()
|