Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type { UserStore } from "./users.js";
import { DEFAULT_WINDOW_MINUTES, readServiceHealth, telemetryAppliesTo, telemetryTargetFromEnv } from "./observe.js";
import type { TelemetryTarget } from "./observe.js";
import { repositoryKeyFold } from "./repository-reference.js";
import { makeRecoveryPlan, runDeliveryJourney, runRecovery } from "./deployment-adapter.js";
import type { DeploymentAdapter } from "./deployment-adapter.js";

/** What a delivery record holds. Additive only; fields grow, never rename. */
export interface DeliveryRecord {
Expand Down Expand Up @@ -524,6 +526,48 @@ export async function recheckApprovingActor(
};
}

/**
* The opt-in alternative to the inline teploy commands (S27, SHIP_DEPLOY_ADAPTER
* =teploy): a factory for the DeploymentAdapter the deploy step and the
* rollback run through. Absent (the default) leaves every line below on the
* legacy path. `unavailable` is an honest hold, e.g. no service in teploy.yml.
*/
export interface DeliveryAdapterFactory {
create(input: { deployDir: string; destination: string }): Promise<{ adapter: DeploymentAdapter; service: string } | { unavailable: string }>;
}

/** A deploy that went through the adapter journey: `acted` means the target may have changed. */
async function deployViaAdapter(
record: DeliveryRecord,
image: string,
destination: string,
factory: DeliveryAdapterFactory,
deployDir: string,
now: string,
): Promise<{ acted: boolean; reason: string }> {
const made = await factory.create({ deployDir, destination });
if ("unavailable" in made) return { acted: false, reason: `deployment adapter unavailable: ${made.unavailable}` };
const identity = { service: made.service, destination, artifact: image, revision: record.mergedSha!.slice(0, 7) };
const journey = await runDeliveryJourney(made.adapter, {
identity,
authorisation: {
kind: "deploy",
actor: record.actor ?? "",
service: made.service,
destination,
expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
},
...(record.actor !== undefined ? { holder: record.actor } : {}),
});
const adapterName = made.adapter.capabilities().adapter;
return {
acted: journey.acted,
reason: journey.outcome === "confirmed"
? `deployment adapter (${adapterName}) deployed and read the target back on the approved version and image; the sweep records the confirmation`
: `deployment adapter (${adapterName}) ${journey.outcome}: ${journey.reason}${journey.acted ? " — the target may have changed; reading it back before deciding" : ""}`,
};
}

