nomadctl 0.2.0__tar.gz → 0.3.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 (65) hide show
  1. {nomadctl-0.2.0 → nomadctl-0.3.0}/PKG-INFO +32 -6
  2. {nomadctl-0.2.0 → nomadctl-0.3.0}/README.md +31 -5
  3. {nomadctl-0.2.0 → nomadctl-0.3.0}/pyproject.toml +1 -1
  4. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/__init__.py +1 -1
  5. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/cli.py +2 -1
  6. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/run.py +50 -22
  7. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/render.py +59 -21
  8. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/report.py +9 -3
  9. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/stop.py +28 -19
  10. nomadctl-0.3.0/src/nd/commands/update.py +345 -0
  11. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/render.py +34 -2
  12. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/allocation.py +8 -1
  13. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/jobs.py +2 -2
  14. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/live_panel.py +48 -6
  15. {nomadctl-0.2.0 → nomadctl-0.3.0}/LICENSE +0 -0
  16. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/__init__.py +0 -0
  17. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/env.py +0 -0
  18. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/runner.py +0 -0
  19. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/__init__.py +0 -0
  20. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/_common.py +0 -0
  21. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/clean.py +0 -0
  22. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/exec.py +0 -0
  23. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/list.py +0 -0
  24. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/logs.py +0 -0
  25. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/plan.py +0 -0
  26. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/__init__.py +0 -0
  27. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/command.py +0 -0
  28. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/__init__.py +0 -0
  29. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/command.py +0 -0
  30. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/report.py +0 -0
  31. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/constants.py +0 -0
  32. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/jobfiles.py +0 -0
  33. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/__init__.py +0 -0
  34. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/client.py +0 -0
  35. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/config.py +0 -0
  36. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/errors.py +0 -0
  37. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/__init__.py +0 -0
  38. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/agent.py +0 -0
  39. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/deployment.py +0 -0
  40. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/evaluation.py +0 -0
  41. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/job.py +0 -0
  42. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/node.py +0 -0
  43. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/volume.py +0 -0
  44. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/__init__.py +0 -0
  45. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/agent.py +0 -0
  46. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/allocations.py +0 -0
  47. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/base.py +0 -0
  48. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/deployments.py +0 -0
  49. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/evaluations.py +0 -0
  50. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/nodes.py +0 -0
  51. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/status.py +0 -0
  52. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/system.py +0 -0
  53. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/volumes.py +0 -0
  54. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/transport.py +0 -0
  55. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/__init__.py +0 -0
  56. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/alloc_target.py +0 -0
  57. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/selection.py +0 -0
  58. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/__init__.py +0 -0
  59. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/alloc_rows.py +0 -0
  60. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/duration.py +0 -0
  61. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/links.py +0 -0
  62. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/panels.py +0 -0
  63. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/prompts.py +0 -0
  64. {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/styles.py +0 -0
  65. {nomadctl-0.2.0 → nomadctl-0.3.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.3.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
@@ -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. |
@@ -197,12 +198,13 @@ touching the cluster:
197
198
 
198
199
  ```bash
199
200
  nd run --dry-run
201
+ nd update web --dry-run
200
202
  nd stop web --dry-run
201
203
  nd volume register --dry-run
202
204
  ```
203
205
 
204
- For `nd run`, a dry run still validates each job file locally, so it catches a
205
- broken spec without registering anything.
206
+ For `nd run` and `nd update`, a dry run still validates each job file locally, so it
207
+ catches a broken spec without registering anything.
206
208
 
207
209
  ### Deploying jobs
208
210
 
@@ -216,6 +218,30 @@ nd run web # deploy the job whose name starts with "web"
216
218
  nd run web --detach # register and return immediately
217
219
  ```
218
220
 
221
+ ### Updating jobs
222
+
223
+ `nd update` recreates a job that is already running. Reach for it to roll out an
224
+ edited job file, or to pull a fresh version when the file is unchanged, such as a
225
+ container that tracks a moving tag. It only offers jobs that are both running and
226
+ have a local file.
227
+
228
+ Each selected job is stopped, drained, purged, then re-registered from its local
229
+ file and watched until the new rollout settles. The job is fully recreated, so
230
+ expect brief downtime. `nd update` confirms before it acts unless you pass `--force`
231
+ (`-f`), and purges by default (unlike `nd stop`, which keeps the job unless you pass
232
+ `--purge`); pass `--no-purge` to keep the job's version history.
233
+
234
+ ```bash
235
+ nd update # choose from every running job that has a local file
236
+ nd update web # recreate the job whose name starts with "web"
237
+ nd update web --no-purge # recreate but keep the version history
238
+ nd update web --force # skip the confirmation prompt
239
+ ```
240
+
241
+ Whether a new container image is actually pulled depends on the job's Docker driver
242
+ config, such as `force_pull` or a pinned digest, not on `nd`. The recreate
243
+ guarantees fresh allocations; the image policy stays with your job spec.
244
+
219
245
  ### Working with logs
220
246
 
221
247
  `nd logs` streams both stdout and stderr live until you press Ctrl-C. Narrow or
@@ -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. |
@@ -165,12 +166,13 @@ touching the cluster:
165
166
 
166
167
  ```bash
167
168
  nd run --dry-run
169
+ nd update web --dry-run
168
170
  nd stop web --dry-run
169
171
  nd volume register --dry-run
170
172
  ```
171
173
 
172
- For `nd run`, a dry run still validates each job file locally, so it catches a
173
- broken spec without registering anything.
174
+ For `nd run` and `nd update`, a dry run still validates each job file locally, so it
175
+ catches a broken spec without registering anything.
174
176
 
175
177
  ### Deploying jobs
176
178
 
@@ -184,6 +186,30 @@ nd run web # deploy the job whose name starts with "web"
184
186
  nd run web --detach # register and return immediately
185
187
  ```
186
188
 
189
+ ### Updating jobs
190
+
191
+ `nd update` recreates a job that is already running. Reach for it to roll out an
192
+ edited job file, or to pull a fresh version when the file is unchanged, such as a
193
+ container that tracks a moving tag. It only offers jobs that are both running and
194
+ have a local file.
195
+
196
+ Each selected job is stopped, drained, purged, then re-registered from its local
197
+ file and watched until the new rollout settles. The job is fully recreated, so
198
+ expect brief downtime. `nd update` confirms before it acts unless you pass `--force`
199
+ (`-f`), and purges by default (unlike `nd stop`, which keeps the job unless you pass
200
+ `--purge`); pass `--no-purge` to keep the job's version history.
201
+
202
+ ```bash
203
+ nd update # choose from every running job that has a local file
204
+ nd update web # recreate the job whose name starts with "web"
205
+ nd update web --no-purge # recreate but keep the version history
206
+ nd update web --force # skip the confirmation prompt
207
+ ```
208
+
209
+ Whether a new container image is actually pulled depends on the job's Docker driver
210
+ config, such as `force_pull` or a pinned digest, not on `nd`. The recreate
211
+ guarantees fresh allocations; the image policy stays with your job spec.
212
+
187
213
  ### Working with logs
188
214
 
189
215
  `nd logs` streams both stdout and stderr live until you press Ctrl-C. Narrow or
@@ -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.3.0"
32
32
 
33
33
  [project.urls]
34
34
  Homepage = "https://github.com/natelandau/nd"
@@ -1,6 +1,6 @@
1
1
  """The nd package."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.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")
@@ -17,7 +17,7 @@ from nd.commands._common import VerboseOption, configure_verbosity
17
17
  from nd.constants import DEPLOY_TIMEOUT_SECONDS, HEALTHY_ALLOC_STATUSES, POLL_INTERVAL_SECONDS
18
18
  from nd.jobfiles import candidates_for, discover_job_files, load_job_directories
19
19
  from nd.nomad import NomadClient, NomadConfig
20
- from nd.nomad.errors import NomadError
20
+ from nd.nomad.errors import NomadDecodeError, NomadError
21
21
  from nd.targets import resolve_targets, select_candidates
22
22
  from nd.ui.alloc_rows import alloc_children
23
23
  from nd.ui.duration import summary_title
@@ -106,10 +106,12 @@ def _task_role(lifecycle: dict[str, object] | None, index: int) -> tuple[int, st
106
106
  return (1_000 + index, "main")
107
107
 
108
108
 
109
+ # Outcome labels carry the same color as their glyph so the status word reads as
110
+ # success/failure at a glance, not just the leading mark.
109
111
  _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"),
112
+ DeployStatus.DEPLOYED: (OUTCOME_GLYPH["ok"], "[green]deployed[/]"),
113
+ DeployStatus.FAILED: (OUTCOME_GLYPH["fail"], "[red]failed[/]"),
114
+ DeployStatus.TIMEOUT: (OUTCOME_GLYPH["warn"], "[yellow]still deploying[/]"),
113
115
  }
114
116
 
115
117
 
@@ -316,8 +318,13 @@ async def _deploy_one(
316
318
  lifecycle = task_lifecycle(body)
317
319
  update("registering")
318
320
  resp = await client.jobs.register(body)
319
- outcome = await _watch(
320
- client, candidate.name, node_names=node_names, lifecycle=lifecycle, update=update
321
+ outcome = await watch_deploy(
322
+ client,
323
+ candidate.name,
324
+ node_names=node_names,
325
+ lifecycle=lifecycle,
326
+ update=update,
327
+ since_index=resp.job_modify_index,
321
328
  )
322
329
  # Attach any register warnings so the caller can surface them after the panel closes.
323
330
  return replace(outcome, warnings=resp.warnings)
@@ -325,13 +332,14 @@ async def _deploy_one(
325
332
  return DeployOutcome(candidate.name, DeployStatus.FAILED, str(exc))
326
333
 
327
334
 
328
- async def _watch(
335
+ async def watch_deploy(
329
336
  client: NomadClient,
330
337
  job_id: str,
331
338
  *,
332
339
  node_names: dict[str, str],
333
340
  lifecycle: TaskLifecycle,
334
341
  update: PanelUpdate,
342
+ since_index: int = 0,
335
343
  ) -> DeployOutcome:
336
344
  """Poll a registered job until its deployment (or allocations) settle or time out.
337
345
 
@@ -346,27 +354,47 @@ async def _watch(
346
354
  node_names: Map of node ID to node name for the per-allocation detail rows.
347
355
  lifecycle: Task ordering and labels from the compiled job spec.
348
356
  update: Callback to update the live panel phase text and detail rows.
357
+ since_index: The ``JobModifyIndex`` from this registration. Deployments
358
+ created before it belong to a previous run and are ignored so a re-run
359
+ of a dead job is not reported as instantly deployed off a stale record.
349
360
 
350
361
  Returns:
351
362
  The terminal deploy outcome for this job.
352
363
  """
353
364
  deadline = time.monotonic() + DEPLOY_TIMEOUT_SECONDS
354
365
  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)
366
+ try:
367
+ allocs = await client.jobs.allocations(job_id)
368
+ deployments = await client.jobs.deployments(job_id)
369
+ # The plural endpoint's ordering is undocumented, so pick this run's
370
+ # deployment by index rather than trusting position. A job that has ever
371
+ # run keeps its prior deployments listed; ignoring those created before
372
+ # this registration is what stops a stale "successful" record from
373
+ # ending the watch the instant a dead job is re-run.
374
+ mine = [d for d in deployments if d.create_index >= since_index]
375
+ latest = max(mine, key=lambda d: d.create_index) if mine else None
376
+ dep = await client.deployments.read(latest.id) if latest else None
377
+ except NomadDecodeError as exc:
378
+ # A freshly-placed allocation can momentarily serialize in a shape we
379
+ # cannot decode (e.g. TaskStates: null before its tasks start). Skip
380
+ # this tick and retry rather than failing an otherwise-healthy deploy;
381
+ # the deadline below is the backstop if it never recovers.
382
+ pp.debug(f"{job_id}: skipping poll after transient decode error: {exc}")
383
+ else:
384
+ children = alloc_children(allocs, node_names, lifecycle)
385
+ if dep is not None: # service job: follow this run's deployment
386
+ if dep.status == _DEPLOY_SUCCESS:
387
+ return DeployOutcome(job_id, DeployStatus.DEPLOYED)
388
+ if dep.status in _DEPLOY_FAILURE:
389
+ return DeployOutcome(job_id, DeployStatus.FAILED, dep.status_description)
390
+ update(deploy_phase(dep), children)
391
+ elif deployments: # service job whose new deployment has not appeared yet
392
+ update("registering", children)
393
+ else: # batch/system job: follow allocations
394
+ running = sum(1 for a in allocs if a.client_status in HEALTHY_ALLOC_STATUSES)
395
+ if allocs and running == len(allocs):
396
+ return DeployOutcome(job_id, DeployStatus.DEPLOYED)
397
+ update(f"placing {running}/{len(allocs) or '?'} allocs", children)
370
398
  if time.monotonic() >= deadline:
371
399
  return DeployOutcome(job_id, DeployStatus.TIMEOUT, "deploy still in progress")
372
400
  await asyncio.sleep(POLL_INTERVAL_SECONDS)
@@ -69,29 +69,67 @@ def _banner_title(report: StatusReport) -> str:
69
69
  return title
70
70
 
71
71
 
72
+ def _fraction(part: int, whole: int, *, suffix: str = "") -> str:
73
+ """Render an ``n/total`` fraction, flagging a shortfall in yellow.
74
+
75
+ Values stay at full brightness so the data rail reads louder than the dim label rail.
76
+ """
77
+ value = f"{part}/{whole}"
78
+ colored = value if part >= whole else f"[yellow]{value}[/]"
79
+ return f"{colored} {suffix}" if suffix else colored
80
+
81
+
82
+ def _count(value: int, label: str, *, style: str) -> str:
83
+ """Render a ``value label`` pair, applying ``style`` only when the count is non-zero."""
84
+ shown = f"[{style}]{value}[/]" if value else str(value)
85
+ return f"{shown} {label}"
86
+
87
+
88
+ def _allocs_value(report: StatusReport) -> str:
89
+ """Render the running/failed/pending alloc breakdown with severity-coded counts."""
90
+ return " ".join(
91
+ (
92
+ _count(report.allocs_running, "running", style="green"),
93
+ _count(report.allocs_failed, "failed", style="red"),
94
+ _count(report.allocs_pending, "pending", style="yellow"),
95
+ )
96
+ )
97
+
98
+
72
99
  def _banner(report: StatusReport) -> Panel:
73
- """Build the top summary banner."""
100
+ """Build the top summary banner as an aligned two-column key/value grid."""
74
101
  style = _HEALTH_STYLE[report.health]
75
- grid = Table.grid(padding=(0, 0))
76
- grid.add_row(f"[{style}]●[/] [bold {style}]{report.health.value}[/]")
77
- grid.add_row(
78
- f"Servers {report.servers_alive}/{report.servers_total} · "
79
- f"leader {report.leader_name or 'none'} "
80
- f"Nodes {report.nodes_ready}/{report.nodes_total} ready"
81
- )
82
- grid.add_row(
83
- f"Jobs {report.jobs_running}/{report.jobs_total} running "
84
- f"Allocs {report.allocs_running} running · "
85
- f"{report.allocs_failed} failed · {report.allocs_pending} pending"
86
- )
87
- grid.add_row(
88
- f"Deployments {len(report.deployments_active)} active "
89
- f"Evals {len(report.evals_problem)} blocked "
90
- f"Volumes {report.volumes_total}"
91
- )
92
- return Panel(
93
- grid, title=_banner_title(report), title_align="left", border_style=style, expand=False
94
- )
102
+
103
+ # Left group is infrastructure, right group is workload; uppercase labels echo the
104
+ # table column headers below so the banner and panels read as one type system.
105
+ left: list[tuple[str, str]] = [
106
+ ("SERVERS", _fraction(report.servers_alive, report.servers_total, suffix="alive")),
107
+ ("LEADER", report.leader_name or "[red]none[/]"),
108
+ ("NODES", _fraction(report.nodes_ready, report.nodes_total, suffix="ready")),
109
+ ("VOLUMES", str(report.volumes_total)),
110
+ ]
111
+ right: list[tuple[str, str]] = [
112
+ ("JOBS", _fraction(report.jobs_running, report.jobs_total, suffix="running")),
113
+ ("ALLOCS", _allocs_value(report)),
114
+ ("DEPLOYS", _count(len(report.deployments_active), "active", style="cyan")),
115
+ ("EVALS", _count(len(report.evals_problem), "blocked", style="yellow")),
116
+ ]
117
+
118
+ # Left-aligned label and value columns form four vertical rails the eye can follow; the
119
+ # dim divider stacks into a continuous hairline that splits infrastructure from workload.
120
+ grid = Table.grid(padding=(0, 2))
121
+ grid.add_column(justify="left", style="dim") # left label
122
+ grid.add_column(justify="left") # left value
123
+ grid.add_column(justify="left", style="dim") # divider
124
+ grid.add_column(justify="left", style="dim") # right label
125
+ grid.add_column(justify="left") # right value
126
+ for (l_label, l_value), (r_label, r_value) in zip(left, right, strict=True):
127
+ grid.add_row(l_label, l_value, "│", r_label, r_value)
128
+
129
+ # The verdict lives in the title, right after the host, so the body is pure data.
130
+ verdict = f"[{style}]●[/] [bold {style}]{report.health.value}[/]"
131
+ title = f"{_banner_title(report)} · {verdict}"
132
+ return Panel(grid, title=title, title_align="left", border_style=style, expand=False)
95
133
 
96
134
 
97
135
  def _nodes_panel(report: StatusReport) -> Panel:
@@ -25,6 +25,9 @@ if TYPE_CHECKING:
25
25
 
26
26
  # Allocation client statuses that represent live work (counted in the per-node/per-job columns).
27
27
  _ACTIVE_ALLOC_STATUSES = frozenset({"running", "pending"})
28
+ # Desired statuses Nomad assigns to allocations it has retired (rescheduled, drained, superseded).
29
+ # Their client_status is historical, so they must not count toward current cluster health.
30
+ _RETIRED_DESIRED_STATUSES = frozenset({"stop", "evict"})
28
31
  # Deployment statuses that represent an in-progress (notable) rollout.
29
32
  _ACTIVE_DEPLOYMENT_STATUSES = frozenset({"running", "pending", "blocked", "paused", "unblocking"})
30
33
  # Evaluation statuses that indicate the scheduler is stuck.
@@ -137,9 +140,12 @@ def build_report( # noqa: PLR0913
137
140
  key=lambda e: e.job_id,
138
141
  )
139
142
 
140
- allocs_failed = sum(1 for a in allocs if a.client_status == "failed")
141
- allocs_pending = sum(1 for a in allocs if a.client_status == "pending")
142
- allocs_unhealthy = any(a.client_status not in HEALTHY_ALLOC_STATUSES for a in allocs)
143
+ # Only allocs Nomad still wants running reflect current health; a "failed" corpse that has
144
+ # already been rescheduled carries desired_status "stop"/"evict" and must be ignored.
145
+ live_allocs = [a for a in allocs if a.desired_status not in _RETIRED_DESIRED_STATUSES]
146
+ allocs_failed = sum(1 for a in live_allocs if a.client_status == "failed")
147
+ allocs_pending = sum(1 for a in live_allocs if a.client_status == "pending")
148
+ allocs_unhealthy = any(a.client_status not in HEALTHY_ALLOC_STATUSES for a in live_allocs)
143
149
  node_alloc_counts = Counter(
144
150
  a.node_id for a in allocs if a.client_status in _ACTIVE_ALLOC_STATUSES
145
151
  )
@@ -19,7 +19,7 @@ from nd.constants import (
19
19
  TERMINAL_ALLOC_STATUSES,
20
20
  )
21
21
  from nd.nomad import NomadClient, NomadConfig
22
- from nd.nomad.errors import NomadError
22
+ from nd.nomad.errors import NomadDecodeError, NomadError
23
23
  from nd.targets import resolve_targets, select_candidates
24
24
  from nd.ui.alloc_rows import alloc_children
25
25
  from nd.ui.duration import summary_title
@@ -53,14 +53,15 @@ class StopOutcome:
53
53
  detail: str = ""
54
54
 
55
55
 
56
- # Maps each terminal stop status to its outcome glyph and row label.
56
+ # Maps each terminal stop status to its outcome glyph and row label. Each label
57
+ # carries its glyph's color so the status word reads as success/failure on its own.
57
58
  _OUTCOME_ROW: dict[StopStatus, tuple[str, str]] = {
58
- StopStatus.STOPPED: (OUTCOME_GLYPH["ok"], "stopped"),
59
- StopStatus.TIMEOUT: (OUTCOME_GLYPH["warn"], "still draining"),
60
- StopStatus.FAILED: (OUTCOME_GLYPH["fail"], "failed"),
59
+ StopStatus.STOPPED: (OUTCOME_GLYPH["ok"], "[green]stopped[/]"),
60
+ StopStatus.TIMEOUT: (OUTCOME_GLYPH["warn"], "[yellow]still draining[/]"),
61
+ StopStatus.FAILED: (OUTCOME_GLYPH["fail"], "[red]failed[/]"),
61
62
  # The workload did stop; only the follow-up garbage-collection failed, so this
62
63
  # warns rather than reading as a hard "failed to stop".
63
- StopStatus.PURGE_FAILED: (OUTCOME_GLYPH["warn"], "stopped, purge failed"),
64
+ StopStatus.PURGE_FAILED: (OUTCOME_GLYPH["warn"], "[yellow]stopped, purge failed[/]"),
64
65
  }
65
66
 
66
67
 
@@ -153,21 +154,29 @@ async def stop_and_wait(
153
154
 
154
155
  deadline = time.monotonic() + STOP_TIMEOUT_SECONDS
155
156
  while True:
156
- start = time.perf_counter()
157
- allocs = await client.jobs.allocations(job.id)
158
- pending = sum(1 for a in allocs if a.client_status not in TERMINAL_ALLOC_STATUSES)
159
- elapsed_ms = (time.perf_counter() - start) * 1000
160
- pp.trace(
161
- f"GET /v1/job/{job.id}/allocations -> {len(allocs)} allocs "
162
- f"({pending} not terminal), {elapsed_ms:.0f}ms"
163
- )
164
- if all_allocs_terminal(allocs):
165
- if purge:
166
- return await _purge_dead_job(client, job, update=update)
167
- return StopOutcome(job, StopStatus.STOPPED)
157
+ try:
158
+ start = time.perf_counter()
159
+ allocs = await client.jobs.allocations(job.id)
160
+ except NomadDecodeError as exc:
161
+ # A post-stop/cleanup task that just (re)started can momentarily
162
+ # serialize in a shape we cannot decode; skip this tick and retry
163
+ # rather than reporting the stop as failed. The deadline below is
164
+ # the backstop if it never recovers.
165
+ pp.debug(f"{job.id}: skipping drain poll after transient decode error: {exc}")
166
+ else:
167
+ pending = sum(1 for a in allocs if a.client_status not in TERMINAL_ALLOC_STATUSES)
168
+ elapsed_ms = (time.perf_counter() - start) * 1000
169
+ pp.trace(
170
+ f"GET /v1/job/{job.id}/allocations -> {len(allocs)} allocs "
171
+ f"({pending} not terminal), {elapsed_ms:.0f}ms"
172
+ )
173
+ if all_allocs_terminal(allocs):
174
+ if purge:
175
+ return await _purge_dead_job(client, job, update=update)
176
+ return StopOutcome(job, StopStatus.STOPPED)
177
+ update(phase_text(allocs), alloc_children(allocs, node_names, None))
168
178
  if time.monotonic() >= deadline:
169
179
  return StopOutcome(job, StopStatus.TIMEOUT, "stop requested, still draining")
170
- update(phase_text(allocs), alloc_children(allocs, node_names, None))
171
180
  await asyncio.sleep(POLL_INTERVAL_SECONDS)
172
181
  except NomadError as exc:
173
182
  return StopOutcome(job, StopStatus.FAILED, str(exc))
@@ -0,0 +1,345 @@
1
+ """The ``nd update`` command: recreate a running job from its local job file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import enum
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Annotated
9
+
10
+ import typer
11
+ from nclutils import pp
12
+
13
+ from nd.binary import NomadBinary, NomadBinaryError
14
+ from nd.commands._common import VerboseOption, configure_verbosity
15
+ from nd.commands.run import DeployStatus, task_lifecycle, watch_deploy
16
+ from nd.commands.stop import StopStatus, stop_and_wait
17
+ from nd.jobfiles import candidates_for, discover_job_files, load_job_directories
18
+ from nd.nomad import NomadClient, NomadConfig
19
+ from nd.nomad.errors import NomadError
20
+ from nd.targets import resolve_targets, select_candidates
21
+ from nd.ui.duration import summary_title
22
+ from nd.ui.live_panel import PanelUpdate, run_rows
23
+ from nd.ui.prompts import select_one
24
+ from nd.ui.styles import OUTCOME_GLYPH
25
+
26
+ if TYPE_CHECKING:
27
+ from nd.jobfiles import JobFile
28
+ from nd.nomad.models.job import JobListStub
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class UpdateTarget:
33
+ """A running job paired with the local file that declares it.
34
+
35
+ Carries both halves the recreate needs: the ``file`` to compile and register,
36
+ and the running ``job`` stub to stop and drain.
37
+ """
38
+
39
+ name: str
40
+ file: JobFile
41
+ job: JobListStub
42
+
43
+
44
+ def build_update_targets(files: list[JobFile], running: list[JobListStub]) -> list[UpdateTarget]:
45
+ """Pair each declared job that is currently running with its local file.
46
+
47
+ A job running in the cluster with no local file cannot be updated (there is no
48
+ spec to re-register), and a local job that is not running is a ``run``, not an
49
+ update, so both are omitted.
50
+
51
+ Args:
52
+ files: Discovered local job files and the names each declares.
53
+ running: The jobs currently running in the cluster.
54
+
55
+ Returns:
56
+ One target per declared job name that is also running.
57
+ """
58
+ by_name = {job.name: job for job in running}
59
+ targets: list[UpdateTarget] = []
60
+ for candidate in candidates_for(files):
61
+ job = by_name.get(candidate.name)
62
+ if job is not None:
63
+ targets.append(UpdateTarget(name=candidate.name, file=candidate.file, job=job))
64
+ return targets
65
+
66
+
67
+ class UpdateStatus(enum.StrEnum):
68
+ """The terminal outcome of recreating one job."""
69
+
70
+ UPDATED = "updated"
71
+ FAILED = "failed"
72
+ TIMEOUT = "timeout"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class UpdateOutcome:
77
+ """The result of recreating one job, ready for summary rendering."""
78
+
79
+ name: str
80
+ status: UpdateStatus
81
+ detail: str = ""
82
+ warnings: str = ""
83
+
84
+
85
+ # Outcome labels carry their glyph's color so the status word reads on its own.
86
+ _OUTCOME_ROW: dict[UpdateStatus, tuple[str, str]] = {
87
+ UpdateStatus.UPDATED: (OUTCOME_GLYPH["ok"], "[green]updated[/]"),
88
+ UpdateStatus.FAILED: (OUTCOME_GLYPH["fail"], "[red]failed[/]"),
89
+ UpdateStatus.TIMEOUT: (OUTCOME_GLYPH["warn"], "[yellow]still deploying[/]"),
90
+ }
91
+
92
+ # Map the deploy watch's terminal status onto the update outcome.
93
+ _DEPLOY_TO_UPDATE: dict[DeployStatus, UpdateStatus] = {
94
+ DeployStatus.DEPLOYED: UpdateStatus.UPDATED,
95
+ DeployStatus.FAILED: UpdateStatus.FAILED,
96
+ DeployStatus.TIMEOUT: UpdateStatus.TIMEOUT,
97
+ }
98
+
99
+ # Stop outcomes that mean the workload reached a terminal state, so the recreate
100
+ # may safely re-register. A drain that timed out or errored must not be re-run.
101
+ _STOP_PROCEED = frozenset({StopStatus.STOPPED, StopStatus.PURGE_FAILED})
102
+
103
+
104
+ async def _update_one(
105
+ client: NomadClient,
106
+ target: UpdateTarget,
107
+ *,
108
+ node_names: dict[str, str],
109
+ update: PanelUpdate,
110
+ nomad: NomadBinary,
111
+ purge: bool,
112
+ ) -> UpdateOutcome:
113
+ """Recreate one running job: compile, stop, re-register, and watch the rollout.
114
+
115
+ The new spec is compiled (and thereby parsed) before the running job is stopped,
116
+ so a bad file never tears down a healthy job. A drain that does not reach a
117
+ terminal state aborts the recreate without re-registering; a re-register that
118
+ fails after a clean stop is reported as the job being left down. Never raises:
119
+ Nomad/binary failures become a terminal outcome so a sibling job is unaffected.
120
+
121
+ Args:
122
+ client: Authenticated Nomad client.
123
+ target: The running job paired with its local file.
124
+ node_names: Map of node ID to node name for the per-allocation detail rows.
125
+ update: Callback to update the live panel phase text and detail rows.
126
+ nomad: Configured ``nomad`` binary handle for the compile step.
127
+ purge: Whether to garbage-collect the job after it drains.
128
+
129
+ Returns:
130
+ The terminal outcome for this target.
131
+ """
132
+ try:
133
+ update("compiling")
134
+ # compile_to_json shells out to the nomad binary (blocking); run it off the
135
+ # event loop so sibling recreates keep making progress concurrently.
136
+ body = await asyncio.to_thread(nomad.compile_to_json, target.file.path)
137
+ lifecycle = task_lifecycle(body)
138
+ except NomadBinaryError as exc:
139
+ return UpdateOutcome(target.name, UpdateStatus.FAILED, f"compile failed: {exc}")
140
+
141
+ # Stop and watch the drain (and optional purge) before touching the new version.
142
+ stop_outcome = await stop_and_wait(
143
+ client, target.job, purge=purge, node_names=node_names, update=update
144
+ )
145
+ if stop_outcome.status not in _STOP_PROCEED:
146
+ # The job is still draining or the stop errored; leaving it untouched is safer
147
+ # than re-registering on top of a job that has not come down.
148
+ status = (
149
+ UpdateStatus.TIMEOUT
150
+ if stop_outcome.status is StopStatus.TIMEOUT
151
+ else UpdateStatus.FAILED
152
+ )
153
+ return UpdateOutcome(
154
+ target.name,
155
+ status,
156
+ f"not re-deployed (stop {stop_outcome.status}): {stop_outcome.detail}",
157
+ )
158
+
159
+ try:
160
+ update("registering")
161
+ resp = await client.jobs.register(body)
162
+ except NomadError as exc:
163
+ # The old version is already gone here, so call out that the job is down.
164
+ return UpdateOutcome(
165
+ target.name, UpdateStatus.FAILED, f"stopped but re-deploy failed: {exc}"
166
+ )
167
+
168
+ try:
169
+ outcome = await watch_deploy(
170
+ client,
171
+ target.name,
172
+ node_names=node_names,
173
+ lifecycle=lifecycle,
174
+ update=update,
175
+ since_index=resp.job_modify_index,
176
+ )
177
+ except NomadError as exc:
178
+ # watch_deploy polls the cluster, so a transient error here would otherwise
179
+ # escape and crash the concurrent panel, killing every sibling job's watch.
180
+ # The job is already registered, so report the watch failure rather than raise.
181
+ return UpdateOutcome(
182
+ target.name, UpdateStatus.FAILED, f"re-registered but watch failed: {exc}"
183
+ )
184
+ return UpdateOutcome(
185
+ target.name, _DEPLOY_TO_UPDATE[outcome.status], outcome.detail, warnings=resp.warnings
186
+ )
187
+
188
+
189
+ # allow_interspersed_args lets options follow the positional JOB argument; Typer
190
+ # groups disable that by default, which would parse flags as subcommands.
191
+ app = typer.Typer(context_settings={"allow_interspersed_args": True})
192
+
193
+
194
+ @app.callback(invoke_without_command=True)
195
+ def update(
196
+ ctx: typer.Context,
197
+ job: Annotated[
198
+ str | None,
199
+ typer.Argument(
200
+ help="Running job to update; matches any running job whose name starts "
201
+ "with this and has a local file. Omit to pick from a list."
202
+ ),
203
+ ] = None,
204
+ no_purge: Annotated[ # noqa: FBT002
205
+ bool,
206
+ typer.Option(
207
+ "--no-purge",
208
+ help="Stop without garbage-collecting the job (keeps its version history).",
209
+ ),
210
+ ] = False,
211
+ force: Annotated[ # noqa: FBT002
212
+ bool,
213
+ typer.Option("--force", "-f", help="Skip the confirmation prompt."),
214
+ ] = False,
215
+ dry_run: Annotated[ # noqa: FBT002
216
+ bool,
217
+ typer.Option("--dry-run", "-n", help="Resolve and validate without recreating."),
218
+ ] = False,
219
+ verbose: VerboseOption = 0,
220
+ ) -> None:
221
+ """Recreate one or more running jobs from their local job files.
222
+
223
+ Each selected job is compiled and validated, then stopped, drained, purged (unless
224
+ --no-purge), re-registered, and watched to a terminal deploy state. Use this to
225
+ roll out a changed job file or to force a fresh version (e.g. re-pull a docker
226
+ image); whether an image is actually re-pulled depends on the job's docker driver
227
+ config (force_pull), not on nd.
228
+ """
229
+ configure_verbosity(ctx, verbose)
230
+ exit_code = asyncio.run(_run(job_arg=job, no_purge=no_purge, force=force, dry_run=dry_run))
231
+ if exit_code != 0:
232
+ raise typer.Exit(exit_code)
233
+
234
+
235
+ async def _run(*, job_arg: str | None, no_purge: bool, force: bool, dry_run: bool) -> int: # noqa: PLR0911
236
+ """Resolve running targets with local files, confirm, then recreate them.
237
+
238
+ Returns the exit code: 0 on clean success, 1 on any failure. The new spec for
239
+ every target is validated up front, before any job is stopped.
240
+ """
241
+ files = discover_job_files(load_job_directories())
242
+ config = NomadConfig.resolve()
243
+ async with NomadClient.from_config(config) as client:
244
+ jobs = await client.jobs.list()
245
+ running = [j for j in jobs if j.status == "running"]
246
+ targets_all = build_update_targets(files, running)
247
+ if not targets_all:
248
+ pp.info("No running jobs have a local job file to update.")
249
+ return 0
250
+
251
+ resolution = resolve_targets(targets_all, job_arg, name_of=lambda t: t.name)
252
+ targets = await select_candidates(
253
+ resolution,
254
+ "Select jobs to update",
255
+ label_of=lambda t: f"{t.name} [{t.file.path.name}]",
256
+ )
257
+ if targets is None:
258
+ return 0
259
+ if not targets:
260
+ pp.error(f"No running job with a local file matching '{job_arg}'")
261
+ return 1
262
+
263
+ purge = not no_purge
264
+ if not force and not await _confirm(targets, purge=purge):
265
+ pp.info("Aborted")
266
+ return 0
267
+
268
+ try:
269
+ nomad = NomadBinary.create(config)
270
+ # dict.fromkeys dedups so a multi-job file is validated once.
271
+ for path in dict.fromkeys(t.file.path for t in targets):
272
+ nomad.validate(path)
273
+ except NomadBinaryError as exc:
274
+ pp.error(str(exc))
275
+ return 1
276
+
277
+ if dry_run:
278
+ for t in targets:
279
+ pp.dryrun(f"would recreate {t.name} ({t.file.path})")
280
+ return 0
281
+
282
+ outcomes = await _update_all(client, targets, nomad, purge=purge)
283
+
284
+ return 0 if all(o.status is UpdateStatus.UPDATED for o in outcomes) else 1
285
+
286
+
287
+ async def _confirm(targets: list[UpdateTarget], *, purge: bool) -> bool:
288
+ """Ask the user to confirm recreating the resolved jobs."""
289
+ names = ", ".join(t.name for t in targets)
290
+ verb = "Stop, PURGE and re-deploy" if purge else "Stop and re-deploy"
291
+ answer = await select_one(
292
+ [("Yes", True), ("No", False)],
293
+ f"{verb} {len(targets)} job(s): {names}?",
294
+ )
295
+ return bool(answer)
296
+
297
+
298
+ async def _update_all(
299
+ client: NomadClient, targets: list[UpdateTarget], nomad: NomadBinary, *, purge: bool
300
+ ) -> list[UpdateOutcome]:
301
+ """Recreate every target concurrently under one live panel.
302
+
303
+ Args:
304
+ client: Authenticated Nomad client.
305
+ targets: The running jobs to recreate.
306
+ nomad: Configured ``nomad`` binary handle for the compile step.
307
+ purge: Whether to garbage-collect each job after it drains.
308
+
309
+ Returns:
310
+ Ordered list of outcomes, one per target.
311
+ """
312
+ # Resolve node IDs to names once so every job's detail rows can show placement.
313
+ node_names = {node.id: node.name for node in await client.nodes.list()}
314
+
315
+ async def do_work(target: UpdateTarget, update: PanelUpdate) -> UpdateOutcome:
316
+ return await _update_one(
317
+ client, target, node_names=node_names, update=update, nomad=nomad, purge=purge
318
+ )
319
+
320
+ ordered = await run_rows(
321
+ targets,
322
+ do_work,
323
+ label_of=lambda t: t.name,
324
+ initial_phase="compiling",
325
+ finish_of=lambda o: _OUTCOME_ROW[o.status],
326
+ running_title=f"Updating {len(targets)} job(s)",
327
+ final_title=_final_title,
328
+ )
329
+
330
+ # The live panel is transient on a pipe/CI; emit a durable line for anything that
331
+ # did not update cleanly so timeouts and a left-down job are never silent.
332
+ for o in ordered:
333
+ if o.status is UpdateStatus.TIMEOUT:
334
+ pp.warning(f"{o.name}: {o.detail or 'still deploying'}")
335
+ elif o.status is UpdateStatus.FAILED:
336
+ pp.error(f"{o.name} failed to update", details=[o.detail] if o.detail else None)
337
+ if o.warnings:
338
+ pp.warning(f"{o.name}: {o.warnings}")
339
+ return ordered
340
+
341
+
342
+ def _final_title(outcomes: list[UpdateOutcome], elapsed_seconds: float) -> str:
343
+ """Build the final panel title with updated totals and elapsed seconds."""
344
+ ok = sum(1 for o in outcomes if o.status is UpdateStatus.UPDATED)
345
+ return summary_title("Updated", ok, len(outcomes), elapsed_seconds)
@@ -5,16 +5,36 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from nclutils import pp
8
+ from rich.console import Group
8
9
  from rich.tree import Tree
9
10
 
10
11
  from nd.commands.volume.report import ALREADY_REGISTERED_REASON
11
12
  from nd.ui.panels import status_table, titled_panel
12
13
 
13
14
  if TYPE_CHECKING:
15
+ from rich.console import RenderableType
16
+
14
17
  from nd.commands.volume.report import Registration, VolumeRow
15
18
  from nd.nomad.models.volume import HostVolumeListStub
16
19
 
17
20
 
21
+ def _print_volume_groups(title: str, trees: list[Tree]) -> None:
22
+ """Print per-volume trees stacked in one titled panel, blank-line separated.
23
+
24
+ Mirrors the framed result panels of ``nd run``/``nd stop`` so every command
25
+ speaks the same visual language, and the blank line between groups gives each
26
+ volume room to breathe instead of crowding the next one.
27
+ """
28
+ if not trees:
29
+ return
30
+ body: list[RenderableType] = []
31
+ for index, tree in enumerate(trees):
32
+ if index:
33
+ body.append("") # blank line so adjacent volume groups stay distinct
34
+ body.append(tree)
35
+ pp.console().print(titled_panel(Group(*body), title))
36
+
37
+
18
38
  def render_list(rows: list[VolumeRow]) -> None:
19
39
  """Print the host-volume table inside a titled panel with plain node names.
20
40
 
@@ -77,11 +97,17 @@ def render_registration_results(
77
97
  for reg, outcome in results:
78
98
  groups.setdefault(reg.spec.name, []).append((reg, outcome))
79
99
 
100
+ trees: list[Tree] = []
80
101
  for name, items in groups.items():
81
102
  tree = Tree(f"[bold]{name}[/]")
82
103
  for reg, outcome in items:
83
104
  tree.add(_registration_leaf(reg, outcome))
84
- pp.console().print(tree)
105
+ trees.append(tree)
106
+
107
+ # A dry-run never actually registers, so the title must not claim it did.
108
+ verb = "Would register" if any(outcome == "dryrun" for _, outcome in results) else "Registered"
109
+ count = len(groups)
110
+ _print_volume_groups(f"{verb} {count} volume{'s' if count != 1 else ''}", trees)
85
111
 
86
112
 
87
113
  def _deletion_leaf(vol: HostVolumeListStub, outcome: str, node_names: dict[str, str]) -> str:
@@ -125,8 +151,14 @@ def render_deletion_results(
125
151
  for vol, outcome in results:
126
152
  groups.setdefault(vol.name, []).append((vol, outcome))
127
153
 
154
+ trees: list[Tree] = []
128
155
  for name, items in groups.items():
129
156
  tree = Tree(f"[bold]{name}[/]")
130
157
  for vol, outcome in items:
131
158
  tree.add(_deletion_leaf(vol, outcome, node_names))
132
- pp.console().print(tree)
159
+ trees.append(tree)
160
+
161
+ # A dry-run never actually deletes, so the title must not claim it did.
162
+ verb = "Would delete" if any(outcome == "would-delete" for _, outcome in results) else "Deleted"
163
+ count = len(groups)
164
+ _print_volume_groups(f"{verb} {count} volume{'s' if count != 1 else ''}", trees)
@@ -24,10 +24,17 @@ class AllocListStub(msgspec.Struct, rename="pascal", frozen=True, kw_only=True):
24
24
  task_group: str
25
25
  client_status: str
26
26
  desired_status: str
27
- task_states: dict[str, TaskState] = msgspec.field(name="TaskStates", default_factory=dict)
27
+ # Nomad sends TaskStates: null (not an empty object) for a freshly-placed
28
+ # allocation whose tasks have not started yet, so this must tolerate null.
29
+ task_states_raw: dict[str, TaskState] | None = msgspec.field(name="TaskStates", default=None)
28
30
  create_index: int
29
31
  modify_index: int
30
32
 
33
+ @property
34
+ def task_states(self) -> dict[str, TaskState]:
35
+ """Per-task run state, with Nomad's null (tasks not yet started) read as empty."""
36
+ return self.task_states_raw or {}
37
+
31
38
 
32
39
  class Allocation(AllocListStub, frozen=True, kw_only=True):
33
40
  """An allocation as returned by ``GET /v1/allocation/:id``.
@@ -64,7 +64,7 @@ class JobsResource(BaseResource):
64
64
  async def deployments(self, job_id: str) -> builtins.list[DeploymentListStub]:
65
65
  """List a job's deployments (``GET /v1/job/:id/deployments``), following pagination.
66
66
 
67
- Nomad returns the most recent deployment first, used to watch a freshly
68
- registered service job roll out.
67
+ The endpoint's ordering is undocumented, so callers that need the most
68
+ recent deployment must select it by ``CreateIndex`` rather than position.
69
69
  """
70
70
  return await self._paginate_list(f"/job/{job_id}/deployments", DeploymentListStub)
@@ -13,12 +13,14 @@ from dataclasses import dataclass, field
13
13
  from typing import TYPE_CHECKING, Protocol
14
14
 
15
15
  from nclutils import pp
16
+ from rich import box
16
17
  from rich.live import Live
17
18
  from rich.spinner import Spinner
18
19
  from rich.table import Table
19
20
 
20
21
  from nd.ui.duration import fmt_elapsed
21
22
  from nd.ui.panels import titled_panel
23
+ from nd.ui.styles import OUTCOME_GLYPH
22
24
 
23
25
  if TYPE_CHECKING:
24
26
  from collections.abc import Awaitable, Callable, Sequence
@@ -68,30 +70,70 @@ def finish_row(
68
70
  row.ended_at = clock()
69
71
 
70
72
 
73
+ def _last_siblings(children: list[LiveChild]) -> list[bool]:
74
+ """Flag whether each child is the last among its siblings at its own depth.
75
+
76
+ Lets the tree close a branch with ``└`` and continue it with ``├`` so the
77
+ indentation reads as a real tree instead of every row looking like a leaf.
78
+ A child is the last sibling when no later row shares its depth before the
79
+ indentation drops back out of its branch.
80
+ """
81
+ result = [True] * len(children)
82
+ for i, child in enumerate(children):
83
+ for nxt in children[i + 1 :]:
84
+ if nxt.depth < child.depth:
85
+ break # left this branch; later rows belong to an outer level
86
+ if nxt.depth == child.depth:
87
+ result[i] = False
88
+ break
89
+ return result
90
+
91
+
71
92
  def _build_panel(rows: list[LiveRow], *, title: str, now: float) -> Panel:
72
93
  """Render the panel: a spinner for in-flight rows, a glyph for finished ones.
73
94
 
74
95
  Parent rows are bold and carry the spinner/glyph; child rows nest under them
75
96
  with a tree marker indented inside the label column so the text follows the
76
- tree rather than staying flush left.
97
+ tree rather than staying flush left. A dim hairline separates each parent
98
+ group, and a parent's detail rows dim once it finishes healthy so the eye
99
+ stays on the rows still in flight.
77
100
  """
78
- table = Table.grid(padding=(0, 2))
101
+ # A real table (not Table.grid) so add_section() can draw a dim hairline
102
+ # between groups; show_edge=False keeps the rule internal, collapse_padding
103
+ # reproduces the grid's tight column spacing.
104
+ table = Table(
105
+ box=box.HORIZONTALS,
106
+ show_header=False,
107
+ show_edge=False,
108
+ pad_edge=False,
109
+ collapse_padding=True,
110
+ padding=(0, 2),
111
+ border_style="dim",
112
+ )
79
113
  table.add_column() # spinner / glyph (parent rows only)
80
114
  # no_wrap keeps the tree marker attached to its label on a narrow terminal
81
115
  # (the label truncates with an ellipsis instead of splitting across lines).
82
116
  table.add_column(no_wrap=True) # label, tree-indented for children
83
117
  table.add_column() # phase / role
84
118
  table.add_column(justify="right") # elapsed / status
85
- for row in rows:
119
+ for index, row in enumerate(rows):
120
+ if index:
121
+ table.add_section() # dim hairline giving each group room to breathe
86
122
  glyph = Spinner("dots") if row.glyph is None else row.glyph
87
123
  ended = row.ended_at if row.ended_at is not None else now
88
124
  table.add_row(
89
125
  glyph, f"[bold]{row.label}[/]", row.phase, fmt_elapsed(ended - row.started_at)
90
126
  )
91
- for child in row.children:
127
+ # A healthy finish lets the detail rows recede; failures stay bright so
128
+ # the tasks remain readable while debugging.
129
+ recede = row.glyph == OUTCOME_GLYPH["ok"]
130
+ for child, is_last in zip(row.children, _last_siblings(row.children), strict=True):
92
131
  cells = [*child.cells, "", "", ""][:3] # pad/truncate to the 3 detail columns
93
- label = f"[dim]{' ' * child.depth}└[/] {cells[0]}"
94
- table.add_row("", label, cells[1], cells[2])
132
+ connector = "└" if is_last else "├"
133
+ detail = (f"[dim]{' ' * child.depth}{connector}[/] {cells[0]}", cells[1], cells[2])
134
+ if recede:
135
+ detail = tuple(f"[dim]{cell}[/]" for cell in detail)
136
+ table.add_row("", *detail)
95
137
  return titled_panel(table, title)
96
138
 
97
139
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes