nomadctl 0.2.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.
- nd/__init__.py +7 -0
- nd/binary/__init__.py +10 -0
- nd/binary/env.py +43 -0
- nd/binary/runner.py +192 -0
- nd/cli.py +97 -0
- nd/commands/__init__.py +1 -0
- nd/commands/_common.py +101 -0
- nd/commands/clean.py +50 -0
- nd/commands/exec.py +67 -0
- nd/commands/list.py +120 -0
- nd/commands/logs.py +76 -0
- nd/commands/plan.py +103 -0
- nd/commands/run.py +372 -0
- nd/commands/status/__init__.py +29 -0
- nd/commands/status/command.py +102 -0
- nd/commands/status/render.py +172 -0
- nd/commands/status/report.py +339 -0
- nd/commands/stop.py +412 -0
- nd/commands/volume/__init__.py +25 -0
- nd/commands/volume/command.py +216 -0
- nd/commands/volume/render.py +132 -0
- nd/commands/volume/report.py +146 -0
- nd/constants.py +43 -0
- nd/jobfiles.py +125 -0
- nd/nomad/__init__.py +29 -0
- nd/nomad/client.py +51 -0
- nd/nomad/config.py +156 -0
- nd/nomad/errors.py +52 -0
- nd/nomad/models/__init__.py +1 -0
- nd/nomad/models/agent.py +26 -0
- nd/nomad/models/allocation.py +37 -0
- nd/nomad/models/deployment.py +40 -0
- nd/nomad/models/evaluation.py +21 -0
- nd/nomad/models/job.py +51 -0
- nd/nomad/models/node.py +41 -0
- nd/nomad/models/volume.py +28 -0
- nd/nomad/resources/__init__.py +1 -0
- nd/nomad/resources/agent.py +25 -0
- nd/nomad/resources/allocations.py +24 -0
- nd/nomad/resources/base.py +45 -0
- nd/nomad/resources/deployments.py +28 -0
- nd/nomad/resources/evaluations.py +19 -0
- nd/nomad/resources/jobs.py +70 -0
- nd/nomad/resources/nodes.py +24 -0
- nd/nomad/resources/status.py +14 -0
- nd/nomad/resources/system.py +25 -0
- nd/nomad/resources/volumes.py +42 -0
- nd/nomad/transport.py +141 -0
- nd/targets/__init__.py +32 -0
- nd/targets/alloc_target.py +166 -0
- nd/targets/selection.py +91 -0
- nd/ui/__init__.py +1 -0
- nd/ui/alloc_rows.py +93 -0
- nd/ui/duration.py +44 -0
- nd/ui/links.py +22 -0
- nd/ui/live_panel.py +199 -0
- nd/ui/panels.py +31 -0
- nd/ui/prompts.py +46 -0
- nd/ui/styles.py +52 -0
- nd/volumefiles.py +143 -0
- nomadctl-0.2.0.dist-info/METADATA +268 -0
- nomadctl-0.2.0.dist-info/RECORD +65 -0
- nomadctl-0.2.0.dist-info/WHEEL +4 -0
- nomadctl-0.2.0.dist-info/entry_points.txt +3 -0
- nomadctl-0.2.0.dist-info/licenses/LICENSE +21 -0
nd/__init__.py
ADDED
nd/binary/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Wrappers around the local `nomad` binary, used where the HTTP API cannot serve.
|
|
2
|
+
|
|
3
|
+
`NomadBinary` is a configured handle to the binary (HCL2 compile/validate, plus
|
|
4
|
+
interactive exec and log streaming), bound to one cluster via :meth:`NomadBinary.create`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from nd.binary.env import NomadBinaryError
|
|
8
|
+
from nd.binary.runner import NomadBinary
|
|
9
|
+
|
|
10
|
+
__all__ = ["NomadBinary", "NomadBinaryError"]
|
nd/binary/env.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared discovery and environment for the local `nomad` binary.
|
|
2
|
+
|
|
3
|
+
The Nomad HTTP API cannot parse HCL2 and does not own the raw-TTY exec protocol, so
|
|
4
|
+
some operations shell out to the local `nomad` binary. `NomadBinary` (in `runner.py`)
|
|
5
|
+
uses these helpers to locate the binary and build the connection-env overlay that
|
|
6
|
+
targets the same cluster as the API client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from nclutils.sh import which
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from nd.nomad.config import NomadConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NomadBinaryError(Exception):
|
|
23
|
+
"""Raised when the `nomad` binary is missing or a `nomad` invocation fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_nomad() -> Path:
|
|
27
|
+
"""Return the path to the `nomad` binary, or raise if it is not on PATH."""
|
|
28
|
+
found = which("nomad")
|
|
29
|
+
if found is None:
|
|
30
|
+
msg = "The `nomad` binary was not found on PATH; install it to plan or run jobs."
|
|
31
|
+
raise NomadBinaryError(msg)
|
|
32
|
+
return found
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def binary_env(config: NomadConfig) -> dict[str, str]:
|
|
36
|
+
"""Build the environment for a `nomad` binary invocation.
|
|
37
|
+
|
|
38
|
+
Overlays the resolved connection settings onto the current environment so the
|
|
39
|
+
spawned binary targets the same cluster, token, and namespace as the API client
|
|
40
|
+
rather than relying on the ambient env alone (which would miss nd config-file
|
|
41
|
+
overrides). Shared by every binary wrapper so they stay consistent.
|
|
42
|
+
"""
|
|
43
|
+
return {**os.environ, **config.to_env()}
|
nd/binary/runner.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""A configured handle to the local `nomad` binary, used where the HTTP API can't serve.
|
|
2
|
+
|
|
3
|
+
The HTTP API cannot parse HCL2 and does not own the raw-TTY exec protocol, so some
|
|
4
|
+
operations shell out to the local `nomad` binary. `NomadBinary` binds the resolved
|
|
5
|
+
binary path and the connection-env overlay (which targets the same cluster as the API
|
|
6
|
+
client) to one object, so a multi-file deploy or a long exec/log session resolves the
|
|
7
|
+
binary and builds the env once rather than per call.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from nclutils import pp
|
|
17
|
+
from nclutils.sh import ShellCommandError, run_command
|
|
18
|
+
|
|
19
|
+
from nd.binary.env import NomadBinaryError, binary_env, ensure_nomad
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from nd.nomad.config import NomadConfig
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NomadBinary:
|
|
28
|
+
"""The local `nomad` CLI, bound to one cluster's connection settings.
|
|
29
|
+
|
|
30
|
+
Build it with :meth:`create`, which resolves the binary on PATH. The job-spec
|
|
31
|
+
methods (`validate`/`plan`/`compile_to_json`) act on local HCL2 files; the
|
|
32
|
+
allocation methods (`exec_shell`/`stream_logs`) act on a running task.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: NomadConfig, path: Path) -> None:
|
|
36
|
+
self._path = str(path)
|
|
37
|
+
# Build the connection-env overlay once; it is invariant for this cluster.
|
|
38
|
+
self._env = binary_env(config)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def create(cls, config: NomadConfig) -> NomadBinary:
|
|
42
|
+
"""Resolve the `nomad` binary on PATH and bind it to ``config``.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
NomadBinaryError: If the binary is not on PATH.
|
|
46
|
+
"""
|
|
47
|
+
return cls(config, ensure_nomad())
|
|
48
|
+
|
|
49
|
+
# --- job specs (HCL2 compile/validate) -------------------------------------------
|
|
50
|
+
|
|
51
|
+
def validate(self, file: Path) -> None:
|
|
52
|
+
"""Validate a job file with `nomad job validate`.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
NomadBinaryError: If validation fails or the binary cannot run.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
run_command([self._path, "job", "validate", str(file)], env=self._env)
|
|
59
|
+
except ShellCommandError as exc:
|
|
60
|
+
msg = f"`nomad job validate {file}` failed: {_stderr(exc)}"
|
|
61
|
+
raise NomadBinaryError(msg) from exc
|
|
62
|
+
|
|
63
|
+
def plan(self, file: Path) -> int:
|
|
64
|
+
"""Preview a job with `nomad job plan`, streaming its output verbatim.
|
|
65
|
+
|
|
66
|
+
Returns the binary's exit code (1 means changes are present, 0 means none).
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
NomadBinaryError: If the binary cannot be launched.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
result = run_command(
|
|
73
|
+
[self._path, "job", "plan", str(file)], env=self._env, stream=True, check=False
|
|
74
|
+
)
|
|
75
|
+
except ShellCommandError as exc:
|
|
76
|
+
msg = f"`nomad job plan {file}` could not run: {_stderr(exc)}"
|
|
77
|
+
raise NomadBinaryError(msg) from exc
|
|
78
|
+
return result.returncode
|
|
79
|
+
|
|
80
|
+
def compile_to_json(self, file: Path) -> bytes:
|
|
81
|
+
"""Compile a job file to its JSON register payload via `nomad job run -output`.
|
|
82
|
+
|
|
83
|
+
Returns the ``{"Job": {...}}`` JSON bytes without submitting anything.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
NomadBinaryError: If compilation fails.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
result = run_command([self._path, "job", "run", "-output", str(file)], env=self._env)
|
|
90
|
+
except ShellCommandError as exc:
|
|
91
|
+
msg = f"`nomad job run -output {file}` failed: {_stderr(exc)}"
|
|
92
|
+
raise NomadBinaryError(msg) from exc
|
|
93
|
+
return result.stdout.encode("utf-8")
|
|
94
|
+
|
|
95
|
+
# --- allocations (interactive exec, log streaming) -------------------------------
|
|
96
|
+
|
|
97
|
+
def exec_shell(self, alloc_id: str, task: str, command: list[str]) -> int:
|
|
98
|
+
"""Run an interactive command (a shell) inside a running task via `nomad alloc exec`.
|
|
99
|
+
|
|
100
|
+
``command`` is the in-container argv to launch, e.g. ``["/bin/bash"]`` or the
|
|
101
|
+
``["/bin/sh", "-c", ...]`` bash-with-fallback probe. Inherits the parent
|
|
102
|
+
terminal's stdio so the session is fully interactive. Returns the exit code.
|
|
103
|
+
"""
|
|
104
|
+
argv = [self._path, "alloc", "exec", "-task", task, "-i"]
|
|
105
|
+
# Only request a pseudo-tty when stdin is a real terminal; forcing -t against a
|
|
106
|
+
# pipe (CI, `nd exec ... | cat`) makes the binary fail or hang allocating a PTY.
|
|
107
|
+
if sys.stdin.isatty():
|
|
108
|
+
argv.append("-t")
|
|
109
|
+
argv += [alloc_id, *command]
|
|
110
|
+
pp.debug("exec: " + " ".join(argv))
|
|
111
|
+
completed = subprocess.run(argv, env=self._env, check=False) # noqa: S603
|
|
112
|
+
return completed.returncode
|
|
113
|
+
|
|
114
|
+
def stream_logs(
|
|
115
|
+
self,
|
|
116
|
+
alloc_id: str,
|
|
117
|
+
task: str,
|
|
118
|
+
*,
|
|
119
|
+
streams: tuple[str, ...] = ("stdout", "stderr"),
|
|
120
|
+
tail: int | None = None,
|
|
121
|
+
export_path: Path | None = None,
|
|
122
|
+
) -> int:
|
|
123
|
+
"""Stream, tail, or export a task's logs via `nomad alloc logs`.
|
|
124
|
+
|
|
125
|
+
``streams`` selects which of ``stdout``/``stderr`` to read; the default reads both.
|
|
126
|
+
By default follows live until interrupted. ``tail`` shows the last N lines
|
|
127
|
+
statically. ``export_path`` writes the currently-available logs to a file instead
|
|
128
|
+
of streaming. Returns the binary's exit code; for an export, 0 on a successful write.
|
|
129
|
+
|
|
130
|
+
In follow mode both streams are read together through Nomad's native interleaving
|
|
131
|
+
(no stream flag, its `-f` default). A tail read or an export is one-shot, and Nomad
|
|
132
|
+
cannot merge streams without `-f`, so each requested stream is read in turn (stdout
|
|
133
|
+
then stderr) and concatenated.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
NomadBinaryError: If a read fails.
|
|
137
|
+
"""
|
|
138
|
+
if tail is None and export_path is None:
|
|
139
|
+
argv = [self._path, "alloc", "logs", "-f"]
|
|
140
|
+
# One stream -> select it explicitly; both -> omit the flag so Nomad
|
|
141
|
+
# interleaves stdout and stderr (its native `-f` behavior).
|
|
142
|
+
if len(streams) == 1:
|
|
143
|
+
argv.append(f"-{streams[0]}")
|
|
144
|
+
argv += ["-task", task, alloc_id]
|
|
145
|
+
pp.debug("logs: " + " ".join(argv))
|
|
146
|
+
# Log streaming is one-way output; detach stdin so it never inherits the
|
|
147
|
+
# parent's terminal or a broken pipe.
|
|
148
|
+
completed = subprocess.run( # noqa: S603
|
|
149
|
+
argv, env=self._env, stdin=subprocess.DEVNULL, check=False
|
|
150
|
+
)
|
|
151
|
+
return completed.returncode
|
|
152
|
+
|
|
153
|
+
# One-shot tail/export: read each requested stream in turn (Nomad cannot merge
|
|
154
|
+
# streams without -f).
|
|
155
|
+
chunks: list[bytes] = []
|
|
156
|
+
exit_code = 0
|
|
157
|
+
for stream in streams:
|
|
158
|
+
argv = [self._path, "alloc", "logs"]
|
|
159
|
+
if tail is not None:
|
|
160
|
+
argv += ["-tail", "-n", str(tail)]
|
|
161
|
+
argv += [f"-{stream}", "-task", task, alloc_id]
|
|
162
|
+
pp.debug("logs: " + " ".join(argv))
|
|
163
|
+
if export_path is not None:
|
|
164
|
+
completed = subprocess.run( # noqa: S603
|
|
165
|
+
argv, env=self._env, stdin=subprocess.DEVNULL, capture_output=True, check=False
|
|
166
|
+
)
|
|
167
|
+
if completed.returncode != 0:
|
|
168
|
+
detail = completed.stderr.decode("utf-8", "replace").strip()
|
|
169
|
+
msg = f"`nomad alloc logs` failed: {detail}"
|
|
170
|
+
raise NomadBinaryError(msg)
|
|
171
|
+
chunks.append(completed.stdout)
|
|
172
|
+
else:
|
|
173
|
+
# Tail prints straight to the terminal; with both streams this prints
|
|
174
|
+
# stdout's tail then stderr's.
|
|
175
|
+
completed = subprocess.run( # noqa: S603
|
|
176
|
+
argv, env=self._env, stdin=subprocess.DEVNULL, check=False
|
|
177
|
+
)
|
|
178
|
+
exit_code = exit_code or completed.returncode
|
|
179
|
+
|
|
180
|
+
if export_path is not None:
|
|
181
|
+
export_path.write_bytes(b"".join(chunks))
|
|
182
|
+
pp.success(f"Wrote logs to {export_path}")
|
|
183
|
+
return 0
|
|
184
|
+
return exit_code
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _stderr(exc: ShellCommandError) -> str:
|
|
188
|
+
"""Extract stderr (or the message) from a shell error for a friendly report."""
|
|
189
|
+
result = getattr(exc, "result", None)
|
|
190
|
+
if result is not None and getattr(result, "stderr", ""):
|
|
191
|
+
return result.stderr.strip()
|
|
192
|
+
return str(exc)
|
nd/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Root Typer application and process entry point for the ``nd`` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from nclutils import pp
|
|
10
|
+
|
|
11
|
+
from nd import __version__
|
|
12
|
+
from nd.commands import clean, exec, logs, plan, run, status, stop, volume # noqa: A004
|
|
13
|
+
from nd.commands import list as list_cmd
|
|
14
|
+
from nd.nomad import (
|
|
15
|
+
NomadAuthError,
|
|
16
|
+
NomadConfigError,
|
|
17
|
+
NomadConnectionError,
|
|
18
|
+
NomadError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
add_completion=False,
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
)
|
|
25
|
+
app.add_typer(status.app, name="status")
|
|
26
|
+
app.add_typer(stop.app, name="stop")
|
|
27
|
+
app.add_typer(clean.app, name="clean")
|
|
28
|
+
app.add_typer(list_cmd.app, name="list")
|
|
29
|
+
app.add_typer(plan.app, name="plan")
|
|
30
|
+
app.add_typer(run.app, name="run")
|
|
31
|
+
app.add_typer(logs.app, name="logs")
|
|
32
|
+
app.add_typer(exec.app, name="exec")
|
|
33
|
+
app.add_typer(volume.app, name="volume")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AppState:
|
|
38
|
+
"""Shared CLI state passed to subcommands via the Typer context object."""
|
|
39
|
+
|
|
40
|
+
verbose: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _version_callback(value: bool) -> None: # noqa: FBT001
|
|
44
|
+
"""Print the version and exit when ``--version`` is passed."""
|
|
45
|
+
if value:
|
|
46
|
+
pp.console().print(f"nd {__version__}")
|
|
47
|
+
raise typer.Exit
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback(invoke_without_command=True)
|
|
51
|
+
def root(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
verbose: Annotated[
|
|
54
|
+
int,
|
|
55
|
+
typer.Option(
|
|
56
|
+
"-v", "--verbose", count=True, help="Increase verbosity (-v debug, -vv trace)."
|
|
57
|
+
),
|
|
58
|
+
] = 0,
|
|
59
|
+
version: Annotated[ # noqa: ARG001, FBT002
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--version",
|
|
63
|
+
is_eager=True,
|
|
64
|
+
callback=_version_callback,
|
|
65
|
+
help="Show the version and exit.",
|
|
66
|
+
),
|
|
67
|
+
] = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Manage Nomad jobs, allocations, and host volumes from the command line."""
|
|
70
|
+
pp.configure(verbosity=verbose)
|
|
71
|
+
ctx.obj = AppState(verbose=verbose)
|
|
72
|
+
|
|
73
|
+
# With no subcommand, default to the status dashboard rather than printing help.
|
|
74
|
+
if ctx.invoked_subcommand is None:
|
|
75
|
+
status.status(ctx, verbose=verbose)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
"""Run the CLI, mapping Nomad client errors to clean, non-zero exits."""
|
|
80
|
+
try:
|
|
81
|
+
app()
|
|
82
|
+
except KeyboardInterrupt as exc:
|
|
83
|
+
# 130 = 128 + SIGINT(2), the conventional shell exit code for Ctrl-C.
|
|
84
|
+
pp.warning("Aborted")
|
|
85
|
+
raise SystemExit(130) from exc
|
|
86
|
+
except NomadConnectionError as exc:
|
|
87
|
+
pp.error("Could not reach the Nomad agent", details=[str(exc)])
|
|
88
|
+
raise SystemExit(1) from exc
|
|
89
|
+
except NomadAuthError as exc:
|
|
90
|
+
pp.error("Not authorized by Nomad (check NOMAD_TOKEN)", details=[str(exc)])
|
|
91
|
+
raise SystemExit(1) from exc
|
|
92
|
+
except NomadConfigError as exc:
|
|
93
|
+
pp.error("Invalid Nomad configuration", details=[str(exc)])
|
|
94
|
+
raise SystemExit(1) from exc
|
|
95
|
+
except NomadError as exc:
|
|
96
|
+
pp.error("Nomad request failed", details=[str(exc)])
|
|
97
|
+
raise SystemExit(1) from exc
|
nd/commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI subcommands for the nd tool."""
|
nd/commands/_common.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Shared wiring for the ``nd`` subcommands: verbosity and progress-step helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any, NoReturn, Protocol
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from nclutils import pp
|
|
11
|
+
from nclutils.pp import Verbosity
|
|
12
|
+
|
|
13
|
+
from nd.binary import NomadBinary, NomadBinaryError
|
|
14
|
+
from nd.targets import resolve_target
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
|
|
19
|
+
from nd.nomad.config import NomadConfig
|
|
20
|
+
|
|
21
|
+
# Every subcommand accepts the same -v/--verbose count option; declare it once.
|
|
22
|
+
VerboseOption = Annotated[
|
|
23
|
+
int,
|
|
24
|
+
typer.Option("-v", "--verbose", count=True, help="Increase verbosity (-v debug, -vv trace)."),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure_verbosity(ctx: typer.Context, verbose: int) -> int:
|
|
29
|
+
"""Apply the effective verbosity and return it.
|
|
30
|
+
|
|
31
|
+
A subcommand accepts ``-v``/``-vv`` either before it (the root callback, which
|
|
32
|
+
stores its count on ``ctx.obj``) or after it; take the louder of the two so
|
|
33
|
+
either position works, then configure ``pp`` with the result.
|
|
34
|
+
"""
|
|
35
|
+
verbose = max(getattr(ctx.obj, "verbose", 0), verbose)
|
|
36
|
+
pp.configure(verbosity=verbose)
|
|
37
|
+
return verbose
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StepLike(Protocol):
|
|
41
|
+
"""Structural type for the progress step object yielded by ``pp.step``."""
|
|
42
|
+
|
|
43
|
+
def sub(self, text: str) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def record_step(
|
|
47
|
+
coro: Awaitable[Any],
|
|
48
|
+
*,
|
|
49
|
+
step: StepLike | None,
|
|
50
|
+
verbose: int,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
count_items: bool = False,
|
|
54
|
+
) -> Any: # noqa: ANN401
|
|
55
|
+
"""Await one API call, recording the request on the progress step per verbosity.
|
|
56
|
+
|
|
57
|
+
The default view stays quiet; ``-v`` names the ``<method> /v1<path>`` request, and
|
|
58
|
+
``-vv`` adds the elapsed time (plus the response item count when ``count_items`` is
|
|
59
|
+
set and the result is a list). Returns the awaited result so callers can use it.
|
|
60
|
+
"""
|
|
61
|
+
start = perf_counter()
|
|
62
|
+
result = await coro
|
|
63
|
+
if step is not None:
|
|
64
|
+
if verbose >= Verbosity.TRACE:
|
|
65
|
+
count = len(result) if count_items and isinstance(result, list) else None
|
|
66
|
+
elapsed_ms = (perf_counter() - start) * 1000
|
|
67
|
+
items = f"{count} items, " if count is not None else ""
|
|
68
|
+
step.sub(f"{method} /v1{path} → {items}{elapsed_ms:.0f}ms")
|
|
69
|
+
else:
|
|
70
|
+
step.sub(f"{method} /v1{path}")
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_alloc_action(
|
|
75
|
+
config: NomadConfig,
|
|
76
|
+
*,
|
|
77
|
+
job: str | None,
|
|
78
|
+
task: str | None,
|
|
79
|
+
running_only: bool,
|
|
80
|
+
action: Callable[[NomadBinary, str, str], int],
|
|
81
|
+
) -> NoReturn:
|
|
82
|
+
"""Resolve an exec/logs target, run a ``nomad`` binary action against it, then exit.
|
|
83
|
+
|
|
84
|
+
Shared tail of ``nd exec`` and ``nd logs``: resolve the (job, allocation, task)
|
|
85
|
+
target through the API client, exit cleanly when there is nothing to act on, then
|
|
86
|
+
build the configured ``NomadBinary`` and hand it (with the resolved alloc id and
|
|
87
|
+
task name) to ``action``. A missing binary or failed invocation becomes a friendly
|
|
88
|
+
exit 1; otherwise the command exits with the action's own return code.
|
|
89
|
+
"""
|
|
90
|
+
exit_code, target = asyncio.run(
|
|
91
|
+
resolve_target(config, job_arg=job, task_arg=task, running_only=running_only)
|
|
92
|
+
)
|
|
93
|
+
if target is None:
|
|
94
|
+
raise typer.Exit(exit_code)
|
|
95
|
+
try:
|
|
96
|
+
nomad = NomadBinary.create(config)
|
|
97
|
+
code = action(nomad, target.alloc_id, target.task)
|
|
98
|
+
except NomadBinaryError as exc:
|
|
99
|
+
pp.error(str(exc))
|
|
100
|
+
raise typer.Exit(1) from exc
|
|
101
|
+
raise typer.Exit(code)
|
nd/commands/clean.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The ``nd clean`` command: force Nomad cluster garbage collection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from nclutils import pp
|
|
9
|
+
|
|
10
|
+
from nd.commands._common import VerboseOption, configure_verbosity, record_step
|
|
11
|
+
from nd.nomad import NomadClient, NomadConfig
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.callback(invoke_without_command=True)
|
|
17
|
+
def clean(ctx: typer.Context, verbose: VerboseOption = 0) -> None:
|
|
18
|
+
"""Force garbage collection and reconcile job summaries on the cluster.
|
|
19
|
+
|
|
20
|
+
Runs Nomad's system garbage collection, then reconciles job summaries. Both
|
|
21
|
+
operations are safe to run anytime: they only reclaim dead objects and correct
|
|
22
|
+
summary counts, never touching running jobs.
|
|
23
|
+
"""
|
|
24
|
+
verbose = configure_verbosity(ctx, verbose)
|
|
25
|
+
asyncio.run(_run(verbose=verbose))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _run(*, verbose: int) -> None:
|
|
29
|
+
"""Run the cluster housekeeping operations and report a friendly result.
|
|
30
|
+
|
|
31
|
+
Garbage collection and summary reconciliation run sequentially so the
|
|
32
|
+
progress sub-lines render in a stable order. Both are safe, idempotent
|
|
33
|
+
operations whose Nomad failures propagate as ``NomadError`` for ``main()``
|
|
34
|
+
to map onto a clean non-zero exit.
|
|
35
|
+
"""
|
|
36
|
+
config = NomadConfig.resolve()
|
|
37
|
+
pp.debug("Resolved Nomad config", details=[f"address={config.address}"])
|
|
38
|
+
async with NomadClient.from_config(config) as client:
|
|
39
|
+
with pp.step("Cleaning up the cluster") as step:
|
|
40
|
+
await record_step(
|
|
41
|
+
client.system.gc(), step=step, verbose=verbose, method="PUT", path="/system/gc"
|
|
42
|
+
)
|
|
43
|
+
await record_step(
|
|
44
|
+
client.system.reconcile_summaries(),
|
|
45
|
+
step=step,
|
|
46
|
+
verbose=verbose,
|
|
47
|
+
method="PUT",
|
|
48
|
+
path="/system/reconcile/summaries",
|
|
49
|
+
)
|
|
50
|
+
pp.success("Cluster cleaned", details=["garbage collected", "job summaries reconciled"])
|
nd/commands/exec.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The ``nd exec`` command: open an interactive shell inside a running task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from nd.commands._common import VerboseOption, configure_verbosity, run_alloc_action
|
|
10
|
+
from nd.constants import DEFAULT_EXEC_SHELL, EXEC_SHELL_PROBE
|
|
11
|
+
from nd.nomad import NomadConfig
|
|
12
|
+
|
|
13
|
+
# allow_interspersed_args lets options follow the positional JOB (e.g. `nd exec web -s sh`).
|
|
14
|
+
app = typer.Typer(context_settings={"allow_interspersed_args": True})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _container_command(shell: str | None) -> list[str]:
|
|
18
|
+
"""Build the in-container argv: an explicit shell, or bash-with-sh fallback.
|
|
19
|
+
|
|
20
|
+
An explicit ``--shell`` is run verbatim. With no flag, probe for bash inside the
|
|
21
|
+
container and fall back to sh so the nicer shell is used when present without
|
|
22
|
+
failing on minimal images that ship only sh.
|
|
23
|
+
"""
|
|
24
|
+
if shell is not None:
|
|
25
|
+
return [shell]
|
|
26
|
+
return [DEFAULT_EXEC_SHELL, "-c", EXEC_SHELL_PROBE]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback(invoke_without_command=True)
|
|
30
|
+
def exec_(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
job: Annotated[
|
|
33
|
+
str | None,
|
|
34
|
+
typer.Argument(
|
|
35
|
+
help="Running job to enter; matches any job whose name starts with this. "
|
|
36
|
+
"Omit to pick from a list."
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
task: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option("--task", "-t", help="Target task; skips the task prompt."),
|
|
42
|
+
] = None,
|
|
43
|
+
shell: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--shell", "-s", help="Shell to launch (default: bash, or sh if bash is absent)."
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
verbose: VerboseOption = 0,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Open an interactive shell inside a running task's allocation.
|
|
52
|
+
|
|
53
|
+
Resolves a running job, allocation, and task, prompting only where the choice is
|
|
54
|
+
ambiguous, then opens a shell over nomad alloc exec. With no --shell it prefers
|
|
55
|
+
bash and falls back to sh, so it works on minimal images. Requires an interactive
|
|
56
|
+
terminal.
|
|
57
|
+
"""
|
|
58
|
+
configure_verbosity(ctx, verbose)
|
|
59
|
+
config = NomadConfig.resolve()
|
|
60
|
+
command = _container_command(shell)
|
|
61
|
+
run_alloc_action(
|
|
62
|
+
config,
|
|
63
|
+
job=job,
|
|
64
|
+
task=task,
|
|
65
|
+
running_only=True,
|
|
66
|
+
action=lambda nomad, alloc_id, task_name: nomad.exec_shell(alloc_id, task_name, command),
|
|
67
|
+
)
|