Operator runbook¶
Backup, escrow and restore, audit verification, key rotation, upgrade and rollback for a hosted cloop hub.
The command output on this page is real. Where a step can't be undone (a lost key, a one-way migration), the warning is next to that step.
- Layout
- Health checks
- Backup and restore
- Database maintenance
- Audit chain verification
- The git interception proxy
- The Kubernetes access monitor
- Key rotation
- The root key
- Upgrade
- Rollback
- Fleet operations
- Incident playbooks
Layout¶
cloop-secret-key 0600 CLOOP_SECRET_KEY, beside .cloop/ and never in it
(cloop-secret-key.cred when systemd-creds sealed it)
.cloop/
state.db canonical store (SQLite, WAL mode)
state.db-wal state.db-shm WAL sidecars — never copy these by hand
config.yaml 0600 operator-written; safe to commit
hub.env 0600 CLOOP_UI_TOKEN — never commit
tls/cert.pem tls/key.pem 0600 when the hub terminates TLS itself
backups/state-<UTC>.db + a .db.meta.json sidecar per backup
A hub bootstrapped by a release before v0.0.6 keeps CLOOP_SECRET_KEY in
hub.env instead. That still works, and the root key explains
how to move it.
Two rules prevent most problems:
- The root key opens every sealed secret. Back it up separately from
state.db, never in the same place: a backup that holds both is effectively a plaintext secret store. For the same reason the hub refuses to read a key file from inside a.cloop/directory, because backups, snapshots and seeded sandboxes all carry that directory. - Never copy
state.dbwithcp. Usecloop db backup. It writes a single self-contained file that doesn't depend on the-wal/-shmfiles.
Health checks¶
| Endpoint | Question | Behaviour |
|---|---|---|
/healthz |
is the process alive? | Never fails while the process can accept a connection. Do not restart the hub when the database is slow. |
/readyz |
should traffic come here? | Three gates, in order: the state database, the identity provider, the execution path. Fails during startup, when storage is lost, while the issuer has never resolved, and when strict mode leaves no isolating executor registered. |
/metrics |
Prometheus text | Requires the audit.read permission, unlike the two probes above. |
/api/hub/health |
will it keep working? | cloop hub doctor's checks. Each serving hub member runs them on itself every five minutes (ui.hub_health_interval) and whenever something they check changes. Requires config.write. Shown in Settings → Hub health; see Hub health in the dashboard. |
/healthz and /readyz skip authentication and rate limiting, so a flood of
requests or a broken IdP can't lock out a probe. /metrics is an ordinary
authorised route, so a scraper needs a credential with audit.read. See
Metrics.
Because of the second and third gates, rolling out a misconfigured hub fails the rollout. It doesn't go green.
The identity gate catches a hub pointed at an issuer that is unreachable,
misspelled or wrongly registered. Without it, such a hub would come up green
and fail for the first person who tried to sign in. The hub resolves the issuer
at startup (discovery and the JWKS fetch) and reports not_ready with
"check": "identity" for as long as that has never succeeded. After one
success the gate stays open: existing sessions keep working through a later
provider outage, and taking the hub out of its Service during one would turn a
login outage into a total outage. To make the hub refuse to start instead, set
ui.oidc.require_idp (or pass cloop ui --require-idp).
The execution gate checks that runs have somewhere to go. A hub with
allow_host_process: false and no container, Kubernetes or enrolled-agent
executor can only answer a run request with a 409, so it reports not_ready,
and the response body says how to fix it:
{
"status": "not_ready",
"check": "executors",
"reason": "strict mode is on (executors.allow_host_process: false) and no isolating executor is registered, so every run would be refused",
"remediation": "enable executors.container or executors.kubernetes in .cloop/config.yaml, enroll a remote agent (`cloop executor enroll`), or set executors.allow_host_process: true to permit un-isolated host execution"
}
The check is live. A hub that starts with no isolating executor becomes ready as soon as an edge device enrolls, without a restart. An executor that registered but failed preflight counts as degraded, not missing, and still satisfies this gate: a cluster that was briefly unreachable while the hub started shouldn't keep the hub out of service until someone restarts it.
$ cloop hub healthcheck --url https://hub.example.com --endpoint readyz
$ kubectl -n cloop exec deploy/cloop-cloop-hub -- /usr/local/bin/cloop hub healthcheck --endpoint readyz
The image is distroless, with no shell and no curl, so this command is the
container's HEALTHCHECK and the Kubernetes exec probe. Options: --timeout
(default 3 s) and --ca-file for a private CA. It exits 0 when healthy and 1
otherwise.
Configuration health: cloop hub doctor¶
healthcheck tells you whether the hub is up. doctor tells you whether it is
configured to keep working. A hub whose certificate expires next week, whose
issuer moved, or whose RBAC policy leaves nobody an admin passes both probes
and is still broken.
$ cloop hub doctor # in the hub's directory
$ cloop hub doctor --json | jq '.findings[] | select(.severity=="fail")'
$ cloop hub doctor --offline # config only; contacts nothing
$ cloop hub doctor --port 8081 # the hub on :8081, with its overlay
When two dashboards share a directory, each one reads its hub-scope settings
from its own .cloop/config.ui-<port>.yaml, merged over config.yaml. Pass
--port to check one of them as it runs. The default is 8080, the port
cloop ui uses without --port, so cloop hub doctor with no flags checks
the hub that cloop ui with no flags starts. --port 0 reads config.yaml
alone. In every case the report lists on stderr any overlay it did not merge.
See two dashboards in one directory.
Each check calls the same code the hub runs instead of keeping a copy of its
rules. Whether cloop ui would start with ui.oidc is decided by
oidcauth.New. Whether a redirect path can be served is decided by the rule the
router is built from. Whether the git proxy starts is decided by tlsconf
loading its key pair and gitproxy.NormalizeBaseURL checking its base URL, and
so on. A doctor with its own copies of the rules could fail a working hub (one
that registered its callback at /auth/oidc, which the hub serves, was told
the hub "only serves the callback at /auth/callback") and pass a broken one.
The checks, and what each one catches that nothing else does:
| Group | Checks |
|---|---|
policy |
Whether executors.allow_host_process was set explicitly or only left at its default. executors.host_sandbox: fail when single sign-on is on, runs may fork on the hub's host and the host sandbox is off, because then every person's agent can read every other person's projects and Claude login; fail too when the host cannot give the sandbox (no Landlock ABI 3), so the hub refuses the runs that need it; warn when executors.host_sandbox.read_only names a path the hub leaves out because it would expose the hub's files or other people's, and on a hub running as root below Landlock ABI 6, where a sandboxed run can still reach the host daemons' abstract sockets. |
oidc |
Whether cloop ui would start with ui.oidc (oidc.startup, decided by oidcauth.New, the constructor startup runs). The redirect URI: its path is checked by the hub's own rule, so any path under /auth/ that the router can serve passes (an Entra SPA registration commonly uses /auth/oidc), and only a path the hub refuses fails, saying the hub will not start. Its origin is checked against ui.external_url. Issuer discovery, the issuer name in the discovery document, and JWKS keys cloop can actually verify with. Whether the client secret comes from the environment and not the committed config. Whether privileged actions require a recent sign-in (oidc.sudo): warn while ui.oidc.sudo_max_age_minutes is -1, because a stolen session cookie can then mint tokens and grant secrets until it expires. In Settings → Hub health, the issuer the hub signs in with is read from its sign-in client, not resolved again: oidc.discovery and oidc.jwks report when the client resolved the issuer and fetched its keys, and why its last fetch failed, with a warn for a key-set refresh that failed while the client still holds keys. oidc.running warns, until a restart, that the configured issuer is not the one this process signs in with. |
tls |
The certificate and key load as a matching pair, the same way the listener loads them (tlsconf). The chain the listener presents is in order and parses. Expiry (warns 30 days ahead). The SANs cover the external hostname. Key file permissions. TLS terminated at a proxy, judged by the rule an edge agent applies to ui.external_url. |
ui |
ui.exposure: where the process on --port actually listens (read from the kernel's socket table), and whether it answers GET /api/projects without credentials. Fail if a hub without sign-in can be reached from beyond loopback, or would be after a restart (a ui.listen beyond loopback together with ui.allow_unauthenticated_network, or a setting cloop ui refuses to start with). This applies whichever build serves the port, so it also catches binaries built before loopback became the default. Warn if a hub with sign-in serves plaintext beyond loopback while its public URL (ui.external_url, else ui.oidc.redirect_url) is https. With nothing on the port, it judges what cloop ui --port N would do; if the hub uses CLOOP_UI_TOKEN, export it from hub.env first. ui.static_token: warn while a static token is accepted (exported in the doctor's shell, or reported by a running hub) alongside single sign-on with an enforced role policy, saying how long the token has gone unused; pass once it is retired. Reports nothing without SSO, where the token is the sign-in, or without a role policy, where rbac.enforced is the finding. |
secret_key |
Where the root key comes from (secret_key.source): a source the hub would refuse fails; an environment variable on a host that systemd runs, or a copy of a key file's key left in the environment, warns. That CLOOP_SECRET_KEY is present and is generated key material, not a passphrase or a placeholder copied from the docs; for a weak key the finding tells you to change the root, not to rotate. Whether the key opens this hub's sealing keys, asked read-only of the keyring the broker opens (secret_key.matches): a live key that no configured root derives fails, because the hub would not start. A warning for as long as CLOOP_SECRET_KEY_PREVIOUS is set (secret_key.previous). The escrow on record (secret_key.escrow). With a KMS among the roots: its configuration (secrets.kms.config); whether each KMS answers and how fast (secrets.kms.reachable, a failure for one that does not); a change of root still in progress (secrets.kms.previous); and keys still wrapped under a KMS key version the KMS no longer encrypts under (secrets.kms.rewrap). |
rbac |
Whether a role policy is in force at all (rbac.enforced, from authz.Enforced, the same answer the request gate acts on). It fails, naming the issuer, for single sign-on with no role_mappings and no default_role: every identity the issuer authenticates then holds every permission except executor administration. See when RBAC is in force. That the mappings parse. How much the default role allows, reported only where a policy makes that meaningful. Group bindings without the groups scope. Whether anybody maps to admin. All of these are judged on the policy as authz normalizes it, so role: Admin is admin, and a project-scoped admin is not a hub admin. Where a policy is in force, every live API token it suspends: tokens with no owner, minted while no policy was in force or before the hub recorded how, and not approved since (api_tokens.unapproved, a failure naming each). Approve the ones you can vouch for and revoke the rest. |
scim |
Whether cloop ui would start with ui.oidc.scim (scim.config). Whether the directory the identity provider provisioned can reach the people in it (scim.directory): when the provider deactivates a provisioned user with no value for match_claim, the hub has nobody to refuse, and two users sharing one value get no guess and neither user's groups. See SCIM provisioning. |
offboard |
Whether the Claude Code home of an identity the control plane denies (an offboarded or deactivated user) is still on this host (offboard.claude_homes). It holds that person's refresh token in plaintext. Running members remove such homes from their own trees at startup and every hour, so a copy found here belongs to a stopped member, or is one a sweep could not remove. |
images |
Whether the policy is valid, and whether it constrains registries at all (asked of the policy's own evaluation). Digest pinning. Whether cosign is installed and its keys are readable wherever signatures are verified: the container and Kubernetes executors, and the hub itself for a device or virtual executor whose sandbox runs a container. The operator's own executor images, which are exempt from the policy unless govern_operator_images is set, and are reported as exempt. Whether the registries the policy names can be reached. A device fleet and a Kubernetes executor need them from the hub too: the hub resolves a device's tag there before verifying it, and resolves every tag a Pod would carry before building the Pod (cloop executor test kubernetes adds the registry-login and image-resolution checks). images.provenance: whether cosign is on the hub's PATH, and its version (read from the binary's build information, without running it); executors.image_provenance; and what each of cloop's own images in use resolved to: verified, unsigned or unverifiable (a warning, or a failure under required), or wrongly signed (a failure). cloop hub doctor verifies the images as the next dispatch would; Settings → Hub health reports what the hub's dispatches found. |
executors |
Reconciliation diagnostics, the strict-mode gate (asked of the same check /readyz serves), and a liveness probe and capability report for each executor. Each device on the edge channel, compared with this build's place on main (executors.edge_lag): a warning when it is more than 20 sequences behind, or when its build carries no sequence, which rollback protection does not cover yet. Each device that follows releases with a build that carries no sequence (every release before v0.0.5), with how to cover it (executors.rollback_protection). Each executor's isolation claim, compared with what the isolation probe found under its runtime (executors.isolation): a failure for a runtime named for isolation that provides less (a kata that is really runc), where projects that require isolation are refused; a warning for a claim never verified, or for evidence older than two days. Each live executor pool, from its queue, its runners and what its controllers last told the hub (executors.pools): a failure when runs wait and no controller is in contact, because the pool has no live credential, none was ever used, or none was used in ten minutes (executors.pools.contact); a warning when a run has waited ten minutes while the pool has every runner its max_runners allows, while it has room for more that its controller does not start (often an ExecutorPool spec.maxRunners lower than the hub's), or while it is cordoned (executors.pools.capacity); when its controller reports runner volumes stuck Pending or other trouble starting runners (executors.pools.storage); and when a controller used in the last day speaks an older pool-controller protocol than this hub (executors.pools.protocol). The Executors tab's pool cards and cloop executor pool list show the same problems. |
runs |
The build each cloop run on this host executes, compared with the hub's (runs.build_lag). It reports a run that is behind (a host-process run can adopt the newer build at its next task boundary) and a run that recorded no build (it has to be restarted once before it can). See Long-lived runs after an upgrade. Every adopted run whose hub process could not take over a credential yet (runs.lease_takeovers): a warning for each lease, workspace lease or egress session the run's owner row lists as undecided. The warning says since when, how many attempts, why, until when it will be retried, and whether the member that owns the run still serves. It reads the run claims, so it works with no member running. See A takeover the store cannot decide. |
gitproxy |
Whether pushes are brokered at all. Whether the proxy would start: its TLS material loaded the way the listener loads it, and its base URL checked by gitproxy.NormalizeBaseURL. The base URL sandboxes are pointed at (advertise_url, or the bound address as the hub advertises it), whether only this machine can reach it, and a bounded dial of it. The branch allowlist and delete authority. With the proxy off, any GitHub grant whose branch list therefore cannot be enforced (gitproxy.branch_grants). Under tls: auto, the certificate the hub issued the proxy (readable, verifies against the hub CA, names the advertised host, not about to lapse) and the hub CA's expiry (hubca.expiry; see hubca below). |
hubca |
The hub's own CA under tls: auto. Its expiry (hubca.expiry): a warning within a year and a failure within 30 days, naming the rotation. A rotation in progress (hubca.rotation): a stage older than the longest session_minutes a monitor grants (the runs it waits for should have ended by then), a previous CA still trusted that long after its activation, and a failure when the leader has not reissued the certificates minutes after an activation. Whether every live member takes part (hubca.members): a member on a build that predates rotation, which an activation refuses to run alongside, or a member that has fallen behind. The active CA's name constraints, compared with the names the monitors are configured at (hubca.names, a failure for a name the constraints do not permit). See Rotating the hub CA. |
kubeguard |
Whether kubeconfig grants are brokered at all. Whether the monitor would start: its TLS material and CA bundle read the way the hub reads them, and its base URL checked by kubeguard.NormalizeBaseURL. Where sandboxes are pointed, whether only this machine can reach it, and a bounded dial. The verb ceiling. Under tls: auto, the hub-issued certificate and the hub CA, as for the git proxy. |
egress |
Whether the broker is on. For each running hub, where its proxy listens and is advertised, or why it would not bind (egress.hosted, read from the status every hub records at startup). How each kind of sandbox this hub runs reaches the proxy, by the hub's own routing rules: containers by the bind address (egress.listen_addr), Pods and devices by advertise_addr (egress.advertise_addr), with a bounded dial of it. An internal: true filter with no broker to proxy through, which leaves the sandbox no way out. |
knowledge |
Whether semantic knowledge search sends anything out of the hub (knowledge.embeddings). It passes when search is off. When it is on, there is always a finding naming the endpoint that receives the text of every chunk and every search query, with the model and how many chunks have a vector from it: a warning for plain http beyond loopback, and a failure for a section refused at load. Whether the key that secret_ref names can be presented (knowledge.embeddings.secret). The sources whose last sync could not embed (knowledge.embeddings.sync), quoting each one's warning. See semantic knowledge search. |
firewall |
Whether any egress allowlist opens a cloud metadata service by including it (firewall.metadata). An egress_filter allowlist in force in config.yaml is a fail (cloop ui refuses to start with it), and one in a switched-off section is a warning. Every stored rule set or egress grant saved before the rule existed: the service stays closed, but the rule set should name or deny it. This covers the device, virtual-executor and project firewalls and the active grants, each reported by where it is and what it contains. |
projects |
Every registered project's directory, checked against what the hub must never bind into a sandbox (projects.confinement): the control plane, the projects root, another hub's directory or another project. A fail names each project the hub refuses to run and what it overlaps; a tree too large to search is a warning. Nothing is moved or deleted. |
storage |
quick_check. The schema version compared with this binary's, which catches a rollback: it names the build that moved the schema, and fails only where the hub's guard refuses the database (a database ahead only by additive migrations opens). Whether CLOOP_ALLOW_SCHEMA_DOWNGRADE is suppressing that guard. Free space on the volume holding .cloop, compared with orchestrator.min_free_disk_mb (storage.free_space): a warning below twice the floor and a failure below it; see Disk space low. The write-ahead log beside state.db (statedb.wal, measured before any other check opens the database): a warning once it is larger than both 64 MiB and a quarter of the database; see The write-ahead log. |
retention |
Whether the janitor will run here at all (retention.janitor) and when it last ran (retention.last_run). What the row tables, the disk, the audit archive and plan history hold, compared with the retention policy (retention.tables, retention.disk, retention.archive, retention.history). The share of state.db that is free pages a VACUUM would give back (retention.freelist). |
audit |
Whether the control-plane chain records lost events (audit.gaps: its audit.gap rows and how many events they cover). Whether a hub process here still holds losses it has not recorded, or exited holding some (audit.unrecorded, read from the status files under .cloop/audit-failures/). The head checkpoints: whether they are written at all, to stderr only or to a file outside .cloop/ (audit.checkpoints: a fail for a file that may not hold them, and a warning when the newest control-plane record is more than three intervals old); whether they are sealed or unsigned (audit.checkpoints.signed); and whether the newest 50 still agree with the chain (audit.checkpoints.chain: a fail naming a truncated tail or a restored backup). See Audit chain verification. Whether the trail's content leaves the machine (audit.forward: no sink is a warning), whether each configured sink could start (audit.forward.sink), and whether it keeps up (audit.forward.lag: a sink that has never acknowledged a row while rows are waiting, or whose oldest waiting row is older than audit.forward.max_lag). See Forwarding the trail off the box. |
config |
Every value that loading had to repair (config.repaired). A value reset to its default is a warning. A section switched off because it could not start as written is a failure; for the git proxy, the Kubernetes monitor and the egress proxy it is reported under the section's own check, which would otherwise read as "disabled". Drift between .cloop/config.yaml and the copy mirrored in state.db, the usual cause when you change a setting and nothing happens. |
quotas, budget |
Whether the policy is valid. Limits set to 0, which means none allowed, not unlimited (except max_sessions, where 0 means no cap). Both are judged as quota.New holds them, so a negative ceiling is unlimited. Unbounded spend on a multi-tenant hub, judged by the daily caps budget.Enforce holds a run to: the project's own caps, read from config.yaml, which is where runs read a budget. The host-wide caps in the doctor's ~/.config/cloop are reported alongside them, because they only limit runs on the host's own driver (an isolated run doesn't get them). monthly_usd is never counted, because nothing enforces it. |
The exit status is 1 on any failure and 0 with only warnings, so you can use it
as a deployment gate. --strict fails on warnings too. Every finding that isn't
a pass comes with a one-line remediation, and the check ids in --json are
stable.
Three of the checks dial an address. executors.git_proxy.advertise_url,
executors.kube_guard.advertise_url and executors.egress.advertise_addr are
the only configuration values a hub hands to a sandbox and never uses itself,
so a value that works on the hub but can't be routed from a Pod shows up
nowhere else. The dial is bounded (3 s, or --timeout) and never fails the
run: the hub isn't on the sandbox's network, and for the git proxy and the
Kubernetes monitor a Service name that doesn't resolve on the hub is often the
correct setting. (The egress proxy is different: the hub refuses to give Pods
a cluster-internal name, and egress.advertise_addr fails on one.)
gitproxy.advertise_reachable, kubeguard.advertise_reachable and
egress.advertise_reachable tell "nothing is listening" apart from "this was
never checked". --offline reports them as skipped.
Run the doctor twice: once before the first helm install or
docker compose up, and once in CI against the config repository. With
--offline the CI run is cheap.
Hub health in the dashboard¶
cloop hub doctor needs a shell on the hub host, in the hub's directory, with
both configuration files in reach. Administrators of a Helm or compose
deployment, or without shell access, don't have that. Settings → Hub health
runs the same checks inside the serving hub (GET /api/hub/health), and the
header tells administrators when a check fails:
- Whose view. The report uses the hub's own directory and the configuration
that process runs under:
config.yamlwith itsconfig.ui-<port>.yamloverlay merged, ascloop hub doctor --port <its port>would read them. It also uses the process's own environment, so the root key, the KMS and the static token are the hub's. The listener checks (ui.exposure) describe the process that answered, so the report names the cluster member, its host and its port. Behind a load balancer, each member answers with its own report. - What it does not do. It doesn't run
--smokeor--probe-network-policy, because both start work; the panel prints the command that runs them on the host. Where the doctor in a shell has to ask from outside, the hub answers from what it already knows. The executor checks read the drivers the hub reconciled at startup and what its executor supervisor last recorded about each one, without reconciling or probing again (every member reads the same record, and only an executor nobody has probed yet is asked, once).ui.exposurejudges the listener by how the process admits requests, without sending it a request that has no credentials, which a token-only hub would count as a failed sign-in. The sealing-key checks use the KMS clients the hub pinned at startup, so a report never logs in to Vault. The issuer the hub signs in with is read from its own sign-in client: the discovery document and keys it holds, when it fetched them, and why its last fetch failed. A report about a resolved issuer therefore sends nothing to the identity provider, and a brief problem at the provider only becomes a finding when it affected the hub: a sign-in that could not begin, or a key set it could not refresh for a token that needed it. While the client has no usable resolution (it never resolved the issuer, holds no keys, or its last fetch failed), a report asks it to try again at most every 15 minutes, and Run the checks again at most every minute, so a failure that has cleared doesn't stay red until somebody signs in. The report resolves an issuer itself, at most every 15 minutes, only when the running process doesn't sign in with it (single sign-on was switched on, or the issuer changed, after the process started;oidc.runningsays so). The other network probes are the ones the checks already make (registries, the advertised proxy addresses), each bounded to 5 seconds. - When a check misbehaves. Each check runs on its own goroutine with its
own 20-second deadline, and its panics are recovered. A check that doesn't
finish is reported as
doctor.unfinished, with whatever it found before the deadline. A check that panics is reported asdoctor.panic, with the stack trace in the hub's log (eventhealth_check). The rest of the report is unaffected. A check that never returns isn't started again; until it returns, each report says it is still running.cloop hub doctoron the host runs the same check without a deadline, which shows where it stops. - How fresh. Each member makes its own reports, one at a time. It makes one
every five minutes (
ui.hub_health_interval, between1mand1h; the setting is per process, so a member can set its own in its overlay). It also makes one within a few seconds of a change to either configuration file (from Settings, from another member, or an editor on the host), and soon after an event that changes what the checks find: an executor enrolled, lost or reconfigured, a step of a hub CA rotation, a sealing key rotated, retired, rewrapped or escrowed withcloop hub key, or the static token retired. A burst of events produces one report, never sooner than a minute after the previous one, and an event that arrives while a report is being made triggers the next one as well. Events from outside the member (another member's, and those ofcloop hub key) arrive over the cluster bus. A hub started withui.cluster.exclusivedoesn't read the bus, so it sees them at its next scheduled report. Reads are answered from the latest report, so behind a load balancer the number of reports a member makes doesn't depend on how many dashboards are open. Seven checks are too expensive to repeat in every report. They run at most every 15 minutes, and the reports in between carry their last result with the time it was made:storage.integrity(quick_checkandforeign_key_checkread the whole database file),retention(it sums the largest tables' rows),projects.confinement(it walks every registered project's tree),gitproxy,kubeguardandegress(each dials the address sandboxes reach the monitor at, often this process's own listener, which logs the bare connection as a TLS handshake error), and resolving an issuer the process doesn't sign in with. If one of them overruns its deadline, its result is still kept when it finishes. Run the checks again reruns everything whose last run is more than a minute old, and answers from the last report if that report is less than ten seconds old.cloop_hub_health_reports_totalcounts the reports by what triggered them. - Who sees it. Anyone with
config.writeacross the hub, as for the hub-wide Settings around it. Under an enforced role policy, a viewer or an operator gets403, and their page never asks for the report. An administrator's header shows a red ⚠ count while any check fails. The member pushes each report's summary (the failing count, its generation, when it was made and when the next is due) over the dashboard's socket as thehub_healthevent. It sends the summary only to sockets whose identity may read the report, judged on the identity as it is now: the session's current claims, the token as it reads now. The member checks again at once when a session's claims are refreshed or the session ends, or when a token or the static token is revoked, and every thirty seconds for changes nobody announced, such as a runtime role binding. A socket that may no longer read the report is told so, without a count, if it was sent the summary or opened while its identity could read the report (its page then read the summary itself), and its page turns the indicator off. Deliveries go out one at a time with the latest report, so a withdrawal is never overtaken by a count decided before it. A page asks for the report only when its socket opens or reopens. While Settings → Hub health is on screen, the page re-reads the full report when a new one is pushed, so an administrator's open page costs the member nothing beyond the reports it makes anyway. - What leaves the hub. Every title, message, remedy and detail goes through
two redactions first. The first removes the exact credential values the hub's
configuration and environment hold (provider keys, the OIDC client secret,
webhook URLs and headers, the password in any configured URL, and every
*_TOKEN,*_SECRET,*_PASSWORDand*_KEYvariable). The second applies the credential-shape registry. The page renders the result as text, so a name containing markup is shown as that markup.
Each finding links to its group's row in the table above. To get the same
report from a script, use an API token with config.write:
$ curl -s -H "Authorization: Bearer $TOKEN" https://hub.example.com/api/hub/health \
| jq '.member, (.findings[] | select(.severity == "fail") | {check, message, remediation})'
$ curl -s -H "Authorization: Bearer $TOKEN" 'https://hub.example.com/api/hub/health?summary=1' | jq .failing
Dispatch health: cloop hub doctor --smoke¶
The checks above only read configuration. They can't catch a common failure
after enrolling a device: every check passes, but the first real task still
fails, because the harness image has no shell, or has an ENTRYPOINT that
swallows the arguments, or the agent can't write to its workspace, or the
driver drops credential files it claims to support.
--smoke runs a trivial workload through the same steps a real task goes
through and reports each step separately.
$ cloop hub doctor --smoke # every non-cordoned executor
$ cloop hub doctor --smoke --executor edge-01 # one device
$ cloop hub doctor --smoke --json | jq '.smoke[] | select(.first_failure != "")'
DISPATCH SMOKE TEST
container (container, isolation container) — 426ms
✔ placement the scheduler would place a task on container
✔ workspace the workload read a file the hub wrote and could write its own
✔ lease the sandbox opened the leased credential file and it held this run's material
✔ dispatch the workload ran in the sandbox and exited 0
✔ logs streamed 130 bytes, complete and correctly attributed
– write_back this backend shares its workspace with the hub, so there is nothing to write back
✔ revocation the credential was destroyed and the lease no longer renews
✔ cleanup every container, credential and directory this run created was removed
the dispatch circuit works end to end
| Stage | What a failure means |
|---|---|
placement |
The scheduler would not choose this executor at all: host-execution policy, a cordon, or an agent below executors.min_agent_build. |
workspace |
The sandbox got no usable working directory. On a driver that shares the filesystem, it could not see a file the hub wrote. |
lease |
A throwaway credential is minted, granted and delivered. A failure names the delivery path, not the grant. |
dispatch |
The workload did not start, or exited non-zero. The last lines of its output are in --json under details.output. |
logs |
Output did not come back, was truncated, dropped chunks, or carried another run's id. The last is a confidentiality problem on a multi-tenant hub, not a logging bug. |
write_back |
A driver asked for a write-back produced no result, so a task's commits would be made in the sandbox and lost without an error. |
revocation |
The credential outlived its lease. This is checked by stat-ing the files and asking the broker to renew. |
cleanup |
Something this run created could not be removed. |
A stage reports skip, never pass, when it doesn't apply to the backend or
can't be checked hermetically: write_back on a driver whose workspace is the
hub's directory, or lease on a driver that can't take a credential back.
Currently write_back is skipped everywhere. A write-back is measured against
the commit the sandbox's tree was built from, which needs a tree the driver
built from git, and the smoke workload runs in a plain directory. A run of a
project with a git workspace, or of a feature, exercises it.
It is safe to run against production. The workload uses no network, clones
no repository and calls no model. The credential it mints is a kubeconfig
pointing at 127.0.0.1:1 with a TTL of seconds, and it is destroyed, and
checked to be destroyed, before the command exits. Its lease carries that
credential and nothing else. Every other grant aimed at the executor (a real
kubeconfig, a PAT, anything) is withheld before its payload is opened, with one
audit row each, so none reaches the smoke sandbox whatever kube_guard or
git_proxy say. Everything else (the container, workspace, lease directory,
and the secret and grant rows) is removed on every exit path, including
Ctrl-C. Anything that could not be removed is named in the report, and the
command exits 5.
Exit codes. You can use it as a Kubernetes readiness gate or a post-deploy CI step without parsing JSON:
| Code | Meaning |
|---|---|
| 0 | every target smoked clean |
| 1 | a configuration check failed (the hub is misconfigured regardless of dispatch) |
| 3 | no executor to dispatch to, which is not the same as healthy |
| 4 | an executor failed a stage |
| 5 | the run could not clean up after itself and needs a person to look at it |
By default it tests every executor that isn't cordoned, so one command tells
you which of your devices is broken. Cordoned and draining devices are skipped,
and the report lists them, so that a clean result for seven of ten devices
isn't read as a clean result for all ten. Naming a device with --executor
tests it anyway, which is how you check that a cordoned device is fixed before
you return it to rotation.
It starts a container per executor, so don't run it on every kubelet poll.
Point the liveness probe at /healthz, and run this on a timer or after
deploys.
The control-plane lease¶
Several hubs can serve one state.db as members of one cluster. Every
cloop ui joins the control plane in its working directory. Start more in the
same directory, or raise replicaCount in the Helm chart, and they serve the
same state behind one load balancer. They share the database, forward each
other the requests only one member can answer, relay live events between their
dashboards, and elect one leader for the work that must happen exactly once.
Hub cluster describes how.
The leader holds the lease. It runs retention, backups, auto-resume, the session janitor and the periodic orphan sweep. The other members serve everything else.
$ cloop hub cluster status
Hub cluster
/srv/cloop/.cloop/state.db
hub_59f28a07ceea8b8fa2b4386edc1cef40 leader serving
host hub-1 pid 1234, listening :8080, reachable at http://127.0.0.1:8080
version v0.9.0, started 2026-09-12T17:23:15Z
hub_d74093a016f4198a7ec530075841bd4f serving
host hub-1 pid 1301, listening :8081, reachable at http://127.0.0.1:8081
version v0.9.0, started 2026-09-12T17:25:02Z
2 member(s) serving
$ cloop hub lease status
Control-plane lease
/srv/cloop/.cloop/state.db
state held
instance hub_59f28a07ceea8b8fa2b4386edc1cef40
host hub-1 (pid 1234)
serving :8080
acquired 2026-09-12T17:23:15Z
last beat 11s ago
lapses in 49s if the holder stops renewing
How it behaves¶
| Situation | What happens |
|---|---|
Clean shutdown (SIGTERM, helm upgrade, systemctl restart) |
The member hands over its runs and agents, releases the lease if it led, and leaves. Another member, or the restarted process, leads within seconds. |
| A member killed outright (SIGKILL, OOM) on the same host | The others probe its recorded pid and take over at once: its runs are adopted, and if it led, a new leader is elected. |
| A member gone on another host or node, in another container, or after a reboot | The pid can't be trusted. The member counts as dead after 20s of silence and its runs are adopted. If it led, the lease lapses at its last heartbeat + 60s. |
| A leader paused past the TTL and its lease taken | It steps down and keeps serving as a member. |
| A second hub started in the same directory | It joins as a member. |
| A hub from before clustering holds the lease | A current build refuses to start alongside it; see below. |
None of this needs an operator, including after a crash. A stale lease is free to take, so a dead hub can't block its own restart.
When a hub refuses to start¶
In two situations a hub refuses to start instead of joining.
A hub from before clustering is serving. It holds the lease without being a member, so it would neither forward requests to a new member nor see its events:
Error: another cloop hub controls this state and cannot share it
holder hub_59f28a07… on hub-1 (pid 1234), serving :8080
version v0.8.2
last beat 4s ago
Upgrade it or stop it. Restarting it is enough, because the old process releases the lease as it exits. If it was killed instead of stopped, a new build on the same host probes its pid and starts at once. On another host, its lease has to lapse first, which takes up to a minute.
ui.cluster.exclusive is set, on this hub or on the one already serving.
The setting restores the pre-cluster rule of exactly one process, and the
error names the member that is serving. Stop that member, or remove the
setting to let them join.
In either case:
- Run
cloop hub cluster statusandcloop hub lease statusto see who is serving and who holds the lease. Is the holder this machine, and is its pid alive? - If you meant to run one hub, stop the other. Under Kubernetes, look for a Pod of an older release.
- If the holder really is gone, wait for
lapses into reach zero.cloop hub lease clearreleases a lapsed lease explicitly, and refuses while the lease is live. There is no--force, because a live lease means a hub is renewing it right now.
Backup and restore¶
Backup¶
cloop db backup uses VACUUM INTO under a read transaction, so it is safe
while the hub is running and tasks are executing.
$ cloop db backup
cloop db backup
source: /srv/cloop/.cloop/state.db
output: /srv/cloop/.cloop/backups/state-20261011T112707Z.db
WAL checkpoint: TRUNCATE log_frames=0 checkpointed=0
size: 892.0 KB
duration: 32ms
sha256: 0495b530781c1654129e99b9b3d9e5e569beb584fb9cdd57eac9c39514408063
schema version: 65
audit head: id 9 (212d86cf4c7e…)
sealing keys: 1, sealing 1 row(s)
metadata: /srv/cloop/.cloop/backups/state-20261011T112707Z.db.meta.json
sidecar seal: under CLOOP_SECRET_KEY a9a0a761a2b0ee6e
Backup complete.
--output <path> changes the destination. The .meta.json sidecar records the
SHA-256, size, source, schema version and duration. It also holds the
manifest, which a restore checks before it changes anything: the sealing
keys the backup's secrets, sessions and hub CA keys are sealed under, how many
rows each key seals, and where the backup's audit chain ends. The manifest is
read back from the copy, not from the live database.
The whole sidecar is sealed with an HMAC under a key derived from
CLOOP_SECRET_KEY with its own label, the same construction the audit
checkpoints use. The seal carries the key's fingerprint, which every checkpoint
and escrow of that key also carries. Nothing in a database can produce the
seal, and neither can an attacker who holds only the backup, so a restore
refuses a backup that was edited and had its SHA-256 recomputed. Without
CLOOP_SECRET_KEY the sidecar is written unsealed and the backup output says
so; restoring it then needs --allow-unsealed. Backups made by a hub's
backup.auto_backup are sealed the same way.
Keep the sidecar with the backup. Restoring without it needs --skip-checksum,
which checks nothing.
A backup contains sealed secrets. It isn't plaintext, but it is half of a
plaintext secret store. The other half is the root key (in hub.env, or in
the key file a newer hub reads through CLOOP_SECRET_KEY_FILE or a systemd
credential), or its escrow. On a hub whose keys are
held by a KMS, the other half is the KMS key, and a backup
only restores where that key answers. The report then says
sealing keys: … held by the KMS …, and you have to keep the KMS key, and
every version of it the backup's keys were wrapped under, for as long as you
keep the backup.
A nightly backup, kept for 30 days:
cloop db backup --output /var/backups/cloop/state-$(date -u +%Y%m%dT%H%M%SZ).db
find /var/backups/cloop -name 'state-*.db*' -mtime +30 -delete
Escrowing the sealing key¶
Every key-encryption key is derived from CLOOP_SECRET_KEY, so rotating them
doesn't protect you against losing that key. If you lose the key file, every
sealed secret, session and hub CA key is lost with it. Escrow the key once the
hub has started, and again after every
change of root; the change won't finish
until the new key is escrowed. (On a hub whose keys are
held by a KMS, no escrow opens them, and the commands list
those keys as KMS-HELD. The escrow still recovers the audit checkpoint key
and the backup sidecar seals, so keep making one.)
$ cloop hub key escrow export --output /mnt/root-credentials/cloop-hub.escrow
Escrow passphrase:
Again:
Escrowed CLOOP_SECRET_KEY
file: /mnt/root-credentials/cloop-hub.escrow (opened again to check it)
escrow: esc_881bc88ef57b3a35
key: a9a0a761a2b0ee6e
derivation: argon2id t=3 m=64MiB p=4, then aes-256-gcm
recorded in the hub: the escrow id and key fingerprint, nothing secret
The file holds the key sealed under your passphrase: argon2id with RFC 9106's
second recommended parameters, then AES-256-GCM, with every header field bound
into the cipher's associated data. The command reads the file back and opens it
before it reports success. The escrow is only as strong as the passphrase. A
passphrase under 15 characters is refused; five or six random words is a good
choice. Keep the file where you keep root credentials, off this host (a path
inside .cloop/ is refused), and keep the passphrase somewhere else again. The
key is never written to disk on the way out. --passphrase-file <file> (or -
for stdin) replaces the prompt.
The command refuses a CLOOP_SECRET_KEY that doesn't open this hub's sealing
keys, because escrowing the wrong key is worse than escrowing none. During a
change of root it escrows the new key and counts the old one alongside it:
together they open every key, and the retire step waits for this escrow. The
hub records the escrow's id and the key's fingerprint and nothing else.
cloop hub doctor reports secret_key.escrow: a warning while no escrow is
recorded, and a failure when the escrow on record holds a different key
from the one in use. During a change of root, an escrow that holds the previous
key is only a warning.
Restore¶
Stop the hub first, then check the backup before restoring it:
$ cloop db restore /var/backups/cloop/state-20261011T112707Z.db --check
…
SEALING KEY STATE SEALED ROWS OPENS
kek_814ca52cd82fe6c050dc0ae2 primary secrets=1 yes
PASS — this backup restores under this key.
$ cloop db restore /var/backups/cloop/state-20261011T112707Z.db --force --reason "disk failure on hub-1"
--reason is required and is recorded. Before anything changes, a read-only
preflight checks everything the restore depends on. If any check fails, the
restore is refused and nothing changes. Each error names the flag that
overrides it:
| Refusal | Means | Overridden by |
|---|---|---|
| integrity, schema | PRAGMA integrity_check fails, or the backup's schema is newer than this binary |
nothing |
| no sidecar | there is no .meta.json beside the backup |
--skip-checksum, which checks nothing |
| checksum mismatch | the file is not the one its sidecar describes | --skip-checksum |
| seal does not verify | the backup and its SHA-256 were changed after it was taken, or the sidecar was | --skip-checksum |
| seal cannot be checked | the sidecar is unsealed (taken before sidecars were sealed, or without a key), sealed under another key, or there is no CLOOP_SECRET_KEY here |
--allow-unsealed; the digest is still checked |
| sealing keys | neither the CLOOP_SECRET_KEY here nor CLOOP_SECRET_KEY_PREVIOUS can derive a key the backup's secrets, sessions or hub CA keys are sealed under |
--accept-secret-loss |
Refused — nothing was changed:
• the CLOOP_SECRET_KEY here (key 8260957539fa2819) cannot open the backup's sealing keys
kek_814ca52cd82fe6c050dc0ae2 (1 secrets): every secret, session and hub CA key sealed under
them would be unreadable, and the hub would refuse to start on them. Restore the key they
were sealed under (`cloop hub key escrow import`)
--accept-secret-loss proceeds anyway
The key check is the same one the hub makes when it starts, made before the
swap instead of after. The fix for it is the escrow, not a flag.
--accept-secret-loss is separate from --force, which only allows
overwriting a live database. It is for when there is no escrow: it restores
anyway and retires the keys it can't open in the restored database (every
key the hub would refuse to start on), so the hub can start and mint a fresh
one. Every secret sealed under the retired keys then reports that its key was
retired. The flag is recorded on the restore's audit row and in its restore
record. The backup file keeps the keys, so you can restore it again under the
right key to get them back. The flag needs a CLOOP_SECRET_KEY (the new one
the hub will run on), because a variable missing from your shell is not a lost
key: with no key set, the restore is still refused.
A backup taken before the hub's key was changed
holds keys that only the old root derives, and its sidecar is sealed under the
old root. Restore it with the old key as CLOOP_SECRET_KEY_PREVIOUS alongside
the new one; its keys open, and its seal checks, under either. Run the restored
hub the same way, and finish with cloop hub key rotate --retire-previous
again: the old root's restored keys are live again, and the retire step moves
them onto the new one. The restore record is sealed under the new key, and
checkpoints sealed under the old one still account for what the restore gave
up.
--force first moves the existing database aside as .pre-restore.<UTC>,
together with its -wal and -shm files, which would otherwise be replayed
into the restored file. That way, restoring the wrong backup can be undone too.
A restore never takes a database away from a hub that is still serving it:
while a hub holds the control-plane lease, or a cluster member is live, the
restore is refused, because a hub writing to the moved file would lose
everything it wrote after the swap. The backup is hashed as it is copied, and a
file that changed between the preflight and the copy is not restored. Exit
codes: 0 restored and recorded; 1 refused or failed, nothing changed; 2 the
restore could not run; 3 restored, but the record of the restore is incomplete
(the output says what is missing).
What a restore records¶
To the audit checkpoints, a restore looks the same as a tail deleted while the hub kept writing: the rows the newest checkpoints saw are gone, and new rows take their ids. So a restore records itself in two places, each out of reach of the other:
- an
audit.restoredrow, appended to the restored chain, with the backup's SHA-256 and time, the head the chain was restored to, the newest checkpoint superseded, the events lost, who ran the restore and why; - a restore record, sealed under the checkpoint key and written to
audit.checkpoints.file(or--checkpoints <file>) and stderr. It says the same and pins that row's hash.
cloop hub audit verify --checkpoints accepts the restore record as the
explanation for checkpoints sealed before it that saw past the restored head.
It reports the restore and exits 0:
RESTORED control-plane /srv/cloop/.cloop/state.db — acknowledged
3 checkpoint(s) agree with the chain; the newest, at 2026-10-11T11:28:04.819549679Z, saw id 13
restored (acknowledged): 5 events lost between 2026-10-11T11:27:07.34123538Z and 2026-10-11T11:27:30.227423779Z, by cli:root, reason "disk failure on hub-1"
restore record on line 3, sealed under key a9a0a761a2b0ee6e; backup sha256 0495b530781c… taken 2026-10-11T11:27:07.406627001Z, restored to id 9; 2 checkpoint(s) superseded
The lost window runs from the newest event the backup holds to the newest
checkpoint that saw what came after. Events written after that checkpoint
aren't counted, because nothing outside the database recorded them. A restore
of a backup whose sidecar was not checked (--skip-checksum,
--allow-unsealed) is acknowledged the same way and says so: "from a backup
nothing vouched for". cloop hub doctor reports the restore as a warning on
audit.checkpoints.chain for as long as it is among the 50 newest checkpoints
it reads.
Only a record sealed under the key the verifier holds explains anything, so
that write access to the database or the file is never enough to explain a
truncation away. A hand-written or unsigned restore record is refused. A record
sealed under another key explains nothing. A record dated ahead of the
verifier's clock is refused. A record whose own head, or the head it says the
chain was restored to, is no longer in the chain fails as restore-mismatch. A
record only accounts for checkpoints sealed before it, so a tail deleted after
the restore, while the hub keeps writing, is still a finding.
To restore a project's database, run the same command in the project's
directory. The project's configuration doesn't name the hub's checkpoint file,
so pass it: --checkpoints /var/log/cloop/audit-checkpoints.jsonl.
Disaster recovery drill¶
Rehearse the whole recovery, including the case where the key file is gone, in a scratch directory. Don't use the live hub:
# 1. The escrow still opens, its passphrase is still known, and it holds the
# key this hub runs on. Run it where the hub's key is: CLOOP_SECRET_KEY_FILE
# pointing at its key file (or, on an older hub, its hub.env loaded).
cloop hub key escrow verify /mnt/root-credentials/cloop-hub.escrow
# → OPENS, IN USE, OPENS THE HUB, ON RECORD
# 2. A fresh directory with no key, as after losing the host.
mkdir -p /tmp/cloop-drill/.cloop && cd /tmp/cloop-drill
alias nokey='env -u CLOOP_SECRET_KEY -u CLOOP_SECRET_KEY_FILE -u CLOOP_SECRET_KEY_PREVIOUS \
-u CLOOP_SECRET_KEY_PREVIOUS_FILE -u CREDENTIALS_DIRECTORY'
nokey cloop db restore /var/backups/cloop/state-<UTC>.db --check
# → Refused: there is no CLOOP_SECRET_KEY here to open the backup's sealing keys …
# 3. Recover the key from escrow into this directory's key file.
nokey cloop hub key escrow import /mnt/root-credentials/cloop-hub.escrow
export CLOOP_SECRET_KEY_FILE=/tmp/cloop-drill/cloop-secret-key
# 4. The backup now restores under it, and its credentials deliver.
cloop db restore /var/backups/cloop/state-<UTC>.db --check # → PASS
cloop db restore /var/backups/cloop/state-<UTC>.db --reason "DR drill" \
--checkpoints /tmp/cloop-drill-checkpoints.jsonl
cloop secret lease --project <a project holding a grant> # → its materials
cloop hub audit verify --hub . --project . # → OK
# 5. Clean up: the directory holds the hub's key and its secrets.
cd / && rm -rf /tmp/cloop-drill /tmp/cloop-drill-checkpoints.jsonl
For a real recovery, in order:
- Stop the hub. Don't restore under a running process.
- If the key is lost, run
cloop hub key escrow import <file>and point the hub at the key file it writes (CLOOP_SECRET_KEY_FILE, or re-encrypt it for a unit that loads it withLoadCredentialEncrypted=). cloop db restore <backup> --checkcloop db restore <backup> --force --reason "…"cloop db verify- Start the hub and confirm
/readyz. cloop hub audit verify --checkpoints <file>reports the restore as acknowledged, with the window it lost, and exits 0.cloop hub doctor:secret_key.escrowpasses. If the backup predates the escrow, record the escrow again withcloop hub key escrow verify <file> --record.cloop executor ls: enrolled agents reconnect on their own backoff.
Database maintenance¶
$ cloop db verify # integrity_check + foreign_key_check (read-only)
$ cloop db verify --quick # quick_check — faster, less thorough
Exit codes: 0 pass, 1 issues found, 2 the check could not run.
$ cloop db maintain --dry-run
cloop db maintain — dry-run (size report only)
database: /srv/cloop/.cloop/state.db
size before: 541.9 MB (138739 pages, 4096 bytes/page, 38740 freelist)
wal before: 152.2 MB
last maintenance: 504h12m40s ago (vacuum+analyze, freed 1.96 GB)
Dry run — no changes written.
estimated reclaim: 151.3 MB (freelist_count × page_size)
write-ahead log: 152.2 MB (a real run truncates it)
$ cloop db maintain # with the hub stopped
cloop db maintain — VACUUM + ANALYZE
…
size after: 390.6 MB (99999 pages)
wal after: 0 B (truncated)
operations: [VACUUM ANALYZE]
Reclaimed 151.3 MB.
cloop db maintain runs VACUUM + ANALYZE, records the run in
maintenance_log, and finally truncates the write-ahead log, through which the
VACUUM passed every page of the database. --auto does nothing
unless the database has grown more than 20 % since the last vacuum, which suits
a cron entry:
VACUUM rewrites the database and needs free space roughly equal to its size,
or twice that while it runs, because the rewritten pages pass through the log
before the final checkpoint frees them.
The write-ahead log¶
Every write to the database goes to state.db-wal first, and a checkpoint
copies it into state.db. Normally the log is a few megabytes. After each
checkpoint SQLite reuses the file from the start but never shrinks it, so a
burst of writes could leave the log at that size for as long as anything had
the database open. Such bursts are a VACUUM, which writes the whole database
through the log, and bulk deletes by row retention or cloop hub audit prune.
On 2026-10-06 cloop's own hub had a 340 MB log beside a 409 MB database, on a
disk that was 98% full. Three things keep the log bounded:
- Every read-write connection trims it. cloop opens its databases with
journal_size_limitset to 64 MiB, so whichever connection starts the log over after a checkpoint cuts the file back to 64 MiB, or to the size of its own write if that is larger. On a busy hub that happens within seconds of the burst ending. Only connections from this version on do it: older builds sharing the database (a long-livedcloop run, a second dashboard started from an older binary) write without the limit. - The leader truncates it. The leading hub runs
PRAGMA wal_checkpoint(TRUNCATE)at the end of every retention pass, on every project's database as well as its own, and every minute while its own database's log is over 64 MiB. The checkpoint waits at most 250 ms for other connections. A TRUNCATE checkpoint holds the write lock while it waits, so the usual 5 s busy timeout would hold up every writer for that long and fail writers whose own 5 s ran out. It first copies the log back with a PASSIVE checkpoint, which takes no lock a writer waits on, so the TRUNCATE has little left to copy while it holds the lock. If a connection is still reading from the log, the checkpoint reports busy, nothing changes, and it tries again the next minute. The hub logs the first busy attempt, then one an hour:
retention [/srv/cloop] write-ahead log is 324.2 MB, over the 64.0 MB limit, and another connection was still using it after 250ms (1 attempt(s) so far); retrying every 60 s
retention [/srv/cloop] truncated the write-ahead log, 324.2 MB → 0 B, after 3 busy attempt(s)
cloop hub retention --applytruncates it with the same short wait, whether or not a hub is running.cloop db maintain, which needs the hub stopped, ends every run that goes ahead the same way.
To see the log's size, use ls -l .cloop/state.db-wal, the
cloop_statedb_wal_bytes gauge (metrics),
or cloop hub doctor's statedb.wal, which warns once the log is larger than
both 64 MiB and a quarter of the database. cloop db maintain prints
wal before: and wal after:.
A log that stays large is being held. Either a connection keeps a read
transaction open the whole time (the leader's attempts every minute then all
report busy, and its log says so every hour), or every connection writing the
database predates the limit. fuser -v .cloop/state.db-wal lists the processes
that have the log open, and go version -m on /proc/<pid>/exe shows each
one's version. Until the older processes are replaced, the leader's checks keep
the file small. If no hub is running, cloop hub retention --apply truncates
it.
A task row that will not load¶
Three task columns decide what runs: depends_on, on_success and
on_failure. If one of them holds something that isn't a JSON list (after a
truncated write, a restored backup or a hand edit), every load of the project
fails with an error that names the row. Reading the value as "no dependencies"
would release the task early:
task 7: column depends_on is not decodable JSON ("[1,2"): statedb: corrupt task column: unexpected end of JSON input
Saves are refused too, because a save replaces every stored task and would delete or overwrite the one the error names. A run in progress therefore stops at its next write instead of carrying on with work it can no longer record (see "A run stopped: run progress could not be saved" below). Back up, then write the list you want:
$ cloop db backup
$ sqlite3 .cloop/state.db "UPDATE plan_tasks SET depends_on = '[1,2]' WHERE id = 7"
A damaged tags, annotations or links value doesn't stop the load. The
field reads as empty, and the hub logs statedb: task N: column C is not
decodable JSON, field dropped once per damaged value.
Migration 0054 adds columns that cloop migrate or a development build may
already have created. It adopts an existing column only if it matches the
declared column exactly. Otherwise startup fails with plan_tasks.<column>
already exists as <what is there>, but this migration declares it <what 0054
wants>. Check what the column holds, then drop it
(ALTER TABLE plan_tasks DROP COLUMN <column>) or rebuild it to match, and
start cloop again.
Disk space low¶
A run doesn't start work on a disk too full to record the result. Before every
task attempt and every evolve round, it measures the volume holding the
project's .cloop and the working tree's volume. Below
orchestrator.min_free_disk_mb (default 1024 MB), nothing starts and the run
pauses with reason disk_low.
What you see. The project shows Paused: volume / has 812.3 MB free, below
the 1.00 GB floor with a Stop button, because the run is still up. It
measures again every minute and carries on by itself once every volume is back
above the floor plus a tenth (1.1 GB with the default). The event history has a
session_paused row with pause_code: disk_low, the volume, the free bytes,
the floor and what was about to start. When the run carries on, a
session_started row "Run resumed: disk space recovered" follows. In parallel
mode, the round in progress finishes and records its outcomes first, and no new
task starts. While the hub's own state volume is below the floor, admins also
see a banner across the dashboard, cloop hub doctor fails
storage.free_space, and cloop_hub_disk_free_bytes{volume} shows the trend.
Alert on that metric at twice the floor, before runs start pausing.
What to do. Free space on the volume the message names; the run notices within a minute. The usual places to look, largest first on a busy hub:
$ df -h / # what the run measured
$ ls -l .cloop/state.db-wal # the write-ahead log; see "The write-ahead log"
$ cloop hub retention # dry run: what the janitor would reclaim
$ cloop hub retention --apply # ...and reclaim it, including VACUUM and the log
$ cloop compact --dry-run # per project: old artifacts, snapshots, checkpoints
$ go clean -cache # on a build host: often gigabytes
$ du -sh /tmp/* 2>/dev/null | sort -h | tail # test runs leave scratch here
Before deleting a file in /tmp, check what it belongs to (lsof +D), because
it may hold another run's work. To end the wait instead, press Stop. The pause
is recorded as stopped while waiting for disk space, and the next Run checks
again.
The reserve. Each project keeps .cloop/reserve, 16 MiB of preallocated
blocks, put in place whenever the check passes. If the disk still fills up
between the check and the write of a task's verdict or outcome (usually because
of the task's own build), the run deletes the reserve, writes again into the
space that frees, and then pauses disk_low before starting anything else. Its
pause detail then begins "task #N's verdict only reached the disk by releasing
the 16.0 MB free-space reserve". The reserve comes back by itself at the next
check that has room. Only if the second write fails too does the run stop, with
state_not_persisted. Free space, then press Run; the next run recovers the
tasks the stopped one left in progress. The reserve holds no data: snapshots,
cloop compact, the janitor and the disk-usage reports leave it out, and
deleting it by hand does no harm.
Tuning. Set the floor in Settings → Disk & Retention (admins), with
cloop config set orchestrator.min_free_disk_mb <MB>, or in a project's
config.yaml. 0 turns off the check and the reserve. The hub doctor then
warns, because a full disk can corrupt outcomes again. A run in the hub's own
directory gets the Settings value even when it is stored in the per-instance
overlay; every other project's run reads its own config.yaml. See
Free-space floor.
A run killed while it waited (an OOM kill, a host reboot) leaves the waiting pause behind. When the hub notices, it settles it as previous run ended unexpectedly, the same as a stale "running".
A run stopped at its disk limit¶
A container executor keeps a workload's workspace within its disk limit
(.cloop/sandbox.yaml resources.disk, or an operator's disk ceiling) by
measuring it while the workload runs. This is separate from the free-space
floor above: the floor protects the volume, and the limit caps one project's
share of it.
What you see. The project shows Paused: the workspace grew to 21.3 GB, over
its disk limit of 20 GB (from .cloop/sandbox.yaml resources.disk); free space in
the workspace, or raise resources.disk …, then press Run, with a Start
button: the run was stopped (SIGKILL) and is not retried. The task it was
running is back to pending, with a note from cloop giving both sizes. The event
history has a disk_limit row with disk_used_mb, disk_limit_mb and the
workspace path in its details. The task's task.finish audit row carries
stop: disk_limit, and the workload's own output ends with
[cloop] disk limit: … stopping the workload.
cloop_executor_task_failures_total{reason="disk_limit"} counts these stops.
What to do. Decide which is wrong, the tree or the limit:
$ du -sh --exclude=.cloop /srv/projects/api # what the limit counts (allocated size)
$ du -h --max-depth=2 --exclude=.cloop /srv/projects/api | sort -h | tail
Then either free space in the workspace (build output, caches, a dataset the
task shouldn't have downloaded there), or raise the limit: resources.disk in
the project's .cloop/sandbox.yaml, or, when the message says an operator's
disk ceiling, the executor's Limits in the Executors panel, the project's
ceiling (cloop hub limits set <project> --disk …), or
executors.limits.max_disk. Then press Run. A workspace that is still over the
limit is refused at the start with both sizes in the error, so pressing Run
without doing either won't start a run that would only be stopped again.
A disk limit: could not measure the workspace … unknown line in a
workload's output means a measurement missed its one-minute deadline or reached
a directory the measuring user can't read. It isn't counted as under the limit,
and the next attempt waits ten times as long as the failed one took. If a tree
routinely takes that long to walk, split it or exclude parts of it (move caches
out of the workspace). Where the walk doesn't run as root (a device's agent, a
hub on rootless podman), an unreadable directory is one the workload made
unreadable, and its contents are not counted.
An executor that can't enforce it (the host-process driver, a device running
payloads on its host, a device whose agent predates protocol v20) shows
disk: not supported on its card. A project that sets resources.disk is
refused there at placement, with constraint disk_limit for an agent older
than v20 and resource_limits for an executor that enforces no limits at all.
A disk ceiling that covers such an executor is reported in the project's
journal on every dispatch as not limiting the run. Upgrade the agent, or run
the device's payloads in a container.
Audit chain verification¶
audit_events is hash-chained: each row's row_hash covers its content and the
previous row's hash, so editing or deleting a row breaks the chain from that
point. There are two chains, in the hub's control-plane state.db and in each
project's .cloop/state.db. Verify both:
$ cloop hub audit verify --hub /srv/cloop --project /srv/projects/api
OK control-plane /srv/cloop/.cloop/state.db — 18213 events verified
GAPS project /srv/projects/api/.cloop/state.db — 912 events verified
1 recorded gap covering 37 lost event(s): audit.gap rows #640
cloop audit-log verify checks the one chain the working directory resolves
to. Both commands exit 0 when the chains are intact, 2 when one is
broken (naming the row id and the reason), and 3 when they are intact but
hold recorded gaps. Run cloop hub audit verify --checkpoints <file> on a
schedule and alert on anything but 0: a break means corruption or tampering,
and a gap means lost evidence.
Gaps: events that could not be appended¶
An audit append can fail when the database is locked past its retries, read-only, or on a full disk. A failed append leaves no hole in the hash chain (the next append links to the last one that succeeded), so the append path itself counts every failure, by chain, action and cause, and reports it on stderr: the first at once, then a summary at most once a minute:
[audit] could not append to the audit trail at /srv/cloop/.cloop/state.db: statedb audit: begin: database or disk is full (13) — counting every failure from here on; they are recorded as an audit.gap row once the database takes writes again (3 event(s) so far: executor.cordon ×2, session.revoked ×1)
When the database takes writes again (at the next append that succeeds, at a
flush every 30 seconds, or when the hub shuts down), cloop writes one
audit.gap row into the same chain. It says how many events were lost, of
which actions, between which times, and why. The gap row is hash-chained like
any other, so the record of the loss is as tamper-evident as the events would
have been. To read the gap rows:
Until a loss is recorded, only the process that suffered it knows about it. You
can see it in two places: that hub's Audit tab (a banner above the trail) and
cloop hub doctor (audit.unrecorded), which reads the status file the hub
keeps under .cloop/audit-failures/. A hub that dies with losses it hasn't
recorded leaves that file behind. The next hub started in the directory adopts
it and records the loss, naming the process it came from (adopted_from).
cloop_audit_append_failures_total and cloop_audit_gap_events_total count
the failures and the recorded losses (metrics).
Once the cause is fixed and the gaps are accounted for, --allow-gaps makes
verification exit 0 again. It still reports the gaps.
Checkpoints: catching a shortened trail¶
The chain detects changes and insertions anywhere in the history, and deletions
anywhere except at the newest end. Truncating the newest rows leaves a shorter
chain that still verifies, and nothing inside the database can tell it apart
from a quiet period. Head checkpoints can. Every
audit.checkpoints.interval (5 minutes), the cluster leader (a lone hub is its
own leader) writes one record per chain: the newest row's id and hash, the row
count, the latest prune anchor, the time and the member. It writes one more at
a clean shutdown.
audit:
checkpoints:
interval: 5m
file: /var/log/cloop/audit-checkpoints.jsonl # must lie outside .cloop/
stderr: true # the default
Each record is sealed (HMAC-SHA256) under a key derived from CLOOP_SECRET_KEY
with its own label, so nothing in state.db can forge one, and carries that
key's fingerprint. Records are appended to the file with an fsync, and printed
as one JSON line on stderr for a container platform's log pipeline to ship off
the machine. Put the file on storage the database doesn't share. A record
that the attacker who rewrote the database can also delete proves nothing.
Checkpoints are on by default, to stderr only. cloop hub doctor warns until a
file is set; you don't need one if a log pipeline already ships stderr off the
machine. A hub without CLOOP_SECRET_KEY writes unsigned records. They still
pin each head against anyone who can't write where they are kept, and the
doctor reports that they are unsigned.
Check the chains against them:
$ cloop hub audit verify --hub /srv/cloop --project /srv/projects/api \
--checkpoints /var/log/cloop/audit-checkpoints.jsonl
…
Checkpoints /var/log/cloop/audit-checkpoints.jsonl — 4211 record(s)
checking seals under key 9cfc998abbd68ba4 (from CLOOP_SECRET_KEY)
OK control-plane /srv/cloop/.cloop/state.db
3925 checkpoint(s) agree with the chain; the newest, at 2026-10-10T18:05:00.133126574Z, saw id 18213
TRUNCATED project /srv/projects/api/.cloop/state.db
finding: tail truncated after id 790 although a checkpoint at 2026-10-10T18:05:00.4Z saw id 812
or: the database was restored from a backup taken before then, and nothing has been written since
1 of 286 checkpoint(s) disagree; the first is on line 4207
line 4207 truncated the chain ends at id 790, before the id 812 the checkpoint at 2026-10-10T18:05:00.4Z saw
--checkpoints also reads a log export of the stderr copies
(journalctl -u cloop-ui -o cat, -o json, or a container runtime's JSON log
file), so you can check the copy that left the machine as it arrived. Each
finding exits 2:
| Finding | Means |
|---|---|
truncated |
The chain ends before an id a checkpoint saw, and nothing was written since. Its tail was deleted, or a backup older than that checkpoint was restored and the hub has not run since. |
restored |
The chain no longer agrees with a checkpoint and has been written to since: a database restored from a backup older than that checkpoint (the report names the last checkpoint it still agrees with), or a tail deleted while the hub kept writing. |
rewritten |
The chain holds a different row under an id a checkpoint saw: history was edited and every later hash recomputed. The chain alone can't catch this kind of rewrite. |
deleted |
A row a checkpoint saw is missing from inside the chain. |
anchor-missing, anchor-rewritten |
A prune anchor a checkpoint saw is gone or altered. |
archive-mismatch |
A checkpointed row was pruned, and the archive its anchor sealed no longer matches the anchor's digest, or is not the intact chain the anchor's boundary ends in. Every archived row is re-hashed and linked, so a file that only holds the right hash on the right id doesn't pass. |
prune-unproven |
A checkpointed row is covered by a prune anchor whose archive cannot be read here, and no trusted checkpoint records the hub verifying that archive. Whoever can delete rows can also write an anchor over the hole, so until the archive is checked this is indistinguishable from a deletion. Run the command where the archive is. |
refused |
A record's seal does not verify under the key it claims: the file was edited or the record forged. Also an unsigned restore record when the verifier holds a key. It is not used as evidence. |
restore-mismatch |
A sealed restore record whose own head, or the head it says the chain was restored to, the chain no longer holds: the chain was cut back or rewritten after the restore it records. |
A checkpoint that disagrees with the chain because the database was restored is
not a finding when a restore record sealed under the hub's key accounts for it.
cloop db restore writes one, and verify reports restored (acknowledged) with
the events lost and exits 0; see What a restore records.
A prune is not a truncation. A checkpoint below a prune anchor is checked
against the archive the anchor sealed: first the archive's digest, as
verify-seals would check it, then every row in it, re-hashed into the chain
the anchor's boundary ends in. The archive must hold the row with the hash the
checkpoint saw. An archive moved to cold storage is reported as unverified, not
failed, when a checkpoint sealed under the hub's key records that the hub
verified that anchor's archive itself. The hub reads each new anchor's archive
once, in the first window it sees the anchor, and prints a prominent warning on
stderr when one doesn't verify. Otherwise nothing outside the database vouches
for the prune, and it fails as prune-unproven until the command runs where
the archive is. An archive the hub verified and somebody deleted afterwards
still reads as unverified, not failed. The archives hold the copy of what a
prune removed, so keep audit.export_dir where whoever can rewrite the
database can't delete from. Records sealed under another key (because
CLOOP_SECRET_KEY was rotated since) are reported and still checked for what
they say. Without CLOOP_SECRET_KEY the seals aren't checked at all, and the
output says so.
Checkpoints can't stop someone deleting records from the file. Storage off the machine covers that, and the doctor warns when the newest control-plane checkpoint is more than three intervals old.
Checkpoints pin a chain's length. To keep its content off the machine, use the
forwarder described below; cloop audit-log export is for backfill.
Forwarding the trail off the box¶
Whoever can write state.db can delete rows. A checkpoint shows that rows were
deleted, but only a copy kept somewhere else shows what they were. With
audit.forward, the cluster leader sends every row of every chain (the control
plane's and each registered project's) to one or more sinks as it is written:
audit:
forward:
hostname: hub-prod-1 # names this hub in what it sends; set it in a cluster
max_lag: 15m # `cloop hub doctor` warns past this (default 15m)
sinks:
- name: siem # syslog: RFC 5424 messages, RFC 5425 framing
type: syslog
address: siem.corp.example:6514
format: json # or cef
ca_file: /etc/cloop/siem-ca.pem
client_cert: /etc/cloop/hub-client.pem
client_key_secret: siem-client-key
- name: splunk # https: an NDJSON POST, or Splunk HEC events
type: https
variant: splunk_hec
url: https://splunk.corp.example:8088/services/collector/event
token_secret: splunk-hec
splunk_index: cloop_audit
- name: archive # file: appended and fsynced
type: file
path: /mnt/audit/cloop/audit.jsonl
Every key is in the configuration reference.
Every member of a hub cluster must run the same audit.forward, so keep it in
config.yaml or in the same overlay on every member. Only the leader forwards,
using its own configuration. Members that disagree record the configuration as
changed at every change of leader, and each retires the other's cursors, which
releases their hold on retention.
Credentials go in the secret store, never in the configuration. A HEC token,
an API key or a client certificate's private key is a shared env secret in
the hub's secret store, and the sink names it. No field can hold the value
itself, and a URL containing user:password@ is refused.
$ cloop secret mint splunk-hec --kind env --file hec-token.txt
$ cloop secret mint siem-client-key --kind env --file hub-client.key
A secret holding one value is presented as it is. For a secret holding several
({"API_KEY":"…","USER":"…"}), name the one you want as name#KEY. A personal
secret is refused, because the hub presents only the organisation's
credentials as itself. The hub reads the secret when it starts a sink, after
any failed send, and at least every hour, so a rotated value is used within the
hour. To switch at once, point the sink's token_secret at a new secret.
A file sink's directory must exist. The sink never creates it, so a missing
directory fails the send. An unmounted mount point is still an existing, empty
directory, so put the file in a subdirectory that only exists on the mounted
volume (/mnt/audit/cloop/, not /mnt/audit/). A volume that isn't mounted
then makes the send fail with an error, and the rows don't land on the root
disk underneath.
What is refused at load. Plaintext (transport: tcp, or an http:// URL),
unless the address is loopback and the sink sets
allow_plaintext_loopback: true; a local rsyslog or forwarder is the only case
where plaintext is acceptable. A file sink inside any .cloop/ directory. A URL
with a query parameter that looks like a credential (?api_key=, ?token=);
those go in the secret store too. A sink that fails these checks is switched
off, not repaired, and cloop hub doctor fails audit.forward.sink.
What is refused on the wire. The forwarder checks the address each socket
is about to connect to after DNS resolution, so a hostname can't get around the
checks. A cloud metadata service (from the
same table the firewall uses)
is refused for every sink, and a plaintext sink can only reach a literal
loopback address. The https sink follows no redirects (a 3xx is a failed send,
so the trail and its token are never carried elsewhere) and ignores
HTTPS_PROXY.
Delivery. Each sink has a cursor per chain in the control plane: the newest
row it acknowledged, by id and row_hash. A batch (batch_size, default 500
rows; max_batch_bytes, default 1 MiB) is read from the database after the
cursor and sent, and the cursor only moves once the sink acknowledges it:
| Sink | Acknowledged when |
|---|---|
https |
the collector answers 2xx (and, for HEC, with a body whose code is 0) |
file |
the batch is written and fsynced; a batch cut short is truncated back off the file, so a retry never lands after a half line |
syslog |
the receiver's TCP stack has acknowledged every byte, on a connection it has not closed. RFC 5425 has no acknowledgement of its own, so a receiver that crashes holding acknowledged bytes loses them; use https where the collector itself must confirm |
Delivery is at-least-once. If a leader dies after a sink acknowledged a
batch but before the cursor moved, its successor sends the batch again. A
receiver recognises the repeat by its row_hash, the idempotency key. Only the
leader forwards, so a cluster sends each row once per sink, not once per member.
A sink that is down never holds up an audit append. It has its own goroutine
and database handles, backs off from a second up to five minutes, and keeps its
backlog in the database: the forwarder holds at most one batch in memory
however far behind it falls. A new sink starts with every row still in the
database (start: oldest); start: latest starts at each chain's head. Rows
already pruned into archives, or written before the hub could forward, are
backfilled with cloop audit-log export (below).
What a sink receives. With format: json, each row is the record that
cloop audit-log export --format jsonl writes, plus the chain it came from and
the hub that sent it:
{"id":4182,"timestamp":"2026-10-13T09:14:02.118409Z","actor":"alice@corp.example","event_type":"secret.grant","entity_type":"secret","entity_id":"s_7f3c","payload":{"decision":"allowed","secret_name":"gh-deploy"},"prev_hash":"9c1e…","row_hash":"4b07…","source":"project","dir":"/srv/projects/api","hub":"hub-prod-1"}
Over syslog the record is the MSG of an RFC 5424 message, <PRI>1 TIMESTAMP HOSTNAME
cloop - MSGID - MSG, with the event type as MSGID, facility local0 unless
facility says otherwise. The severity is warning for a denial, notice for a
credential or executor event, and informational for everything else. A row
larger than max_message_bytes (default 8192) is sent without its payload and
says so (payload_truncated, payload_bytes, payload_sha256, an excerpt). format:
cef is the export's CEF line, adding dvchost (the hub), cs5 (the chain's
role) and cs6 (its directory) alongside cs2 (row_hash) and cs3
(prev_hash). CEF truncates payloads, so you can verify a chain from CEF by its
links but not by its hashes.
Verifying what a sink holds. Every record carries prev_hash and
row_hash, so the copy is the chain itself, and you can check it without the
hub:
$ cloop hub audit forward verify /mnt/audit/cloop/audit.jsonl
OK control-plane /srv/cloop ids 1…18213, 18213 row(s), 500 repeat(s)
OK project /srv/projects/api ids 1…912, 912 row(s), 0 repeat(s)
It reads a JSON sink's file, a collector's export of the records (HEC events
are unwrapped), or syslog lines with the record after the header. For each
chain it reports missing ids, ids received with two different hashes (a
fork: the chain changed under the forwarder, through a tail truncated and
written past, a restored backup or a rewrite), records that don't hash to their
row_hash, and broken links. A receiver can run the same check itself.
row_hash is SHA-256 over v1, prev_hash, id, timestamp, actor,
event_type, entity_type, entity_id and the payload, separated by tabs,
with the payload as the chain holds it: the record's payload verbatim when it
is JSON, or the string it holds when it is a JSON string.
When the chain moves under a sink. A cursor whose row is no longer in the
chain (because a prune was forced past it, a tail was truncated after it was
sent, or the chain was rewritten) is a discontinuity. The forwarder resumes
from the newest acknowledged point that the chain still holds, so the sink
receives the chain as it now stands alongside what it already has. It records
an audit.forward.discontinuity
row naming the sink, the chain, the reason and any rows lost, and forwards that
row like any other. A change to the sinks themselves is recorded as
audit.forward.configured by the
first leader to run with the new configuration, however the file was edited.
Watching it.
$ cloop hub audit forward status
✓ siem syslog json → tls://siem.corp.example:6514
backlog 0 row(s), lag 0s, delivered 18213 row(s), last acknowledged 2s ago
! splunk https json → https://splunk.corp.example:8088/services/collector/event
lags 2h14m3s behind, beyond audit.forward.max_lag (15m0s): 3210 row(s) waiting; last error: POST … answered 503 Service Unavailable
It exits 1 when a sink is refused, has never delivered (including a sink no hub
has started yet), or lags beyond max_lag. cloop hub doctor reports the same
as audit.forward.sink, audit.forward.lag and audit.forward, and the hub's
stderr has [audit-forward] lines when a sink starts and stops failing. The
metrics are
cloop_audit_forward_rows_total, cloop_audit_forward_lag_seconds and
cloop_audit_forward_backlog_rows.
Retention waits for the sinks. cloop hub audit prune and
cloop db maintain stop at the newest row every live sink has acknowledged,
and say which sink held back the rest. --force (--force-audit-prune) prunes
past it; those rows then reach the sink only through the archive, and the
forwarder records the loss. A sink removed from the configuration stops holding
back retention once the leader has run without it.
Splunk¶
Create an HTTP Event Collector token whose allowed indexes include the one the
sink names, store it as a secret, and point an https sink at the collector's
event endpoint with variant: splunk_hec (the splunk sink above). Each row
arrives as one event with sourcetype=cloop:audit and its record under
event. A resent row is a second event with the same row_hash, so
deduplicate searches on it:
index=cloop_audit sourcetype="cloop:audit" | dedup row_hash | table _time hub source dir event_type actor entity_id
Splunk truncates an event at TRUNCATE characters (10000 by default) for its
source type. The largest rows cloop writes are a few kilobytes, but raise the
limit for cloop:audit if a payload is bigger. A JSON reply whose code is not
0 is a refusal, and the batch is sent again. The exception is a reply refusing
the data itself (invalid format, a blank event): that chain then backs off on
its own while the others carry on. cloop sends a request channel, so a token
with indexer acknowledgement turned on accepts the batch. code 0 then means
received but not yet indexed, and cloop doesn't poll for the acknowledgement.
Elastic¶
Logstash's http input takes the NDJSON body as one event per line, and
Elasticsearch deduplicates a resend automatically when the document id is the
row_hash:
input {
http {
port => 8443
ssl_enabled => true
ssl_certificate => "/etc/logstash/tls.crt"
ssl_key => "/etc/logstash/tls.key"
user => "cloop"
password => "${CLOOP_INGEST_PASSWORD}"
codec => json_lines
}
}
output {
elasticsearch {
hosts => ["https://es.corp.example:9200"]
index => "cloop-audit"
document_id => "%{row_hash}"
}
}
- name: elastic
type: https
url: https://logstash.corp.example:8443/
auth_scheme: basic
token_secret: logstash-ingest # an env secret holding "cloop:<password>"
(The option names are Logstash 8's; earlier versions spell ssl_enabled as
ssl.) For Elastic Agent's syslog/TCP inputs, use the syslog sink with
octet-counted framing (framing: rfc6587).
Microsoft Sentinel¶
Sentinel's Common Event Format (CEF) via AMA connector reads CEF from a Linux
log forwarder: rsyslog receives the messages and the Azure Monitor Agent ships
them into CommonSecurityLog. Send CEF over TLS to the forwarder's rsyslog:
- name: sentinel
type: syslog
format: cef
address: cef-forwarder.corp.example:6514
ca_file: /etc/cloop/forwarder-ca.pem
# /etc/rsyslog.d/10-cloop-tls.conf on the forwarder
global(DefaultNetstreamDriver="gtls"
DefaultNetstreamDriverCAFile="/etc/rsyslog.d/tls/ca.pem"
DefaultNetstreamDriverCertFile="/etc/rsyslog.d/tls/cert.pem"
DefaultNetstreamDriverKeyFile="/etc/rsyslog.d/tls/key.pem")
module(load="imtcp" StreamDriver.Name="gtls" StreamDriver.Mode="1" StreamDriver.AuthMode="anon")
input(type="imtcp" port="6514")
Collect facility local0 (or the sink's facility) in the connector's data
collection rule. CEF's custom strings land in DeviceCustomString1…6, so
row_hash is DeviceCustomString2:
CommonSecurityLog
| where DeviceVendor == "cloop"
| summarize arg_max(TimeGenerated, *) by RowHash = DeviceCustomString2
| project TimeGenerated, Hub = DeviceName, Chain = DeviceCustomString6, DeviceEventClassID, SourceUserName
Backfill with export¶
To fill in rows already pruned into archives, rows written before a sink was
configured with start: latest, or a time range a collector lost, export them
on demand:
$ cloop audit-log list --entity secret --since 7d
$ cloop audit-log export --format jsonl --since 2026-09-01 --until 2026-10-01 --verify --output backfill.jsonl
$ cloop audit-log export --format cef --since 24h --verify | logger -t cloop -p local0.notice
| Format | Use |
|---|---|
jsonl |
lossless, with structured payloads; use it for archives |
csv |
flat table for spreadsheets |
cef |
ArcSight Common Event Format, syslog-ready |
Reconstructing one task¶
Auditors usually ask about a single piece of work: which executor ran task 63, under which secret leases, and how did it end?
$ cloop audit-log --task 63
Task #63 — Provision the staging cluster
2 execution(s) recorded
── run 1/2 run_0123456789abcdef0123456789abcdef
executor edge-pi4 kind=remote isolation=remote
actor alice@example.com
image ghcr.io/acme/harness@sha256:3f1a…
sandbox spec sha256:9c4e0b1a7d22
leases lease-7f3c
2026-09-15 09:14:02 secret.lease gh-deploy-key
outcome failed after 1m30s
reason terraform apply exited 1: quota exceeded in eu-west-1
This reads audit_events and no other table: not the plan, and not the task
record. An auditor reviewing an incident often has an exported trail but no live
database, and a reconstruction that needed the hub's working state would be
useless then. A test in pkg/statedb drops plan_tasks before reconstructing,
so this can't break unnoticed.
It joins rows on the run id, which every dispatch, terminal and lease row carries. A task id isn't enough: task 63 above ran twice, and each run held a different credential. Joining credentials on the task id would wrongly report that the task held both credentials the whole time.
| Event | Written when |
|---|---|
task.dispatch |
a task starts executing; records the executor, isolation, pinned image digest, sandbox spec hash, lease ids and actor |
task.finish |
it stops executing, by any path (signal, kill, timeout, provider abort, or a hub crash reconciled later); records the outcome, duration and reason |
task.status |
a person changes a status from the dashboard or the CLI; names that person |
task.finish is written where the status is stored, not at each of the
orchestrator's exit paths, so an exit path added later can't miss it. A run
still in progress, or one whose hub died before it wrote a terminal row, shows
as no terminal row instead of being given an invented outcome.
Retention: keeping the trail bounded¶
The trail only grows, and on a busy hub it grows fast: cloop's own control plane reached 1.1M rows and 2.4 GB. Retention moves the oldest rows out of the database without breaking verification.
Retention is opt-in. Without audit.retention_days, nothing is ever
pruned.
audit:
retention_days: 90 # 1–3650; 0 or absent keeps everything
export_dir: /srv/audit-archive # default: .cloop/audit-archive
prune_on_maintain: true # let `cloop db maintain` apply it
A prune streams the oldest rows to a JSONL archive, verifies the chain of every row on the way out, digests the file, and only then removes the rows. It records an anchor holding the boundary hash, the id the chain resumes at, and the archive's SHA-256. Unless forced, it never removes a row that an audit sink has not acknowledged: the prune stops at the slowest sink's cursor and names that sink.
$ cloop hub audit prune --before 90d --dry-run
$ cloop hub audit prune --before 90d
$ cloop hub audit anchors # every truncation, oldest first
$ cloop hub audit verify-seals # re-hash each archive against its anchor
After a prune, cloop audit-log verify uses the anchor to walk across the gap
and still reports OK. The anchor pins the id the chain must resume at, so
deleting a few more rows just past the boundary is still caught.
Three things to know:
- Pruning doesn't shrink the file. It frees pages onto SQLite's freelist.
Run
cloop db maintainto return them to the filesystem. That command runsVACUUM, which rewrites the whole file, so it refuses while any hub is serving the control plane. Stop the members (cloop hub cluster statuslists them) or wait for the lease to lapse (cloop hub lease status). verify-sealschecks against something outside the database. Chain verification can only prove that the remaining rows agree with what the anchors claim, and anyone who can rewriteaudit_eventscan rewriteaudit_anchorstoo. Copy the archives somewhere the hub can't write; that copy is your evidence.- A pruned trail can no longer be replayed from the beginning.
cloop events replayrefuses to rebuild a partial database and names the archive holding the missing events. Pass--allow-truncatedto rebuild only the remaining rows.
With --verify, export refuses a broken chain, so an exported file passed
verification at export time. Every format carries prev_hash and row_hash,
so the recipient can verify the file independently. Filters: --actor,
--entity, --entity-id, --type, --since, --until, --limit. Output
files are written with mode 0600.
Audit rows never contain credential material. Five separate checks test this, including one that scans every row rendering, not a sample.
The git interception proxy¶
The proxy is off unless configured; cloop hub bootstrap turns it on with
tls: auto. With the proxy on, a sandbox that fetches and pushes the project's
source never holds the forge PAT. It gets a session token for a proxy the hub
runs, and the proxy enforces the branch allowlist on the ref updates in each
push. The design is in git interception proxy;
this section covers running it.
Turning it on¶
- Give it a certificate the sandbox will accept. The sandbox's git
validates the certificate, not the hub. The simplest option is
tls: auto: the hub issues the certificate from its own CA, sealed underCLOOP_SECRET_KEY, and tells every sandbox it dispatches (host process, container, Pod, edge device) to trust that CA for the proxy's URL only, so no image needs changing. Otherwise usecert_fileandkey_file. A public-CA certificate needs nothing more. A self-signed or private-CA certificate needs its CA in the sandbox image's trust store, or in a bundle named bySSL_CERT_FILE/GIT_SSL_CAINFOon the machine git runs on. On Kubernetes the driver mounts the CA from a ConfigMap and trusts it for the proxy's URL only:executors.kubernetes.git_ca_bundle. - Decide what the sandbox can reach.
listen_addris the bind address.advertise_urlbecomes the sandbox's remote, so it has to be routable from where git runs: a Service for the Kubernetes backend, or the hub's address on the link for an edge device. The table of forms covers the rest. - Write the section and restart the hub.
executors:
git_proxy:
enabled: true
tls: auto
listen_addr: "0.0.0.0:8443"
advertise_url: "https://hub.internal:8443"
session_minutes: 60
- Confirm it came up. One line on stderr names the bind address, the URL
sandboxes are pointed at, and the allowlist. Under
tls: auto, a second line describes the certificate:
ui: git interception proxy on 0.0.0.0:8443, advertised as https://hub.internal:8443; pushes limited to refs/heads/cloop/**
ui: git proxy serves a certificate the hub issued (tls: auto) for hub.internal, valid until …; sandboxes are told to trust the hub CA for https://hub.internal:8443 only
From then on cloop hub doctor checks it: gitproxy.tls fails a leaf with
less than a day left (the cluster leader renews it when two are left) or one
that doesn't name advertise_url's host. A failed renewal prints
ui: the git proxy's certificate could not be renewed: …. A device whose
agent speaks a protocol older than v21 is refused runs that would use the
proxy, with an error naming the upgrade; upgrade it from the Executors
panel.
- Run one task on the executor and check the audit trail for the session and
the fetch:
cloop audit-log list --entity gitproxy --since 1h.
With the Helm chart, the steps above are values: executor.gitProxy.enabled
and a certificate from executor.monitorTLS (auto, selfSigned, or an
existingSecret from cert-manager plus a way for the Pods to trust its CA). The
chart advertises the proxy at the hub's Service name and adds the port to the
Pod and the Service. For a Secret it also renders git_ca_bundle with the CA
in a ConfigMap in the workload namespace. Under auto it renders nothing
extra, because each Pod gets the hub's CA from its run's lease Secret. See
deploy/README.md.
CI runs a Pod through this configuration on a kind cluster on every push
(tests/kube).
Two failure lines matter. Both mean GitHub access has stopped; neither opens it
up. After ui: git interception proxy NOT started: …, every git workspace that
needs a credential is refused with ErrWorkspaceUnavailable, and every GitHub
grant is left out of the leases it would have been part of, so it never
reaches the sandbox. ui: executor <id> is NOT routed through the git proxy: …
does the same to one executor's workspaces. The hub still starts in both cases,
so alert on these lines; a failed start won't tell you. A third line,
ui: git proxy decisions will go to stderr, not the audit trail: …, is less
serious: the proxy still refuses what it should, but the evidence isn't in the
database.
The most dangerous failure prints no ui: line at all. A section with
enabled: true that the configuration loader can't use (neither tls: auto
nor cert_file and key_file, or an unusable listen_addr or
advertise_url) is switched off at load with a
warning: config executors.git_proxy.… line. The hub then runs as if it had no
proxy, and credentials are delivered into sandboxes as before. After enabling
the section, look for the ui: git interception proxy on … line from step 4:
if it is missing, the proxy isn't running. See
how it fails.
Session lifetime is not run lifetime¶
A workspace session, the one cloop's own fetch and write-back use, lasts as
long as its run. It is minted for session_minutes (60 by default, 720 at most)
and, while the run lives, renewed by the same amount whenever five minutes or
less are left, with a gitproxy.session_renewed row each time. It is closed
once the run is done with it: after the fetch, or once the workload has
finished and its write-back has run. A run longer than session_minutes still
pushes its work back, and a run that ends early ends its session too. After a
restart, the member that adopts the run renews and ends the session. A member
on an older build only restores sessions that are still in their first period.
If such a member adopts a run whose session was renewed beyond it, the push
fails with an authentication error from git and a gitproxy.rejected row;
finish the upgrade.
A session minted for a GitHub lease, the one the workload's own git uses,
ends with its lease or after its own session_minutes, whichever comes first.
The lease ends when the run ends, when the lease is revoked from the Secrets
panel or with POST /api/leases/{id}/revoke, when the grant it was minted for
is revoked, and when the lease lapses. A run's lease is issued for 15 minutes
and extended in place while the run is live, so it only lapses when the run is
gone or a grant it holds was revoked or has expired. The janitor then sweeps it
within a minute, closing the session. Each extension is a secret.renew row; a
refused extension (because the grant was revoked) is a denied secret.renew
naming the grant. See
how long a session lives.
A workspace session ends early when its lease is revoked. That lease is kept
alive while the session lives and is listed with the hub's other leases
(GET /api/leases, the Secrets panel); revoking it there closes the session at
once. A hub restart suspends a recorded session instead of closing it. That
covers a guarded session (the form a GitHub lease is delivered in) and a
workspace session whose lease is recorded, and the process that adopts the run
restores it (see after a control-plane restart).
A session with no record is closed, as gitproxy.session_closed with the reason
"the hub is shutting down". Otherwise a workspace session ends with its run
and a lease session with its lease, or either lapses at its deadline once
nothing extends it. The deadline is enforced at authentication, whether or not
the five-minute reaper has swept the session. Revoking the underlying grant
(from the Secrets panel or with cloop secret revoke) closes the sessions it
fed at once, on every hub member, and stops the next dispatch from minting
anything. Rotate the PAT at the forge as well.
The lease keepalive doesn't extend the GitHub App token a session presents
upstream, which GitHub accepts for an hour. The session renews the token itself,
at the grant's original scope, when about ten minutes are left. So with a
github_app grant, a lease session's life is capped by session_minutes, as
with a PAT, and a workspace session's by its run. See
GitHub App tokens past their hour.
GitHub App tokens past their hour¶
A github_app grant's installation token expires an hour after it is minted,
and the hub replaces it before then with a token of the same scope. With the
proxy, the session's request path does this; without it, the lease keepalive
does, rewriting the sandbox's github-token file in place. The full behaviour
is in the secrets guide.
If a long run loses GitHub access anyway, the cause is one of the following, and
the audit trail says which:
$ cloop audit-log list --type secret.renew --since 24h --json # re-mints, and refusals
$ cloop audit-log list --type lease.refresh --since 24h --json # deliveries to the executor
$ cloop audit-log list --type github_app.token_destroy --since 24h # superseded and refused tokens
$ cloop audit-log list --type gitproxy.session_closed --since 24h # sessions a refusal closed
| What you see | What happened | What to do |
|---|---|---|
a denied secret.renew naming a revoked or expired grant |
the grant behind the token was withdrawn; its tokens were destroyed and, under the proxy, the session closed | nothing, if intended; re-grant otherwise |
a denied secret.renew quoting GitHub (suspended, not accessible to the installation, 401) |
GitHub refused the re-mint permanently: the installation was suspended or uninstalled, a repository was removed from it, or the App's key was deleted | fix the App installation, then start the run again |
denied secret.renew rows saying will retry |
GitHub was unreachable, rate-limiting or answering 5xx; the held token kept working while the hub retried each minute | check the hub's route to api.github.com; access ends only if this outlasts the token |
a denied lease.refresh and a credential_refresh journal row naming the agent's protocol |
the device's agent is older than v17, so it was not sent the new token; the run kept its first token until it expired | upgrade the agent to the hub's build (the row says how) |
a denied lease.refresh naming "patch" |
a Kubernetes executor's Role predates the patch verb on secrets |
add patch to the Role's secrets rule (the shipped chart has it) |
a denied lease.refresh saying the agent is not connected |
the device was offline when the token was due | the keepalive retries each minute while the old token lasts |
A refresh doesn't cross members of a hub cluster. The keepalive runs on the member that issued the lease and reaches only the agents connected to that member. A device whose agent reconnected to another member mid-run keeps its token until it expires.
Alert on gitproxy.push_denied¶
$ cloop audit-log list --type gitproxy.push_denied --since 7d
$ cloop audit-log list --entity gitproxy --since 24h --json
A push_denied row means a sandbox tried to move a ref outside its allowlist
(refs/heads/main, say) and was refused before anything was forwarded to the
forge. This row is the only record of the attempt, because nothing else in
cloop is on the push path.
A well-behaved task never produces one, so don't tune this alert away. Each
occurrence is either a workload doing something it wasn't asked to do or a
policy narrower than the task it was minted for, and both need a person to look.
The related gitproxy.push_allowed row is written before the forge's answer
is forwarded, so a push_allowed with no matching change on the forge means the
forge refused the push (branch protection, a non-fast-forward), not the proxy.
Alert on gitproxy.push_secret_refused¶
Every push the policy allows is scanned on its way to the forge for the
credentials its run was given (executors.git_proxy.push_scan, values by
default; see the push scan). A
push_secret_refused row means a run tried to publish one. Its findings name
each object, its path and commit, and the grant and secret the value came from,
but never the value. The forge got no complete pack and no ref moved, so nothing
was published. The agent usually tries again, though, so look at the run's
history (a git_push_refused row) and the task's details, where the same
refusal appears as a line. A grant whose env secret holds values that aren't
secret (a region, a hostname) refuses any push that mentions them; take those
values out of the secret, or narrow the grant with --env-keys. A push refused
at a push_scan_* limit, or for a pack the proxy could not read, is a
gitproxy.push_denied row with scan set (limit, unreadable).
cloop_gitproxy_push_scan_seconds shows how close pushes come to
push_scan_seconds.
The Kubernetes access monitor¶
The monitor is off unless configured; cloop hub bootstrap turns it on with
tls: auto. With the monitor on, a kubeconfig granted to a project stays in
the hub. The sandbox's kubectl gets a kubeconfig naming the monitor and a
session token, and each request is checked against the grant's verbs and
namespaces before the cluster credential is attached. A grant with no verbs is
read-only. The design is in
Kubernetes access monitor; this section
covers running it.
Turning it on¶
- Certificate. With
tls: autothe hub issues it from its own CA, as for the git proxy (cloop hub bootstrapwrites both). Either way, nothing has to change in the sandbox image: the monitor embeds its CA (the hub's, orca_file, elsecert_file) in every kubeconfig it issues. - Reachability.
advertise_urlbecomes the kubeconfig'sserver:, so it has to be routable from wherekubectlruns: the hub's Service, for Pods. - Write the section (
executors.kube_guard; the reference has every key) and restart the hub, or with the Helm chart setexecutor.kubeGuard.enabled=truebesideexecutor.monitorTLS. - Confirm it came up:
ui: kubernetes access monitor on 0.0.0.0:8444, advertised as https://cloop-hub.cloop.svc:8444; policy floor no verb ceiling
- Check it from a sandbox. Grant a project a kubeconfig with
--namespaces team-aand no verbs, run a task, and look forkubeguard.session_mintedand, when the task writes, akubeguard.request_denied:cloop audit-log list --entity kubeguard --since 1h.
Its failure lines match the proxy's:
ui: kubernetes access monitor NOT started: … means kubeconfig leases are
refused, not delivered unguarded.
On every push, CI installs the Helm chart on kind with the monitor on, grants a
project a kubeconfig that can edit a namespace (with no verbs in the grant),
and runs a task. The task Pod's kubectl get pods is forwarded and its
kubectl create configmap is refused by the monitor. Both are audited, nothing
is created, and no cluster token is readable in the Pod. This runs on one
replica and on a hub cluster of three
(tests/kube), and the same run tests the git
proxy.
Alert on kubeguard.request_denied¶
A task doing what it was asked never produces one, so each row is a workload
writing to, or reading outside, what it was granted, or a grant narrower than
its task. Allowed requests are recorded only when
executors.kube_guard.audit_allowed (in the chart,
executor.kubeGuard.auditAllowed) is on. Each forwarded request is then a
kubeguard.request_allowed row naming the verb, resource and namespace. Turn it
on where the trail has to show what a sandbox read, and expect a burst of
rows per kubectl invocation and one per watch.
SCIM provisioning¶
The identity provider keeps the hub's directory up to date at /scim/v2, and a
person it deactivates is suspended without an operator
(guide). Running it is mostly a matter of
noticing when it stops working, because a failing provisioning job only reports
errors at the provider.
cloop hub doctor checks it (scim.config, scim.directory). It reports a
live credential on a hub without single sign-on, which serves no /scim/v2,
and provisioned users who have no value for ui.oidc.scim.match_claim or share
one, so that deactivating them would reach nobody. cloop hub scim status
shows the credentials, when each was last used, and the same counts.
Alert on scim.user_suspend_incomplete¶
The suspension's transaction committed (the person is denied, and their
sessions and tokens are gone), but a lease or a running task could not be ended
afterwards. The row's warnings say which. Revoke the lease under
Secrets → Leases and stop the task. A lease whose holder is unreachable
still lapses within 15 minutes, because its grant is suspended and can't be
renewed.
Alert on scim.match_ambiguous¶
The provider deactivated a user whose match value is shared by more than one
hub identity. The hub denies the value itself instead of guessing which person
was meant, so everyone else with that value is refused too; that is the symptom
you will notice. Fix the mapping at the provider or choose a unique claim
(oid, sub). Then cloop hub role list --identity shows what the suspension
wrote.
Watch scim.request_refused¶
A steady stream with reason: unknown or revoked means the provider's job is
about to be quarantined, usually after a rotation whose new secret never
reached the provider. sso_disabled means single sign-on was turned off on the
hub while the provider was still working, and rate_limited means a first
cycle larger than ui.oidc.scim.rate_limit_per_minute. A burst from an address
the provider doesn't use is somebody probing /scim/v2. Past five a minute,
SCIM's own lockout answers that address's wrong credentials with locked_out
(429), while the provider's valid credential is still served from the same
address. unavailable means the hub could not read the credential store; the
provider retries. The rows are rate-limited per minute, so their count is a
minimum.
Key rotation¶
There are seven credentials, each with its own procedure in a subsection below:
- the TLS key rotates with a staged overlap;
- the dashboard token rotates by replacement, which causes a logout;
- an enrollment token or agent credential is revoked and the device re-enrolled;
- the sealing key rotates online with
cloop hub key rotate; - the hub's own CA rotates online, in stages, with
cloop hub ca rotate; - grants rotate by expiring;
- SCIM credentials rotate with an overlap.
The certificates the hub issues to its git proxy and Kubernetes monitor under
tls: auto need no procedure: they renew themselves two days before they
expire (see the git interception proxy).
cloop hub key rotate re-seals the CA's key, and a staged CA's, under the new
sealing key without reissuing either CA. Replacing the CA itself is
its own procedure.
TLS certificate and agent pins — supported, staged¶
Agents pin the hub's SPKI (a hash of the public key), not the certificate. A routine renewal that reuses the key doesn't change the pin, so an enrolled fleet needs no action.
$ cloop hub pin # from ui.tls.cert_file
$ cloop hub pin --cert /path/new.pem # the pin of a certificate you are about to roll onto
Moving to a new key does change the pin. Stage the change; agents accept a comma-separated set of pins so that the old and new pins can overlap:
- Run
cloop hub pin --cert new.pemto get the new pin. - Distribute
--pin <old>,<new>to the agents and restart them. - Move the hub to the new certificate.
- Confirm that
cloop executor lsshows every agent reconnected. - Distribute again with only
<new>.
Doing steps 2 and 3 in the opposite order locks every agent out.
cloop hub tls-init generates a self-signed certificate, for development only.
The git proxy has its own certificate, which the
steps above don't cover. Under tls: auto there is nothing to rotate by hand.
The cluster leader replaces each monitor's leaf certificate two days before it
expires, every member serves the new one within a minute, and sandboxes trust
the hub's CA, not the leaf, so a run in progress isn't interrupted.
cloop hub doctor fails gitproxy.tls and kubeguard.tls if renewal stops.
The CA itself is valid for ten years and is replaced only when an operator
rotates it, in steps that change what every new sandbox trusts before any
certificate changes; see Rotating the hub CA. With an
operator's cert_file and key_file, nothing pins the certificate, but every
sandbox's git validates it. A move to a new CA therefore has to reach the
sandbox image's trust store before the hub starts serving the new
certificate. Otherwise every provisioning fetch fails to verify the peer.
Rotating the hub CA¶
Under tls: auto the hub issues its git proxy's and Kubernetes monitor's
certificates from its own CA, and every sandbox is told to trust that CA for
the monitors' URLs. The certificates renew themselves. The CA is replaced with
cloop hub ca rotate, online and in three steps, so that no sandbox is cut off
unexpectedly. Rotate it:
- when its key may have leaked (a backup taken together with the root key, a KMS identity used where it shouldn't have been, an operator who held either leaving);
- when
cloop hub doctorreports it nearing expiry (hubca.expiry: a warning within a year, a failure within 30 days); - before moving a monitor's advertise URL to a host the CA's name constraints don't permit (see below).
Settings → Hub health offers the same steps to an administrator without a shell on the hub host.
$ cloop hub ca rotate --status # where it stands: CAs, members, runs a step waits for
$ cloop hub ca rotate --stage # mint the new CA; every new sandbox trusts both
$ cloop hub ca rotate --status # the runs dispatched before the stage, still running
$ cloop hub ca rotate --activate # once none is left: certificates come from the new CA
$ cloop hub ca rotate --retire-previous --yes # once every member serves them: the old CA is destroyed
Stage. The new CA is minted and sealed the same way as the first: under the
KEK registry (so under the KMS when secrets.kms is set), and rewrapped by
cloop hub key rotate like every other sealed key. Every hub member re-reads
its CAs within a minute (at once, when the cluster bus carries the step), and
every sandbox it dispatches from then on trusts both CAs: git's per-URL
http.<url>.sslCAInfo bundle, the kube monitor kubeconfig's
certificate-authority-data, the harness's NODE_EXTRA_CA_CERTS when its model
calls go through the relay, a Pod's lease Secret, and a device's or virtual
executor's start frame. The certificates still come from the old CA. Each
dispatch records which CAs it was told to trust on the spec its executor
session keeps (executor.LabelHubCATrust), and the next step reads that
record. The egress proxy needs nothing: it is a cleartext CONNECT proxy that
never installs a CA, and sandboxes reach the monitors directly, not through it.
Activate. The staged CA becomes the CA that certificates are issued from,
and the cluster leader reissues both monitors' certificates at once. Every
member serves them within a minute, and keeps serving the old CA's certificate
until then; every sandbox dispatched since the stage trusts both. (A member
whose leader has not reissued after three minutes reissues the certificates
itself.) --activate is refused while a run dispatched before the stage is
still running, because that run trusts only the old CA and would stop
verifying the monitors. The error names each run, with its project, task and
executor. Wait for them, or use --activate --force --yes, which breaks them
and records them on the rotation. Activation is also refused while a live hub
member has not adopted the stage, or runs a build that predates rotation. Such
a member would keep signing with the CA it opened at startup, so no flag
overrides this: upgrade or stop it. The command waits up to --wait (two
minutes) for every member to serve the new certificates and says which members
it is waiting for.
Retire the previous CA. Once every member serves certificates from the new
CA, --retire-previous --yes takes the old one out of every bundle and deletes
its row on a secure-delete connection. Its sealed key is then gone from the
database file, not left in SQLite's free space. Every write to hub_tls zeroes
what it frees, so no earlier copy of the row survives either (from a rewrap,
or from the activation that moved it). Retirement is refused while a stored
certificate or a member still serves a certificate from the old CA. Old pages
held in the write-ahead log last until the log is reused, and a backup taken
before the retirement keeps the old key, so take those backups again or
destroy them. Until you retire it, the old CA is what you roll back to, and nothing
else needs undoing.
--abort --yes destroys a staged CA that was never activated; every dispatch
that trusted it trusts the active CA too.
An emergency: --now --yes. If you believe the key is being used now, this
does every step at once. A fresh CA is minted (a staged CA is abandoned too,
because its key is in the same database) and activated, both monitors'
certificates are reissued from it, and the old CA is retired. The runs whose
dispatch trusted only the replaced CAs are named and recorded on the rotation.
Every hub member ends those runs' git proxy, Kubernetes monitor and model relay
sessions within a minute and records hub_ca.sessions_ended, because whoever
holds the old key could otherwise impersonate the monitors and take the session
tokens those runs present. Those runs fail from then on; restart them. The
command is refused while a hub member runs a build that predates rotation. A
leaked CA key usually means the sealing key or the database leaked with it, so
change the root as well
(Changing CLOOP_SECRET_KEY itself) and
re-mint the credentials that root could open.
Name constraints, and what moving an advertise URL involves. A CA minted by
a rotation carries X.509 name constraints that permit only the names the
monitors are reached at: every name a certificate was issued for so far, the
configured advertise_url and listen_addr hosts, and each --name. git
already trusts the CA for the monitors' URLs only, but a harness's Node.js
trusts it for every host. Without constraints, whoever holds the key could
present a certificate for any host to a relayed harness; with them, Go, OpenSSL
and GnuTLS refuse it. --no-name-constraints mints a CA without them. The
constraints are part of the CA's certificate, so moving a monitor to a host
they don't permit requires a rotation. Without one, the certificate a member
would serve leaves the name out, every sandbox reaching the monitor by that
name refuses it, and cloop hub doctor fails hubca.names. Plan the move:
--stage --name <new-host> (the old names are kept), wait for the runs
dispatched before the stage, --activate, then change advertise_url and
restart the members. The CA a control plane minted at its first start has no
constraints; its first rotation adds them.
Two kinds of older component hold a rotation back. A device agent before protocol v25 can't take two CAs for Node. A relayed run there is given only the active CA, so it trusts only the old one and blocks the activation like a run dispatched before the stage; upgrade such devices first. A hub member on a build from before rotation also blocks it, as described above. Restoring a backup taken before a rotation brings its CA back as the active one. Sandboxes dispatched after the retirement don't trust it, and their runs fail until restarted.
Every step is in the audit trail and in the
metrics. The audit actions are
hub_ca.rotation_staged, hub_ca.rotation_activated (naming any runs it was
forced past), hub_ca.previous_retired, hub_ca.rotation_abandoned,
hub_ca.emergency_rotated (alert on it) and each member's
hub_ca.sessions_ended. The metrics show the CAs' expiry, the phase, how long
the current step has lasted, and the runs and members it waits for.
cloop hub doctor warns when a stage has lasted longer than the longest
session_minutes a monitor grants (hubca.rotation), when a previous CA is
still trusted that long after its activation, and when a member predates
rotation or has fallen behind (hubca.members).
Dashboard token (CLOOP_UI_TOKEN) — retire the old value, deploy a new one¶
It's better not to rotate it at all: once single sign-on works, retire it and
give each remaining caller a scoped API token (cloop hub token create).
To rotate it anyway, generate 32 random bytes, replace the value in hub.env
(or the Kubernetes Secret) and restart each member. A member accepts the value
it was started with, so both values work during a rolling restart. When every
member runs the new value, retire the old one so that every member permanently
refuses any copy left behind:
$ cloop hub token static status # both fingerprints, which hubs hold which
$ cloop hub token static retire --fingerprint <old> --reason "rotated, CHG-2291"
Retirement is per value: a retired value is never accepted again, so the next rotation needs a new value too. OIDC sessions are unaffected throughout.
Enrollment tokens and agent credentials — revoke and re-enrol¶
$ cloop executor agents # enrolled agents + outstanding tokens
$ cloop executor revoke <token-id|agent-id>
$ cloop executor enroll --name edge-1 --ttl 15m
Revocation cascades from the enrollment record to the credential derived from
it, and the hub sends bye with reconnect=false so the agent stops instead of
retrying. Enrollment tokens expire on their own (15 min by default, 24 h at
most). Agent credentials don't expire; revocation is the only way to end
one.
On a device running the installed service, you re-enrol by replacing the credential file. The unit stays as it is:
# on the device, as root
$ install -m 0600 /dev/null /var/lib/cloop-executor/enrollment
$ printf '%s\n' "$CLOOP_ENROLL_BUNDLE" > /var/lib/cloop-executor/enrollment
$ rm -f /var/lib/cloop-executor/agent.json # drop the revoked identity
$ systemctl restart cloop-executor
The agent removes the enrollment file once it has redeemed the token, so an
empty /var/lib/cloop-executor/enrollment on a healthy device is normal. To
remove the device entirely, run
cloop executor agent install --uninstall --purge. It is idempotent, and it
checks afterwards that no unit, credential or state directory is left.
Sealing keys — online, resumable, no credential re-minting¶
Stored credentials aren't encrypted under CLOOP_SECRET_KEY directly. Each row
gets its own random data-encryption key (DEK), which seals the payload, and
only the DEK is sealed under a key-encryption key (KEK) derived from the
passphrase. Rotation therefore rewraps sixty bytes per row and never decrypts a
payload, which makes it safe to run against a serving hub.
Several KEKs can be open at once. Rows move to the new one individually, and reads keep working with whichever key each row still names, so nothing is ever unreadable during a rotation.
$ cloop hub key status # which key is sealing what
$ cloop hub key rotate --dry-run # count what would move
$ cloop hub key rotate # mint a new KEK and rewrap onto it
$ cloop hub key list # keys, and whether each is still openable
rotate covers both kinds of long-lived sealed material, brokered secrets and
session refresh tokens, because they share one registry. Enrollment tokens and
agent credentials aren't covered and don't need to be: they are stored as
SHA-256 hashes, never sealed, so rotating a key can't affect them (see
enrollment tokens).
Interruption is safe. After Ctrl-C, a restart, or a SIGKILL mid-write, the row being written is protected by its transaction and everything else is untouched, because rotation retires nothing and the old key stays usable. There is no cursor to corrupt: the remaining work is "every row not yet under the target key", so you resume by running the command again.
Concurrent writes aren't lost. Each rewrap is a compare-and-swap against the
ciphertext that was read. A credential re-minted, or a session refreshed, during
a rotation is left alone and counted as skipped; run --continue once
afterwards to pick those up.
Run one rotation at a time. Two concurrent runs each promote their own key
and move rows back and forth. cloop detects this (a row that keeps coming back
is reported, and the run exits incomplete instead of looping forever), but the
work is wasted. If you see still not under ... after 5 attempts, check for a
second cloop hub key rotate, then run --continue.
A hub that is serving while you rotate needs no coordination. It re-reads the current primary key before every seal, so new material is sealed under the new key from the moment it is promoted, and the hub starts using that key the first time it needs it.
Retiring the old key — a deliberate second step¶
Rotation never destroys a key. Retirement does, and it can't be undone. It
blanks the KEK's salt so the key can never be derived from CLOOP_SECRET_KEY
again, and for a key held by a KMS it destroys the KMS's
ciphertext of the key, so not even the KMS can give it back. Every write to the
key registry uses secure delete, so neither the blanked bytes nor an older copy
of the row from an earlier write stays in SQLite's free space. Old pages held in
the write-ahead log last until the log is reused, and a backup taken before the
retirement keeps everything. Anything still sealed under the key can't be
recovered after that, which is why retirement refuses while any row
references the key.
Retire a KMS-held key with this build or a later one. An older
cloop hub key retire (v0.0.6 and earlier) blanks only the columns it knows
about and leaves the KMS's ciphertext of the key in its row. A hub on this
build destroys that ciphertext at its next start, and the cluster leader at its
next KMS poll, and logs it
(destroyed the KMS ciphertext … retired sealing key(s) still carried).
After retirement, reading material that still names that key fails with a
specific error, sealing key retired, naming the key and when it was retired,
instead of a generic decryption error. That tells you to re-mint the
credential, not to look for a passphrase problem.
Changing CLOOP_SECRET_KEY itself¶
Every KEK is derived from CLOOP_SECRET_KEY, so rotating KEKs limits the life
of each key but not of the secret they all come from. You can change that
secret online too. A hub holds a current root, which mints new KEKs and
opens existing ones, and, while a change is in progress, a previous root,
which only opens them. Nothing is re-minted at a forge or a cluster, and the
hub keeps serving throughout.
- Generate the new key and install it alongside the old one on every hub
member: the new key as
CLOOP_SECRET_KEY(its file, credential or variable; see the root key), and the old one asCLOOP_SECRET_KEY_PREVIOUS(CLOOP_SECRET_KEY_PREVIOUS_FILE, or the credentialcloop-secret-key-previous).
- Rolling restart. Restart the members one at a time. Each starts on the new key with the old one alongside it (a hub starts only if every live KEK derives from one of its two roots) and advertises the new root's fingerprint in its cluster member row. Until step 4 the registry's primary key stays the old root's key, so restarted and not-yet-restarted members seal under the same key and can read each other's writes. Until then, you can back out the change by restarting with the old configuration.
$ cloop ui …
Root key: de46939974158c63, from the file /srv/cloop/cloop-secret-key (CLOOP_SECRET_KEY_FILE)
Previous root key: 7246e27af489f0e0, from the file /srv/cloop/cloop-secret-key.old (CLOOP_SECRET_KEY_PREVIOUS_FILE) — a change of root is in progress; finish it with `cloop hub key rotate --retire-previous` once every member runs on de46939974158c63
-
Escrow the new key with
cloop hub key escrow export --output <file off this host>, in a shell holding both keys. Once the old root is retired, the new key is the only thing that opens this hub. -
Retire the previous root, in a shell holding both keys:
$ cloop hub key rotate --retire-previous --dry-run
$ cloop hub key rotate --retire-previous
Change of root 7246e27af489f0e0 → de46939974158c63
ready member hub_cb87… (pid 4121 on hub-1): root de46939974158c63, previous 7246e27af489f0e0
ready member hub_50af… (pid 3880 on hub-2): root de46939974158c63, previous 7246e27af489f0e0
ready escrow esc_9d2c… holds the new key
minted kek_b876… from the new root and made it primary
…
Every row is sealed under kek_b876…, and the previous root's 2 key(s) are retired: kek_42a9…, kek_8f0e….
It refuses, and changes nothing, until every live member advertises the
new key as its current root and an escrow of the new key is on record. A
member still on the old key would be unable to read anything once the old
keys are gone, and a member that advertises nothing predates the
advertisement. It also refuses while a hub serves under
ui.cluster.exclusive, which has no member row to advertise in; stop that
hub for this step. Then it mints a KEK from the new root, rewraps every
secret, session refresh token and hub CA key onto it with the same
compare-and-swap rotation rotate uses, and retires every KEK of the old
root. If it is interrupted, it retires nothing; run it again to continue,
and it mints nothing the second time. A plain cloop hub key rotate during
the change is refused, because it would mint from the new root without those
checks.
- Drop the previous key from every member and restart them again.
cloop hub doctorwarns (secret_key.previous) for as long as it is set. A member started with only the old key now refuses to start, naming the keys it can't derive. So does a member with a mistyped new key and no previous key.
If the database leaked too, this is not enough. A rewrap moves each row's DEK under the new root's KEK, but it doesn't change the DEK, and every payload's ciphertext stays as it was. Someone holding the old key and a copy of the database from before the change can still unwrap the old DEKs, and those open today's payloads. The change of root stops the old key from being used from now on; it can't take back what a copy of the database already reveals. If both leaked, re-mint the credentials at their sources as well:
- Record what exists (never the values):
cloop secret grants --all > grants.txt. - Rotate the underlying credentials at their sources (GitHub, cluster, registry).
- Replace each stored secret with its new value: delete it (Settings →
Secrets, or
DELETE /api/secrets/{id}),cloop secret mintit again under the same name (a mint seals under a new DEK), andcloop secret grantit again fromgrants.txt. - Run
cloop secret lease --project <p>to confirm delivery.
Session refresh tokens need no re-mint: sign everyone out
(cloop hub session revoke --all), and the next sign-in issues new ones.
Keys in a KMS¶
Everything above derives each KEK from CLOOP_SECRET_KEY, so the database and
the key file together open every credential: offline, from a backup, a volume
snapshot or a Pod's mounted Secret, for as long as anybody keeps the copy. With
secrets.kms,
each KEK is random and the database keeps only the ciphertext a key-management
service made of it. Opening the store then takes a call to the KMS, which the
KMS logs, and the KMS owner can revoke the hub's access. The unwrapped KEKs
exist only in the hub's memory.
Two kinds of KMS:
- A Kubernetes KMS v2 plugin (
provider: kmsv2), over a unix socket. This is the protocol the API server's own encryption at rest uses, so Azure Key Vault, AWS KMS, Google Cloud KMS and Vault are reached through the plugins their vendors already ship. In the Helm chart the plugin is a sidecar (kms.*values) on an emptyDir that only the Pod's containers can reach. On a host it is a service whose socket is in a directory only it and the hub can write; a socket in/tmpor/dev/shmis refused. - Vault or OpenBao Transit (
provider: transit), over HTTPS, authenticated by a token file (a Vault Agent sink) or Vault's Kubernetes auth method. The hub needs this policy on its key, and nothing more:
path "transit/encrypt/cloop" { capabilities = ["update"] }
path "transit/decrypt/cloop" { capabilities = ["update"] }
path "transit/keys/cloop" { capabilities = ["read"] }
On Azure, deploy/terraform/azure-entra-id/modules/key-vault-kms creates the
Key Vault key (wrap and unwrap only, HSM-protected by default) and grants the
hub's identity Key Vault Crypto Service Encryption User on that one key; its
helm_values_yaml output is the chart's kms block for the AKS KMS plugin.
Moving to a KMS is a change of root,
with the KMS as the new root and CLOOP_SECRET_KEY as the previous one:
- Add the KMS beside the passphrase on every member and restart them one at a time:
secrets:
kms:
provider: kmsv2
kmsv2: {endpoint: unix:///var/run/kmsplugin/socket.sock}
previous_root: passphrase
(With the chart: kms.enabled=true, the plugin's image and args, and
kms.movingFromPassphrase=true.) Each member contacts the KMS before it
serves, waiting up to startup_timeout for a sidecar that is still
starting, and prints the result:
KMS: kmsv2:/var/run/kmsplugin/socket.sock, the KMS v2 plugin at unix:///var/run/kmsplugin/socket.sock, key key-3 (answered in 4ms)
Sealing keys: held by the KMS kmsv2:/var/run/kmsplugin/socket.sock
Previous root: 7246e27af489f0e0, from the file /etc/cloop/root-key/cloop-secret-key (CLOOP_SECRET_KEY_FILE), named by secrets.previous_root — a change of root is in progress; …
Nothing moves yet. The primary key stays the passphrase's key, so restarted
and not-yet-restarted members seal under the same key, and you can back out
by restarting with the old configuration. cloop hub doctor reports
secrets.kms.reachable for each member's KMS.
- Retire the passphrase's keys, in a shell with the same
secretssection:
$ cloop hub key rotate --retire-previous
Change of root 7246e27af489f0e0 → kmsv2:/var/run/kmsplugin/socket.sock
ready member hub_cb87… (pid 4121 on hub-1): root kmsv2:/var/run/kmsplugin/socket.sock, previous 7246e27af489f0e0
ready the KMS kmsv2:/var/run/kmsplugin/socket.sock answers, encrypting under key-3; no escrow is needed for it — a restore
of this hub needs access to that KMS key instead
minted kek_9c21… from the new root and made it primary
It refuses, and changes nothing, until every live member advertises the KMS as its root and the KMS answers. No escrow is needed, because no escrow can open what a KMS holds. Then it mints a KEK under the KMS (checking that the KMS unwraps it before anything is recorded), rewraps every secret, session refresh token and hub CA key onto it, and retires every passphrase-derived KEK.
- Drop
previous_rootfrom every member and restart them.cloop hub doctorwarns (secrets.kms.previous) until you do.
From then on, a copy of the database and the key file opens nothing. Backups
taken before step 2 still open with CLOOP_SECRET_KEY, because they hold the
passphrase-derived KEKs, salts included. Destroy them, or protect them as
carefully as the key. Moving between two KMSs is the same procedure, with the
old one as secrets.kms_previous. Moving back to the passphrase is
secrets.kms_previous with no secrets.kms, and the new passphrase then has to
be escrowed before the retire step will run.
Rotating the KMS key needs no action on the hub. Every member polls its KMS
once a minute. When the key the KMS encrypts under differs from the one a KEK
was wrapped under (a new Key Vault key version the plugin was pointed at, a
rotated Transit key), the cluster leader wraps that KEK again under the new
key, checks that it unwraps, swaps it into the record and records
sealing_key.kms_rewrapped. cloop hub key rewrap does this immediately. One
row per KEK changes, and no secret does. Disable the old KMS key version only
once cloop hub key list shows it on no live key, and never purge it while you
still keep a backup from before the rewrap.
When the KMS doesn't answer, nothing falls back to the passphrase:
- A starting hub refuses to start after
startup_timeout, naming the KMS and what it said;cloop hub doctorreportssecrets.kms.reachablefailing. - A serving hub keeps working with the KEKs it already holds in memory, logs the
outage once, and polls every ten seconds until the KMS is back. A KEK it
hasn't opened yet (one minted by another member in the meantime) can't be
opened, and reads that need it fail as
kms_unavailable(metrics).
Revoking the hub's access at the KMS (removing the role assignment, revoking the Vault policy) stops every new hub process, every restore and every offline copy from opening the store. A process already serving keeps the KEKs it holds in memory until it exits, so to cut off a running hub, revoke access and then restart its members.
Upgrading a hub that predates envelope encryption¶
Migration 0019_envelope_encryption.sql marks every existing row legacy and
adds the new columns. It can't re-encrypt anything, because SQL can't decrypt.
Those rows keep opening under the old scheme until the first rotation converts
them:
$ cloop hub key status # shows N rows as "pre-envelope"
$ cloop hub key rotate # upgrades them, once, permanently
Nothing breaks if you never run it, but until you do, rotation doesn't cover
those rows, and status keeps saying so.
Grants — rotate by expiry, not by procedure¶
Short TTLs mean grants rotate themselves. See choosing TTLs.
SCIM credentials — rotate with an overlap¶
The identity provider's credential for /scim/v2 rotates in place, keeping its
name, so its audit actor (scim:<name>) doesn't change:
The new secret is printed once, and the old one is still accepted for the grace
period (at most seven days). Paste the new one into the provider within that
time; with the Terraform module, set scim_secret_token and apply. Without
--grace the old secret stops working at once, and the provider's next cycle
fails with 401 until the new one is in place. Revoke a leaked credential
instead of rotating it: cloop hub scim credential revoke entra --reason "…"
ends both secrets immediately. See
the credential.
The root key¶
The hub, and every command that seals or opens anything (the cloop hub key
commands, backup and restore, the escrow, the audit checkpoints,
cloop secret), reads CLOOP_SECRET_KEY through one resolver, pkg/rootkey.
Each of its two slots is read from three sources, in this order:
| Source | Current key | Previous key (a change of root only) |
|---|---|---|
| a file | CLOOP_SECRET_KEY_FILE=/abs/path |
CLOOP_SECRET_KEY_PREVIOUS_FILE=/abs/path |
| a systemd credential | cloop-secret-key in $CREDENTIALS_DIRECTORY |
cloop-secret-key-previous |
| the environment | CLOOP_SECRET_KEY |
CLOOP_SECRET_KEY_PREVIOUS |
A key file must be an absolute path to a regular file that the hub's user
owns, with mode 0400 or 0600, outside every .cloop/ directory. One
trailing newline is not part of the key. Anything else is refused, and the hub
doesn't start: a file another user can read or replace, a path through
.cloop/ (even through a symlink), or an empty file. The hub accepts two kinds
of file it doesn't own, because Kubernetes and systemd create them that way. One
is a Secret volume's file, owned by root and readable by the Pod's fsGroup
and nobody else (mode 0440). The other is the credential systemd 255 and later
hand to a unit with User=, owned by root and readable through an ACL entry
naming that user and nobody else. stat shows the credential as 0440 too, but
that is the ACL's mask: no group can read it. A root-owned file whose ACL also
lets another user or group read it is refused.
Two sources that disagree are refused; the hub never picks one. A key moved
into a file with a different key left in hub.env is two keys, and the choice
would decide whether the hub opens anything. Two sources that agree are fine,
but cloop hub doctor warns about a copy left in the environment, because that
copy keeps every exposure the file was meant to remove.
Use the environment only as a last resort. A variable shows up in
/proc/<pid>/environ, in systemctl show, in docker inspect and
kubectl get pod -o yaml, and in every child process it isn't removed from.
The doctor warns about one on a host that systemd runs. cloop hub bootstrap
writes a credential file instead, sealed with systemd-creds encrypt when the
host supports it (--systemd-creds), so reading it needs the host's credential
secret or its TPM. It also prints a unit that loads the file:
[Service]
EnvironmentFile=/srv/cloop/.cloop/hub.env
LoadCredentialEncrypted=cloop-secret-key:/srv/cloop/cloop-secret-key.cred
# or, unencrypted: LoadCredential=cloop-secret-key:/srv/cloop/cloop-secret-key
To move an existing hub's key out of hub.env:
- Write it to a file:
umask 077; printf %s "$CLOOP_SECRET_KEY" > /srv/cloop/cloop-secret-key. - Seal it if you want to:
systemd-creds encrypt --name=cloop-secret-key /srv/cloop/cloop-secret-key /srv/cloop/cloop-secret-key.cred && shred -u /srv/cloop/cloop-secret-key. - Add the
LoadCredential(Encrypted)=line to the unit. - Delete the
CLOOP_SECRET_KEYline fromhub.env, and restart.
The key is the same, so nothing else changes.
In a shell, point commands at the same file:
export CLOOP_SECRET_KEY_FILE=/srv/cloop/cloop-secret-key. A key sealed by
systemd-creds can only be read through systemd. Run the command under
systemd-run -p LoadCredentialEncrypted=cloop-secret-key:<file> --pty …, or
decrypt it to a 0600 file outside .cloop/ for the duration of the operation.
The Helm chart mounts the Secret's CLOOP_SECRET_KEY at
/etc/cloop/root-key/cloop-secret-key, sets CLOOP_SECRET_KEY_FILE and
CREDENTIALS_DIRECTORY=/etc/cloop/root-key, and blanks the variable envFrom
would set. For a change of root, the only change the chart needs is adding
CLOOP_SECRET_KEY_PREVIOUS to the Secret.
A serving hub reads the key once, at startup, and prints where it came from:
A key file that changes under a running hub takes effect at its next restart, which is how a change of root is rolled out. The fingerprint the hub prints is the one its cluster member row advertises.
Upgrade¶
Schema migrations live in pkg/statedb/migrations/, numbered from
0001_init.sql. They are embedded in the binary and applied automatically by
statedb.Open() on every start. Each runs in a transaction and records itself
in schema_migrations, with the version of the binary that applied it, so a
crash during a migration rolls back cleanly and the next start tries again.
There are no down-migrations. Migrations only go forward, which is why step 1 below is required.
$ cloop db backup # 1. NOT optional — the only way back
$ cloop migrate --dry-run # 2. what would change
$ cloop version # 3. install the new binary
$ cloop migrate # 4. or just start the hub; Open() migrates
$ cloop db verify # 5.
$ cloop hub healthcheck --endpoint readyz
$ cloop audit-log verify
Rolling the container image:
docker pull ghcr.io/blechschmidt/cloop:<new>
docker stop hub && docker rm hub
docker run -d --name hub ... ghcr.io/blechschmidt/cloop:<new>
Helm:
helm upgrade cloop deploy/helm/cloop-hub --namespace cloop \
--set image.tag=<new> --wait --timeout 5m
--wait returns only once the readiness probe passes, which means the PVC
mounted, the ConfigMap arrived, and SQLite opened under
readOnlyRootFilesystem and runAsNonRoot. In production, pin the hub's own
image by digest, because nothing in cloop verifies the image the chart
deploys. The published hub images carry build provenance and an SBOM. Those the
publish workflow pushes for main, and for releases after v0.0.7, also carry a
keyless cosign signature from that workflow, so you can check one with
cosign verify before you deploy it. sandbox.image_policy.require_signature
applies to the images projects name for their sandboxes, never to the hub's
own.
Enrolled agents reconnect on their own backoff (1 s → 2 min). They don't need
upgrading at the same time, but check cloop executor ls afterwards. Confirm
that cloop executor list still shows the expected backends and that
executors.allow_host_process is still false. After every upgrade, check
that a configuration merge hasn't turned host execution back on.
Executor pools' controllers come after the hub. The upgraded hub serves its
new build's chart at /charts/cloop-executor-controller.tgz; apply the
ExecutorPool CustomResourceDefinition from that build, which Helm never
upgrades, then helm upgrade each controller's release with the hub's chart
and image tag. cloop executor pool show <pool> prints the command for a
controller that is behind the hub, and hub doctor warns
executors.pools.protocol until it is upgraded. See
Upgrading in the
executor controller guide.
Rehearsing a rolling upgrade¶
You upgrade a hub cluster by
starting a member on the new build alongside the old ones and stopping the old
ones one at a time. Meanwhile two builds serve one database: the old members
tolerate the migrations they don't have and hand their runs over as they stop.
tests/upgrade rehearses this with the real
binaries (cloop's latest published release alongside the build under test) on
every push to main, in CI's Upgrade rehearsal job. The release workflow
rehearses it once more, from the previous release, against the binary it is
about to publish:
| Variable | Meaning |
|---|---|
CLOOP_UPGRADE_E2E=1 |
Run it; without it, both scenarios skip and say so |
CLOOP_UPGRADE_MAIN_BIN |
The build under test; otherwise one is built from the checkout, stamped like the :8888 deploy (dev+g<commit>, its sequence on main) |
CLOOP_UPGRADE_FROM |
The release to start from; otherwise releases/latest, or, when the build under test is a release, the published release before it |
CLOOP_UPGRADE_CACHE |
Where release archives are cached, by tag (default: the user cache directory) |
CLOOP_UPGRADE_COSIGN |
The cosign binary, when it is not on PATH |
CLOOP_UPGRADE_KEEP=1 |
Leave the scratch tree and every process's log behind |
The release binaries are verified before they run. The linux-amd64
archive, Sigstore bundle and checksums.txt come from the release's download
URLs. On every use, cached or not, the archive is checked against
checksums.txt and its bundle is verified with cosign against the release
identity that pkg/provenance pins (the
same check a device's installer requires) before the binary is extracted. The
binary must report the tag it was asked for. A cached archive that fails is
deleted and downloaded again. Without network access the scenarios skip,
naming the host they could not reach; CI requires both to have passed, by name.
Forward: TestRollingUpgradeFromTheLatestRelease. In one hub directory,
behind a load balancer, with single sign-on (a test identity provider, an
operator role mapping, deny-by-default), a token, and --listen 127.0.0.1:
- The release builds a project and saves its tasks. Two release members serve it, an operator signs in, and the release's own agent enrolls through the load balancer.
- The operator starts a device run, which a release member dispatches with a secret lease, a git proxy session and a Kubernetes monitor session (a GitHub App and a kubeconfig, each against a fake upstream). The release records CI exchanges and the run's quota counter.
- A member on the build under test joins and migrates the control plane and both projects, recording each migration's verdict and the build that applied it. The release members keep serving.
- Every column the build under test added, plus a column no build knows about
on each table older builds rewrote (
plan_tasks,ci_exchanges,quota_counters), is set in every row. Then release members write: they save a task, record a CI exchange, dispatch and settle a second project's run on the migrated database (taking its quota slot and giving it back), and use the build under test's session. Every row must keep what it held. - The release members are stopped one at a time, starting with the one holding the run. The run moves to the other release member and then to the build under test, which must hold its lease and both proxy sessions. The workload then pushes and lists pods through it.
- The release's agent, reconnected to the build under test, must be listed online as an older build, not refused, with an Upgrade target its installer accepts. The run settles once, and its lease is released after the takeover.
- Both audit chains verify against the checkpoints both builds sealed, with
both the build under test's
cloop hub audit verifyand the release's.
Backward: TestRollbackToTheReleaseAfterMainMigrated. Once the build under
test has migrated a hub, the release must tolerate or refuse it as
Rollback describes. The migrations actually applied are judged by
the verdicts recorded for them, and copies of the database with one more
migration recorded as breaking, unclassified, under an unknown verdict,
additive and additive-columns exercise every branch. A refusal must come
before the member binds its port or touches the membership and lease rows, in
the words the Rollback section shows for cloop ui and cloop status (the
test reads them from this page), with the release's hub doctor failing
storage.schema. CLOOP_ALLOW_SCHEMA_DOWNGRADE=1 must open the database, with
storage.schema_guard failing.
It doesn't cover container or Kubernetes executors, releases from before the hub cluster (v0.0.4 and earlier), or the device's own upgrade: the test checks the Upgrade offer but doesn't press it.
Last result: 2026-10-27, v0.0.7 → dev+gba5e0984 (sequence 1179). It
passed in CI on its first run (run 40565034886). The job took 132 s: 67 s to
build the commit and 47 s for the rehearsal step, which compiles the suite,
downloads and verifies v0.0.7 into an empty cache, and runs both scenarios. On
the development host the scenarios take 23 s once built (forward 17 s, rollback
6 s). Building the rehearsal found four defects, all fixed:
- The Rollback example on this page showed what
cloop statusprints, not whatcloop uiprints. Since the hub cluster,cloop uisayshubcluster: open <path>: statedb: …, so an operator comparing a refused rollback with the runbook found a different sentence. - Every command of a binary refusing a database printed the refusal twice, the
first time as an
executor: handle persistence unavailablewarning about surviving restarts. - The release's agent under a build of main was shown as a material "cannot be
compared" version skew, although both builds carry their place on main. It is
now shown as
older, which on its own is not material. - A run handed from member to member never gave its tenant's concurrency slot back. The member that dispatched it gave the slot back only when its own stream closed, which a stopping member never sees, and the member that adopted the run didn't know it held one. Every rolling update took one slot from every tenant with a run in progress, until a member started alone. The slot is now recorded on the run's owner row and given back by the member that settles the run. A run dispatched by a release from before this fix still keeps its slot; the rehearsal of v0.0.7 reports that instead of failing.
Long-lived runs after an upgrade¶
An upgrade replaces the hub's binary, but not the runs the hub started. A hub
restart keeps them alive (the unit uses KillMode=process, and a restarted hub
adopts the runs it finds), so a run in auto-evolve, which never ends on its
own, keeps executing the build it started with for as long as it lives. On
2026-10-06 the run working on cloop's own repository was 132 builds behind the
hub serving it. As a result, "done means committed", switched on for the
project three days earlier, had never been enforced, and the disk floor was
inactive on a 98%-full disk.
Seeing it. When it starts, every run records its process and its build
(version, commit, sequence on main, the schema it embeds) in its project, as
the run-owner record, and journals it in the run's session_started row.
Anything that reads the project compares that record with its own build:
- the Overview's status line and the Tasks tab's run bar say Running build dev+g4e35bf6, 132 builds behind this hub while the run lags;
GET /api/statecarriesrun_build(build,reference,behind,comparable,live,adoptable,pid,executor), therun_ownerrecord itself,follow_builds, and a pendingadopt_request;cloop statusprintsBuild: running dev+g4e35bf6 (sequence 831 on main) (pid 4242), 132 builds behind this cloop;cloop hub doctorwarnsruns.build_lagfor everycloop runon the host that is behind the doctor's build, or that never recorded one.
A run started by a cloop from before this record existed shows no build at all, and the doctor says so ("has not reported its build"). Restart it once (Stop, then Start), and from then on it can move to a new build by itself.
Moving a run without stopping it (host-process runs only):
# once, from the dashboard: the run's Overview → "Adopt at next task boundary"
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://hub.example.com/api/run/adopt-build?project_idx=0"
# or for good: Overview → Active Options → Follow New Builds, or
cloop run --follow-builds # stores the option with the project, then runs
The request needs the same permissions as Start (run.start, and the
executor's audience) and is audited as run.adopt_requested. It names the
run's process (pid, start time, boot id), so a run started later never acts on
it. The run acts at its next safe point: after the task in progress has been
settled and stored, and before the next task or evolve round starts. A parallel
run first lets its round finish and dispatches nothing new in the meantime. At
that point the run checks that no child process is alive, saves its state, and
validates the binary at the path it was started from
(/usr/local/bin/cloop-latest on :8888). <path> version --json must answer
within ten seconds with a sequence strictly greater than the run's own and a
schema at least as new as the project database's, and the new build must
accept the run's own command line (<path> run … --help). A request never moves
a run past the build of the hub that filed it. Follow New Builds waits until the
deployed file has been in place for three minutes: the deploy health-checks the
new hub for half a minute and puts the old binary back if that fails, and a run
that had moved within that window would have migrated its database beyond what
the rolled-back hub can open. The run then writes
.cloop/run-handoff-<pid>.json (run id, evolve iteration, the consecutive
failure and empty-evolve counters, its status and pause reason, and the reason
it moved) and calls execve with the same argv and environment plus
CLOOP_RUN_HANDOFF.
For the hub, nothing changes: the pid, the (starttime, boot_id) identity the
local driver checks, the hub's run claim and the live log's stdout pipe all
carry over. The new image journals run_reexecuted (old and new build, and
why), writes run.reexecuted to the project's audit trail, updates the
run-owner record (reexecs, previous), and continues the run. There is no new
session_started, no --replan, no --retry-failed reset, no pre_plan hook,
and no re-applied command-line toggles.
When it doesn't move. Every refusal is a run_adoption row in the event
history, with the reason, and the run carries on with the build it has:
| Journal says | Meaning | What to do |
|---|---|---|
… is at the same sequence as this run's build / still holds the build this run is executing |
Nothing newer was deployed at the path | Wait for the deploy; check /var/lib/cloop-latest/commit |
… builds older than this run's build |
The path holds a rollback | Adoption never moves a run backwards; restart the run if you mean to |
… carries no sequence |
An unstamped build (go build) |
Deploy a build stamped by the deploy script or scripts/build-release.sh |
… embeds schema N, behind the project database at M |
The deployed build is older than the database | Deploy a build at least as new as the schema |
`version --json` failed / did not answer within 10s (attempt N of 3) |
A broken binary, or a host too loaded to answer | Tried again at the next boundaries, three times per deployed file |
not an ELF executable / not a regular file / writable by its group / belongs to uid |
Not an installed binary | Fix the deploy; the next file at the path is looked at afresh |
does not accept this run's command line |
The new build dropped or renamed a flag the run was started with | Restart the run without it |
newer than the hub that asked |
The path holds a build the hub isn't running yet: a deploy in progress | Ask again once the hub runs it |
child processes are still running |
Something the run started has not exited | It retries at the next boundary (Follow New Builds) or refuses the request |
… executor, which keeps its own upgrade path |
A container or device run | Upgrade the device (Executors → Upgrade) or rebuild the image |
To confirm a move on the host: the pid in cloop status is the same,
/proc/<pid>/stat field 22 (the start time) is unchanged, and
readlink /proc/<pid>/exe names the deployed file instead of … (deleted).
Rollback¶
An older binary can't open a database with a newer schema once the difference matters, and there are no down-migrations. cloop enforces this: an older binary compares the database's recorded version with the highest migration it embeds and, for every version it is missing, checks what that migration did.
Each migration is classified when it is applied, and the verdict is stored in
schema_migrations.compat. A migration that only added objects (new tables,
new non-unique indexes, new views) is additive: a binary that predates it has
no statement that refers to them, so their presence changes nothing it does.
Appending a column counts as additive too, under conditions that are checked:
the statement must be ADD COLUMN, the column must have a DEFAULT or be
nullable, and it must add no constraint (no UNIQUE, PRIMARY KEY,
REFERENCES or CHECK). This is safe because no query in pkg/statedb uses
SELECT *. Every read names its columns, so an older binary never sees an
appended column, and the default keeps the rows it inserts valid. A column that
is NOT NULL with no default is breaking, because every older INSERT would
fail on it.
Naming columns keeps an older binary's reads and inserts away from a new
column, but not a write that replaces a whole row. Releases before v0.0.5 (and
development builds from before 2026-10-06) wrote three tables that way:
plan_tasks (every save emptied the table and inserted the plan again),
ci_exchanges (INSERT OR REPLACE) and quota_counters (the gauge rows were
deleted and inserted again). Each such write resets every column the writing
binary doesn't know, so a build one migration behind erased that migration's
column on its first save. A column appended to one of those tables is therefore
recorded as additive-columns. v0.0.5 and later write those rows in place and
open the database. Older builds don't know the verdict, read it as unknown,
and refuse to open the database instead of erasing the column. Migrations
0001–0059 keep the verdicts they were recorded with: an older build that
predates one of their columns keeps erasing it until it is replaced.
Otherwise, a migration that altered, dropped or constrained something that
already existed is breaking. Anything unclassified (including rows written by
a build that predates this bookkeeping), and any verdict the reading build
doesn't know, counts as breaking.
A database whose extra migrations are all ones the binary can tolerate
(additive, and from v0.0.5 on also additive-columns) opens normally.
Otherwise the binary refuses, naming both versions, the build that moved the
schema forward, and the specific migrations that are incompatible. Development
builds from before 2026-09-12, older than every release, have no guard at all
and open any database. No verdict can stop them, so don't run one against a
database a newer build has migrated.
$ cloop ui
Error: hubcluster: open /srv/cloop/.cloop/state.db: statedb: database schema is newer
than this binary: database is at schema version 30 but this binary carries 29 (applied
by cloop v0.4.0 as 0030_widget_policy.sql at 2026-05-02T09:14:22Z); … Incompatible:
0030_widget_policy.sql. …
$ cloop status
Error: statedb: database schema is newer than this binary: database is at schema
version 30 but this binary carries 29 (applied by cloop v0.4.0 as
0030_widget_policy.sql at 2026-05-02T09:14:22Z); … Incompatible:
0030_widget_policy.sql. …
cloop ui refuses while it joins the hub cluster,
and names the database it could not join; every other command says the same
without that prefix. A migration recorded with a verdict the binary doesn't
know is listed with what was recorded, for example
0060_widget_policy.sql (recorded "additive-columns", a verdict this build does not know),
which is what every build older than v0.0.5 says about that verdict. A
migration nothing classified is listed as (unclassified). v0.0.7 and earlier
print the whole refusal twice, the first time as an
executor: handle persistence unavailable warning. The
upgrade rehearsal checks on every push to main
that the latest release prints these words.
The refusal happens before the hub binds a port or takes the control-plane
lease, so a rolled-back image fails its health check instead of serving from a
schema it only partly understands. Every command goes through the same path,
and cloop hub doctor reports it as storage.schema even when the hub can't
start. Run the doctor first when a rollback won't come up.
So a rollback means restoring a backup, not downgrading the database:
- Stop the hub.
- Install the previous binary or image tag.
cloop db restore <backup taken before the upgrade> --forcecloop db verify- Start the hub; check
/readyz, thencloop audit-log verify.
When the schemas are known-compatible¶
Purely additive gaps are handled automatically (see above). The override is
for the rest: a migration classified breaking that you have read and judged
harmless for your rollback, or one left unclassified by a build too old to
record a verdict. If you have checked the migrations between the two versions
and none of them touches what the older build uses,
CLOOP_ALLOW_SCHEMA_DOWNGRADE=1 opens the database anyway:
Use it only for a rollback you have already thought through. Don't use it with
a build older than v0.0.5 to open a database whose extra migrations include one
recorded additive-columns: that build does open it, and its first write to
that table erases the new column. cloop hub doctor reports the variable as its
own finding, storage.schema_guard: a warning on its own, and a failure while a
skew is actually present. That way an override left in a Deployment manifest
doesn't outlast the incident it was set for. Unset it once the rollback is
over.
Everything between the backup and the rollback is lost: task state, audit rows, grants minted in that window. If you can't afford that, export the audit trail before upgrading:
The .pre-restore.<UTC> file that restore leaves behind is your way back out of
a bad rollback. Don't delete it until you have confirmed the rollback is good.
Fleet operations¶
$ cloop executor list # registered backends and what they isolate
$ cloop executor ls # health, in-flight work, last contact
$ cloop executor test container # preflight: run `cloop version` inside it
list shows the sandbox type of each executor, including hypervisor-backed
for one configured with a Kata runtime. test checks that it actually works: on
a Kata executor it also checks that the runtime name is registered with the CLI
and that /dev/kvm opens, then boots a real workload. See
the Kata guide.
Taking a node out of service:
$ cloop executor cordon edge-01 --reason "kernel patch" # no new work; running work continues
$ cloop executor drain edge-01 # no new work; wait for zero in-flight
$ cloop executor uncordon edge-01 # back to whatever the probes justify
uncordon doesn't automatically mark the node ready. It returns the node to the
state its probes support.
On an edge device, cordon and drain also take back the leases the device
holds, with a scrub, as the dashboard's buttons do. The CLI doesn't contact any
device itself. It records the revocations, tells the running hubs over the hub
bus, and prints each member's answer (which member holds the device's
connection, and what happened to each lease) and the leases still pending. With
no hub serving, it says so, and the next hub to start takes the recorded leases
back when the device reconnects. Cordoning the hub's own container, Kubernetes
or host executor takes no credentials back.
If a node dies instead of draining, the supervisor handles it: three missed
heartbeats (~45 s) mark it unreachable, and its in-flight sessions are placed
on surviving nodes exactly once. See
failover.
reap takes an executor ID and acts on that one backend, not the whole fleet. A
hub reaps by itself at startup, so this command is for cleaning up after a hub
that is not running, or for a runtime shared with a hub that will never run
again.
Moving a device onto the edge channel¶
A hub that runs an unreleased build of main (the reference deployment's
dev+g<commit>) can give its devices its own build, signed by CI, once a
device is on the edge channel. Do this once per
device, on the device:
# 1. cosign, which every build is verified with (a single static binary).
$ sudo install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
# 2a. A device whose cloop already knows the channel:
$ sudo cloop executor agent install --upgrade --channel edge
# 2b. One whose cloop predates it: fetch an edge build, verify it by hand, and
# let it install itself — see the guide for the download and the cosign line.
$ sudo ./cloop executor agent install --upgrade --channel edge --to edge:<commit>
Installing onto the channel writes 20-update-channel.conf next to the unit and
installs the remote-upgrade helper (cloop-executor-upgrade.path and
.service). Both survive later upgrades; only --channel stable /
--remote-upgrade=false on the device removes them. Check what the agent
reports about itself:
$ journalctl -u cloop-executor -n 30 | grep -E 'updates:|firewall:|remote upgrade'
firewall: nft(8) — CAP_NET_ADMIN held by the agent, never by its workloads
updates: edge channel — releases, and signed builds of main
$ systemctl is-active cloop-executor-upgrade.path
active
If the path unit is inactive, upgrade requests are never read. The agent then
refuses upgrades and says so in its banner
(remote upgrade: unavailable: … is not active …) until you run
sudo systemctl enable --now cloop-executor-upgrade.path.
Then, in the dashboard, choose Executors → the device's Upgrade → this
hub's build (<commit>). The device files the request, the helper verifies
and installs the build, and the device reconnects on it; the row's build chip
updates to match. On the device, the helper's journal shows the whole process:
When the dialog doesn't offer the hub's build, it says why: an unpushed commit,
CI still running, CI or the edge workflow failed, the build was pruned, the
build would lower the protocol, or the device is already later on main than
the hub. The
guide's table gives
the fix for each. To go back: /usr/local/bin/cloop.prev is the previous
binary, --to <release> --force installs a verified release, and
--channel stable takes the device off the channel. Moving a device back to an
earlier build of main needs that --force on the device, because its
installer refuses an earlier build however the hub or its agent asks
(rollback protection). The
refusal is a refused: line in journalctl -u cloop-executor-upgrade.
The hub's deploy builds the local main. If it deploys a commit before the
commit is pushed, the dialog says so ("the deploy built commit … which is not
on GitHub"). The build is offered as soon as the push's CI run passes and
edge.yml publishes it, about half an hour later. A push of several commits
runs CI, and so publishes an edge build, for the newest commit only.
After a control-plane restart¶
A hub killed mid-run doesn't lose the workloads it dispatched. The containers,
Pods and edge-device processes keep running, and on the next start each driver
reattaches to its own workloads from executor_handles. Whatever is left over
after that (session rows nothing will close, workloads nothing can reattach to,
worktrees nothing will merge) is cleaned up in the background with a two-minute
limit, so a git prune or a runtime listing never delays the listener. See
durable handle identity.
The sweep reports what it did in the hub's normal log, prefixed ui: under
cloop ui and apiserver: under cloop serve. The lines to recognise:
executor: 3 in-flight session(s) reattached to a still-running workload
executor: closed 1 stale in-flight session(s) left by a previous control plane (drain would otherwise have waited on them forever)
executor container: garbage-collected 2 exited container(s) from a previous run: cloop-app-a1b2, cloop-app-c3d4
executor container: killed 1 container(s) still running from a previous control plane: cloop-app-e5f6
executor: pruned 2 leaked task worktree(s) in /srv/app: worktree gc: removed 2 worktree(s) and 0 branch(es); kept 5; 0 error(s)
"reattached" is the line you want. Those runs survived the restart: their output continues in the dashboard and their exit codes are still collected.
A Kubernetes run that fails with "the per-run Secret … no longer exists" had
its harness container held by the kubelet in CreateContainerConfigError. The
harness reads its whole environment from the run's cloop-lease-<handle> Secret
when the kubelet creates the container, and that Secret was gone. A hub doesn't
delete it before the harness has started, so the usual cause is outside cloop
(an operator or a cleanup job deleting Secrets in the workload namespace), or a
hub older than v0.0.5 restarting mid-fetch. The run is failed instead of left
pending, and its Pod is deleted; dispatch the task again. The same failure one
container earlier names cloop-ws-<handle>, the workspace credential.
An edge device's run is adopted when its agent reconnects. The sweep runs
before any agent has reconnected, so it leaves a device's session open instead
of closing it as failed. When the agent reconnects, the process it reaches
adopts the run
(adopting a run from another hub member … reason="its agent reconnected to this hub member")
and takes over the run's secret lease, logging one
took over the secret lease of an adopted run line per lease. From then on
that process owns the run: it streams it, merges the device's result into the
project once, keeps the lease alive, releases it when the run ends, and closes
the session with how the run ended. If a lease lapsed while the hub was down
(for longer than the lease had left, which the keepalive keeps between about
five and fifteen minutes), or its grant was revoked in the meantime, the lease
is scrubbed from the device instead (lease.revoke_sent by janitor), and the
run carries on without those credentials. A run whose agent never comes back
keeps its session open for failover, and keeps its lease until the lease lapses
and the leader's lease janitor sweeps it.
What the lease feeds comes back with it. The adopting process restores the run's git proxy and Kubernetes monitor sessions under the ids and tokens the workload already holds, logging one line each:
ui: restored git session 3K2x… of lease lease_9f1c…, held until then by hub_7d0e…
ui: restored kube session qT8w… of lease lease_9f1c…, held until then by hub_7d0e…
and writes a gitproxy.session_restored / kubeguard.session_restored row. A
GitHub App token behind the git session is minted again at the scope recorded
for it (an allowed secret.renew row: "re-minted at the scope recorded for it
by the hub process that took the lease over"), and the new process's keepalive
renews an App token the workload holds as a file before its hour is up. The
run's egress session is restored too, with its byte counters; the project's
journal says "egress: proxy session … restored by the hub process that adopted
this run". A request the workload makes in the seconds before its agent
reconnects waits up to 60 seconds for this instead of failing.
A session that doesn't come back says why on its close row: a
gitproxy.session_closed, kubeguard.session_closed or egress.close whose
reason starts "not restored by the hub process that adopted its run" (its
grant was revoked or expired while the hub was down, its recorded scope is
wider than the grant or the proxy's policy now allow, its record names an
upstream other than the forge this hub's git proxy fronts, or this hub runs no
such proxy), or "lapsed … while no hub process held it". This hub never
writes a record naming another upstream, so treat one as a sign the database
was edited, and check who could write to it. From then on the workload gets a
401 (git, kubectl) or a 407 (egress); stop and start the run to get a new
lease. A line ending "(will retry)" is a restore that failed for a reason
that may clear (the database busy, GitHub unreachable for the App token). The
lease's keepalive retries it every minute (an egress session after two seconds,
with the wait doubling up to a minute), and a request presenting the session
retries it at once. Count them with
cloop_proxy_session_restores_total{kind,outcome} (see
metrics).
A takeover the store cannot decide is tried again. Taking over the lease can end undecided: the secret store unreadable for a moment, the database locked, the disk at its floor, or a KMS down for a process that hasn't opened the store yet. The adopting process logs this,
ui: take over lease lease_9f1c… of the run on edge-1 (will retry): secretbroker: take over lease lease_9f1c…: read its revocations: … database is locked
and keeps trying: after two seconds, then with the wait doubling up to a minute, until an attempt decides it or the lease's recorded expiry passes. Meanwhile:
- the lease stays listed in the run's owner row, next to an entry saying its takeover is undecided, so a process that adopts the run after this one tries too;
- the run's event history has a
lease_takeoverrow: "The run's secret lease … could not be taken over by the hub process that adopted this run (…): …. It is tried again until it lapses at …"; cloop hub doctorand Settings → Hub health warnruns.lease_takeovers, naming the run, the lease, the attempts and the reason, andcloop_lease_takeovers_total{outcome="retrying"}counts the attempts;- a request the workload makes waits up to 60 seconds, as it does before the adoption, and brings the next attempt forward.
Fix what the doctor's other findings name: the storage checks, and
secrets.kms.reachable for a KMS that doesn't answer. The next attempt then
takes over the lease and restores what it feeds, and a second lease_takeover
row says it "was taken over … after N attempts". Nothing needs restarting. A
lease still undecided when it lapses, or when its run ends, gets a final row
saying it "was never taken over" and why, and counts as failed. The run
carried on without it, and its git pushes and kubectl calls through the hub
were refused, so stop and start the run to get a new lease. The run's workspace
lease and its egress session are retried the same way. On v0.0.7 and earlier
the stderr line is all there is: the lease drops off the owner row, nothing
extends it, and the run's next push fails with nothing on the run saying why.
A device workspace's pinned session comes back too, when the device keeps
it for a push write-back (executors.write_back: push). The run's owner row
names its workspace lease, which the adopting process takes over (one
took over the workspace lease of an adopted run line), and the session is
restored like the lease's, with the same restored git session … line. Its
upstream is the project's own origin as the hub's checkout currently names
it, so a record whose repository no longer matches is refused with "it is
pinned to …, and the project's origin is … now". The device's write-back push
then succeeds whenever it arrives, and the project's journal records it as a
write_back row naming the branch.
A Pod's workspace session comes back too, whether or not the run pushes its
work back. A Pod's init container fetches its workspace after the dispatch, so
its session matters from the start. The run's owner row names the Pod's
workspace lease and session. The adopting process logs the same took over
the workspace lease of an adopted run and restored git session … lines, and
an init container that fetches after the restart is served. Once the Pod's
fetch is confirmed, the session is closed ("the run is done with its workspace
credential") and the lease released; under write_back: push both last until
the workload ends. A fetch made before the adoption waits for it, for at most a
minute. On a hub whose leader is being replaced, the adoption can take longer
than that, and the Pod then fails its provisioning; start the run again. See
limits that remain.
A CI relay session is restored by the next call that presents it, not by an
adoption, because no run or lease stands behind it. A hub stopped gracefully
writes a ci.session.suspended row per live session. The job's next call to
any hub process restores the session, with a ci.session.restored row whose
detail names the process that held it and anything its rule narrowed. A
session whose rule was deleted or disabled, or no longer admits its pipeline,
gets a ci.session.closed row instead ("not restored by the hub process that
received its next request: its rule is disabled"), and the job gets a 401. A
session killed with its hub may be missing up to its last 15 seconds of spend.
The Live sessions panel lists a session no process serves as suspended;
Revoke there ends it before its job calls again. A revocation, rule edit or
switch-off that ends a record another member serves, or one a member is
restoring at that moment, reaches that member at its next checkpoint, within
15 seconds; the API has already answered by then. A Revoke answered with
503 ("its record could not be closed") ended the session on the member that
answered but left its record open, so another process could still restore it.
Revoke it again.
Switching CI federation off applies per hub instance, that is, per
cloop ui port, whose
overlay can
set ui.ci.enabled. Each session's record names the instance that served it.
When an instance's configuration turns federation off:
- The process serving it ends its sessions: at once through Settings, which also tells the other members to re-read their own configuration, or within 15 seconds when the file is edited.
- Its suspended records end at once through Settings, and otherwise within a minute. The leader reads every instance's configuration the way that instance does, since the overlays live in the hub's directory, so this happens whether or not the instance is running. Before then, a restore refuses such a record ("federation is switched off on the hub instance that served it (port …)").
- An instance that keeps federation on keeps its records.
So:
- To stop every pipeline at once in a cluster whose overlays differ, disable or delete the rules. That closes their records wherever they are held, and members drop the sessions within 15 seconds.
- A quick off-and-on ends only what was noticed while it was off. Every relay call is refused while it is off, whichever member receives it.
- A record a live member took over but could not restore stays until it lapses or its rule changes. It is restored only under its rule as the rule stands then.
Read "killed N container(s) still running from a previous control plane"
carefully. It means a sandbox was executing a harness when it was
collected, so work in progress was destroyed. It is worded differently from the
"garbage-collected N exited" line so you can tell the two apart in a log. It
only fires for a container older than the
grace period that carries
this executor's own labels and that no live handle claims, and after a healthy
restart there should be none: rehydration adopts what it can, so anything
reaped was dispatched by a process with no handle store, or one whose rows were
lost. If you see it routinely, handle persistence isn't working. Check the
startup log for handle persistence unavailable, which names the reason and
warns that workloads dispatched by that process won't survive a restart.
localprocess is the driver that recovers least, and you see that in the run's
own log, not the hub's. A host workload that survived a restart has a [cloop]
line saying its live output was lost with the pipe that carried it, and that
the process is still being watched. A workload whose pid was recycled, or whose
host rebooted, is reported failed with exit code -1 and a message saying
which. The hub can't collect the exit status of a process that isn't its own
child, and reporting exited(0) would mark failed work as done.
A drain never finishes¶
cloop executor drain <id> (and the dashboard's drain button) waits for the
executor's in-flight session count to reach zero, and gives up with
ErrDrainTimeout. A hub that restarted while a run was in progress used to
leave a running row in executor_sessions that nothing would ever close, so
drain timed out on that executor every time: the count never moved, however
long you waited.
The startup sweep closes those rows, so first try restarting the hub: it closes
rows whose executor no longer knows the handle. If drain still hangs after
that, the count is real. The sweep leaves a row alone when it can't tell
whether the workload is running: sessionOutcome treats a driver that can't
answer (an unreachable cluster, a device that hasn't reconnected) as live,
because closing a session whose workload is actually running would let the
scheduler place its task again and put two agents in one repository. Check the
executor's health with cloop executor
ls and fix the reachability. --force stops waiting instead of failing: the
node stays draining, and the sessions it reports are still running and
untouched.
A project is paused because its executor was lost¶
The project's status badge reads "executor lost", with the executor named in the reason. Its executor stopped answering mid-run (after about 1½–3 minutes of failed probes) and the hub failed the run over: the tasks it was running went back to pending (or failed; see below), and nothing continued the run, for the reason the badge gives:
| The reason says | What it means |
|---|---|
| no executor could take the run | No other healthy executor could take it: a single-device project, or every other executor is down, cordoned, or restricted to an access list. (A failover never moves a run onto such an executor, and never moves an isolated run onto the hub's own host.) |
| failover gave up after N re-dispatches | The run had already been failed over as often as executors.failover.max_attempts allows. The task it was on failed, naming every node it was lost on. |
| task N is quarantined as a suspected node killer | Two executors went down under that task; see below. |
| the replacement on X could not start | The replacement was refused as any run would be. The Event History row says why (no Claude credential granted for X, a sandbox X can't honour, …). |
A replacement refused because it could not act for the person who started the run is paused as starter's Claude account unavailable instead; see the next section.
The Event History has a failover row saying "Executor X was lost …" with the
full reason. To continue, bring the executor back or bind the project to
another one, and press Run for an ordinary dispatch. If the device does come
back, it is told to terminate the run it was carrying: that run's tasks are
already back in the plan, and its credentials were released when it was lost.
A project whose run was failed over keeps running on the replacement. Its Event History says "Run failed over from executor X to Y", the live log marks where one run ended and the other began, and Stop stops the replacement.
A paused run is not resumed: its starter's Claude account is unavailable¶
The status badge reads "starter's Claude account unavailable", naming a person. The run had stopped at a usage cap whose window has reopened, or its executor was lost and another could take it. The hub normally continues both kinds of run by itself, but on a hub with single sign-on such a run acts for the person who started it, on their own Claude account, and the hub could not act for them:
| The reason says | What it means |
|---|---|
| has no Claude Code login on this hub that the hub may use for them | Their Claude home is gone (an offboarding removed it) or they never signed in here, and the run would share the hub's filesystem, where it uses that home. Or the hub denies them: a deny binding on them, hub-wide or on the project, or a deactivation at the identity provider. The hub's log says which (auto-resume: held, or ui: failover of …); the project's reason never does. |
| may not use the executor this project runs on | the executor is restricted to an access list whose entries all name people by email (or all by subject) and none of them is the starter, so their own Run would get a 403. Add them, or press Run as someone on the list |
| nobody is recorded as having started this run | a run older than the record of its starter, in a project with no owner |
| this run was not started from this hub | the paused run recorded itself (run_owner) well after the hub last dispatched the project (a cloop run from a shell), so whoever last pressed Run here is not the person it acts for |
| nobody could be named as having started this run | a resume carried such a run on where no Claude home was needed, or the hub's two records of the starter disagree because an older hub build dispatched the project since |
| … whose usage could show the window reopened | an older build's extra-usage pause, which clears by the starter's own usage windows, for a starter with no home here to read them under |
If the hub can't read the control plane at that moment, it refuses the resume without holding the run: the pause stays a usage cap, and the next sweep tries again.
The project's Event History has the full sentence: a session_paused row
("Auto-resume held: …") for a resume, and a failover row for a lost executor.
This pause can't be resumed automatically, so the sweep leaves it alone. To
continue, press Run; the run then acts for you, on your own account. Hubs
before v0.0.7 restarted such runs with the cloop ui process's own
environment, the shared login that per-user mode exists to remove.
To see whom a project's next resume would act for, read the control plane (read-only):
sqlite3 -readonly .cloop/state.db \
"select key, value from metadata where key like 'hub.harness.initiator.run:%'"
Each value names the person (identity), how they were chosen (basis:
starter, owner or unknown) and the run it describes (run_id). An empty
identity on the starter basis is a run started on behalf of no one (by the
static bearer token or a service token), which resumes on the hub's own login,
as it started. Builds before v0.0.7 kept only the bare identity, under
hub.harness.initiator:<project>, and it is still written alongside the record
for them. Each run.cap_resumed audit row names whom that resume acted for
(acted_for, acted_for_basis), and each placed executor.failover row names
whom the replacement acted for.
A task is quarantined as a suspected node killer¶
Two or more executors became unreachable while one task was running, so the hub's failover failed the task and marked it. It won't run again, on the next dispatch or under retry-failed, until somebody resets it. To list such tasks, with the nodes that went down under each:
The task's details in the dashboard say the same, and the project's Event
History has a failover row for each lost node. Before releasing the task,
find out why those nodes went down. A device's own journal (journalctl -b -1
after a reboot, the kernel's OOM and panic lines) usually names the cause, and
a task that exhausted memory on two devices will do the same on a third. Two
unrelated node failures can mark an innocent task, which is why a person has to
clear the mark.
To run it again, reset it from the task's status control in the dashboard, or in the project's directory:
The reset deletes the mark and the recorded losses behind it, so the task
starts with a clean record. The reset is recorded as task.quarantine_release
with who did it. The run the task was in was not re-dispatched when the task
was marked, so the project is paused with an executor_lost reason. Press Run
once the task is reset, or leave it held: the gate skips a held task and runs
the rest. A run that loses more nodes than executors.failover.max_attempts
allows stops being re-dispatched whether or not any task is marked; its
journal row names every node it lost.
Pruning leaked worktrees by hand¶
In parallel mode, each task gets a git worktree under
.cloop/worktrees/task-<id> on a cloop/task-<id>-<slug> branch. A run killed
between creating the worktree and merging it leaves both behind. On its next
start the hub removes those directories that are older than two hours,
skipping any task it believes is still running. It never touches a branch,
because an unmerged branch is the only copy of that task's work.
$ cloop worktree list # what is on disk, what git knows, and how old
$ cloop worktree prune --dry-run # the plan, including the branches it would keep
$ cloop worktree prune # remove directories older than 2h
$ cloop worktree prune --delete-branches # …and merged cloop/task-* branches
Run these from inside the repository; both act on the current working
directory. list shows two kinds of leak that a sweep looking at only one
source would miss: a directory git no longer registers (GIT no), and a
registration whose directory is gone (DIR no).
--min-age 0 turns off the age check and is the only flag here that can
destroy work: a live parallel run's worktrees are in that directory right now,
and git can't restore what was never committed. Use it only when you have
confirmed that nothing is running. --delete-branches is safe in comparison.
It deletes only branches already contained in the base branch (--base,
defaulting to the checked-out one), the check is made twice, and
git branch -d itself refuses to delete unmerged work. A branch reported as
kept because it "is not merged" holds the only copy of a task's work, so cloop
won't delete it; merge or delete it yourself.
A non-zero exit from prune means some worktree could not be removed (a
permission error, a busy mount), and the output names it. The others were still
removed.
A worktree locked with the reason cloop: kept for the task's next attempt
is not a leak. Under done means committed,
an attempt that left its changes uncommitted (or stopped before it finished)
keeps its worktree for the next attempt at the task, which reopens it. Neither
prune nor the startup sweep touches a locked worktree. If the task will never
run again, look at what it left (git -C <path> status), keep what you want,
then run git worktree unlock <path> and prune.
Incident playbooks¶
Access emergencies¶
Sequences you can paste. They are CLI commands, not only dashboard actions, because you may need them when a browser won't help: the listener is stuck, you are on the host over SSH, or the account you need to contain is the one that would do the containing.
Every command that changes something requires --reason and records it in the
hash-chained audit trail along with the OS user who ran it, so
cloop audit-log list shows who did what, when and why. Read the entries back
with:
cloop audit-log list --entity session --since 24h
cloop audit-log list --entity role_binding --since 24h
cloop audit-log list --entity quota --since 24h
An open hub is reachable from the network¶
cloop hub doctor fails ui.exposure with a hub without sign-in listens on
*:8080 (GET /api/projects … answered 200 without credentials). Anyone who can
reach that port can list the projects and start agent runs on the host, so
close it first and investigate afterwards:
# 1. Close it, either way:
systemctl stop cloop-ui # the unit serving that port
iptables -I INPUT -p tcp --dport 8080 ! -i lo -j DROP # or keep it, loopback only
ip6tables -I INPUT -p tcp --dport 8080 ! -i lo -j DROP
# 2. What did it do while it was open? Projects, runs and tasks it was asked for:
cloop audit-log list --since 72h
# 3. Restart it with a build that defaults to loopback, or give it sign-in
# (ui.oidc, or CLOOP_UI_TOKEN) before it listens on the network again.
# Retire a static token once SSO works: `cloop hub token static retire`.
The finding names the cause when it can: this build would listen on 127.0.0.1:8080, so the process on the port is an older build or was started with --listen. A binary from before the loopback default binds every interface whatever its configuration says, so only stopping it, or the firewall, closes it.
Then check what forwards to the port. A reverse proxy on the same host that
passes requests to the hub without authenticating them (an nginx
proxy_pass http://127.0.0.1:8080 with no auth_request or auth_basic)
exposes the hub whatever it binds and whatever the firewall drops on 8080, and
the doctor can't see it. Test through the proxy instead:
curl -so /dev/null -w '%{http_code}' https://<host>:<proxy-port>/api/projects
answering 200 is the same finding. Remove that server block, or add
authentication to it, as well.
The static token leaked¶
--token / CLOOP_UI_TOKEN acts as an administrator outside RBAC and never
expires. Retire it first (no Secret edit and no restart needed), then work out
what it was used for:
# 1. Refuse it on every member, now. Running hubs refuse it within about a
# second and close the dashboards and sandbox terminals it opened.
cloop hub token static retire --reason "leaked in a CI log, INC-4412"
# On a hub with no single sign-on and no admin API token this is refused:
# nobody could administer the hub afterwards. Mint one first, or add --force.
cloop hub token create break-glass --role admin --expires-in 7d
# 2. Is anyone still presenting it? Refusals are counted, with the last address.
cloop hub token static status
# 3. What did it do while it worked? Where a role policy is in force its
# requests are recorded under the actor static-token; on a hub without one,
# under local.
cloop audit-log list --actor static-token --since 72h
cloop audit-log list --type static_token.retired
# 4. Callers that still need a credential get a scoped API token — or, if a
# static token is unavoidable, a new CLOOP_UI_TOKEN value and a restart.
The dashboard has the same action: Settings → Static admin token → Retire. The
token can retire itself, so an operator who holds nothing else can use it one
last time to lock it. A retired token stays configured (a token-only hub stays
closed and accepts only API tokens), and each refusal tells the caller when and
by whom it was retired; the reason stays in the trail. A member started with
ui.cluster.exclusive reads no bus and refuses the token within 30 seconds.
A session was stolen¶
Contain the session first, then the credentials behind it. Revoking the session first stops the active use while you work out what else the account holds.
# 1. See what the account has open, and from where. The IP column is usually
# what tells a stolen cookie from the user's own second browser.
cloop hub session list --identity alice@example.com
# 2. End one session, or all of theirs.
cloop hub session revoke 3f9c1e7a2b5d48c0 --reason "reported stolen laptop, INC-4412"
cloop hub session revoke --identity alice@example.com --reason "credential compromise INC-4412"
# 3. Sessions are not the only credential. Revoke any API token the account holds.
# A token listed "(unapproved)" was minted while no role policy was in force;
# a hub enforcing one refuses it until `cloop hub token approve <id>`.
cloop hub token list
cloop hub token revoke <token-id>
# 4. If the account itself is suspect rather than just one cookie, demote it too
# — see "A compromised admin" below.
$ cloop hub session list --identity alice@example.com
ID IDENTITY IP IDLE EXPIRES IDP CHECKED
3f9c1e7a2b5d48c0 alice@example.com 198.51.100.24 3s 2026-09-14 23:49Z never
a1d4f80b6c2e93aa alice@example.com 203.0.113.9 17m 2026-09-14 23:49Z never
$ cloop hub session revoke --identity alice@example.com --reason "credential compromise INC-4412"
Revoked 2 session(s).
3f9c1e7a2b5d48c0 alice@example.com
a1d4f80b6c2e93aa alice@example.com
Running hubs were told: they stop honouring these sessions and close the
dashboards and sandbox terminals they opened within about a second. A hub
started with ui.cluster.exclusive reads no announcements: it stops honouring
them within 30s (its session cache) and closes their streams at the next
30s re-check after that.
API tokens are a separate credential — see `cloop hub token list`.
Revocation reaches what the session already opened. The command announces
each session it ended on the hub bus. Every hub reading the bus (every cloop
ui that joined the control plane, which is the default) drops the session from
its cache and closes the dashboard WebSockets, SSE streams and
sandbox terminals it opened, within one bus poll. The person sees
"Your session was ended" and is sent back to sign in; their terminal into a
sandbox shows the same message and closes. The same applies to an api_token revoked with cloop
hub token revoke, and everything cloop hub user offboard ends. Revoking from
the Sessions or Tokens panel does the same without the bus round trip.
Two delays remain. A hub started with ui.cluster.exclusive reads no bus.
It serves the session from its 30-second cache, and closes the session's
streams at their next 30-second re-check after the cache drops the session, so
up to a minute in all. And if the command warns that it could not notify
running hubs, every hub is in that position. If you need either delay to be
zero, stop the hub.
This command reads and writes the session table directly and takes no control-plane lease, because it is meant for a hub whose listener is stuck, and that hub still holds its lease.
A tenant is running away¶
One identity is consuming the shared budget, executor slots, or project count. Cap it now and work out why afterwards.
# 1. What is this identity actually consuming?
cloop hub quota list
# 2. Cap it. The override is sparse — setting one ceiling leaves every other
# one exactly as it was, inherited or overridden.
cloop hub quota set alice@example.com \
--limit daily_cost_usd=5 \
--limit max_concurrent_tasks=2 \
--reason "runaway plan INC-4413"
# 3. Stop what is already running. A daily budget applied to a live hub does
# end the run, but only once the spend catches up with it; the others
# (max_concurrent_tasks, max_projects) bound the next request and nothing else.
cloop hub session revoke --identity alice@example.com --reason "runaway plan INC-4413"
# 4. Afterwards: drop one ceiling back to configured policy, or all of them.
cloop hub quota set alice@example.com --unset max_concurrent_tasks --reason "tasks back to policy"
cloop hub quota clear alice@example.com --reason "INC-4413 closed"
$ cloop hub quota set alice@example.com --limit daily_cost_usd=5 --limit max_concurrent_tasks=2 --reason "runaway plan INC-4413"
Quota override for alice@example.com:
max_concurrent_tasks 2
daily_cost_usd 5
Takes effect when the hub next starts — it loads overrides once. Start it now,
or apply the same change through the Quotas panel on a running hub.
$ cloop hub quota list
IDENTITY RESOURCE OVERRIDE USED UPDATED BY
alice@example.com max_concurrent_tasks 2 - cli:root
alice@example.com daily_cost_usd 5 - cli:root
This command refuses while a hub is running, and points you to the REST API:
$ cloop hub quota set alice@example.com --limit daily_cost_usd=5 --reason "runaway plan"
Error: a hub is running here and holds the control-plane lease: hub-1 (cloop-0 pid 812)
This command writes state that hub loaded into memory at startup, so a
write now would neither take effect nor survive its next edit. Use PUT
/api/quotas/{identity} or the Quotas panel while it is running, or stop it first.
The enforcer reads overrides once at startup, so a write behind a running hub's back would be ignored and overwritten by the next edit made in the panel. Use the Quotas panel, or:
curl -X PUT https://hub.example.com/api/quotas/alice@example.com \
-H "Authorization: Bearer $CLOOP_PAT" \
-H 'Content-Type: application/json' \
-d '{"limits":{"daily_cost_usd":5,"max_concurrent_tasks":2}}'
A daily_cost_usd or daily_token_budget set this way does stop the run that
caused it. The hub adds each project's cost rows to the enforcer every 30
seconds, so the run ends at the first task boundary after the budget runs out,
with quota.spend_refused in the audit trail naming the identity, the limit
and the usage. The other ceilings only limit the next request.
cloop hub quota list shows how much an identity is over. To see where it
went, use GET /api/cost/identities?window=today across the fleet, or
cloop cost report --by-identity inside one project. See
per-identity attribution.
Somebody leaves¶
One command. Disabling an account at the identity provider doesn't, by itself, end anything this hub has already issued. Seven things survive it:
| Surface | What survives an IdP disablement |
|---|---|
| Sessions | Until the absolute TTL. The refresh grant proves the grant is alive, not that the person still works here. |
| API tokens | Until ExpiresAt. The owner binding isn't checked when someone leaves. |
| Glasses links | The same rows and the same problem, for up to 30 days |
| Deny binding | Doesn't exist until somebody writes it |
| Secret leases | Real credentials, materialised inside a running sandbox |
| Running tasks | Still executing, in their name |
| Project memberships | Other people's projects shared with them by name, until a maintainer removes them |
Four more are things they have stored here:
| Stored | What survives an IdP disablement |
|---|---|
| Personal secrets | Their GitHub PATs (including the git-proxy-guarded ones) and kubeconfigs, sealed, indefinitely |
| Grants over them | Live, including grants to projects other people own, which keep using the departed person's credential |
| Grant requests | Still pending; an approver could mint a grant for somebody the hub no longer admits |
| Claude Code login | Their OAuth refresh token in plaintext, the CLI's transcripts and project history, under every hub member's config directory, and any login they had in progress, in a member's memory |
cloop hub user offboard ends all seven, destroys all four, and reports one
more thing, the projects they own, without touching them.
With SCIM provisioning, the identity provider ends access itself
(guide). Disabling, deleting or unassigning
the person there suspends them here at the provider's next cycle: the deny
binding, sessions, API tokens and glasses links in one transaction, then their
leases and running tasks, and every grant over their personal secrets is
suspended. That covers the first six of the seven without anyone running
anything. A suspension keeps their memberships and stored credentials, because
a disabled account is often only disabled for a while. The provider's
DELETE, or ui.oidc.scim.offboard_after_days of deactivation, runs this
command's full offboarding as scim:<credential>. So with provisioning, run
the command by hand to cover the minutes before the provider's next cycle
(about 40 on Entra; or use its Provision on demand), or to finish what a
suspension keeps without waiting. cloop hub audit --entity-type scim_user
shows what the provider did.
# 1. Look first. Nothing is changed, and the set printed is exactly the set
# the write acts on.
cloop hub user offboard alice@example.com --dry-run
# 2. Do it.
cloop hub user offboard alice@example.com --reason "left the company, HR-882"
# Under a legal hold: sever access, keep what they stored.
cloop hub user offboard alice@example.com --keep-credentials --reason "left, legal hold LH-17"
Sessions, API tokens, glasses links, project memberships and the deny binding are written in one transaction: either the person is removed from all five or nothing changes, so you never find a half-offboarded account a month later. Leases, the stored credentials, running tasks and Claude Code logins can't be part of that transaction (they live in broker memory, other databases and other hub members' filesystems), so they are handled after it, in that order. Any failure there is reported, not rolled back, because the transaction has already ended the person's access. A partial run exits non-zero.
What they stored is destroyed after that, and each step reports its own failure:
- every live grant over their personal secrets is revoked; the dry run lists each one with its subject, so you can see a shared project that is about to lose a colleague's credential before you confirm;
- their personal secrets are deleted through the broker: the sealed payload and wrapped data key are overwritten before the row is deleted, the write-ahead log is checkpointed so the database file itself holds zeroes (a checkpoint that a reader held off is a warning), the deletion is audited, and a tombstone keeps the name;
- their pending grant requests are withdrawn; your reason goes on the audit row, not in the note that request reviewers see;
- their running tasks are told to stop, as before;
- their Claude Code login ends on every hub member: a login in progress is
cancelled, each home is logged out with
claude auth logout, which revokes the session at Anthropic, and its directory is removed.
None of this needs CLOOP_SECRET_KEY: destroying a credential removes rows
without reading them, so the command works from a shell without the hub's
sealing passphrase. On a hub with executors.allow_host_process: false, the
dashboard's offboarding skips the logout, because that hub runs no program on
behalf of a request, and the report says the credential was destroyed on the
hub but not revoked upstream. The CLI, running in an operator's shell, logs out
the homes in its own tree whatever the policy, and members handle their own. A
task stop is asynchronous: if one of their runs on the hub's host was still
finishing, run the offboarding again once it has stopped, so that nothing it
wrote into a removed directory survives.
Every member's Claude home. A home lives under the config directory of the
process that created it, not in the database. The command removes the copy
under its own directory and asks every running hub member over the bus to
remove theirs; the dashboard asks the other members over the peer channel. A
copy that couldn't be reached is reported as a failure that names it, but you
don't have to clean it up by hand. Every hub member also removes from its own
tree the Claude home of every identity the control plane denies hub-wide, at
startup and every hour after, logging it out first where it is allowed to and
recording each as user.claude_home_swept. A legal hold keeps the homes
(--keep-credentials records a hold on the identity before anything is denied;
release it with cloop hub user hold release), and so does a SCIM suspension
that is still in force, which a reactivation undoes. cloop hub doctor fails
offboard.claude_homes while a home that a sweep would remove is still on its
host, naming each directory:
| Failure says | What to do |
|---|---|
hub member … did not answer … a build older than this one |
Nothing, once it runs this build or later: it removes the copy at its next sweep, within the hour, or as it restarts. On an older build, upgrade it, or remove the listed claude-identities/<slug> directories under its config directory by hand |
… holds this control plane exclusively (ui.cluster.exclusive) |
That hub reads no bus, but sweeps its own tree like any member, within the hour. Offboard from its dashboard to remove them now |
… stopped … kept Claude logins under … on <host> |
A stopped member left its tree on a host that is still up. It removes them itself when it starts again; if it never will, remove the named directories there (cloop hub doctor on that host names them) |
refused to end the Claude logins of … |
A member found no deny binding for that spelling; check cloop hub role list --identity … |
Running it again is safe: anything already destroyed isn't found again.
A colleague's project that loses a credential is told. If one of the
person's personal secrets was granted to a project somebody else runs, that
project's next run is refused the secret by name
(github_pat "alice-pat", a personal credential of alice@example.com, was destroyed on 2026-10-10 when its owner was offboarded),
in the lease's audit row and, once per hub process, as a credential_refused
event on the project's own Activity tab. Under a legal hold it reads "was
withdrawn … when its owner was offboarded", and it still names the credential
when the secret is destroyed days later. Your offboarding reason is never
included, and the dashboards that refresh their Secrets panel aren't told whom
it concerns. The project needs a credential owned by somebody who is still
here. A lease the project's run already holds isn't taken away mid-run. Its
revoked grant stops the lease from being extended, so it lapses within 15
minutes, and the dry run names it with the time. Revoke the lease under
Secrets → Leases to end it sooner.
A legal hold (--keep-credentials, or Legal hold in the dashboard) keeps
their personal secrets and Claude Code homes as they are. You can also place a
hold before anyone offboards
(cloop hub user hold place alice@example.com --reason "LH-17"), and then
every offboarding of that identity honours it, including those the identity
provider triggers through SCIM, where nobody is at a prompt to pass
--keep-credentials. cloop hub user
hold list shows the holds in force; release lifts one and destroys nothing
by itself. Access is still ended: the grants are revoked, the requests
withdrawn and any login in progress cancelled. A hold preserves evidence; it
doesn't keep a departed person's credential in use. The report lists what was
kept, and user.offboard_hold records that the hold was in force. When the
hold is lifted, run the offboarding again without --keep-credentials: it
finds what was kept and destroys it. The person's sessions are gone by then,
and a session is one of the things that link an email to a subject, so give
each spelling the first report resolved to (the email, then sub:<subject>).
Alternatively, delete the kept secrets from the Secrets panel (admins may
delete another person's personal secret) and remove the homes with the slug
recipe in
per-user Claude Code logins.
Give an email or a subject; either resolves to both. The surfaces are keyed inconsistently (a session carries an email, but a token's owner may carry only a subject), so the command expands what you type to every identifier that turns out to belong to the same person. Matching only the literal input would leave a departed user's PAT working.
Projects are reported, never deleted. They usually hold the team's work.
The command lists them and writes a user.offboard_project audit event so that
the reassignment is on record; use cloop workspace to hand them over.
The trail has one event per surface touched, all under entity type user and
keyed by the identity, so one query finds the whole operation:
The same operation is in the dashboard under Secrets → Offboard a user, and
requires user.manage. Like the CLI, it shows a preview before it writes, and
its Legal hold box is --keep-credentials. It refuses to offboard the
identity making the request, because that would revoke the session making the
request and deny the account that would have to undo it. Use the CLI if that is
really what you want.
Running hubs are told about every session, token and glasses link this ends, and within about a second they close the dashboards and sandbox terminals those opened. A hub started with
ui.cluster.exclusivereads no announcements: it may accept a revoked session for up to 30 seconds (its session cache), and closes what the session opened at the next 30-second re-check. Deny bindings take effect within ten seconds.
An admin is compromised¶
Admin status comes from oidc.admin_emails and oidc.role_mappings, which are
read at startup, so changing them means editing the configuration and
redeploying, with whoever controls the deployment pipeline involved.
Instead, cloop hub role revoke writes a deny binding into the
control-plane database. It takes precedence over every other binding at every
specificity, including the global admin binding that oidc.admin_emails
produces, and a running hub picks it up within ten seconds without a restart.
# 1. Confirm where their authority comes from. This shows both layers: the
# runtime bindings and the configured ones they override.
cloop hub role list --identity alice@example.com
# 2. Demote. This works while the hub is running.
cloop hub role revoke email alice@example.com --reason "credential compromise INC-4412"
# 3. A deny stops them acting. It does not end sessions or revoke tokens.
# `cloop hub user offboard` does all three in one transaction, and is the
# right tool unless you specifically want to demote without ending sessions
# (e.g. containing an account you are still watching).
cloop hub session revoke --identity alice@example.com --reason "credential compromise INC-4412"
cloop hub token list # then revoke anything the account holds
cloop hub token revoke <token-id>
# 4. Rotate anything they could have read: cloop hub key rotate, plus the
# credentials behind any grant they could reach.
# 5. When the investigation closes, remove the binding by id. Deleting a deny
# restores whatever remains in force — usually the configured policy, which
# for an admin_emails entry means restoring admin. Do it deliberately, and
# check `role list --identity` first: if a runtime grant for the same scope
# is also stored, that grant takes over instead of the configured policy.
cloop hub role delete rb_e1be642bd28f --reason "investigation closed, account clean"
$ cloop hub role revoke email alice@example.com --reason "credential compromise INC-4412"
DENIED email=alice@example.com (everywhere)
binding rb_e1be642bd28f
This outranks every other binding, including oidc.admin_emails.
Undo with `cloop hub role delete rb_e1be642bd28f`.
A deny stops them acting; it does not end their sessions or revoke their
tokens. For a compromise, also run:
cloop hub session revoke --identity alice@example.com --reason "..."
cloop hub token list # then revoke anything they hold
A running hub picks this up within 10s. No restart needed.
$ cloop hub role list --identity alice@example.com
Runtime bindings (this database)
ID EFFECT CLAIM VALUE ROLE SCOPE BY REASON
rb_e1be642bd28f deny email alice@example.com - (global) cli:root credential compromise INC-4412
Configured bindings (.cloop/config.yaml)
SOURCE CLAIM VALUE ROLE SCOPE
admin_emails email alice@example.com admin (global)
alice@example.com is DENIED by rb_e1be642bd28f (everywhere) — every request resolves to no permissions.
How long containment takes. There are three delays, which add up in the worst case:
| Bound | Why | |
|---|---|---|
| REST and page loads | 10s | the binding TTL: bindings are re-read from the database at that interval |
| Open WebSocket or SSE stream | +30s | the writer loop re-checks the deny on its keepalive tick and closes the connection |
| Revoked session | ~1s | announced on the hub bus, which drops it from every member's cache and closes the streams and terminals it opened; 30s for a request and up to 60s for a stream on a ui.cluster.exclusive hub, which reads no bus |
So a demoted account can no longer act after about ten seconds and stops receiving after about forty, or after about a second once step 3 has revoked its sessions and tokens, which closes every stream and sandbox terminal they opened. If you need both delays to be zero, stop the hub, which is also what you would do if you suspected the process itself.
Precedence. Two rules decide it, and nothing else:
- Deny wins. An applicable deny binding denies outright, whatever else matches and at whatever specificity.
- The database overrides config. If any runtime binding applies, the configured bindings are not consulted at all.
Step 2 above needs both. If a deny were ranked against a tier-0 admin binding, it would lose; if the configuration were consulted after a runtime match, the admin binding would come back.
Scoping a deny. With neither flag the deny is global. With --project it
removes authority on one project and leaves the rest as they are:
Other claims work too, which matters when the provider releases no email:
cloop hub role revoke sub 8f14e45fce --reason "offboarded, IdP entry not yet removed"
cloop hub role revoke group contractors --reason "vendor breach, containing the whole group"
cloop hub role grant works the same way in the other direction. A runtime
grant overrides the configured bindings, so you can also use it to narrow
somebody's access (--role viewer) or widen it for one project, without a
redeploy. It doesn't override a deny, and it doesn't undo one: granting over a
live deny stores a row that has no effect. The command says so. Use
role delete to undo a deny.
A binding can't be limited to a project and an executor at the same time. No request carries both (an executor action is a fleet action and resolves with no project), so such a binding would match nothing. The command refuses it instead of storing a deny that removes nothing.
Runtime bindings are policy that no reviewed file records, so a hub that has
any reports them at startup. A hub with single sign-on and no role policy
prints RBAC: off there instead and warns on stderr; its runtime denies still
apply, and are counted on the same line. Otherwise the line looks like this:
RBAC: 2 role mapping(s), default role "none", plus 1 runtime binding(s) (1 deny) — see `cloop hub role list`
Treat that line as a reminder to clean up: a deny left over from a closed incident is an account that somebody thinks still works.
A credential leaked.
cloop secret revoke <grant-id> takes the grant back from every running
workload holding it and prints what each hub member took back; read the
output. A member that didn't answer, a device that was unreachable, or a
variable already in a running process's environment (a host process or a
device's task keeps its copy until it exits) means the credential may still be
in use. Stop those runs, or revoke their lease with action=kill from the
Secrets panel. Rotate the credential at its source.
cloop audit-log list --entity secret --since 7d shows who granted what and
when. For egress, cloop egress revoke takes effect immediately: live sessions
are torn down mid-tunnel. That happens at once when you revoke in the dashboard
of the hub holding them, and within a minute when you revoke from the CLI or
another hub member, because the holding hub re-reads the grant every minute
during a run.
An executor is compromised.
Run cloop executor revoke <agent-id> (it cascades to the credential, and the
agent is told not to reconnect), then cloop executor cordon any peer that
shared its grants. Read what both print. The revocation names the hub member
that held the agent's connection, the leases it scrubbed off the device before
dropping it, and any still pending; a cordon names the leases it took back from
the peer. An agent that was offline is refused when it next connects, but
whatever its device already holds stays there. Check the audit log with
--entity executor for what it was given. Assume every secret ever leased to
it is disclosed, and rotate accordingly.
cloop audit-log verify exits 2.
Don't vacuum or run maintenance, because both rewrite pages. Snapshot the file,
run cloop db verify to tell corruption from tampering, and compare with what
your audit sinks received (cloop hub audit forward verify on their copy) to
find where the histories diverge.
cloop hub audit verify exits 3.
The chains are intact and record events that could not be appended.
cloop hub audit list --type audit.gap shows, for each gap, how many events
were lost, of which actions, when, and why (reasons: full, readonly,
locked, …). Fix the cause (usually a full disk, so free space first), and
treat the listed actions as the part of that window the trail can't account
for. Then use --allow-gaps in the scheduled check until the next gap.
--checkpoints names a truncated tail or a restored backup.
Stop the hub before anything else writes, snapshot state.db, and keep the
checkpoint file and its off-machine copies as they are. A planned restore
explains itself, so check for backups taken or restored around the time the
finding names. A restore nobody ran, or a truncation, is an incident. Recover
the rows after the last checkpoint the chain agrees with from your audit sinks'
copy (the forwarder sent them as they were written), and investigate the window
in which they disappeared. The hub's next forward records an
audit.forward.discontinuity row naming the cursor it lost.
/readyz fails but /healthz passes.
Read the check field first; it names the gate that failed.
"check": "sqlite" is storage. Check that the volume is mounted and writable,
then run cloop db verify. Under Kubernetes, check that the PVC is bound and
that every replica runs on the node the volume is attached to: SQLite's
write-ahead log needs every process using it on one kernel, so the chart pins
replicas to that node and refuses to render more than one without persistence
(replicaCount defaults to 1).
"check": "identity" means the hub has never resolved its OIDC issuer, so
nobody can sign in. The error field names the URL that was contacted and the
HTTP status it returned, and remediation says what to change. For the full
diagnosis, run cloop hub doctor on the same host. It makes the same two
requests and prints the resolved authorization, token and JWKS endpoints,
which is the quickest way to tell a wrong realm path from a certificate the hub
doesn't trust. The gate clears by itself once the provider answers, including
through an ordinary sign-in; no restart is needed.
"check": "executors" means the hub has nothing to dispatch to: strict mode is
on and no isolating executor is registered. The remediation field says what
to do. The usual causes, most frequent first:
- the configuration enables no isolating backend at all: set
executors.container.enabledorexecutors.kubernetes.enabled, or enroll an edge device; - one is enabled but could not be built: no container runtime on the hub
host, or no kubeconfig grant.
GET /api/executorshas areconciliationblock with a per-driverstatusandremediation, and the startup log has the same line; - the hub is running in a distroless image (as the chart's is) with
executors.container.enabled: true. There is no container runtime inside that image; use the Kubernetes backend, which is whatexecutor.kubernetes.enabledinvalues.yamlconfigures.
Do not set allow_host_process: true to clear this. It makes the probe
pass by removing the isolation boundary the gate exists to enforce.
Users keep seeing "Your sign-in needs renewing".
The dashboard tried to re-check their claims from the browser, because the hub
holds no refresh token for the session, and the provider wouldn't answer
without showing the user something. Clicking Sign in fixes it for one
max_claim_age_minutes window. If it happens to many users,
cloop_oidc_renewal_total{outcome="interaction_required"} will be rising.
Either their browsers keep the provider's cookies out of frames (Safari,
Firefox, a hardened Chrome), or the provider refuses to be framed at all; the
latter never reaches the hub and shows only in the browser console as a framing
violation. The fix is the same in both cases: set CLOOP_SECRET_KEY and add
offline_access to ui.oidc.scopes, so the hub re-checks the claims itself
and the browser never has to. In cloop hub session list, a session that stays
at never under IDP CHECKED is one the hub has nothing to re-check with. See
Silent renewal from the browser.
The hub exits with "another cloop hub controls this state and cannot share it".
A hub that isn't a cluster member is serving this control plane: one from
before clustering, or one running with ui.cluster.exclusive. The new hub
refused to start so that the two don't diverge. See
when a hub refuses to start.
A member logs "stepped down: leadership was lost". It couldn't renew the lease for a full TTL, usually because of a long pause (a suspended node, a stalled volume), and another member took over the leader's duties. It keeps serving as a member: only the duties moved, not the dashboard. If this happens repeatedly, look for what is stalling that process.
A hub started with ui.cluster.exclusive logs "standing down" and exits.
This is the same loss of the lease, under the pre-cluster rule that setting
restores: another hub now owns the state, so this one stops instead of serving
alongside it. Find the other hub with cloop hub lease status.
A sandbox cannot reach something it should. Work through the layers; each step rules one out.
- Ask the filter.
--checkreports the verdict and the rule that decided it, and puts the verdict in the exit status (0 allow, 1 drop), so you can use it in a script. Give it the address, not a hostname, because a packet filter matches addresses:
$ cloop egress firewall --cidrs 10.8.0.0/24 --ports 6443 --check 10.8.0.5:22
DROP 10.8.0.5 10.8.0.5:22/tcp — private (RFC1918/ULA)
Read the reason. private (RFC1918/ULA) means the address was inside the
granted range but the port was not, so the exception didn't apply and the
block set caught it. default deny means nothing matched at all. Use the
same flags the executor is configured with, or --grant <id> to compile a
stored grant. See
cloop egress firewall.
- Check that DNS is allowed. This is by far the most common cause. A
filteredpolicy with noresolversdrops UDP/53 along with everything else it doesn't name. The symptom isn't "DNS is denied": every connection fails at name resolution, which looks like a network outage. The compiler warns about this:
$ cloop egress firewall --internet --ports 443 --format rules | grep -m1 resolver
warning: no resolver is allowed, so DNS will fail: name lookups leave the sandbox on UDP/53 and this policy drops them. Pass the sandbox's resolvers, or use the broker, which resolves on its behalf.
To fix it, name the sandbox's resolvers in
executors.container.egress_filter.resolvers, or route the sandbox through
the broker, which resolves names on its behalf. On Kubernetes, check that
allow_cluster_dns hasn't been set to false. Resolvers are opened on TCP
as well as UDP, because a truncated answer is retried over TCP, and a
UDP-only resolver fails on large responses in a way that looks like a
different bug.
- Look at the counters on the host. Every rule has one, so the ruleset shows what is actually being dropped:
The table name is cloop_sbx_ plus the executor id in lower case, with
every character outside [a-z0-9] replaced by _. A non-zero counter on a
drop rule names the destination range and the reason. A zero counter on
the default deny line means the traffic never arrived, which points
upstream: at routing, at the image, or at a workload that never tried.
There is no table for an internal: true filter, and that is expected:
that form installs no rules at all, because the runtime gives the bridge no
route out in the first place. If nft list table says the table doesn't
exist, check which form the executor is configured with before concluding
that the filter failed to install. A failed install fails the Start, so a
running sandbox never has missing rules.
-
Run preflight again.
cloop executor test <executor-id>reports the driver'segressfinding (what the filter will enforce, or that it isn't filtering at all), plus a separateegress-scopefinding for anything where the filter is wider than its grant. Exit 2 means preflight found a fatal problem and didn't try a workload. The usual problems arenft(8)missing or the control plane lackingCAP_NET_ADMIN. Both arefail, both have the fix in the message, and you can avoid both by switching the filter tointernal: true, which needs neither.cloop hub doctordoesn't repeat these findings: itsexecutorsgroup reports reconciliation, the strict-mode gate and a liveness probe. -
On Kubernetes, confirm that the CNI implements NetworkPolicy. cloop creates the object, and the API server stores it whether or not anything enforces it, so from the hub's side a cluster running flannel looks the same as one that works. There the failure is the opposite of this playbook's: traffic that should be blocked is not.
kubectl get netpol -n <ns> -o yamlshows what was created; only the CNI's own documentation says whether it is enforced.
If the sandbox should reach the destination through the proxy and not
directly, use cloop egress test <url> instead. It asks the layer-7 question,
and a host allowlist is only enforced there.
A run's requests through the egress proxy fail.
First read the project's Event History for egress rows. Every run whose
project holds a grant gets either a "proxy session … issued … reaches the proxy
at X" row or a row saying why it got none: the grant was revoked or expired,
there is no proxy on the hub, the run has no network, or there is no route from
its executor (a loopback-bound proxy and a container, an advertised address a
device can't reach, an agent older than v18 behind a device firewall).
cloop hub doctor reports where each running hub's proxy listens
(egress.hosted). The audit trail has the rest: egress.redeem, one
egress.connect / egress.request per verdict with the host, port and reason,
egress.renew, and egress.close with the session's byte counts. A harness
that honours HTTPS_PROXY sends its model API traffic through the proxy too,
so when a harness can't reach its model, the usual cause is a grant that
doesn't include the model API's host.
"database is locked".
WAL and busy_timeout are already configured, so this points to a second
writer. Check for a stray cloop process on the same .cloop directory, or a
shared volume.
A run stopped: "run progress could not be saved".
The project database refused a write the run needed (a task's outcome, a pause,
a plan change), so the run stopped instead of reporting work the database
doesn't hold. The badge, cloop status and the Event History name the write
that failed and the database's own error:
The usual causes are a full disk, a read-only mount or file permissions, a
lock another process held past busy_timeout, and a damaged task row (see "A
task row that will not load" above). Fix the cause first; cloop db verify
checks the file itself. Then start the run again. The tasks the stopped run left
in progress are recovered the usual way: an outcome its agent had already
reported is adopted instead of being run again, and anything else is re-queued.
When the database accepted nothing at all, not even the stop, the run exits with status 74 and its last lines of output say what failed. The hub reads that status, records the same pause on the run's behalf, and journals it, so the dashboard shows the same either way, as long as the hub itself can write.
Nothing will schedule.
Read the placement error: it names the constraint and lists the reason each
candidate was rejected. host_policy means strict mode refused the only
available executor: the policy is working, and the fleet needs changing. Bind
the project to an isolated executor or enable the container backend. Do not
set allow_host_process: true to make the message go away.
A project shows "running" but nothing is running.
This resolves itself. A run that is killed instead of stopped (by the kernel's
OOM killer, a SIGKILL, a host reboot) never writes a terminal status, and the
hub reconciles that within a few seconds. The project returns to paused, each
task the run left in progress is either adopted (its agent had finished and
said so, so the outcome is used and the work isn't redone) or re-queued, and
the event journal records why. Look there first: the entry names the cause, and
an out-of-memory kill is reported as such, with the fix (give the executor more
memory, or make the task use less at once). Repeated OOM entries for the same
project are a sizing problem, not a cloop problem.
If it does not resolve, the hub refused to repair it. Check the server log for
stale-run recovery: skipped, state points at another directory. That is a
project whose .cloop was copied or moved from somewhere else, so its stored
WorkDir names its old location, and the repair would be written to the
original project's database. Fix the state file.
Restarting the hub does not stop the runs it started.
This is intended, and it is why the nightly rebuild of cloop-latest doesn't
interrupt work in progress. Runs are orphaned, not killed (KillMode=process
on both hub units), and are designed to outlive the hub: a run whose output
pipe breaks keeps working and still records its outcome, so losing the hub
costs only the live-log stream. Progress keeps being written to state.db, so
the task list stays current even while the log panel is empty; streaming
resumes with the next run, not the one that was in progress. A run on an edge
device fares better: the restarted hub adopts it when its agent reconnects, and
streams, merges and settles it as the stopped hub would have. See
after a control-plane restart.
In older builds such a run died, not from the kill but at its own next log
line, because Go makes SIGPIPE fatal on file descriptors 1 and 2. The work
was committed but the outcome was lost, which was the most common reason for a
project to get stuck showing "running". To stop a run, use the Stop button or
POST /api/projects/{idx}/stop.
See also¶
- Executor architecture: health, cordon, failover
- Security model: what the boundaries authenticate with
- Threat model: deployment-level threats
- Secret and egress grants: grant and revoke procedures
- Kata Containers: VM-isolated sandboxes, setup and verification
deploy/README.md: image, compose stack and Helm chart specifics