Feature Specification: Deployments and Builds

Created: 2026-09-23

Status: Implemented

Input: Reconstructed from the code of api/ at 0bf4ecd36, api/specification.md, pull requests #1011, #1036, #1096, #1900 and #1901, and issue #1882.

User Scenarios & Testing (mandatory)

User Story 1 - Submit a configuration and follow the build (Priority: P1)

An API client submits a configuration for a deployment identifier. api validates it, stores a build with a snapshot of the configuration and the module source, and starts a job. The client follows the progress on the events channels of the deployment and of the build, and reads the log.

Why this priority: this is the main service of api.

Independent Test: a VM test of a full deployment submits a configuration through the HTTP interface, and the deployment becomes active.

Acceptance Scenarios:

  1. Given a valid configuration, When the client submits it, Then api returns the build identifier and the build starts.
  2. Given a configuration that does not match the schema, that the eval of the deployment refuses, or that names a deployment attribute the module source does not give, When the client submits it, Then api refuses it as an invalid configuration, stores nothing and starts no build.
  3. Given a configuration, When the client asks for a preview, Then api returns the nodes that a deploy stands up and the names that each node publishes, and stores nothing.
  4. Given a running build, When the client reads the build list and the log, Then it gets the builds, newest first, and the log of the build.
  5. Given a running build, When the client cancels it, Then api stops the build and the build gets a final state.

User Story 2 - Show a deployment without reading its configuration (Priority: P1)

A front-end shows the domain and the instances of a deployment, but treats the configuration as opaque. api derives these facts and serves them as typed fields. A front-end that edits or compares the settings of one application also gets from api where the applications sit in a configuration, and which applications changed. A form that edits one part of the configuration at a time gets the parts (sections), with the place of each part and whether it changed. This also works for a draft that the front-end did not submit.

Why this priority: without these fields, a front-end must read option names, and breaks when the configuration model of core changes.

Independent Test: unit tests of the derivation give the same fields for a configuration of an unpinned build and of a build collapsed on one deployment method.

Acceptance Scenarios:

  1. Given a submitted configuration with a domain, When the client reads the deployment, Then each instance has a name, whether the form configures it, whether the operator turns it on and off, whether it is enabled, its domain, its URL and its section.
  2. Given a deployment without a successful build, When the client reads it, Then the deployed configuration and the deployed instances are empty.
  3. Given a draft and a deployed configuration in different shapes, When the client asks for the application paths, Then api gives the place of the applications in each, and lists only the applications whose settings differ.
  4. Given a configuration and the renames of its source, When the client asks for the sections, Then each section has the place of its value after the renames apply, the place of its value in the deployed configuration as stored, and whether it changed.

User Story 3 - Destroy a deployment (Priority: P1)

A delete destroys the infrastructure of a deployment. api enqueues a destroy build and returns at once. The front-end removes its own record of the intent only after the destroy succeeds. It can give a callback that api calls when the destroy succeeds.

Why this priority: an orphaned deployment is a hidden cost and a leak.

Independent Test: in the VM test of a full deployment, a delete of a deployed deployment adds a destroy build, and the deployment becomes idle.

Acceptance Scenarios:

  1. Given a deployment, When the client deletes it, Then api reports the destroy as queued.
  2. Given an unknown deployment, When the client deletes it, Then api reports it as not found, and the client treats the destroy as done.
  3. Given a callback whose origin is not allowed, When the client deletes, Then api refuses the callback.

User Story 4 - Suspend, resume and update a deployment (Priority: P2)

An operator suspends a deployment: its instances stop and their names leave DNS, but the data stays. A resume starts them again. An operator also chooses who applies a new revision of the default source: the operator (manual), api when it finds the update (automatic), or api in a maintenance window. api tells the client which actions it can do on a deployment, so that a client needs no new code for a new action.

Why this priority: these keep a deployment current and let an operator pause it without loss of data. A deployment works without them.

Independent Test: unit tests of the actions, of the update policy and of the window arithmetic.

