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