Skip to content

Cross-host deploy-time artifacts

punix fleet apply deploys many hosts from one command (see Deploying a fleet). Most cross-host facts are known before deploy — addresses, public keys — and travel as plain config. But some facts only exist after a deploy runs: the canonical case is DANE/TLSA, a DNS record that must contain a hash of host A's TLS certificate, which doesn't exist until ACME runs on A. Host B's deploy needs a deploy-time fact of host A's deploy.

This page is the operator recipe for that. The architectural rationale is in ADR-022. The live demo is examples/tangled-fleet/.

The problem

Host A provisions a cert. Host B publishes a TLSA record whose value is a digest of that cert. Three things must hold, and none of them can be expressed by the static fleet data model:

  • The digest doesn't exist until A deploys, so B can't author it in advance.
  • A's cert must not leak to B — only the publishable digest may cross, never the cert or key.
  • B's deploy must not change its identity every time A renews its cert. The renewal is A's concern; B authored nothing new.

A deploy-time artifact is the structural twin of a secret: an opaque reference in pure config, resolved below the seam at deploy time, excluded from every hash and from the store. The one addition is ordering — because the value is produced by another host, the fleet driver must deploy the producer first and thread the digest across.

Producing an artifact

Host A's stack declares what it produces with producesArtifacts:

# appview-host.pcl
module AppviewStack {
  backend = "systemd"
  storeModules = ["Tangled", "Redis", "Nginx"]
  services = ["RedisSvc", "AppviewSvc", "NginxSvc"]
  producesArtifacts = [
    { name = "appview-cert-tlsa"  kind = "tlsa"  fromCert = "appview.local" }
  ]
}