Acceptance Scenarios:

  1. Given an active deployment whose deployed revision supports suspend, When the client suspends it, Then api queues a build of the last successful configuration and source, with the suspended state.
  2. Given a suspended deployment, When the client submits a configuration, Then api refuses it as a conflict.
  3. Given a deployment with the automatic policy, a new default source and an active, not suspended state, When api checks for due updates, Then it submits the stored configuration again against the new default source, and records an automatic update.
  4. Given a deployment with a window policy, When the window is closed, Then api does not start an update.
  5. Given a submit that asks to keep the deployed revision, When api accepts it, Then the build uses the source of the last successful deploy.

User Story 5 - Keep the history over restarts (Priority: P2)

The deployments, builds and audit events survive a restart of api. A build that a restart interrupts gets a final state at the next start.

Independent Test: the unit tests run on SQLite and on a sandbox PostgreSQL.

Acceptance Scenarios:

  1. Given stored deployments, When api restarts, Then the list is the same.
  2. Given a local build that ran when api stopped, When api starts, Then the build is marked as failed.
  3. Given a SQLite store and a new PostgreSQL store, When api starts, Then it imports the SQLite rows one time.

Edge Cases

  • A replace-mode submit while a build of the same deployment runs: api refuses it as a full queue. A queued build does not block it.
  • A submit while all eval slots are in use: api skips its own eval, and the deploy evaluates the configuration. A preview in the same case reports the service as unavailable.
  • A configuration without a domain: the instances have no domain and no URL. A disabled instance has no URL.
  • An action toggle after a failed build: the deployment can be in either state, so both directions are accepted.
  • A maintenance window on a day that a month does not have: the window opens on the last day of the month. A start time that a daylight saving change skips opens one hour late.
  • The local development app can delete its stored state at start.
  • A node that no deploy changed since api keeps SBOMs: the node is unavailable, with the reason that no deploy gave an SBOM yet. The next deploy that changes the node gives its SBOM.
  • A node whose SBOM does not evaluate or build: the SBOM of that node is unavailable, with the error as the reason. The deploy continues.

Requirements (mandatory)

