nomadctl 0.2.0__tar.gz → 0.4.0__tar.gz

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 (66) hide show
  1. {nomadctl-0.2.0 → nomadctl-0.4.0}/PKG-INFO +53 -11
  2. {nomadctl-0.2.0 → nomadctl-0.4.0}/README.md +50 -8
  3. {nomadctl-0.2.0 → nomadctl-0.4.0}/pyproject.toml +6 -6
  4. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/__init__.py +1 -1
  5. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/cli.py +2 -1
  6. nomadctl-0.4.0/src/nd/commands/_orchestration.py +106 -0
  7. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/exec.py +1 -1
  8. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/logs.py +1 -1
  9. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/plan.py +1 -2
  10. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/run.py +76 -35
  11. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/status/render.py +59 -21
  12. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/status/report.py +9 -3
  13. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/stop.py +54 -37
  14. nomadctl-0.4.0/src/nd/commands/update.py +353 -0
  15. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/volume/command.py +34 -9
  16. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/volume/render.py +34 -2
  17. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/allocation.py +8 -1
  18. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/jobs.py +2 -2
  19. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/targets/__init__.py +1 -1
  20. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/targets/alloc_target.py +1 -1
  21. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/targets/selection.py +4 -4
  22. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/live_panel.py +48 -6
  23. {nomadctl-0.2.0 → nomadctl-0.4.0}/LICENSE +0 -0
  24. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/binary/__init__.py +0 -0
  25. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/binary/env.py +0 -0
  26. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/binary/runner.py +0 -0
  27. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/__init__.py +0 -0
  28. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/_common.py +0 -0
  29. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/clean.py +0 -0
  30. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/list.py +0 -0
  31. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/status/__init__.py +0 -0
  32. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/status/command.py +0 -0
  33. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/volume/__init__.py +0 -0
  34. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/commands/volume/report.py +0 -0
  35. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/constants.py +0 -0
  36. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/jobfiles.py +0 -0
  37. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/__init__.py +0 -0
  38. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/client.py +0 -0
  39. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/config.py +0 -0
  40. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/errors.py +0 -0
  41. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/__init__.py +0 -0
  42. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/agent.py +0 -0
  43. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/deployment.py +0 -0
  44. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/evaluation.py +0 -0
  45. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/job.py +0 -0
  46. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/node.py +0 -0
  47. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/models/volume.py +0 -0
  48. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/__init__.py +0 -0
  49. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/agent.py +0 -0
  50. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/allocations.py +0 -0
  51. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/base.py +0 -0
  52. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/deployments.py +0 -0
  53. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/evaluations.py +0 -0
  54. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/nodes.py +0 -0
  55. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/status.py +0 -0
  56. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/system.py +0 -0
  57. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/resources/volumes.py +0 -0
  58. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/nomad/transport.py +0 -0
  59. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/__init__.py +0 -0
  60. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/alloc_rows.py +0 -0
  61. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/duration.py +0 -0
  62. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/links.py +0 -0
  63. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/panels.py +0 -0
  64. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/prompts.py +0 -0
  65. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/ui/styles.py +0 -0
  66. {nomadctl-0.2.0 → nomadctl-0.4.0}/src/nd/volumefiles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nomadctl
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A friendly command-line tool for managing a HashiCorp Nomad homelab cluster.
5
5
  Keywords: cli,devops,hashicorp,homelab,nomad,orchestration
6
6
  Author: Nathaniel Landau
@@ -17,13 +17,13 @@ Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: System :: Systems Administration
18
18
  Classifier: Topic :: Utilities
19
19
  Classifier: Typing :: Typed
20
- Requires-Dist: httpx2>=2.4.0,<3.0.0
20
+ Requires-Dist: httpx2>=2.5.0,<3.0.0
21
21
  Requires-Dist: lark>=1.3.1,<2.0.0
22
22
  Requires-Dist: msgspec>=0.21.1
23
23
  Requires-Dist: nclutils>=3.4.1,<4.0.0
24
24
  Requires-Dist: python-hcl2>=8.1.2,<9.0.0
25
25
  Requires-Dist: rich>=15.0.0
26
- Requires-Dist: typer>=0.26.7
26
+ Requires-Dist: typer>=0.26.8
27
27
  Requires-Python: >=3.13, <3.15
28
28
  Project-URL: Homepage, https://github.com/natelandau/nd
29
29
  Project-URL: Issues, https://github.com/natelandau/nd/issues
