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>
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { loadBindings } from "../src/bindings.js";
|
|
import { loadManifest } from "../src/manifest.js";
|
|
|
|
const FIX = new URL("./fixtures/", import.meta.url).pathname;
|
|
|
|
describe("loadManifest", () => {
|
|
it("parses a valid manifest", () => {
|
|
const m = loadManifest(`${FIX}manifest.yaml`);
|
|
expect(m.project).toBe("widget");
|
|
expect(m.environments.prod.applications["core-api"].build.pack).toBe(
|
|
"nixpacks",
|
|
);
|
|
expect(m.environments.prod.databases?.postgres.backup?.retention).toBe(7);
|
|
});
|
|
it("rejects unknown build packs", () => {
|
|
expect(() =>
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: x
|
|
environments:
|
|
prod:
|
|
applications:
|
|
a:
|
|
source: { repo: o/r, branch: main }
|
|
build: { pack: docker-compose, base_directory: / }
|
|
domains: []
|
|
`,
|
|
}),
|
|
).toThrow(/pack/);
|
|
});
|
|
it("rejects instance identity in manifests (no uuid-like fields)", () => {
|
|
expect(() =>
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: x
|
|
environments:
|
|
prod:
|
|
applications:
|
|
a:
|
|
source: { repo: o/r, branch: main }
|
|
build: { pack: static, base_directory: / }
|
|
domains: []
|
|
server_uuid: abc123
|
|
`,
|
|
}),
|
|
).toThrow(/unrecognized|server_uuid/i);
|
|
});
|
|
it("accepts a dockercompose app with compose_file + service_domains and no port/healthcheck/domains", () => {
|
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: widget
|
|
environments:
|
|
prod:
|
|
applications:
|
|
core:
|
|
source: { repo: acme/widget, branch: main }
|
|
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
|
|
service_domains:
|
|
api: ["https://api.example.com"]
|
|
env_template: core.prod.env.template
|
|
`,
|
|
});
|
|
const app = m.environments.prod.applications.core;
|
|
expect(app.build.pack).toBe("dockercompose");
|
|
expect(app.build.compose_file).toBe("docker-compose.yaml");
|
|
expect(app.service_domains).toEqual({ api: ["https://api.example.com"] });
|
|
expect(app.port).toBeUndefined();
|
|
expect(app.healthcheck).toBeUndefined();
|
|
expect(app.domains).toBeUndefined();
|
|
});
|
|
it("rejects a dockercompose app without compose_file", () => {
|
|
expect(() =>
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: widget
|
|
environments:
|
|
prod:
|
|
applications:
|
|
core:
|
|
source: { repo: acme/widget, branch: main }
|
|
build: { pack: dockercompose, base_directory: / }
|
|
service_domains:
|
|
api: ["https://api.example.com"]
|
|
`,
|
|
}),
|
|
).toThrow(/compose_file/);
|
|
});
|
|
it("rejects a dockercompose app with top-level domains", () => {
|
|
expect(() =>
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: widget
|
|
environments:
|
|
prod:
|
|
applications:
|
|
core:
|
|
source: { repo: acme/widget, branch: main }
|
|
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
|
|
service_domains:
|
|
api: ["https://api.example.com"]
|
|
domains: ["https://api.example.com"]
|
|
`,
|
|
}),
|
|
).toThrow(/domains/);
|
|
});
|
|
it("rejects service_domains on a nixpacks app", () => {
|
|
expect(() =>
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
overrideText: `
|
|
project: widget
|
|
environments:
|
|
prod:
|
|
applications:
|
|
core:
|
|
source: { repo: acme/widget, branch: main }
|
|
build: { pack: nixpacks, base_directory: / }
|
|
domains: ["https://api.example.com"]
|
|
service_domains:
|
|
api: ["https://api.example.com"]
|
|
`,
|
|
}),
|
|
).toThrow(/service_domains/);
|
|
});
|
|
});
|
|
|
|
describe("loadBindings", () => {
|
|
it("parses bindings", () => {
|
|
const b = loadBindings(`${FIX}environments.yaml`);
|
|
expect(b.environments.prod.server).toBe("prod-box");
|
|
expect(b.github_apps.widget).toBe("my-github-app");
|
|
});
|
|
it("carries an environment's forbidden_var_patterns through", () => {
|
|
const b = loadBindings(`${FIX}environments.yaml`);
|
|
expect(b.environments.prod.forbidden_var_patterns).toEqual(["^ALLOW_"]);
|
|
expect(b.environments.staging.forbidden_var_patterns).toBeUndefined();
|
|
});
|
|
});
|