Each entry has a name (how consumers refer to it), a kind, and a source:

  • kind = "literal"value = "..." is published verbatim. The hermetically-testable kind; no effect runs.
  • kind = "tlsa"fromCert = "<domain>" names the provisioned cert to digest. The producer reads <cert-dir>/<domain>/fullchain.pem (the operator's --tls-certs source) at deploy time and publishes its SHA-256, so no preimage ever crosses the network.

The producing host emits only the digest. The certificate file never enters the store, a hash, gen-NNN.json, or provenance — on either host.

Consuming an artifact

Host B references the artifact by { host, name } in a service's environment:

# knot-host.pcl
module KnotSvc {
  name = "knot"  package = "Tangled"  binary = "knot"  args = ["server"]
  environmentFile = true
  environment = {
    KNOT_SERVER_HOSTNAME   = "knot.local"
    KNOT_APPVIEW_CERT_TLSA = { artifact = { host = "appview"  name = "appview-cert-tlsa" } }
  }
}

{ artifact = { host = "appview" name = "appview-cert-tlsa" } } lowers to an opaque ArtifactValue reference — host is the producer's machine name (not the SSH target), name matches the producer's declared name. Like a secret reference, the pure layer can read the reference string but never the value: there is no eval-time operation over an artifact's value. The digest is bound at deploy time, below the seam, by the same post-compose pass that resolves secrets.

The deploy-ordering graph

fleet apply builds the producer→consumer dependency graph before deploying any host:

  • Each consumed { host, name } must be declared in that producer's producesArtifacts, or the fleet fails fast with a located error (host 'knot' consumes artifact appview:appview-cert-tlsa, but 'appview' does not declare it in producesArtifacts). A consuming host must declare a machine.
  • The deploy order is the unique stable topological order over the edges: a producer deploys before any consumer of its artifact; ties keep the fleet's host order. Identical fleet input gives an identical order on every run.
  • A cycle is rejected pre-deploy with a located error naming the participating machines and artifacts (cross-host artifact cycle among machines [...] via artifact(s) [...] — no host deployed). Zero hosts deploy. A cross-host artifact cycle is the import-from-derivation feedback shape at fleet level — refused, not ordered.

Each host's deploy stays atomic and independently rollback-able; ordering is the only coupling added.

Failure outcomes

A host's result is one of four statuses (FleetHostResult.status):

  • ok — deployed.
  • failed — its own deploy raised (recorded with the error; with --fail-fast, the run stops launching new deploys).
  • blocked — a producer it consumes from failed or was blocked. The host is skipped without being attempted; blocked_by names the cause (appview:appview-cert-tlsa). Blocking is transitive — a blocked host blocks its own consumers. The blocked host stays at its prior generation.
  • not-attempted — under --fail-fast, a host the run never reached after an earlier failure (reported distinctly, never silently truncated).

A successfully-deployed producer is never auto-rolled-back because a downstream consumer failed — it is independently valid. The fleet exits non-zero if any host is not ok.

The core guarantee — only the digest crosses

This is the decisive property, and it mirrors secrets exactly:

  • Only the digest crosses the seam. The producing host emits the digest; the consuming host receives the digest. The cert/preimage enters neither host's store, hash, derivation.json, or provenance. The TLSA/DKIM record is computed at deploy time inside the resolver, where the value first exists — never in a recipe or a fold.
  • The generation records the reference, not the digest. B's gen-NNN.json records the consumed artifact as {"key": "KNOT_APPVIEW_CERT_TLSA", "kind": "artifact", "host": "appview", "name": "appview-cert-tlsa"} — the reference, never the resolved digest. (The §20.6 secret analogue.)
  • B's deploy is identical when A's cert renews. Because the recorded form is the reference, two deploys of B that differ only because A renewed its cert have the same logical identity. A cert renewal is not a config change to B.

The live tangled-fleet DANE example

examples/tangled-fleet/ runs this end to end over real SSH (run-multihost.sh). The fleet has two hosts, each with a machine name the artifact graph keys on:

# fleet.pcl
module Fleet {
  hosts = [
    { file = "knot-host.pcl"     stack = "KnotStack"     target = "ssh://root@knot-host"     machine = "knot" },
    { file = "appview-host.pcl"  stack = "AppviewStack"  target = "ssh://root@appview-host"  machine = "appview" }
  ]
}

appview produces a tlsa digest of its cert; knot consumes it in its 0600 env file. Although knot is listed first, the extracted producer→consumer edge forces appview to deploy first; one fleet apply threads the digest from appview to knot across the network. The run asserts both:

ok: knot-host holds appview-host's cert TLSA digest (live cross-host artifact)
ok: the digest is absent from knot's generation (recorded by reference, §20.6)

(A real DANE setup renders the digest into a DNS TLSA record; here an env var demonstrates the mechanism.)

Provenance & rollback (ADR-028)

  • Renewal-invariant identity (ARTIFACT-REF-HASH). A consumer's gen-NNN.json records the artifact-bearing file's sha256 in reference form — the bytes with each digest rendered as its stable {artifact:host/name} token — so two consumer deploys that differ only because the producer's cert renewed have an identical generation identity. The resolved digest is rendered into the file on disk but appears in no hash and no generation field.
  • Provenance edge + service why. Each consumed artifact is recorded as a {path, host, name, kind, token} edge (by reference, never the digest). punix service why <stack> <config-file-path> reads it back and attributes the file to its producer:
$ punix service why KnotStack /etc/punix/env/knot.env --target ssh://root@knot-host
/etc/punix/env/knot.env (stack KnotStack, gen-003) consumes 1 cross-host artifact(s):
  appview/appview-cert-tlsa [tlsa]
  • Rollback orders consumers before producers. fleet rollback rebuilds the artifact graph and rolls back in reverse-topological order (ARTIFACT-ROLLBACK-ORDER). An artifact-bearing file is left at its current (non-dangling) bytes — they already embed the current producer digest — and re-resolved on the next deploy; the rollback reports it honestly as artifact-bearing (not as a secret/§20.6 file).

Honest scope

These pieces are not wired yet:

  • Env position only. An artifact is consumed in a service environment value. Embedding one in a configFiles content string (a real DNS zone file) is not wired.
  • Full structural re-render on rollback is deferred. Rolling a consumer back leaves its artifact file at the current bytes (above), so a consumer's structural env change is not reverted on rollback, and if a producer also rolls back the consumer's file dangles until re-deployed. Re-rendering the file from the generation against a freshly-recomputed producer (the strong ARTIFACT-RERESOLVE) and dangling-detection-and-refuse are follow-ups — spec §6's re-resolve ordering is itself under-specified (ADR-028 "As built").
  • ARTIFACT-STALENESS (re-deploy-on-artifact-change no-op) is gated. Blocked on the real digest below (the placeholder flips on benign renewals).
  • tlsa hashes the cert bytes. The current digest is SHA-256 of fullchain.pem. The RFC-6698-exact selector-1/matching-1 SPKI extraction is a follow-up.

Where in the code

  • src/punix/deploy/stack.pyArtifactProduced + _read_produces_artifacts (the producer declaration); ArtifactValue refs in Service.environment.
  • src/punix/deploy/artifacts.pyconsumed_artifacts, resolve_artifacts (the twin of resolve_secrets), compute_artifact_digest.
  • src/punix/deploy/fleet.pyArtifactEdge, deploy_order (stable topo + cycle rejection), apply_fleet (the four outcomes).
  • src/punix/cli.py_fleet_artifact_edges (pre-deploy edge extraction) + the per-host oracle; _artifact_provenance (the edge rows) + _write_config_files (reference-form sha256 via ConfigFile.ref_content); service why; _restore_config_files + fleet rollback (reverse-topo + artifact-aware reporting).
  • src/punix/deploy/artifacts.pyResolvedArtifact (the post-resolution marker carrying the producer reference).
  • Deploying a fleet — the per-host fleet mechanism this orders.
  • Secrets at deploy — the structural twin (reference-not-value, hash exclusion).
  • Rollback — the per-host rollback; fleet rollback orders consumers before producers here.
  • ADR-022 (ordering) · ADR-028 (provenance + rollback) · spec 022.