Deployment provider adapters

Clank separates durable deployment coordination from infrastructure mutations. The control plane owns desired generations, node identity, placement, operation leases, release provenance, and fencing. A deployment provider owns the host spec

8 min read1,596 wordsClank 0.22.1

Clank separates durable deployment coordination from infrastructure mutations. The control plane owns desired generations, node identity, placement, operation leases, release provenance, and fencing. A deployment provider owns the host-specific act of converging one project to a running release or a stopped state.

This boundary is intentionally small:

ts
import type { DeploymentProvider } from "@clank.run/framework/provider";

const provider: DeploymentProvider = {
  kind: "microvm",
  async reconcile(request) {
    // Node and operation credentials are never passed to provider code.
    // Persist the highest generation and fence before mutating infrastructure.
    await reconcileMicroVm({
      projectId: request.operation.projectId,
      operationId: request.operation.id,
      fence: request.operation.fence,
      generation: request.desired.generation,
      state: request.desired.state,
      releaseId: request.desired.releaseId,
      artifact: request.artifact,
      runtime: request.runtime,
      signal: request.signal,
    });
  },
  async rollback(request) {
    await providerService.rollback(request);
  },
  async delete(request) {
    await providerService.delete(request);
  },
};

For a running generation, request.artifact contains the original compressed bytes, their SHA-256, and a decoded clank-deploy/1 bundle. Clank independently checks the transport digest, every file path, size, mode, checksum, deployment config, and aggregate bound before the provider runs. A reconcile operation that selects clank-runtime/1 also receives request.runtime: the project/release/generation-bound final environment, database placement intent and optional SQLite snapshot, and ingress identity. Its request.artifact is the verified release nested in that capsule. A stopped generation has no release, artifact, or runtime.

Standard agent

openProviderDeploymentAgent() layers this contract over the normal remote-node lifecycle:

ts
import {
  createDeploymentCoordinatorClient,
  fileDeploymentNodeCredentials,
} from "@clank.run/framework/runner";
import {
  openProviderDeploymentAgent,
} from "@clank.run/framework/provider";

const agent = await openProviderDeploymentAgent({
  client: createDeploymentCoordinatorClient({
    baseUrl: "https://clank.example.com",
  }),
  node: {
    id: "runner-west-01",
    region: "us-west",
    capacity: 20,
    labels: { isolation: "microvm", architecture: "amd64" },
  },
  provider,
  registrationToken: process.env.CLANK_RUNNER_REGISTRATION_TOKEN,
  credentials: fileDeploymentNodeCredentials(
    "/var/lib/clank-runner/credentials.json",
  ),
  concurrency: 4,
});

The wrapper accepts canonical reconcile, rollback, and delete operations. Reconcile validates desired state and downloads either the legacy artifact or explicitly selected runtime capsule only for a running state. It independently verifies the bytes, binds a capsule to the exact project, release, and generation, then reports the matching generation as observed. A rejected stale observation fails the operation instead of claiming success.

Rollback/delete payloads contain only the exact current generation. The wrapper derives the required confirmation after validating the canonical operation lease, never downloads release content, and never publishes a desired-state observation. Node identity, node credentials, and operation lease tokens are removed before every provider call.

The operation result sent back to the control plane is fixed to provider kind, generation, release ID, and state. Provider return values, exception details, credentials, paths, and runtime metadata do not become durable control-plane records.

Authenticated HTTP bridge

Provider code can run in a separate private service. Clank includes both sides of one exact binary protocol:

ts
import {
  createDeploymentProviderHandler,
  createHttpDeploymentProvider,
} from "@clank.run/framework/provider";

const remoteProvider = createHttpDeploymentProvider({
  baseUrl: "https://runtime.internal.example",
  token: process.env.CLANK_PROVIDER_TOKEN!,
});

const handler = createDeploymentProviderHandler(provider, {
  token: process.env.CLANK_PROVIDER_TOKEN!,
  maxArtifactBytes: 100 * 1024 * 1024,
  maxRuntimeBytes: 768 * 1024 * 1024,
  onError(error) {
    privateOperatorLog(error);
  },
});