/**
* Execute one approved delivery against the TRUSTED working copy. This is
* the S14 execution boundary; it runs on the worker host via argv arrays
Expand All @@ -542,6 +586,8 @@ export async function executeDelivery(
now?: () => string;
/** The live governance/account stores for the S15 actor recheck. */
authority?: ApproveAuthoritySources;
/** S27 opt-in: deploy through a DeploymentAdapter instead of the inline `teploy deploy`. Absent = legacy. */
adapter?: DeliveryAdapterFactory;
},
): Promise<DeliveryRecord> {
const now = options.now?.() ?? new Date().toISOString();
Expand All @@ -566,6 +612,9 @@ export async function executeDelivery(
if (record.mergedSha === undefined) {
return { ...record, ...patch({ reason: "the merged SHA was never proven (read-back did not confirm the merge); re-verify before delivery" }) };
}
if (options.adapter !== undefined && (record.destination === undefined || record.destination === "")) {
return { ...record, ...patch({ reason: "the deployment adapter path needs the destination recorded at approval (none was); re-approve naming the destination" }) };
}
const cwd = options.dir;
const exec = async (argv: string[], timeoutMs = 120_000) => options.run(argv, { cwd, timeoutMs });

Expand Down Expand Up @@ -679,6 +728,13 @@ export async function executeDelivery(
await exec(["git", "worktree", "remove", "--force", tree]).catch(() => undefined);
return { ...record, ...patch({ reason: `the trusted build printed no image identity: ${build.stdout.slice(0, 300)}` }) };
}
if (options.adapter !== undefined) {
const via = await deployViaAdapter(record, image, record.destination!, options.adapter, tree, now);
await exec(["git", "worktree", "remove", "--force", tree]).catch(() => undefined);
// Held means "never touched the target": only a journey that did not act.
if (!via.acted) return { ...record, ...patch({ reason: via.reason }) };
return { ...record, artifactDigest: image, ...(configIdentity !== undefined ? { configIdentity } : {}), state: "unknown", updatedAt: now, reason: via.reason };
}
const deployed = await options.run(
["teploy", "deploy", "--image", image, "--version", record.mergedSha.slice(0, 7), "--skip-dns-check"],
{ cwd: tree, timeoutMs: 900_000 },
Expand Down Expand Up @@ -1005,6 +1061,8 @@ export async function executeDeliveryRollback(
now?: () => string;
/** The live governance/account stores for the requester recheck. */
authority?: ApproveAuthoritySources;
/** S27 opt-in: roll back through a DeploymentAdapter instead of the inline commands. Absent = legacy. */
adapter?: DeliveryAdapterFactory;
},
): Promise<NonNullable<DeliveryRecord["rollback"]>> {
const now = options.now?.() ?? new Date().toISOString();
Expand All @@ -1024,6 +1082,9 @@ export async function executeDeliveryRollback(
if (recheck.outcome === "hold") {
return failed(`rollback requester recheck: ${recheck.reason}`);
}
if (options.adapter !== undefined) {
return rollbackViaAdapter(record, options.dir, options.adapter, now, failed);
}
const before = await options.run(["teploy", "status", "--json"], { cwd: options.dir, timeoutMs: 120_000 });
if (before.code !== 0) {
return failed(`the target could not be read before rollback (exit ${before.code}): ${(before.stderr || before.stdout).slice(0, 300)} — refusing to act on an unread target`);
Expand Down Expand Up @@ -1078,6 +1139,47 @@ export async function executeDeliveryRollback(
}
}

/**
* The adapter-path rollback: the same refusals as the inline path (an unread
* target, a target that no longer runs THIS delivery, an already-recovered
* target), decided by runRecovery over the adapter's own readback.
*/
async function rollbackViaAdapter(
record: DeliveryRecord,
dir: string,
factory: DeliveryAdapterFactory,
now: string,
failed: (evidence: string) => NonNullable<DeliveryRecord["rollback"]>,
): Promise<NonNullable<DeliveryRecord["rollback"]>> {
const destination = record.destination ?? "";
if (destination === "" || record.mergedSha === undefined || record.artifactDigest === undefined) {
return failed("the deployment adapter path needs the delivery's destination, merged SHA and artifact digest, and one is not recorded");
}
const made = await factory.create({ deployDir: dir, destination });
if ("unavailable" in made) return failed(`deployment adapter unavailable: ${made.unavailable}`);
const recoveryVersion = record.recoveryVersion!;
const delivered = { service: made.service, destination, artifact: record.artifactDigest, revision: record.mergedSha.slice(0, 7) };
const done = (evidence: string): NonNullable<DeliveryRecord["rollback"]> => ({
...(record.rollback ?? { actor: "", reason: "", requestedAt: now }),
state: "done",
finishedAt: now,
evidence: evidence.slice(0, 500),
});
const read = await made.adapter.readback(delivered);
if (read.kind === "ok" && read.value.serving && read.value.revision === recoveryVersion) {
return done(`target read back: retained version ${recoveryVersion} serving (already — no rollback command was needed)`);
}
const plan = await makeRecoveryPlan(made.adapter, delivered, "rollback-to-version", { revision: recoveryVersion }, () => new Date(now));
if (plan.kind !== "ok") return failed(`rollback plan: ${plan.reason}`);
const actor = record.rollback?.actor ?? "";
const result = await runRecovery(made.adapter, {
plan: plan.value,
authorisation: { kind: "recover", actor, service: made.service, destination, expiresAt: new Date(Date.now() + 30 * 60_000).toISOString() },
});
if (result.outcome === "recovered") return done(`target read back: retained version ${recoveryVersion} serving (${result.reason})`);
return failed(`rollback ${result.outcome}: ${result.reason}`);
}

/**
* A record stuck in `executing` past the staleness window (default 35 min —
* deliberately above the 2×900 s execution ceiling) is a worker that died
Expand Down
6 changes: 6 additions & 0 deletions src/deployment-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export interface AdapterCapabilities {
fencing: readonly FencingMode[];
/** Recovery modes actually supported. Anything else must be refused, never approximated. */
recovery: readonly RecoveryMode[];
/**
* What this adapter knows it cannot honestly provide, stated so a report can
* list it (an adapter that cannot do X must say so, never approximate X).
* Additive and optional: adapters that predate it declare nothing.
*/
unsupported?: readonly { capability: string; reason: string }[];
}

// ------------------------------------------------------------------ authority
Expand Down
139 changes: 139 additions & 0 deletions src/fixtures/fake-teploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env node
// A FAKE `teploy` CLI for teploy-adapter.test.ts. It emulates only the four
// commands the adapter issues (`status --json`, `deploy`, `rollback --to`,
// `exec <server> -- docker image inspect <name>`) over a JSON state file named
// by FAKE_TEPLOY_STATE. Nothing here talks to a network, a docker daemon or a
// real teploy binary.
//
// The status shape is the one captured live from the real CLI on 2026-09-22
// (see delivery.test.ts): top-level `app` and `server`, snake_case
// `state.current_hash`, and containers whose fields are the Go struct's
// capitalised ID/Name/Image/State. The faults a test can arm live in the same
// state file so the test harness can flip them between calls:
// unreadable: "exit" | "garbage" status fails / prints non-JSON
// wrongApp: "<name>" status describes another app
// hideArtifact: true containers report an image ID and `exec`
// cannot resolve it (the artifact is hidden)
// idForm: true containers report an image ID; `exec`
// resolves it through imageTags
// pending: {when, change} a rival change lands at the START of the
// next deploy ("before-deploy") or just
// after it applied ("after-deploy")
// failDeploy: "exit-before" | "exit-after-apply"
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";

const path = process.env.FAKE_TEPLOY_STATE;
if (!path) {
process.stderr.write("fake teploy: FAKE_TEPLOY_STATE is not set\n");
process.exit(2);
}
const load = () => JSON.parse(readFileSync(path, "utf8"));
const save = (s) => writeFileSync(path, JSON.stringify(s, null, 2));
const log = (line) => appendFileSync(`${path}.calls`, `${line}\n`);

const imageFor = (s, image) => {
if (!s.idForm && !s.hideArtifact) return image;
const id = randomBytes(6).toString("hex");
s.imageTags = { ...(s.imageTags ?? {}), [id]: [image] };
return id;
};

/** Make the target serve (hash, image) with a NEW container, as a real redeploy does. */
const land = (s, hash, image) => {
s.current_hash = hash;
s.containers = [{ ID: randomBytes(6).toString("hex"), Name: `${s.app}-web-${hash}`, Image: imageFor(s, image), State: "running" }];
s.releases = [...(s.releases ?? []).filter((r) => r.hash !== hash), { hash, image }];
};

const applyChange = (s, change) => {
if (change.unreadable) s.unreadable = "exit";
else land(s, change.revision, change.artifact);
};

const [cmd, ...args] = process.argv.slice(2);
const flag = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const s = load();
log(`${cmd} ${args.join(" ")}`);

if (cmd === "status") {
if (s.unreadable === "exit") {
process.stderr.write("ssh: connect to host: connection refused\n");
process.exit(1);
}
if (s.unreadable === "garbage") {
process.stdout.write("<html>502 bad gateway</html>");
process.exit(0);
}
process.stdout.write(JSON.stringify({
app: s.wrongApp ?? s.app,
server: s.server,
state: { current_hash: s.current_hash ?? "", schema_version: 2 },
containers: s.containers ?? [],
}));
process.exit(0);
}

if (cmd === "deploy") {
if (s.pending?.when === "before-deploy") {
applyChange(s, s.pending.change);
s.pending = null;
}
if (s.failDeploy === "exit-before") {
save(s);
process.stderr.write("deploy: build cache unavailable\n");
process.exit(1);
}
const image = flag("--image");
const version = flag("--version");
if (!image || !version) {
save(s);
process.stderr.write("deploy: --image and --version are required\n");
process.exit(2);
}
// A real deploy takes seconds; the pause is what lets a test see two
// overlapping deploys if the adapter's caller failed to serialise them.
await new Promise((r) => setTimeout(r, 25));
land(s, version, image);
if (s.pending?.when === "after-deploy") {
applyChange(s, s.pending.change);
s.pending = null;
}
save(s);
if (s.failDeploy === "exit-after-apply") {
process.stderr.write("deploy: timed out waiting for health check\n");
process.exit(1);
}
process.stdout.write("Deployed\n");
process.exit(0);
}

if (cmd === "rollback") {
const to = flag("--to");
const release = (s.releases ?? []).find((r) => r.hash === to);
if (!release) {
process.stderr.write(`rollback: version ${to} is not retained\n`);
process.exit(1);
}
land(s, release.hash, release.image);
save(s);
process.stdout.write(`Rolled back to ${to}\n`);
process.exit(0);
}

if (cmd === "exec") {
// exec <server> -- docker image inspect <name>
const name = args[args.length - 1];
if (s.hideArtifact || s.unreadable === "exit") {
process.stderr.write("exec: ssh: connection refused\n");
process.exit(1);
}
process.stdout.write(JSON.stringify([{ Id: `sha256:${name}`, RepoTags: (s.imageTags ?? {})[name] ?? [] }]));
process.exit(0);
}

process.stderr.write(`fake teploy: unsupported command ${cmd}\n`);
process.exit(2);
Loading
Loading