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.
- {nomadctl-0.2.0 → nomadctl-0.3.0}/PKG-INFO +32 -6
- {nomadctl-0.2.0 → nomadctl-0.3.0}/README.md +31 -5
- {nomadctl-0.2.0 → nomadctl-0.3.0}/pyproject.toml +1 -1
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/__init__.py +1 -1
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/cli.py +2 -1
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/run.py +50 -22
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/render.py +59 -21
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/report.py +9 -3
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/stop.py +28 -19
- nomadctl-0.3.0/src/nd/commands/update.py +345 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/render.py +34 -2
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/allocation.py +8 -1
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/jobs.py +2 -2
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/live_panel.py +48 -6
- {nomadctl-0.2.0 → nomadctl-0.3.0}/LICENSE +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/env.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/binary/runner.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/_common.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/clean.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/exec.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/list.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/logs.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/plan.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/status/command.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/command.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/commands/volume/report.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/constants.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/jobfiles.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/client.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/config.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/errors.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/agent.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/deployment.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/evaluation.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/job.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/node.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/models/volume.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/agent.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/allocations.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/base.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/deployments.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/evaluations.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/nodes.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/status.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/system.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/resources/volumes.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/nomad/transport.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/alloc_target.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/targets/selection.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/__init__.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/alloc_rows.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/duration.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/links.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/panels.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/prompts.py +0 -0
- {nomadctl-0.2.0 → nomadctl-0.3.0}/src/nd/ui/styles.py +0 -0
- {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.
|
|
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`
|
|
64
|
-
shell out to it, because the HTTP API cannot parse HCL2 job files and does
|
|
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
|
|
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`
|
|
32
|
-
shell out to it, because the HTTP API cannot parse HCL2 job files and does
|
|
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
|
|
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
|
|
@@ -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
|
|
320
|
-
client,
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|