The fixed paths are POST /v1/clank/reconcile, POST /v1/clank/rollback, and POST /v1/clank/delete. Legacy running reconcile requests carry application/vnd.clank.deploy+gzip; runtime requests carry application/vnd.clank.runtime. Both are exact binary bytes, not base64 JSON. Bounded headers identify the project, operation, fence, attempt, generation, desired state, release, protocol, and body SHA-256. Secret values, SQLite bytes, and ingress tokens stay only in the runtime body. Stopped requests carry no body, release, protocol, or digest. Rollback/delete carry only the bounded operation identity, project, fence, attempt, and generation headers. Their bodies and content headers are forbidden, and the handler derives the exact confirmation locally. A successful provider returns 204.

Both sides require a separate 32–512 character bearer token. Non-loopback clients require HTTPS, refuse redirects, apply deadlines and body limits, discard bounded failure bodies, and retry only the same idempotent request after network, 408, 425, 429, or 5xx failure. The handler rehashes and decodes the artifact or runtime capsule again because the HTTP hop is a new trust boundary. Provider exceptions reach only the private onError hook; callers receive a stable generic error.

Do not expose this endpoint to browsers or reuse Clank account, CLI, enrollment, node, or operation credentials for it. Prefer a private network plus narrowly scoped edge admission even though the request is authenticated.

Packaged runner

clank-runner connects the coordinator to the HTTP bridge without application code:

sh
export CLANK_CONTROL_URL=https://clank.example.com
export CLANK_RUNNER_NODE_ID=runner-west-01
export CLANK_RUNNER_REGION=us-west
export CLANK_RUNNER_REGISTRATION_TOKEN="$(secret read clank-runner-enrollment)"
export CLANK_RUNNER_CREDENTIALS=/var/lib/clank-runner/credentials.json

export CLANK_PROVIDER_URL=https://runtime.internal.example
export CLANK_PROVIDER_TOKEN="$(secret read clank-runtime-provider)"

clank-runner

Run clank-runner --check before starting the service, or add --json for an agent-readable result. With a saved node credential it authenticates the node; before first enrollment it validates configuration without consuming the one-time value. After successful enrollment, remove CLANK_RUNNER_REGISTRATION_TOKEN from the long-running service. The persisted node credential is sufficient for restart. SIGINT and SIGTERM drain the node before exit. Run clank-runner --help for capacity, label, concurrency, timeout, retry, artifact, and runtime-capsule limits.

This process requires no extra npm package or managed service. A systemd unit, container, VM, or existing scheduler can supervise it. The control plane's normal single-host mode remains the zero-cost default.

Provider correctness contract

Every adapter must satisfy all of these rules:

  1. Store the latest accepted generation and monotonic fence per project in transactional durable state. Reject older work.
  2. Treat the operation ID plus fence as an idempotency key. A response can be lost after the provider commits, so retry is normal.
  3. Pass the abort signal to provider calls and stop local subprocess/container work when possible. Abort does not roll back an already committed remote mutation.
  4. Stage a new release separately, health-check it, switch ingress atomically, then drain the old generation. The packaged provider runtime ingress supports overlapping exact generations plus revoke-before-drain; never overwrite an active release in place.
  5. Keep application data outside immutable release directories. Mount or bind only the one project's data into its runtime.
  6. Keep release artifacts non-secret. When a clank-runtime/1 capsule is supplied, consume its final environment and database only inside the trusted project runtime boundary; never log, cache, or copy the body into generic provider metadata.
  7. Use an isolated runtime boundary for mutually untrusted deployers. A process launcher is not a sandbox; Docker is a minimum, while hostile workloads should use dedicated VMs or microVMs.
  8. Return success only after the requested state is externally usable. Keep private provider errors in operator telemetry.
  9. Garbage-collect only releases no longer referenced by desired, observed, or rollback state.
  10. Test retry after commit, lease loss, stale fences, abort, health failure, partial ingress switch, restart from durable state, and provider outage.

Run the package-supported baseline kit before an adapter joins a runner:

