Metadata-Version: 2.4
Name: nomadctl
Version: 0.6.0
Summary: A friendly command-line tool for managing a HashiCorp Nomad homelab cluster.
Keywords: cli,devops,hashicorp,homelab,nomad,orchestration
Author: Nathaniel Landau
Author-email: Nathaniel Landau <github@natelandau.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Classifier: Typing :: Typed
Requires-Dist: httpx2>=2.9.1,<3.0.0
Requires-Dist: lark>=1.3.1,<2.0.0
Requires-Dist: msgspec>=0.21.1
Requires-Dist: nclutils>=3.4.2,<4.0.0
Requires-Dist: python-hcl2>=8.1.2,<9.0.0
Requires-Dist: rich>=15.0.0
Requires-Dist: typer>=0.27.0
Requires-Python: >=3.13, <3.15
Project-URL: Homepage, https://github.com/natelandau/nd
Project-URL: Issues, https://github.com/natelandau/nd/issues
Project-URL: Repository, https://github.com/natelandau/nd
Description-Content-Type: text/markdown

[![Automated Tests](https://github.com/natelandau/nd/actions/workflows/automated-tests.yml/badge.svg)](https://github.com/natelandau/nd/actions/workflows/automated-tests.yml) [![codecov](https://codecov.io/gh/natelandau/nd/graph/badge.svg?token=HpyhUwExqh)](https://codecov.io/gh/natelandau/nd)

# nd

A friendly command-line tool for managing a Nomad cluster.

`nd` wraps the Nomad HTTP API and the local `nomad` binary behind a small set of
task-focused commands. Instead of stitching together multiple nomad commands, you
get easy to remember commands that marry your on-disk job and volume files and the
live cluster. No more no more hunting for an allocation ID or task name, just use
your easy to remember job and volume names and the cli does the rest.

## Features

- A one-screen cluster dashboard covering nodes, jobs, allocations, deployments,
  evaluations, and host volumes.
- Deploy and stop commands that watch the rollout or drain live and report a clear
  success or failure at the end.
- Job-file aware commands that discover and work with your local `.hcl` and `.nomad` specs
- Interactive shell and log streaming for any task, with a prompt to pick the job,
  allocation, and task when the choice is ambiguous.
- Dynamic host volume management: register, delete, and list host volumes across
  every eligible node.
- Standard `NOMAD_*` environment variables work out of the box, with an optional
  config file for anything you would rather not retype.

## Requirements

- Python 3.13 or 3.14.
- A reachable Nomad cluster.
- The `nomad` binary on your `PATH`. The `plan`, `run`, `update`, `exec`, and `logs`
  commands shell out to it, because the HTTP API cannot parse HCL2 job files and does
  not own the interactive exec protocol. The other commands use the API only.

## Installation

The tool is published to PyPI as `nomadctl`. The installed command is `nd`.

Install it as an isolated CLI with [uv](https://docs.astral.sh/uv/):

```bash
uv tool install nomadctl
```

Or with `pipx`:

```bash
pipx install nomadctl
```

Confirm the install:

```bash
nd --version
```

## Configuration

`nd` reads the standard Nomad environment variables first, then overrides them with
an optional config file. If you already run `nomad` from your shell, `nd` targets
the same cluster with no extra setup.

### Environment variables

| Variable                | Purpose                      | Default                    |
| ----------------------- | ---------------------------- | -------------------------- |
| `NOMAD_ADDR`            | Cluster API address          | `http://127.0.0.1:4646`    |
| `NOMAD_TOKEN`           | ACL token                    | none                       |
| `NOMAD_NAMESPACE`       | Default namespace            | none                       |
| `NOMAD_REGION`          | Default region               | none                       |
| `NOMAD_CACERT`          | Path to a CA certificate     | none                       |
| `NOMAD_CLIENT_CERT`     | Path to a client certificate | none                       |
| `NOMAD_CLIENT_KEY`      | Path to a client key         | none                       |
| `NOMAD_TLS_SERVER_NAME` | TLS server name override     | none                       |
| `NOMAD_UI_URL`          | Base URL for web UI links    | falls back to `NOMAD_ADDR` |

### Config file

For settings you do not want to export every session, create
`~/.config/nd/config.toml` (or `$XDG_CONFIG_HOME/nd/config.toml`). Values here
override the environment.

```toml
[nomad]
address = "https://nomad.example.com:4646"
token   = "your-acl-token"
ui_url  = "https://nomad.example.com"

# Directories nd searches for .hcl and .nomad job files.
[jobs]
directories = ["~/homelab/jobs"]

# Directories nd searches for host volume spec files.
[volumes]
directories = ["~/homelab/volumes"]
```

The `[jobs]` and `[volumes]` directory lists power the file-aware commands. Without
them, `list`, `plan`, `run`, and the `volume` commands have nothing to discover.

## Quick start

Point `nd` at your cluster, then look at it:

```bash
export NOMAD_ADDR="https://nomad.example.com:4646"
export NOMAD_TOKEN="your-acl-token"

nd
```

Add a job directory to your config file, then list your specs against the live
cluster:

```bash
nd list
```

Deploy a job that is not yet running and watch it roll out:

```bash
nd run web
```

Tail its logs, then open a shell inside it:

```bash
nd logs web
nd exec web                    # open a shell inside it
nd exec web -- ps -ef          # or run a single command
nd exec web -T -- env          # -T even at your own terminal, to skip the pty's CRLF translation
```

## Commands

Run `nd --help`, or `nd <command> --help`, for the full option list at any time.

| Command                     | What it does                                                                      |
| --------------------------- | --------------------------------------------------------------------------------- |
| `nd status`                 | Show an at-a-glance overview of the cluster. Also runs when you type `nd` alone.  |
| `nd status --hosts`         | Pivot the same overview to one panel per host, listing the jobs each is running.  |
| `nd list`                   | List discovered job files and whether each is running, dead, or not deployed.     |
| `nd plan [JOB]`             | Preview the changes one or more job files would apply, including to running jobs. |
| `nd run [JOB]`              | Deploy not-yet-running job files and watch the rollout.                           |
| `nd update [JOB]`           | Recreate a running job from its local file and watch the rollout.                 |
| `nd stop [JOB]`             | Stop, and optionally purge, running jobs and watch them drain.                    |
| `nd logs [JOB]`             | Stream, tail, or export a task's logs.                                            |
| `nd exec [JOB] [-- CMD]`    | Open a shell inside a running task, or run one command in it.                     |
| `nd signal [JOB] -s SIG`    | Send a signal to a running task, such as to trigger an on-demand action.          |
| `nd clean`                  | Force garbage collection and reconcile job summaries.                             |
| `nd volume register [NAME]` | Register host volumes on every eligible node.                                     |
| `nd volume delete [NAME]`   | Delete registered host volumes matching the selected specs.                       |
| `nd volume list [NAME]`     | List host volume specs and where each is registered.                              |

### Targeting jobs by name

Commands that take a `JOB` or `NAME` argument match by case-insensitive substring:
any name that contains the text matches, so `web` finds both `web` and `reverse-web`.
A single match runs straight away; several matches open a prompt. Omit the argument
to pick from a list of every candidate.

```bash
nd run web          # runs the job whose name contains "web" (prompts if several match)
nd stop             # prompts you to choose from all running jobs
```

### Previewing before you act

Lifecycle commands accept `--dry-run` (`-n`) to report their targets without
touching the cluster:

```bash
nd run --dry-run
nd update web --dry-run
nd stop web --dry-run
nd signal web -s SIGHUP --dry-run
nd volume register --dry-run
```

For `nd run` and `nd update`, a dry run still validates each job file locally, so it
catches a broken spec without registering anything.

### Deploying jobs

`nd run` only offers jobs that are not already running. Each selected file is
validated and registered, then watched live until its deployment or allocations
settle. Use `--detach` to register and return without watching the rollout.

If a selected job is still present in the cluster as a dead job (stopped without
`--purge`), `nd run` offers to garbage-collect it first so the new version deploys
onto a clean slate rather than on top of stale deployment history. Pass `--clean`
(`-c`) to purge it without prompting; a non-interactive run leaves the dead job in
place and deploys on top.

```bash
nd run                # choose from every deployable job
nd run web            # deploy the job whose name contains "web"
nd run web --detach   # register and return immediately
nd run web --clean    # purge a leftover dead "web" first, then deploy
```

### Updating jobs

`nd update` recreates a job that is already running. Reach for it to roll out an
edited job file, or to pull a fresh version when the file is unchanged, such as a
container that tracks a moving tag. It only offers jobs that are both running and
have a local file.

Each selected job is stopped, drained, purged, then re-registered from its local
file and watched until the new rollout settles. The job is fully recreated, so
expect brief downtime. `nd update` confirms before it acts unless you pass `--force`
(`-f`), and purges by default (unlike `nd stop`, which keeps the job unless you pass
`--purge`); pass `--no-purge` to keep the job's version history.

```bash
nd update                 # choose from every running job that has a local file
nd update web             # recreate the job whose name contains "web"
nd update web --no-purge  # recreate but keep the version history
nd update web --force     # skip the confirmation prompt
```

Whether a new container image is actually pulled depends on the job's Docker driver
config, such as `force_pull` or a pinned digest, not on `nd`. The recreate
guarantees fresh allocations; the image policy stays with your job spec.

### Working with logs

`nd logs` streams both stdout and stderr live until you press Ctrl-C. Narrow or
redirect the output with flags:

```bash
nd logs web                 # follow stdout and stderr
nd logs web --stderr        # follow stderr only
nd logs web --tail 100      # print the last 100 lines, no follow
nd logs web --export run.log  # write the current logs to a file
```

### Signaling a task

Some services expose an out-of-band trigger over a POSIX signal. `nd signal` finds the
running task and delivers one, picking the job, allocation, and task the same way
`nd exec` does:

```bash
nd signal ezbak -s SIGUSR1      # ask a scheduled backup to run now
nd signal ezbak -s usr1         # same thing; the name is case-insensitive
nd signal -s SIGHUP             # pick the job from a list
nd signal web -s SIGHUP -t api  # signal the "api" task, skipping the task prompt
nd signal web -s SIGHUP -n      # show the target without sending anything
```

A success line means Nomad delivered the signal to the task. Whether the process acted
on it is up to the process: it may be busy, or may not handle that signal at all. Check
with `nd logs ezbak`.

The name is checked against the fixed set Nomad's task drivers accept, which is not the
same as your machine's signal list: `SIGCHLD` and `SIGURG` are refused even though your
libc has them, and `SIGNULL` and `SIGIOT` are accepted even though it may not. A name
outside that set is rejected before anything is sent, and the error lists the ones you
can use.

Signaling needs the `alloc-lifecycle` capability on the namespace. A token that works
for every other `nd` command can still be refused here, so a "not authorized" error is
worth checking against your ACL policy before you suspect the token itself.

### Stopping jobs

`nd stop` confirms before it acts unless you pass `--force`. Use `--purge` to
garbage-collect the job afterward, `--detach` to return without watching the drain,
and `--no-shutdown-delay` to skip the configured shutdown delays for an immediate
teardown.

```bash
nd stop web                       # confirm, stop, and watch it drain
nd stop web --purge --force       # purge without a prompt
nd stop web --detach              # request the stop and return immediately
```

### Managing host volumes

`nd volume register` and `nd volume delete` create and remove dynamic host volumes
from your local spec files, and `nd volume list` shows where each spec is registered.
Deleting a host volume is irreversible and orphans data for any job that mounts it,
so `nd volume delete` confirms before it acts unless you pass `--force` (`-f`); a
`--dry-run` (`-n`) previews the registrations that would be removed without prompting.

```bash
nd volume register data            # register the "data" volume on eligible nodes
nd volume delete data              # confirm, then delete the "data" registrations
nd volume delete data --force      # delete without a prompt
nd volume delete data --dry-run    # preview what would be deleted
```

### Running from scripts and cron

Every prompt needs a real terminal on both stdin and stdout. Off one, `nd` fails with
a non-zero exit and names the flag or argument that would have made the choice for it,
rather than assuming an answer. So a scripted run must resolve its own choices: name
the job or volume instead of relying on the picker, and pass `--force` (or `--clean`
for `nd run`'s dead-job cleanup) to skip a confirmation.

```bash
nd stop web --force        # works unattended
nd stop                    # fails: nothing named a job to stop
nd stop web                # fails: nothing answered the confirmation
```

`nd signal` takes no confirmation, so naming the job (and the task, when the job has
more than one) is all it needs to run from cron, provided the job has a single running
allocation. Nothing names an allocation, so a job running several always needs a
terminal:

```bash
nd signal ezbak -s SIGUSR1        # works unattended
nd signal ezbak -s SIGUSR1 -t db  # name the task too when the job runs several
nd signal web -s SIGHUP           # fails when "web" has more than one allocation
```

A named job that is not running is an error, not a no-op, so a scheduled trigger fails
loudly instead of silently doing nothing.

### Verbosity

Add `-v` for debug output or `-vv` to trace each API request with timings. The flag
works before or after the subcommand.

```bash
nd status -v
nd -vv run web
```

## Development

The project uses [uv](https://docs.astral.sh/uv/) for dependency management and
[duty](https://pawamoy.github.io/duty/) as a task runner.

```bash
uv sync                  # install dependencies
uv run nd --help         # run the CLI from source
uv run duty lint         # run ruff, ty, typos, and prek
uv run duty test         # run the test suite with coverage
```

## License

MIT. See [LICENSE](LICENSE).