@@ -60,9 +60,9 @@ your easy to remember job and volume names and the cli does the rest.
60
60
 
61
61
  - Python 3.13 or 3.14.
62
62
  - A reachable Nomad cluster.
63
- - The `nomad` binary on your `PATH`. The `plan`, `run`, `exec`, and `logs` commands
64
- shell out to it, because the HTTP API cannot parse HCL2 job files and does not own
65
- the interactive exec protocol. The other commands use the API only.
63
+ - The `nomad` binary on your `PATH`. The `plan`, `run`, `update`, `exec`, and `logs`
64
+ commands shell out to it, because the HTTP API cannot parse HCL2 job files and does
65
+ not own the interactive exec protocol. The other commands use the API only.
66
66
 
67
67
  ## Installation
68
68
 
@@ -171,6 +171,7 @@ Run `nd --help`, or `nd <command> --help`, for the full option list at any time.
171
171
  | `nd list` | List discovered job files and whether each is running, dead, or not deployed. |
172
172
  | `nd plan [JOB]` | Preview the changes one or more job files would apply, including to running jobs. |
173
173
  | `nd run [JOB]` | Deploy not-yet-running job files and watch the rollout. |
174
+ | `nd update [JOB]` | Recreate a running job from its local file and watch the rollout. |
174
175
  | `nd stop [JOB]` | Stop, and optionally purge, running jobs and watch them drain. |
175
176
  | `nd logs [JOB]` | Stream, tail, or export a task's logs. |
176
177
  | `nd exec [JOB]` | Open an interactive shell inside a running task. |
@@ -181,12 +182,13 @@ Run `nd --help`, or `nd <command> --help`, for the full option list at any time.
181
182
 
182
183
  ### Targeting jobs by name
183
184
 
184
- Commands that take a `JOB` or `NAME` argument match by case-insensitive name prefix.
185
+ Commands that take a `JOB` or `NAME` argument match by case-insensitive substring:
186
+ any name that contains the text matches, so `web` finds both `web` and `reverse-web`.
185
187
  A single match runs straight away; several matches open a prompt. Omit the argument
186
188
  to pick from a list of every candidate.
187
189
 
188
190
  ```bash
189
- nd run web # runs the one job whose name starts with "web"
191
+ nd run web # runs the job whose name contains "web" (prompts if several match)
190
192
  nd stop # prompts you to choose from all running jobs
191
193
  ```
192
194
 
@@ -197,12 +199,13 @@ touching the cluster:
197
199
 
198
200
  ```bash
199
201
  nd run --dry-run
202
+ nd update web --dry-run
200
203
  nd stop web --dry-run
201
204
  nd volume register --dry-run
202
205
  ```
203
206
 
204
- For `nd run`, a dry run still validates each job file locally, so it catches a
205
- broken spec without registering anything.
207
+ For `nd run` and `nd update`, a dry run still validates each job file locally, so it
208
+ catches a broken spec without registering anything.
206
209
 
207
210
  ### Deploying jobs
208
211
 
@@ -212,10 +215,34 @@ settle. Use `--detach` to register and return without watching the rollout.
212
215
 
213
216
  ```bash
214
217
  nd run # choose from every deployable job
215
- nd run web # deploy the job whose name starts with "web"
218
+ nd run web # deploy the job whose name contains "web"
216
219
  nd run web --detach # register and return immediately
217
220
  ```
218
221
 
222
+ ### Updating jobs
223
+
224
+ `nd update` recreates a job that is already running. Reach for it to roll out an
225
+ edited job file, or to pull a fresh version when the file is unchanged, such as a
226
+ container that tracks a moving tag. It only offers jobs that are both running and
227
+ have a local file.
228
+
229
+ Each selected job is stopped, drained, purged, then re-registered from its local
230
+ file and watched until the new rollout settles. The job is fully recreated, so
231
+ expect brief downtime. `nd update` confirms before it acts unless you pass `--force`
232
+ (`-f`), and purges by default (unlike `nd stop`, which keeps the job unless you pass
233
+ `--purge`); pass `--no-purge` to keep the job's version history.
234
+
235
+ ```bash
236
+ nd update # choose from every running job that has a local file
237
+ nd update web # recreate the job whose name contains "web"
238
+ nd update web --no-purge # recreate but keep the version history
239
+ nd update web --force # skip the confirmation prompt
240
+ ```
241
+
242
+ Whether a new container image is actually pulled depends on the job's Docker driver
243
+ config, such as `force_pull` or a pinned digest, not on `nd`. The recreate
244
+ guarantees fresh allocations; the image policy stays with your job spec.
245
+
219
246
  ### Working with logs
