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.
Files changed (65) hide show
  1. nd/__init__.py +7 -0
  2. nd/binary/__init__.py +10 -0
  3. nd/binary/env.py +43 -0
  4. nd/binary/runner.py +192 -0
  5. nd/cli.py +97 -0
  6. nd/commands/__init__.py +1 -0
  7. nd/commands/_common.py +101 -0
  8. nd/commands/clean.py +50 -0
  9. nd/commands/exec.py +67 -0
  10. nd/commands/list.py +120 -0
  11. nd/commands/logs.py +76 -0
  12. nd/commands/plan.py +103 -0
  13. nd/commands/run.py +372 -0
  14. nd/commands/status/__init__.py +29 -0
  15. nd/commands/status/command.py +102 -0
  16. nd/commands/status/render.py +172 -0
  17. nd/commands/status/report.py +339 -0
  18. nd/commands/stop.py +412 -0
  19. nd/commands/volume/__init__.py +25 -0
  20. nd/commands/volume/command.py +216 -0
  21. nd/commands/volume/render.py +132 -0
  22. nd/commands/volume/report.py +146 -0
  23. nd/constants.py +43 -0
  24. nd/jobfiles.py +125 -0
  25. nd/nomad/__init__.py +29 -0
  26. nd/nomad/client.py +51 -0
  27. nd/nomad/config.py +156 -0
  28. nd/nomad/errors.py +52 -0
  29. nd/nomad/models/__init__.py +1 -0
  30. nd/nomad/models/agent.py +26 -0
  31. nd/nomad/models/allocation.py +37 -0
  32. nd/nomad/models/deployment.py +40 -0
  33. nd/nomad/models/evaluation.py +21 -0
  34. nd/nomad/models/job.py +51 -0
  35. nd/nomad/models/node.py +41 -0
  36. nd/nomad/models/volume.py +28 -0
  37. nd/nomad/resources/__init__.py +1 -0
  38. nd/nomad/resources/agent.py +25 -0
  39. nd/nomad/resources/allocations.py +24 -0
  40. nd/nomad/resources/base.py +45 -0
  41. nd/nomad/resources/deployments.py +28 -0
  42. nd/nomad/resources/evaluations.py +19 -0
  43. nd/nomad/resources/jobs.py +70 -0
  44. nd/nomad/resources/nodes.py +24 -0
  45. nd/nomad/resources/status.py +14 -0
  46. nd/nomad/resources/system.py +25 -0
  47. nd/nomad/resources/volumes.py +42 -0
  48. nd/nomad/transport.py +141 -0
  49. nd/targets/__init__.py +32 -0
  50. nd/targets/alloc_target.py +166 -0
  51. nd/targets/selection.py +91 -0
  52. nd/ui/__init__.py +1 -0
  53. nd/ui/alloc_rows.py +93 -0
  54. nd/ui/duration.py +44 -0
  55. nd/ui/links.py +22 -0
  56. nd/ui/live_panel.py +199 -0
  57. nd/ui/panels.py +31 -0
  58. nd/ui/prompts.py +46 -0
  59. nd/ui/styles.py +52 -0
  60. nd/volumefiles.py +143 -0
  61. nomadctl-0.2.0.dist-info/METADATA +268 -0
  62. nomadctl-0.2.0.dist-info/RECORD +65 -0
  63. nomadctl-0.2.0.dist-info/WHEEL +4 -0
  64. nomadctl-0.2.0.dist-info/entry_points.txt +3 -0
  65. nomadctl-0.2.0.dist-info/licenses/LICENSE +21 -0
nd/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """The nd package."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from nd.cli import main
6
+
7
+ __all__ = ["__version__", "main"]
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
@@ -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
+ )