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/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()