maand build
Build reads your workspace from disk, updates the bucket database (maand.db), fills the in-memory KV store (then persists it), and generates TLS material. It does not contact workers.
CLI
maand build [--purge-secrets-kv]
| Flag | Description |
|---|---|
--purge-secrets-kv |
Force purge vars/job/<job> and secrets/job/<job> for workspace jobs with no active allocations |
| (default) | Full workspace reconcile (no job or worker filter) |
Prerequisites
maand initin the bucket directory (creates DB schema, defaultmaand.conf, CA, SSH key,tmp/).CGO_ENABLED=1when compiling maand (SQLite driver).- Host tools for
post_buildhooks:python3,bun(if using Bun commands),bash,ssh,rsync. - Valid
workspace/workers.jsonand at least one job underworkspace/jobs/<name>/withmanifest.jsonandMakefile.
Workspace inputs
workspace/workers.json
Array of workers (hosts are SSH targets from the maand CLI host):
[
{
"host": "10.0.0.1",
"labels": ["worker", "gpu"],
"memory": "4096 mb",
"cpu": "2000 mhz",
"tags": { "zone": "a" }
}
]
host: Worker IP or hostname (required, unique).labels: Used for job selectors (label matching). The labelworkeris always added automatically.memory/cpu: Parsed to MB / MHz; stored on the worker row and exposed in KV. Set manually or probe withmaand collect facts --generate-workers— collect.md.tags: Arbitrary key/value strings → namespacemaand/worker/<ip>/tags/<key>.position: Ordering field (assigned from array index on read).
Optional fields are omitted from the file when unset (no null values).
Workers removed from workers.json are marked removed on allocations (not deleted until deploy/GC).
workspace/jobs/<job>/manifest.json
{
"version": "1.0.0",
"selectors": ["worker"],
"max_concurrent_upgrades": 2,
"max_concurrent_starts": 0,
"resources": {
"memory": { "min": "128 mb", "max": "512 mb" },
"cpu": { "min": "100 mhz", "max": "500 mhz" },
"ports": { "http_port": {} }
},
"hooks": {
"hook_setup": {
"executed_on": ["post_build", "pre_deploy"],
"demands": {
"job": "other_job",
"hook": "hook_base",
"config": {}
}
}
},
"certs": {
"tls": {
"pkcs8": false,
"one": true,
"subject": { "common_name": "app.example" }
}
}
}
| Field | Purpose |
|---|---|
version |
Job version string — see Job version below |
selectors |
Worker labels for placement. When omitted, the job name is used (all selectors must match worker labels). |
max_concurrent_upgrades |
Rolling restart batch size during deploy upgrades (minimum 1; default 1). |
restart_policy |
Default always. reload runs make reload on upgrades; never rsyncs without lifecycle. |
restart_globs |
Only with reload. Changed paths matching a glob run restart instead of reload. |
max_concurrent_starts |
Rolling start batch size on first deploy (0 = all new allocations at once). |
min_allocations_count |
Minimum non-removed allocations after placement (0 = no minimum). Build fails when fewer workers match. |
resources.memory / cpu |
Min/max bounds in manifest; actual reservation from bucket.jobs.conf or bucket.jobs.<env>.conf (job_config_selector in maand.conf) — resources-and-placement.md. |
resources.ports |
Named ports: {} (maand assigns from pool) or integer (fixed; any port number) |
hooks |
Named commands (must be prefixed hook_). |
certs |
Per-job cert definitions → generated into KV per worker. |
Job version
Each job may declare version in manifest.json. Build stores it in job.version and KV (maand/job/<job>/version). When omitted, the target version is stored as 0.0.0.
Per-allocation target version (allocations.new_version) is set on maand build from manifest.json. Running version (hash.current_version) is updated on deploy promote — see deploy.md.
Format (validated when the field is present or required):
- Semver-like:
major.minor.patch(missing segments default to0, so"1"→1.0.0) - Optional leading
v(v2.1.0) - Optional prerelease suffix (
2.0.0-rc1) - Rejected: empty string,
unknown, more than three numeric segments
When version is required
| Job role | Rule |
|---|---|
| Standalone job (no demands, not demanded) | Optional — omit allowed |
Job with demands.job set on any command |
Must declare version |
| Upstream job referenced by another job’s demand | Must declare version |
Version constraints on dependencies
In demands.config, optional bounds are checked against the upstream job’s version at build:
"demands": {
"job": "database",
"hook": "hook_schema",
"config": {
"min_version": "2.0.0",
"max_version": "3.0.0"
}
}
min_version/max_version: string ("2.1.0") or integer (2→2.0.0)- Build fails with
ErrHookDemandVersionMismatchwhen upstream version is out of range
Full rules: manifest.md · deployment-sequence.md
Job directory rules
MakefileorMakefile.tplis required (default deploy usesrunner.py→make start|stop|restart|reloadperrestart_policy;.tplis rendered at deploy).- Reserved directories on disk:
data/,logs/,bin/must not exist under the job folder (they are created on workers at runtime). - Job files are copied into table
job_files(content stored in DB).
Health check (build validation)
A job may define manifest probes, a health_check command, or both:
health_checkinmanifest.json(built-in tcp/http/ssh probes), and/or- A
hook_*withexecuted_on:["health_check"]
When both are defined, manifest probes run first at check time, then commands. Manifest probes are stored in job.health_check (JSON). Command-based health is stored in hooks like other events. See health-check.md.
workspace/disabled.json (optional)
How-to: disable-and-drain.md.
{
"jobs": {
"myjob": {
"allocations": ["10.0.0.1"]
}
},
"workers": ["10.0.0.2"]
}
- Disable entire job:
"jobs": { "myjob": {} }. - Disable specific workers for a job:
"allocations": ["<worker_ip>", ...]. - Disable all jobs on a worker:
"workers": ["<ip>", ...].
Disabled allocations are not active for deploy lifecycle (start/restart/reload/rsync) but still receive hook fan-out and deploy staging; rows remain until removed from workspace/build.
workspace/bucket.jobs.conf (optional)
Per-job reservations for memory and CPU in the current environment. Values must fall within manifest min/max (resources.memory / resources.cpu).
[myjob]
memory = "256 mb"
cpu = "500 mhz"
job_config_selector in maand.conf |
File used |
|---|---|
"" |
bucket.jobs.conf |
"prod" |
bucket.jobs.prod.conf |
See configuration.md and resources-and-placement.md.
workspace/bucket.conf
Bucket-wide settings (TOML key/value). Created on maand init with a default port pool:
port_range = "30000,39999"
When port_range is omitted or empty in an existing file, build still uses 30000,39999.
Job manifests declare named ports in two ways:
{}— maand assigns the lowest free port in the inclusiveport_rangepool at build time.- An integer — you pin the port number in the manifest (any valid port; not limited to the bucket pool).
Assigned numbers are stored in ports and exposed in KV namespace maand/bucket (key <port_name>). Maand-provisioned ports keep the same number across rebuilds while it remains inside port_range. Changing a port from a fixed integer to {} reassigns from the pool when the stored number is outside that range (for example fixed 9500 → {} with default pool 30000,39999). Fixed ports always follow the manifest value.
"resources": {
"ports": {
"database_port": 5432,
"http_port": {}
}
}
Port names must be lowercase identifiers prefixed with <job>_ (for example api_http_port, echo_server_http) so keys stay distinct in the shared maand/bucket KV namespace. The same port number cannot be used by two jobs or two port names in the bucket.
Other keys in bucket.conf are copied to KV namespace vars/bucket.
maand.conf
Bucket-root SSH and cert settings. Full field reference: configuration.md.
What build does (order)
All steps run in one transaction (except post-build hooks), then VACUUM:
| Step | Function | Summary |
|---|---|---|
| 1 | kv.Initialize |
Load KV from DB into memory. |
| 2 | BuildWorkers |
Sync workers.json → worker, labels, tags; drop removed workers from catalog. |
| 3 | BuildJobs |
Sync each job manifest → job, job_selectors, hooks, job_files, ports, job_certs. |
| 4 | ValidateHookDemands |
Verify demand job/hook refs; parse version; check min_version / max_version. |
| 5 | BuildAllocations |
Label-match jobs to workers → allocations rows (alloc_id, deployment_seq initially 0). |
| 6 | BuildDeploymentSequence |
Compute deployment_seq from command demands (dependency order). |
| 7 | BuildVariables |
Populate KV namespaces (workers, jobs, bucket vars, job/allocation metadata). |
| 7b | checkCAExpiry |
Read secrets/ca.crt; warn on stderr when CA is expiring or invalid PEM; fail when CA is expired (before BuildCerts). |
| 8 | BuildCerts |
Regenerate leaf certs when CA/manifest changed or within renewal window; write cert PEMs into KV. |
| 9 | BuildJobAllocationVariables |
Per-allocation keys (*_allocation_index, peer_workers) after certs so cert sync does not delete them. |
| 10 | BuildPrometheusCatalog |
When a prometheus server job exists (prometheus.yml or .tpl): validate all _prometheus/ (alerts, runbook refs, scrape shape); write scrape-only KV. Jobs with maand:port/* targets and no active allocations are omitted from aggregate scrape_jobs / scrape_configs but keep per-job scrape/<job> KV. Without a prometheus server job, scrape KV is cleared and _prometheus/ validation is skipped. |
| 11 | PurgeStaleVersions |
Trim old KV versions (keep 7 per key). |
| 12 | ValidateWorkerResources |
Ensure allocated jobs fit worker memory/CPU. |
| 13 | PersistToTransaction |
Write KV changes into key_value table. |
| 14 | Commit | Persist catalog. |
| 15 | runPostBuildHooks |
Separate transaction; runs post_build commands, then persists vars/job KV; failures fail the build. |
Deployment sequence (deployment_seq)
Derived from hooks.demand_job edges. See deployment-sequence.md for the full reference.
Summary:
- Jobs with no upstream demands → sequence 0.
- If job B’s command demands job A → B’s sequence is greater than A’s (max chain depth if multiple demands).
- Circular demands fail the build with
ErrCircularHookDependency. - All allocations for a job share the same
deployment_seq.
Deploy runs sequences 0 .. max in order so depended-on jobs deploy first. post_build hooks use the same order.
Allocations
- One row per (worker_ip, job) match.
alloc_id: Stable UUID fromhash(job|workerIP).removed: Set when worker or job disappears from workspace (cleaned on deploy).disabled: Fromdisabled.jsonor resource validation.
KV namespaces (build output)
Build populates catalog-backed namespaces (maand/*, vars/bucket/*, worker and job metadata, certs). Stable app config lives in vars/job/<job> and secrets/job/<job>.
Full namespace reference: KV namespaces. Persistence and purge: KV persistence.
Certificates
See certs.md for the full guide (CA, manifest options, auto-rotation, deploy paths). Summary:
- Bucket CA in
secrets/ca.crt/ca.key(created onmaand init; default TTL 10 years). - Before
BuildCerts, build runscheckCAExpiry: warning on stderr when the CA isexpiringor PEM isinvalid; build fails when the CA isexpired. Samecerts_renewal_bufferwindow asmaand cat certs. The CA is not auto-renewed — replacesecrets/ca.crt/ca.keymanually. - Per-job leaf certs from manifest → KV per allocation →
jobs/<job>/certs/on workers at deploy. - Leaf certs regenerate when CA or manifest
certshash changes, or when within thecerts_renewal_bufferwindow. - Removing
certsfrom the manifest purgescerts/*KV keys on the next build.
post_build hooks
After the main commit, build runs every command registered with executed_on containing post_build, in deployment sequence order on non-removed allocations (includes disabled) on the CLI host (same runtime as deploy hooks).
- Uses
hooks.Hook(same as deploy/CLI). - Any hook failure fails
maand build(main catalog commit already succeeded; fix hooks and re-run build). - Useful for validation or codegen that must run before deploy.
Resource validation
If allocated jobs require memory or CPU (resources.memory / resources.cpu in the manifest), each worker hosting those jobs must declare memory / cpu in workers.json. Build fails when requirements exceed capacity or when a worker omits capacity while jobs require it.
When min_allocations_count is set on a job manifest, build fails with ErrInsufficientAllocations if label matching produces fewer non-removed allocations than the minimum.
Database tables touched
worker, worker_labels, worker_tags, job, job_selectors, hooks, job_files, ports, job_certs, allocations, hash, key_value, bucket, schema_version.
Inspect with:
maand cat workers
maand cat jobs
maand cat allocations
maand cat hooks
maand cat certs
maand cat kv
Common errors
| Error | Typical cause |
|---|---|
ErrInvalidWorkerJSON |
Duplicate host, bad memory/cpu format |
ErrInvalidManifest |
Bad resources, missing Makefile |
ErrInvalidHookDemand |
Unknown demand job/hook or partial demand pair |
ErrInvalidJobVersion |
Invalid or missing version on dependency participant |
ErrHookDemandVersionMismatch |
Upstream version outside demand min/max |
ErrPortKeyFormat |
Invalid port name in manifest |
ErrInvalidManifestPort |
Port value is not {} or a valid integer in range |
ErrInvalidPortRange |
Bad port_range in bucket.conf (must be "min,max") |
ErrPortRangeExhausted |
No free ports left in the pool |
ErrCircularHookDependency |
Demand cycle between jobs |
ErrInsufficientAllocations |
Job has fewer non-removed allocations than min_allocations_count |
bucket CA certificate (secrets/ca.crt) expired on … |
Replace secrets/ca.crt / ca.key — see certs.md |
| Worker resource validation | Job memory/CPU exceeds worker capacity, or worker missing memory/CPU when jobs require it |
When to run build
- After changing workers.json, jobs, disabled.json, or bucket.jobs.conf.
- Before
maand deploy(or usemaand deploy --build/-b). - When you need cert rotation or catalog refresh before deploy.
Build does not start or restart processes on workers; that is deploy.
Related
- deploy.md — push artifacts and roll out
- hooks.md · hook-api.md
- KV namespaces · KV persistence
- templates.md —
.tplrendering - configuration.md —
maand.conf,bucket.conf - health-check.md —
health_checkevent