sh
clank workbench provider ./provider.mjs --json

It checks provider shape, a frozen credential-free stopped request, exact operation/fence idempotency, time bounds, and advertised rollback/delete capabilities with canonical confirmations. Missing optional capabilities are skipped. Provider-specific crash, persistence, ingress, and isolation tests remain required.

openDeploymentProviderDataStore() implements the provider-owned portion of rules 1, 2, 5, 6, and 9 for immutable releases plus SQLite. It independently binds runtime capsules to desired state, applies migrations behind a consistent safety snapshot, commits metadata atomically, retains one rollback generation, and journals interrupted apply and rollback work. See Provider data lifecycle before writing a runtime adapter. openDockerDeploymentRuntimeLauncher() supplies the zero-dependency reference implementation for rules 3, 5, 7, and private candidate health, including web/job process topology, resource bounds, owner-scoped orphan cleanup, and restart fail-closure. See Provider Docker runtime. Use Provider runtime ingress for the authenticated private traffic, generation overlap, revocation, and draining portion of rules 4 and 8. openDockerDeploymentProviderService() composes all three components with independently verified capsules, durable operation/generation/fence intent, drain-before-stop, deferred background activation, retry-after-commit recovery, and fail-closed shutdown. See Complete deployment provider service.

Built-in control-plane integration

The provider contract, reconcile/rollback/delete HTTP bridge, runner command, release and runtime transport, object storage, leases, credentials, fencing, provider data lifecycle, isolated Docker launcher, provider-private runtime ingress, and complete Docker provider composition are implemented and package-supported. They remove provider SDKs from Clank and give Docker, VM, microVM, Nomad, Kubernetes, or hosted adapters the same narrow surface.

The built-in control plane can now opt a project into this provider stack. It freezes and encrypts one final runtime environment per generation, creates clank-runtime/1 only for an authenticated current lease, requests stateful endpoint/label placement, and publishes an allowlisted provider origin only after the exact release and generation are observed. Deploy retry, code rollback, fenced immediate-predecessor data rollback, restart-safe routing, preview inheritance, and provider-confirmed deletion use the same contract.

This remains explicit. Enabling enrollment or starting clank-runner does not relocate existing projects. Configure deploymentAgents.placement (or CLANK_PROVIDER_DEFAULT_PLACEMENT=local|provider) and create the project with provider placement. The value local enables per-project selection while retaining the safe default.

Provider backup creation now uses the exact active node and generation-bound snapshot control credential, then immediately encrypts the verified bytes in the ordinary recovery repository. Scheduling, listing, verification, and restore work for both local and provider placement. Provider restore is a new replace generation on the pinned node: it uses an encrypted platform safety point plus the provider data store's post-drain safety snapshot, reapplies current migrations, health-checks, and publishes ingress last. PROVIDER_RESTORE_PENDING means that exact durable generation is still converging and the same request should be retried.

The complete Docker provider also supplies a bounded memory-only application-output tail and one-shot memory/limit, CPU, PID, network-I/O, and block-I/O attribution for the exact active generation. The same boundary reports shared provider-filesystem capacity, which the control plane returns only to a platform administrator's interactive browser session, and exposes payload-free durable-job inspection plus conditional cancellation/retry. The control plane verifies every private response and redacts project secrets before showing it. Automatic failover from node-local SQLite is intentionally not implemented: heartbeat loss alone cannot prove that the old writer stopped. A stateful project stays pinned and unavailable until its node returns or a platform administrator explicitly recovers a verified encrypted backup. Emergency recovery requires the exact source to be revoked and independently fenced, recent browser authentication, exact confirmation, two risk acknowledgements, and compatible target capacity. It unpublishes old ingress before the replacement becomes active and retains an exact generation/audit trail. Operators must not treat release-object retention as a database backup. See Recovery and Remote runtime placement for the precise boundary.

See Remote runtime placement for operator configuration, exact body, and trust boundary, then Provider data lifecycle for its safe consumption. Integrations should consume Complete deployment provider service rather than reimplementing ordering in application code or the control database. Use Provider Docker runtime as the reference process boundary.