Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
3.5 KiB
TypeScript
90 lines
3.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { CoolifyClient } from "../src/coolify.js";
|
|
import { smoke } from "../src/smoke.js";
|
|
|
|
type EnvVar = {
|
|
key: string;
|
|
value: string;
|
|
is_buildtime: boolean;
|
|
uuid: string;
|
|
};
|
|
|
|
// A stateful fetch mock standing in for Coolify's env-var store, so the
|
|
// bulk-write step's effect on previously-written keys is observable —
|
|
// `bulkMode: "upsert"` mirrors verified Coolify 4.1.2 behavior (see
|
|
// syncEnv's comment in cli.ts); `bulkMode: "replace"` simulates a
|
|
// regression to full-replace that smoke must catch.
|
|
function mockEnvStore(
|
|
appUuid: string,
|
|
bulkMode: "upsert" | "replace",
|
|
): typeof fetch {
|
|
let store: EnvVar[] = [];
|
|
let nextUuid = 1;
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
const method = init?.method ?? "GET";
|
|
const path = new URL(String(url)).pathname;
|
|
const base = `/api/v1/applications/${appUuid}/envs`;
|
|
if (method === "GET" && path === base) {
|
|
return new Response(JSON.stringify(store), { status: 200 });
|
|
}
|
|
if (method === "POST" && path === base) {
|
|
const body = JSON.parse(String(init?.body)) as {
|
|
key: string;
|
|
value: string;
|
|
is_buildtime: boolean;
|
|
};
|
|
const created: EnvVar = { ...body, uuid: `env-${nextUuid++}` };
|
|
store.push(created);
|
|
return new Response(JSON.stringify(created), { status: 200 });
|
|
}
|
|
if (method === "PATCH" && path === `${base}/bulk`) {
|
|
const body = JSON.parse(String(init?.body)) as {
|
|
data: Array<{ key: string; value: string; is_buildtime: boolean }>;
|
|
};
|
|
if (bulkMode === "replace") {
|
|
store = body.data.map((v) => ({ ...v, uuid: `env-${nextUuid++}` }));
|
|
} else {
|
|
for (const v of body.data) {
|
|
const existing = store.find((e) => e.key === v.key);
|
|
if (existing) Object.assign(existing, v);
|
|
else store.push({ ...v, uuid: `env-${nextUuid++}` });
|
|
}
|
|
}
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
}
|
|
if (method === "DELETE" && path.startsWith(`${base}/`)) {
|
|
const uuid = path.slice(`${base}/`.length);
|
|
store = store.filter((e) => e.uuid !== uuid);
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
if (method === "GET" && path === "/api/v1/version") {
|
|
return new Response("4.1.2", { status: 200 });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("smoke", () => {
|
|
it("passes and leaves no residue when bulk env write is a true upsert", async () => {
|
|
const fetchImpl = mockEnvStore("app-1", "upsert");
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
await expect(smoke(client, "app-1")).resolves.toBeUndefined();
|
|
expect(log.mock.calls.at(-1)?.[0]).toMatch(/smoke OK/);
|
|
log.mockRestore();
|
|
// both probe vars cleaned up
|
|
const envsRes = await fetchImpl(
|
|
"https://coolify.test/api/v1/applications/app-1/envs",
|
|
{ method: "GET" },
|
|
);
|
|
expect(await envsRes.json()).toEqual([]);
|
|
});
|
|
|
|
it("fails loudly when the bulk env write is destructive (full-replace regression)", async () => {
|
|
const fetchImpl = mockEnvStore("app-1", "replace");
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
await expect(smoke(client, "app-1")).rejects.toThrow(
|
|
/bulk env write is destructive \(full-replace\) — never-delete broken/,
|
|
);
|
|
});
|
|
});
|