220
247
 
221
248
  `nd logs` streams both stdout and stderr live until you press Ctrl-C. Narrow or
@@ -241,6 +268,21 @@ nd stop web --purge --force # purge without a prompt
241
268
  nd stop web --detach # request the stop and return immediately
242
269
  ```
243
270
 
271
+ ### Managing host volumes
272
+
273
+ `nd volume register` and `nd volume delete` create and remove dynamic host volumes
274
+ from your local spec files, and `nd volume list` shows where each spec is registered.
275
+ Deleting a host volume is irreversible and orphans data for any job that mounts it,
276
+ so `nd volume delete` confirms before it acts unless you pass `--force` (`-f`); a
277
+ `--dry-run` (`-n`) previews the registrations that would be removed without prompting.
278
+
279
+ ```bash
280
+ nd volume register data # register the "data" volume on eligible nodes
281
+ nd volume delete data # confirm, then delete the "data" registrations
282
+ nd volume delete data --force # delete without a prompt
283
+ nd volume delete data --dry-run # preview what would be deleted
284
+ ```
285
+
244
286
  ### Verbosity
245
287
 
246
288
  Add `-v` for debug output or `-vv` to trace each API request with timings. The flag
@@ -28,9 +28,9 @@ your easy to remember job and volume names and the cli does the rest.
28
28
 
29
29
  - Python 3.13 or 3.14.
30
30
  - A reachable Nomad cluster.
31
- - The `nomad` binary on your `PATH`. The `plan`, `run`, `exec`, and `logs` commands
32
- shell out to it, because the HTTP API cannot parse HCL2 job files and does not own
33
- the interactive exec protocol. The other commands use the API only.
31
+ - The `nomad` binary on your `PATH`. The `plan`, `run`, `update`, `exec`, and `logs`
32
+ commands shell out to it, because the HTTP API cannot parse HCL2 job files and does
33
+ not own the interactive exec protocol. The other commands use the API only.
34
34
 
35
35
  ## Installation
36
36
 
@@ -139,6 +139,7 @@ Run `nd --help`, or `nd <command> --help`, for the full option list at any time.
139
139
  | `nd list` | List discovered job files and whether each is running, dead, or not deployed. |
140
140
  | `nd plan [JOB]` | Preview the changes one or more job files would apply, including to running jobs. |
141
141
  | `nd run [JOB]` | Deploy not-yet-running job files and watch the rollout. |
142
+ | `nd update [JOB]` | Recreate a running job from its local file and watch the rollout. |
142
143
  | `nd stop [JOB]` | Stop, and optionally purge, running jobs and watch them drain. |
143
144
  | `nd logs [JOB]` | Stream, tail, or export a task's logs. |
144
145
  | `nd exec [JOB]` | Open an interactive shell inside a running task. |
@@ -149,12 +150,13 @@ Run `nd --help`, or `nd <command> --help`, for the full option list at any time.
149
150
 
150
151
  ### Targeting jobs by name
151
152
 
152
- Commands that take a `JOB` or `NAME` argument match by case-insensitive name prefix.
153
+ Commands that take a `JOB` or `NAME` argument match by case-insensitive substring:
154
+ any name that contains the text matches, so `web` finds both `web` and `reverse-web`.
153
155
  A single match runs straight away; several matches open a prompt. Omit the argument
154
156
  to pick from a list of every candidate.
155
157
 
156
158
  ```bash
157
- nd run web # runs the one job whose name starts with "web"
159
+ nd run web # runs the job whose name contains "web" (prompts if several match)
158
160
  nd stop # prompts you to choose from all running jobs
159
161
  ```
160
162
 
@@ -165,12 +167,13 @@ touching the cluster:
165
167
 
166
168
  ```bash
167
169
  nd run --dry-run
170
+ nd update web --dry-run
168
171
  nd stop web --dry-run
169
172
  nd volume register --dry-run
170
173
  ```
171
174
 
172
- For `nd run`, a dry run still validates each job file locally, so it catches a
173
- broken spec without registering anything.
175
+ For `nd run` and `nd update`, a dry run still validates each job file locally, so it
176
+ catches a broken spec without registering anything.
174
177
 
175
178
  ### Deploying jobs
176
179
 
@@ -180,10 +183,34 @@ settle. Use `--detach` to register and return without watching the rollout.
180
183
 
181
184
  ```bash
