A server written down: the manifest
A server written down in git: plan, apply and export.
A manifest is a YAML file that says which projects and applications a server should hold, where
each one lives, its variables, its domains, its limits, its health check, its databases and its backup
schedule, the cron jobs it runs and, on a central, the servers of the fleet: their labels and team, where
an application is placed across them and which server an application or a cron job lives on. It lives
in git. plan says what applying it would change; apply makes the server
match it; export writes down a server that already runs. Applying is idempotent: apply it twice
and the second time finds nothing to do.
noust manifest validate noust-manifest.yaml # anywhere: no server needed
noust --context prod manifest plan noust-manifest.yaml # what would change
noust --context prod manifest apply noust-manifest.yaml --yes # make it so
noust --context prod manifest export > noust-manifest.yaml # write a server downIt is a client of the server's API (remote.md): every read and every change is a request made as the token that applies the manifest, so the role, the team scope, sudo mode, approvals and the audit trail are the server's, and a token that cannot deploy cannot apply a manifest that deploys. Nothing is re-implemented here.
The file
version: 1 # the only version this release reads
name: Acme production # for people
projects: # projects and environments that should exist
- team: acme
project: web
name: Web # when it has to be created
environments: [staging, production]
applications:
- domain: shop.example.com
source: https://github.com/acme/shop
branch: main
team: acme
project: web
environment: production
env: # merged into what the application has; others stay
NODE_ENV: production
DATABASE_PASSWORD: { from_env: SHOP_DB_PASSWORD } # read where the manifest is applied from
API_KEY: { from_file: /run/secrets/shop-api-key }
domains: [www.shop.example.com]
limits: { memory_max_mb: 1024, cpu_quota_percent: 200, tasks_max: 512 }
health: { path: /healthz, expect: [200], timeout: 60 }
databases:
- engine: postgresql
backups: { schedule: daily, retention_count: 7, retention_days: 30, include_databases: true }cron: # the jobs this server runs
- name: nightly-report
command: /usr/bin/python3 /opt/report.py --all
schedule: daily # hourly, daily, weekly, monthly or a systemd calendar
user: www-data # what the manifest leaves out stays as it is
working_directory: /opt
app: shop.example.com
timeout: 600
enabled: true
# server: web-2 # run it on that server of the fleet instead
servers: # on a central: servers it already manages
- name: web-2
labels: { role: web, region: eu } # merged into the labels it has
team: acme # the team that has it (docs/teams.md)
placements: # where an application runs across the fleet
- domain: shop.example.com
servers: [web-1, web-2]
serial: 1 # a number of servers, or "25%"
canary: web-1
max_failures: 0
rollback_on_failure: true
applications:
- domain: api.example.com
server: web-2 # lives on that server, reached through this central
source: https://github.com/acme/apiOnly domain is required of an application. source or recipe (one of them, to create it), type,
branch, port, ssl, team, project and environment say where it comes from and where it
lives. The schema is closed: a key it does not know is an error that names the path
(applications[0].limits.memory_mb: unknown key), never ignored, because a limit that was typed wrong
and silently dropped is a limit that was never set. A document over 1 MiB, more than 200 applications
or the same domain twice is refused.
Cron and the fleet
Cron jobs. A job is created, or rewritten when its command, schedule, user, directory, application,
timeout or service differ from what the manifest says; what the manifest leaves out of a job it already
has stays as it is. A command is compared as the words it splits into (a b is a b) and a schedule as
the calendar the server normalises it to (daily is *-*-* 00:00:00), so a plan converges. A plan names
what differs and never prints a command, which may hold a secret; noust manifest export writes them
as they are, and its header says to read them before committing. Writing a job is root-equivalent, so it
needs sudo mode; switching one on or off does not. Jobs the manifest does not name are listed as
cron:NAME and left alone.
The fleet. A central's manifest can also set what its servers carry and where applications run:
servers merges labels and assigns the team that has a server; placements says the servers an
application is named on, the selector that adds more, and how an update goes out. Registering a server
needs the join code its own noust fleet authorize printed, which does not belong in a document in git, so
a manifest only configures servers that are already managed (noust node add) and warns about one that is
not. On a server that is not a central, or with a token that may not see the fleet, these sections are a
warning and no change, so one manifest can be applied to either.
An application on another server. server: web-2 makes the same calls the console would, one level
down: through the central, at /api/nodes/web-2/api/..., as the same token, with the node's own sudo mode
and permissions. Its project, team, variables, domains, databases and backups are planned and applied
there, a deployment is followed there, and the plan shows web-2:api.example.com. A server the central
does not manage is a warning, and what the manifest puts on it waits. projects are those of the server
the manifest is applied to.
Plan
plan reads the server as the token, compares, and prints one line per change, in the order they would
run: the fleet's servers, projects, environments, applications, moving, branch, limits, health check,
domains, databases, variables, backups, cron jobs and placements. It changes nothing and never prints the
value of a variable or a cron command.
$ noust --context prod manifest plan noust-manifest.yaml
Change What Needs
deploy create shop.example.com from https://... (main)
variables set 3 variables (names only) sudo mode
backups daily, keep 7 for 30 days
Not in the manifest, and left as they are: blog.example.com- Differences that are not reconciled (an application of another type than the manifest says, a source that changed) are warnings, not changes: a manifest does not rewrite what an application is.
- Applications the manifest does not name are reported and left alone. Applying never deletes anything: not an application, not a variable, not a domain.
unverifiedmarks a comparison the token could not make because reading the variables needs sudo mode; the plan then assumes they must be set.--jsonprints the plan for a program;--checkexits 2 when there is something to apply (0 when there is not), for a pipeline that fails a pull request when the repository and the server have drifted.
Apply
apply shows the plan, asks (--yes in a pipeline), and makes each change with the API call the
console would make. A change that fails stops the run: fix the cause and apply again, the plan then
holds only what is left. It waits for the deployments it queues and for their health gate
(--no-wait does not; --timeout bounds it).
- Variables are merged with
PATCH /api/apps/{domain}/env(sudo mode): the names in the manifest are set, the others stay. Applications whose variables changed are listed;--restartrestarts them so they use the new values. - Exit code: 0 when everything is in place, 1 when a change failed (the server's own words are
printed). A deployment still running when the wait ran out counts as failed too, and says how to wait
for it (
noust jobs wait JOB) before applying again. --jsonprints the outcome of every change.
Secrets
A manifest in git cannot hold a secret. A variable's value is a plain value, or {from_env: NAME}
(a variable of the machine that applies the manifest, such as a pipeline's secret) or
{from_file: PATH} (a file only the operator can read; a relative path is read from the manifest's
own folder). They are resolved where the manifest is applied from, never on the server:
planandapplyread them on the machine you run the command on; the value goes to the server over TLS to set the variable and is not printed, logged or kept in the plan.- The console's preview refuses them: a server does not read its own environment on behalf of a document it was sent.
- A variable that is missing where it is applied from is an error that names it, before anything is changed.
From the console
Settings > Manifest takes a pasted manifest and shows what applying it would change on this server, in order, with what needs sudo mode and what could not be checked. Apply these changes then queues the apply as a job and follows it where you are: it waits for the deployments it queues, because what comes after an application's creation (its domains, its databases, its backups) needs the application to exist.
- It is you who applies. The job calls this server's own API with the credentials you used to click:
every change is a request to the route a person would have used, checked against your role, your team,
sudo mode and the approvals, and audited as you (
manifest.applysays who asked and how many changes, never a value). A change you may not make is refused where it happens and the run stops there, with the server's own words; the plan then holds only what is left. - It applies what you read. The preview's digest travels with the apply: if the server or the
manifest changed in between, nothing is applied and you are asked to preview again
(
409 plan_changed). - Sudo mode first, because a manifest is several root-equivalent changes in one. A token applies from the console's API only when it is allowed to confirm sudo mode; from a pipeline, the command line (below) is the way and is a token's own.
- Values are written in the manifest. A variable read from the environment or from a file
(
from_env,from_file) is read where the manifest is applied from, and a server does not read its own for a document it was sent: a manifest that has them is refused here and applied from the machine that has them. - One manifest is applied at a time (
409 apply_running), and a job in the middle cannot be cancelled: every change before the failure stays, every change after it is left. Apply again.
The same thing over the API: POST /api/manifest/plan ({"manifest": "<yaml or json>"}, apps.read)
previews as the caller, and POST /api/manifest/apply ({"manifest", "expect": "<digest>", "restart"},
apps.manage and sudo mode) answers 202 with the job to follow (GET /api/jobs/{id}), or 200 with
queued: false when the server already matches. The preview reads the server as the caller, so a team
sees only what it can see.
In a pipeline
# .github/workflows/deploy.yml
- run: noust --remote "$NOUST_URL" --token env:NOUST_TOKEN manifest plan noust-manifest.yaml --check
- run: noust --remote "$NOUST_URL" --token env:NOUST_TOKEN manifest apply noust-manifest.yaml --yesThe exit codes are the contract (see remote.md): 0 done, 1 failed, 2 changes
waiting (plan --check).
What it does not do
- It does not delete. Taking an application out of the manifest leaves it running; delete it on purpose
(
noust delete). The same goes for a cron job, a label and a placement. - It does not register servers (their join code is a secret), create teams or run a node's own manifest.
- It does not manage accounts, roles or tokens, certificates' issuers, firewall rules or the server's own settings. Those have commands and routes of their own.
- It does not read a manifest from an application's repository.
noust.yamlthere is the project's (hooks, backups of its databases) and is untrusted input; a manifest is the operator's.