Operating a server from elsewhere
Driving a server from a laptop or a pipeline with a context and a token.
The command line normally runs on the server it manages, as root. With a context it also runs from a laptop or a pipeline: the same commands drive a server through its API, with a token, no SSH and no root on the target. Nothing is re-implemented on the way: the command makes the request the console would make, so the token's role, its team scope, sudo mode, approvals and the server's audit trail apply as they do to anyone else. A command the server refuses prints the server's own words.
noust context add prod --url https://noust.acme.test --token - < token.txt # keep where the token is, not the token
noust context use prod
noust list # now runs on prod
noust update shop.acme.test # queues the update, follows it, exits 0 only if it went live
noust --local list # this machine's own, once
noust --remote https://noust.acme.test --token env:NOUST_TOKEN list # one call, nothing savedInstalling the client
The client is the same noust package that runs on a server, and on a machine that is not one it needs
no root, no systemd and none of the server's packages: the plain install brings only the command line's
own libraries (Click, Jinja2, PyYAML, Rich and questionary), about ten small packages, and nothing of the
console, the API or the monitor (noust[web] and noust[all] are for servers). It needs Python 3.10 or
later.
# On a laptop: an environment of its own, so that nothing else sees it
pipx install noust # or: uv tool install noust
# or, with the interpreter alone
python3 -m venv ~/.venvs/noust && ~/.venvs/noust/bin/pip install noust
noust --version
noust context add prod --url https://noust.acme.test --token - < token.txt # keep where the token is
noust context verify prod # calls the server and says who the token is there, and its API version
noust list # runs on prodPin the version in anything that must be repeatable (pip install noust==X.Y.Z). The client and the
server speak the one API of api.md, whose promise is that what a
release shipped keeps working within its version, but a command the client has and the server's
release lacks is refused by the server: use the release the server runs. noust --version says which
one you have, and noust context verify which one the server has.
Tried on Linux with Python 3.12, in a clean environment with the wheel installed and no extras,
against a server's API: context add and verify, list, whoami, update (which queues the job
and follows it), a command with no remote form (it says so and offers --local) and the CI form below.
The package is pure Python, so macOS has what Linux has, but it has not been run there; Windows is
reached through WSL.
A context is saved under the user's own configuration directory (~/.config/noust), never under
/etc/noust, and saving one is not a change to any server, so nothing is written to an audit trail on
the machine you sit at: the server audits the calls it receives, with the token's owner as the actor.
In a pipeline
A runner installs the client for the length of the job and keeps no context: the address and the token come from the environment.
# GitHub Actions
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install noust==X.Y.Z
- run: noust update shop.acme.test # exits 0 only when the new release serves
env:
NOUST_REMOTE: https://noust.acme.test
NOUST_TOKEN: ${{ secrets.NOUST_TOKEN }}# GitLab CI
deploy:
image: python:3.12-slim
script:
- pip install noust==X.Y.Z
- noust --remote "$NOUST_URL" --token env:NOUST_TOKEN update shop.acme.testNOUST_REMOTE with NOUST_TOKEN (a variable) or NOUST_TOKEN_FILE (a private file) is the whole
configuration; --remote URL --token env:NAME says the same on the command line. The token is made
once with only what the step needs (noust token create ci-deploy --scope deploy --expires-hours 720,
below), and the exit code is the outcome. A runner that cannot install Python packages calls the API
with curl, or uses the GitHub Action, which needs no installation: ci.md.
Contexts
A context is a name, an address and where to find a token: a private file or a variable. It is never the token itself.
| Command | What it does |
|---|---|
noust context add NAME --url URL --token -|@FILE|env:NAME |
Saves a context. --token - reads standard input into a private file (mode 0600) of its own; --token-file FILE and --token-env VARIABLE point at what already holds it; --ca-file FILE trusts a private authority; --use also makes it current |
noust context list / current |
The saved ones, and the one in use |
noust context use NAME |
The server commands run on from now on |
noust context verify [NAME] |
Calls the server and says who the token is there |
noust context remove NAME |
Forgets it, and the token file this tool kept for it |
Contexts live in ~/.config/noust/contexts.json ($XDG_CONFIG_HOME/noust when set; mode 0600), and a token
read from standard input in a private tokens/ folder beside it (0700). --context NAME (or NOUST_CONTEXT)
chooses one for a single command; --remote URL with --token, --token-file or --token-env does
without saving anything (NOUST_REMOTE, NOUST_TOKEN and NOUST_TOKEN_FILE are the same things for a
pipeline); --local runs on this machine whatever is in use.
The token never goes in an argument
Everything on a command line is visible to every user of the machine in ps, and stays in the shell's
history. So --token takes where the token is, never the token:
--token -reads standard input;--token @FILEreads a file;--token env:NAMEreads a variable. A literal token is refused (refused_locally) with the way to do it.- A token file must be private to its owner (mode 0600) or it is refused; the token is never printed, never written to the audit log of this machine and never part of an error message.
- The server's address must be HTTPS; plain
http://is accepted only for this machine, such as through an SSH tunnel tohttp://localhost:8080. The certificate is verified;--ca-fileadds an authority. A redirect is not followed: it would send the token somewhere nobody chose.
What runs remotely
list, status, update, rollback, logs, diagnose, releases, jobs, backup, env list,
team list, project list, environment list, health, whoami, api and manifest have a form on
another server. A command with no remote form says so and offers --local.
noust updatequeues the update and follows its log until the health gate decides. Exit 0 means the new release serves; a refused or failed update prints the probes and the journal as the server wrote them and exits 1.--no-waitonly queues it.- A group:
noust update --project web --env production,--team acme,--tag nightly(repeat it for any of several; with a project or an environment, only the tagged applications among them), or several domains, queues every application in one call (POST /api/jobs/update-many); each goes through its own health gate, and a failure does not stop the others.--serialupdates them one after another and stops at the first failure (--keep-goingcarries on). noust env list DOMAINlists the names of an application's variables; their values are never printed, whatever the token may reveal. Variables are set with a manifest (manifest.md).noust api METHOD PATHcalls any route and prints the JSON:noust api POST /jobs/update -d '{"domain": "shop.acme.test"}'. Settings > API reference lists them all and can try them.noust manifest plan|apply|export(manifest.md) writes a server down and makes it match.
Exit codes
Pipelines depend on these:
| Code | Meaning |
|---|---|
| 0 | It worked: the update went live, the manifest is in place, the plan found nothing |
| 1 | It failed or was refused: the server's reason is printed. Also a job that failed, was cancelled, or was still running when --timeout ran out in manifest apply |
| 2 | A usage error, or noust manifest plan --check found something to apply |
| 4 | The job is still running on the server when the wait ran out (update, rollback, backup): the server keeps going; follow it with noust jobs wait ID |
| 130 | Interrupted |
--json prints the job or the answer as one JSON document for a program, and nothing else on standard
output. It is not an answer to a question: a command that asks before it changes something (it has a
--yes) is refused under --json without --yes, so a script says it in so many words. On this
machine --dry-run --json shows what it would do without changing anything; noust manifest plan
is the way to look at what manifest apply would do on a server. A few commands print their plan
under --json and change nothing until --yes (noust compliance enable|disable|set, noust app migrate).
Safe by default
- A
429is waited out (theRetry-Afterthe server gave, at most 30 seconds) and tried again, because the server refused it before running it. A5xxis retried for reads only; a change that may have run is never repeated, because repeating it is not free. - A job that is queued is a job on the server: closing the terminal does not stop it, and
noust jobsshows it again. - Nothing here widens a token: a token that cannot deploy cannot
update, and one bound to a project sees that project only (teams.md). - Several servers: give each a context and choose with
--context. A central drives its nodes itself (docs/CENTRAL.md); a context is for a machine that is not one.
In CI
A pipeline needs three things: the address, a token with only what the step needs (made with
noust token create ci-deploy --scope deploy --expires-hours 720 --owner NAME, or for a project
--team acme --project web), and the exit code.
- run: noust --remote "$NOUST_URL" --token env:NOUST_TOKEN update shop.acme.test
env:
NOUST_URL: https://noust.acme.test
NOUST_TOKEN: ${{ secrets.NOUST_TOKEN }}