182
185
  nd run # choose from every deployable job
183
- nd run web # deploy the job whose name starts with "web"
186
+ nd run web # deploy the job whose name contains "web"
184
187
  nd run web --detach # register and return immediately
185
188
  ```
186
189
 
190
+ ### Updating jobs
191
+
192
+ `nd update` recreates a job that is already running. Reach for it to roll out an
193
+ edited job file, or to pull a fresh version when the file is unchanged, such as a
194
+ container that tracks a moving tag. It only offers jobs that are both running and
195
+ have a local file.
196
+
197
+ Each selected job is stopped, drained, purged, then re-registered from its local
198
+ file and watched until the new rollout settles. The job is fully recreated, so
199
+ expect brief downtime. `nd update` confirms before it acts unless you pass `--force`
200
+ (`-f`), and purges by default (unlike `nd stop`, which keeps the job unless you pass
201
+ `--purge`); pass `--no-purge` to keep the job's version history.
202
+
203
+ ```bash
204
+ nd update # choose from every running job that has a local file
205
+ nd update web # recreate the job whose name contains "web"
206
+ nd update web --no-purge # recreate but keep the version history
207
+ nd update web --force # skip the confirmation prompt
208
+ ```
209
+
210
+ Whether a new container image is actually pulled depends on the job's Docker driver
211
+ config, such as `force_pull` or a pinned digest, not on `nd`. The recreate
212
+ guarantees fresh allocations; the image policy stays with your job spec.
213
+
187
214
  ### Working with logs
188
215
 
189
216
  `nd logs` streams both stdout and stderr live until you press Ctrl-C. Narrow or
@@ -209,6 +236,21 @@ nd stop web --purge --force # purge without a prompt
209
236
  nd stop web --detach # request the stop and return immediately
210
237
  ```
211
238
 
239
+ ### Managing host volumes
240
+
241
+ `nd volume register` and `nd volume delete` create and remove dynamic host volumes
242
+ from your local spec files, and `nd volume list` shows where each spec is registered.
243
+ Deleting a host volume is irreversible and orphans data for any job that mounts it,
244
+ so `nd volume delete` confirms before it acts unless you pass `--force` (`-f`); a
245
+ `--dry-run` (`-n`) previews the registrations that would be removed without prompting.
246
+
247
+ ```bash
248
+ nd volume register data # register the "data" volume on eligible nodes
249
+ nd volume delete data # confirm, then delete the "data" registrations
250
+ nd volume delete data --force # delete without a prompt
251
+ nd volume delete data --dry-run # preview what would be deleted
252
+ ```
253
+
212
254
  ### Verbosity
213
255
 
214
256
  Add `-v` for debug output or `-vv` to trace each API request with timings. The flag
@@ -13,13 +13,13 @@
13
13
  "Typing :: Typed",
14
14
  ]
15
15
  dependencies = [
16
- "httpx2>=2.4.0,<3.0.0",
16
+ "httpx2>=2.5.0,<3.0.0",
17
17
  "lark>=1.3.1,<2.0.0",
18
18
  "msgspec>=0.21.1",
19
19
  "nclutils>=3.4.1,<4.0.0",
20
20
  "python-hcl2>=8.1.2,<9.0.0",
21
21
  "rich>=15.0.0",
22
- "typer>=0.26.7",
22
+ "typer>=0.26.8",
23
23
  ]
24
24
  description = "A friendly command-line tool for managing a HashiCorp Nomad homelab cluster."
25
25
  keywords = ["cli", "devops", "hashicorp", "homelab", "nomad", "orchestration"]
@@ -28,7 +28,7 @@
28
28
  name = "nomadctl"
29
29
  readme = "README.md"
30
30
  requires-python = ">=3.13, <3.15"
31
- version = "0.2.0"
31
+ version = "0.4.0"
32
32
 
33
33
  [project.urls]
34
34
  Homepage = "https://github.com/natelandau/nd"
@@ -62,9 +62,9 @@
62
62
  "pytest-sugar>=1.1.1",
63
63
  "pytest>=9.1.1",
64
64
  "respx>=0.23.1",