Functional Requirements

  • FR-001: api MUST give the procedures of api/specification.md for deployments and builds: list, read, submit, preview, cancel, delete, actions, update policy, build list, build log, SBOM list and SBOM.
  • FR-002: api MUST store the deployment identifier as the client gives it, and MUST NOT depend on its structure. A submit MUST refuse an identifier that is not a DNS label of at most 40 characters, because the identifier names the resources of the deployment. A list MUST filter on the identifiers that the client gives.
  • FR-003: api MUST store with each build a snapshot of the configuration and the module source, and MUST show the configuration of the last successful build as the deployed configuration.
  • FR-004: api MUST derive the domain, the instances and the deployed instances from the stored configurations, and the application paths, the changed applications and the sections from configurations in a request, with the same result for each shape of the configuration. The derivation from a request MUST be public, because it reads only the request. The sections MUST be the configurable applications of the catalog, plus each application with a value that the catalog does not mark as not configurable.
  • FR-005: A delete MUST enqueue a destroy build, and MUST NOT delete the record of the deployment before the destroy.
  • FR-006: api MUST publish each change of state of a deployment and of a build on an events channel of that deployment and of that build.
  • FR-007: api MUST keep its state in SQLite or in PostgreSQL, and MUST keep it over restarts.
  • FR-008: api MUST map its errors to the HTTP codes of the table in api/specification.md.
  • FR-009: api MUST NOT stop or replace a running build, because a program can run a TF graph, and a TF graph is not atomic. In replace mode, a submit while a build of the same deployment runs MUST get a full-queue error. In enqueue mode, a submit MUST always queue. api MUST NOT drop a queued build.
  • FR-010: api MUST record an audit event for each submit, cancel, action, delete, update policy change and automatic update, also when the procedure refuses the call, when the authorization callback denies the call, or when the request does not agree with the published contract. The event MUST have the reason of a refusal, and the reason MUST NOT contain a value of the request. Of the denials without an actor and without the service token, each process of api MUST record only the first in each interval, and MUST then record the count of the others as one event, so that callers without an account cannot fill the store and the log; the denials of a known actor MUST each have their own event. It MUST show the last events of a deployment with the deployment, without the denied calls, MUST give the events of one actor on all deployments, with the denied calls, when the authorization callback allows it, MUST keep the events after the deployment is deleted, and MUST remove events older than the retention period (365 days by default; the provider MAY keep all events). A failed write of an event MUST NOT fail the call. Each event MUST hold the hash of the event before it and of its own content, a removal of old events MUST keep the hash of the last removed event, and a verify command MUST find a changed or removed event. A node MUST run it each day, as a unit that fails when it finds a problem and that reads the store, but not the deploy tokens of the service. The component MUST mark the service as an audit source, thus the node sends the audit lines to the audit tenant of Loki. The verify command MUST be able to give the head of the chain as one structured object, also when the chain has a problem. When the provider sets a sink for the head, a node MUST give the head to that sink at a regular interval, as a unit that fails when the chain has a problem (see "Audit events" in api/specification.md).
  • FR-011: Before a submit stores a configuration, api MUST evaluate the nodes that a deploy stands up and the names they publish, and MUST refuse a configuration that this eval refuses. A preview MUST give the result of this eval and MUST NOT store anything.
  • FR-012: A deployment MUST give the actions that a client can do, each with whether it is enabled and why not. An action other than deploy and delete MUST go through one generic procedure.
  • FR-013: Suspend MUST be a value of the desired state: api MUST pass it to each build of the deployment, also to a destroy, and MUST toggle it with a build of the last successful configuration and source. api MUST refuse a submit while the deployment is suspended. A destroy that succeeds MUST clear it. api MUST refuse a toggle when nothing is deployed, when the deployment is already in that state, when a build runs, or when the deployed revision does not support the action for the deployment method of the deployment.
  • FR-014: api MUST store an update policy for each deployment (manual by default, automatic, or a maintenance window with a frequency, a day, a local start time, a duration and a time zone). A submit without a policy MUST keep the stored one. api MUST refuse an explicit module source while the policy is not manual.
  • FR-015: At regular intervals, api MUST submit the stored configuration again with the default source for each due deployment that is active, not suspended, and whose last successful deploy used another source. It MUST try each default source once for each deployment, and a new policy MUST allow a new try. Without a self-pin, api MUST NOT start an update. A deployment MUST show its policy, whether an update is available, and the result of the last automatic update.
  • FR-016: A submit or a preview MAY ask to keep the deployed revision. Then a submit without a source MUST use the source of the last successful deploy.
  • FR-017: A deploy MUST evaluate the CycloneDX SBOM (system.build.sbom) of each node that it deploys in the same evaluation as the system of the node, and MUST build it while it deploys the node. A failed or slow SBOM MUST NOT change the status of the deploy. When the build succeeds, api MUST store each SBOM, or the reason why the node has none, in its state store before it marks the build as succeeded. A node that the deploy does not change MUST keep its SBOM. api MUST give the state of the SBOM of each node of the deployment, and the SBOM of one node, with the same authorization as the other reads of a deployment. After a destroy that succeeds, api MUST give no SBOM. api MUST store each SBOM component only one time across all deployments.

Key Entities

  • Deployment: an opaque identifier, the last submitted configuration and module source, the suspended state, the update policy, and the derived state (building, active, destroying, idle, failed).
  • Build: one job for one deployment, with a status, a log, a snapshot of the configuration and module source, and an optional job identifier of the job runner.
  • Node SBOM: for one node of a deployment, a status (ready or unavailable), the build whose deploy gave it, the time, and the CycloneDX document without its components, or the reason why there is none.
  • SBOM component: one component of a CycloneDX document, by the SHA-256 of its JSON text. Node SBOMs with the same component refer to the same SBOM component, in the order of their documents.
  • Audit event: one call that changes a deployment: the actor (the subject and its issuer that the authorization callback returned), whether a trusted backend made it, the action, the deployment, the build, the request identifier of the ingress, the result and the time.
  • Instance: a derived fact of a configuration: a served application or component, with its domain and URL.
  • Section: one part of a configuration that a form edits by itself, with the place of its value and whether it changed.
  • Update policy: who applies a new default source, and when.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: In a VM test, a submitted configuration becomes an active deployment, and a delete makes it idle.
  • SC-002: A front-end shows a deployment without reading option names of the configuration.
  • SC-003: After a restart, no build stays in the state "running" without a job.

Assumptions

  • The front-end owns the intent (drafts, names, owners). api owns the runtime facts.
  • The browser orders the two steps of a delete, as api/specification.md describes.

Known Deviations

None.