65
- "ruff>=0.15.18",
66
- "ty>=0.0.52",
67
- "typos>=1.47.2",
65
+ "ruff>=0.15.20",
66
+ "ty>=0.0.55",
67
+ "typos>=1.48.0",
68
68
  "yamllint>=1.38.0",
69
69
  ]
70
70
 
@@ -1,6 +1,6 @@
1
1
  """The nd package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
4
4
 
5
5
  from nd.cli import main
6
6
 
@@ -9,7 +9,7 @@ import typer
9
9
  from nclutils import pp
10
10
 
11
11
  from nd import __version__
12
- from nd.commands import clean, exec, logs, plan, run, status, stop, volume # noqa: A004
12
+ from nd.commands import clean, exec, logs, plan, run, status, stop, update, volume # noqa: A004
13
13
  from nd.commands import list as list_cmd
14
14
  from nd.nomad import (
15
15
  NomadAuthError,
@@ -28,6 +28,7 @@ app.add_typer(clean.app, name="clean")
28
28
  app.add_typer(list_cmd.app, name="list")
29
29
  app.add_typer(plan.app, name="plan")
30
30
  app.add_typer(run.app, name="run")
31
+ app.add_typer(update.app, name="update")
31
32
  app.add_typer(logs.app, name="logs")
32
33
  app.add_typer(exec.app, name="exec")
33
34
  app.add_typer(volume.app, name="volume")
@@ -0,0 +1,106 @@
1
+ """Shared scaffolding for the job-lifecycle commands (``run``, ``stop``, ``update``).
2
+
3
+ These three commands share the same shape: resolve node names once, optionally
4
+ confirm, run every target under one live panel, then emit durable lines for the
5
+ outcomes that did not finish cleanly and compute an exit code. This module owns
6
+ that scaffolding so each command keeps only its own vocabulary (its status enum,
7
+ outcome type, and the labels it shows).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from nclutils import pp
15
+
16
+ from nd.ui.duration import summary_title
17
+ from nd.ui.prompts import select_one
18
+ from nd.ui.styles import OUTCOME_GLYPH
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable, Sequence
22
+
23
+ from nd.nomad import NomadClient
24
+
25
+
26
+ async def node_names_by_id(client: NomadClient) -> dict[str, str]:
27
+ """Map node IDs to node names so a live panel's detail rows can show placement.
28
+
29
+ Fetched once per command run and shared across every concurrent target, since
30
+ the node roster does not change over the life of a single stop/run/update.
31
+ """
32
+ return {node.id: node.name for node in await client.nodes.list()}
33
+
34
+
35
+ async def confirm_jobs(names: Sequence[str], *, verb: str) -> bool:
36
+ """Prompt the user to confirm a job action, returning True to proceed.
37
+
38
+ ``verb`` is the leading phrase (e.g. ``"Stop and PURGE"``); the shared wording
39
+ keeps the confirmation prompt identical across the commands that use it.
40
+ """
41
+ joined = ", ".join(names)
42
+ answer = await select_one(
43
+ [("Yes", True), ("No", False)],
44
+ f"{verb} {len(names)} job(s): {joined}?",
45
+ )
46
+ return bool(answer)
47
+
48
+
49
+ def final_panel_title[O](
50
+ outcomes: list[O], seconds: float, *, verb: str, succeeded: Callable[[O], bool]
51
+ ) -> str:
52
+ """Build the live panel's final title from the ordered outcomes and elapsed time.
53
+
54
+ Counts the outcomes ``succeeded`` marks as clean so the title reads ``Deployed 2
55
+ jobs`` or the ``1 of 2 jobs`` partial form. ``verb`` is the past-tense summary
56
+ word each command supplies.
57
+ """
58
+ ok = sum(1 for o in outcomes if succeeded(o))
59
+ return summary_title(verb, done=ok, total=len(outcomes), seconds=seconds)
60
+
61
+
62
+ def report_outcomes[O]( # noqa: PLR0913
63
+ outcomes: list[O],
64
+ *,
65
+ name_of: Callable[[O], str],
66
+ detail_of: Callable[[O], str],
67
+ is_warn: Callable[[O], bool],
68
+ is_fail: Callable[[O], bool],
69
+ fail_verb: str,
70
+ warn_fallback: str = "",
71
+ warnings_of: Callable[[O], str] | None = None,
72
+ ) -> None:
73
+ """Emit a durable log line for every outcome that did not finish cleanly.
74
+
75
+ The live panel is transient on a pipe or in CI, so each timed-out or failed
76
+ outcome is echoed here: a warning for ``is_warn`` outcomes (falling back to
77
+ ``warn_fallback`` when the outcome carries no detail) and an error reading
78
+ ``<name> failed to <fail_verb>`` for ``is_fail`` outcomes. When ``warnings_of``
79
+ is supplied, any non-empty register warning is surfaced as its own line.
80
+ """
81
+ for outcome in outcomes:
82
+ if is_warn(outcome):
83
+ pp.warning(f"{name_of(outcome)}: {detail_of(outcome) or warn_fallback}")
84
+ elif is_fail(outcome):
85
+ detail = detail_of(outcome)
86
+ pp.error(
87
+ f"{name_of(outcome)} failed to {fail_verb}",
88
+ details=[detail] if detail else None,
89
+ )
90
+ if warnings_of is not None and (warnings := warnings_of(outcome)):
91
+ pp.warning(f"{name_of(outcome)}: {warnings}")
92
+
93
+
94
+ def ok_row(label: str) -> tuple[str, str]:
95
+ """Build a finished-row ``(glyph, label)`` for a clean success outcome."""
96
+ return (OUTCOME_GLYPH["ok"], f"[green]{label}[/]")
97
+
98
+
99
+ def warn_row(label: str) -> tuple[str, str]:
100
+ """Build a finished-row ``(glyph, label)`` for a warning outcome."""
101
+ return (OUTCOME_GLYPH["warn"], f"[yellow]{label}[/]")
102
+
103
+
104
+ def fail_row(label: str) -> tuple[str, str]:
105
+ """Build a finished-row ``(glyph, label)`` for a failure outcome."""
106
+ return (OUTCOME_GLYPH["fail"], f"[red]{label}[/]")
@@ -32,7 +32,7 @@ def exec_(
32
32
  job: Annotated[
33
33
  str | None,
34
34
  typer.Argument(
35
- help="Running job to enter; matches any job whose name starts with this. "
35
+ help="Running job to enter; matches any job whose name contains this. "
36
36
  "Omit to pick from a list."
37
37
  ),
38
38
  ] = None,
@@ -29,7 +29,7 @@ def logs( # noqa: PLR0913
29
29
  job: Annotated[
30
30
  str | None,
31
31
  typer.Argument(
32
- help="Running job to read; matches any job whose name starts with this. "
32
+ help="Running job to read; matches any job whose name contains this. "
33
33
  "Omit to pick from a list."
34
34
  ),
35
35
  ] = None,
@@ -30,8 +30,7 @@ def plan(
30
30
  job: Annotated[
31
31
  str | None,
32
32
  typer.Argument(
33
- help="Job to plan; matches any job whose name starts with this. "
34
- "Omit to pick from a list."
33
+ help="Job to plan; matches any job whose name contains this. Omit to pick from a list."
35
34
  ),
36
35
  ] = None,
37
36
  dry_run: Annotated[ # noqa: FBT002
@@ -14,15 +14,21 @@ from nclutils import pp
14
14
 
15
15
  from nd.binary import NomadBinary, NomadBinaryError
16
16
  from nd.commands._common import VerboseOption, configure_verbosity
17
+ from nd.commands._orchestration import (
18
+ fail_row,
19
+ final_panel_title,
20
+ node_names_by_id,
21
+ ok_row,
22
+ report_outcomes,
23
+ warn_row,
24
+ )
17
25
  from nd.constants import DEPLOY_TIMEOUT_SECONDS, HEALTHY_ALLOC_STATUSES, POLL_INTERVAL_SECONDS
18
26
  from nd.jobfiles import candidates_for, discover_job_files, load_job_directories
19
27
  from nd.nomad import NomadClient, NomadConfig
20
- from nd.nomad.errors import NomadError
28
+ from nd.nomad.errors import NomadDecodeError, NomadError
21
29
  from nd.targets import resolve_targets, select_candidates
22
30
  from nd.ui.alloc_rows import alloc_children
23
- from nd.ui.duration import summary_title
24
31
  from nd.ui.live_panel import PanelUpdate, run_rows
25
- from nd.ui.styles import OUTCOME_GLYPH
26
32
 
27
33
  if TYPE_CHECKING:
28
34
  from nd.jobfiles import JobCandidate
@@ -106,10 +112,12 @@ def _task_role(lifecycle: dict[str, object] | None, index: int) -> tuple[int, st
106
112
  return (1_000 + index, "main")
107
113
 
108
114
 
115
+ # Outcome labels carry the same color as their glyph so the status word reads as
116
+ # success/failure at a glance, not just the leading mark.
109
117
  _OUTCOME_ROW: dict[DeployStatus, tuple[str, str]] = {
110
- DeployStatus.DEPLOYED: (OUTCOME_GLYPH["ok"], "deployed"),
111
- DeployStatus.FAILED: (OUTCOME_GLYPH["fail"], "failed"),
112
- DeployStatus.TIMEOUT: (OUTCOME_GLYPH["warn"], "still deploying"),
118
+ DeployStatus.DEPLOYED: ok_row("deployed"),
119
+ DeployStatus.FAILED: fail_row("failed"),
120
+ DeployStatus.TIMEOUT: warn_row("still deploying"),
113
121
  }
114
122
 
115
123
 
@@ -130,7 +138,7 @@ def run(
130
138
  job: Annotated[
131
139
  str | None,
132
140
  typer.Argument(
133
- help="Job to run; matches any not-running job whose name starts with this. "
141
+ help="Job to run; matches any not-running job whose name contains this. "
134
142
  "Omit to pick from a list."
135
143
  ),
136
144
  ] = None,
@@ -251,7 +259,7 @@ async def _deploy_all(
251
259
  Ordered list of outcomes, one per target.
252
260
  """
253
261
  # Resolve node IDs to names once so every job's detail rows can show placement.
254
- node_names = {node.id: node.name for node in await client.nodes.list()}
262
+ node_names = await node_names_by_id(client)
255
263
 
256
264
  async def do_work(candidate: JobCandidate, update: PanelUpdate) -> DeployOutcome:
257
265
  return await _deploy_one(
@@ -268,20 +276,27 @@ async def _deploy_all(
268
276
  final_title=_final_title,
269
277
  )
270
278
 
271
- for o in ordered:
272
- if o.status is DeployStatus.TIMEOUT:
273
- pp.warning(f"{o.name}: {o.detail or 'still deploying'}")
274
- elif o.status is DeployStatus.FAILED:
275
- pp.error(f"{o.name} failed to deploy", details=[o.detail] if o.detail else None)
276
- if o.warnings:
277
- pp.warning(f"{o.name}: {o.warnings}")
279
+ report_outcomes(
280
+ ordered,
281
+ name_of=lambda o: o.name,
282
+ detail_of=lambda o: o.detail,
283
+ is_warn=lambda o: o.status is DeployStatus.TIMEOUT,
284
+ is_fail=lambda o: o.status is DeployStatus.FAILED,
285
+ fail_verb="deploy",
286
+ warn_fallback="still deploying",
287
+ warnings_of=lambda o: o.warnings,
288
+ )
278
289
  return ordered
279
290
 
280
291
 
281
292
  def _final_title(outcomes: list[DeployOutcome], elapsed_seconds: float) -> str:
282
293
  """Build the final panel title with deployed totals and elapsed seconds."""
283
- ok = sum(1 for o in outcomes if o.status is DeployStatus.DEPLOYED)
284
- return summary_title("Deployed", ok, len(outcomes), elapsed_seconds)
294
+ return final_panel_title(
295
+ outcomes,
296
+ elapsed_seconds,
297
+ verb="Deployed",
298
+ succeeded=lambda o: o.status is DeployStatus.DEPLOYED,
299
+ )
285
300
 
286
301
 
287
302
  async def _deploy_one(
@@ -316,8 +331,13 @@ async def _deploy_one(
316
331
  lifecycle = task_lifecycle(body)
317
332
  update("registering")
318
333
  resp = await client.jobs.register(body)
319
- outcome = await _watch(
320
- client, candidate.name, node_names=node_names, lifecycle=lifecycle, update=update
334
+ outcome = await watch_deploy(
335
+ client,
336
+ candidate.name,
337
+ node_names=node_names,
338
+ lifecycle=lifecycle,
339
+ update=update,
340
+ since_index=resp.job_modify_index,
321
341
  )
322
342
  # Attach any register warnings so the caller can surface them after the panel closes.
323
343
  return replace(outcome, warnings=resp.warnings)
@@ -325,13 +345,14 @@ async def _deploy_one(
325
345
  return DeployOutcome(candidate.name, DeployStatus.FAILED, str(exc))
326
346
 
327
347
 
328
- async def _watch(
348
+ async def watch_deploy(
329
349
  client: NomadClient,
330
350
  job_id: str,
331
351
  *,
332
352
  node_names: dict[str, str],
333
353
  lifecycle: TaskLifecycle,
334
354
  update: PanelUpdate,
355
+ since_index: int = 0,
335
356
  ) -> DeployOutcome:
336
357
  """Poll a registered job until its deployment (or allocations) settle or time out.
337
358
 
@@ -346,27 +367,47 @@ async def _watch(
346
367
  node_names: Map of node ID to node name for the per-allocation detail rows.
347
368
  lifecycle: Task ordering and labels from the compiled job spec.
348
369
  update: Callback to update the live panel phase text and detail rows.
370
+ since_index: The ``JobModifyIndex`` from this registration. Deployments
371
+ created before it belong to a previous run and are ignored so a re-run
372
+ of a dead job is not reported as instantly deployed off a stale record.
349
373
 
350
374
  Returns:
351
375
  The terminal deploy outcome for this job.
352
376
  """
353
377
  deadline = time.monotonic() + DEPLOY_TIMEOUT_SECONDS
354
378
  while True:
355
- allocs = await client.jobs.allocations(job_id)
356
- children = alloc_children(allocs, node_names, lifecycle)
357
- deployments = await client.jobs.deployments(job_id)
358
- if deployments: # service job: follow the most-recent deployment
359
- dep = await client.deployments.read(deployments[0].id)
360
- if dep.status == _DEPLOY_SUCCESS:
361
- return DeployOutcome(job_id, DeployStatus.DEPLOYED)
362
- if dep.status in _DEPLOY_FAILURE:
363
- return DeployOutcome(job_id, DeployStatus.FAILED, dep.status_description)
364
- update(deploy_phase(dep), children)
365
- else: # batch/system job: follow allocations
366
- running = sum(1 for a in allocs if a.client_status in HEALTHY_ALLOC_STATUSES)
367
- if allocs and running == len(allocs):
368
- return DeployOutcome(job_id, DeployStatus.DEPLOYED)
369
- update(f"placing {running}/{len(allocs) or '?'} allocs", children)
379
+ try:
380
+ allocs = await client.jobs.allocations(job_id)
381
+ deployments = await client.jobs.deployments(job_id)
382
+ # The plural endpoint's ordering is undocumented, so pick this run's
383
+ # deployment by index rather than trusting position. A job that has ever
384
+ # run keeps its prior deployments listed; ignoring those created before
385
+ # this registration is what stops a stale "successful" record from
386
+ # ending the watch the instant a dead job is re-run.
387
+ mine = [d for d in deployments if d.create_index >= since_index]
388
+ latest = max(mine, key=lambda d: d.create_index) if mine else None
389
+ dep = await client.deployments.read(latest.id) if latest else None
390
+ except NomadDecodeError as exc:
391
+ # A freshly-placed allocation can momentarily serialize in a shape we
392
+ # cannot decode (e.g. TaskStates: null before its tasks start). Skip
393
+ # this tick and retry rather than failing an otherwise-healthy deploy;
394
+ # the deadline below is the backstop if it never recovers.
395
+ pp.debug(f"{job_id}: skipping poll after transient decode error: {exc}")
396
+ else:
397
+ children = alloc_children(allocs, node_names, lifecycle)
398
+ if dep is not None: # service job: follow this run's deployment
399
+ if dep.status == _DEPLOY_SUCCESS:
400
+ return DeployOutcome(job_id, DeployStatus.DEPLOYED)
401
+ if dep.status in _DEPLOY_FAILURE:
402
+ return DeployOutcome(job_id, DeployStatus.FAILED, dep.status_description)
403
+ update(deploy_phase(dep), children)
404
+ elif deployments: # service job whose new deployment has not appeared yet
405
+ update("registering", children)
406
+ else: # batch/system job: follow allocations
407
+ running = sum(1 for a in allocs if a.client_status in HEALTHY_ALLOC_STATUSES)
408
+ if allocs and running == len(allocs):
409
+ return DeployOutcome(job_id, DeployStatus.DEPLOYED)
410
+ update(f"placing {running}/{len(allocs) or '?'} allocs", children)
370
411
  if time.monotonic() >= deadline:
371
412
  return DeployOutcome(job_id, DeployStatus.TIMEOUT, "deploy still in progress")
372
413
  await asyncio.sleep(POLL_INTERVAL_SECONDS)