feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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 }
|
2026-07-14 22:20:27 +00:00
|
|
|
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml }
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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");
|
2026-07-14 22:20:27 +00:00
|
|
|
expect(app.build.compose_file).toBe("/docker-compose.yaml");
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
2026-07-14 22:20:27 +00:00
|
|
|
// Coolify 4.1.2 validates docker_compose_location against
|
|
|
|
|
// ValidationPatterns::FILE_PATH_PATTERN on create and 422s a path with no
|
|
|
|
|
// leading slash — after `apply` has already made the project and the
|
|
|
|
|
// environment. The manifest knows this before any API call, so it refuses.
|
|
|
|
|
it("rejects a compose_file with no leading slash, and names the fix", () => {
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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"]
|
2026-07-14 22:20:27 +00:00
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/compose_file must be an absolute path.*\/docker-compose\.yaml/s);
|
|
|
|
|
});
|
|
|
|
|
it("rejects a compose_file that is the bare root (a directory, not a file)", () => {
|
|
|
|
|
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: / }
|
|
|
|
|
service_domains:
|
|
|
|
|
api: ["https://api.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/compose_file must be an absolute path/);
|
|
|
|
|
});
|
|
|
|
|
it("rejects a base_directory with no leading slash", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: apps/core }
|
|
|
|
|
domains: ["https://api.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/base_directory must be an absolute path/);
|
|
|
|
|
});
|
|
|
|
|
it("rejects a publish_directory with no leading slash", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
|
|
|
|
build: { pack: static, base_directory: /, publish_directory: dist }
|
|
|
|
|
domains: ["https://api.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/publish_directory must be an absolute path/);
|
|
|
|
|
});
|
|
|
|
|
// Coolify's DIRECTORY_PATH_PATTERN admits the bare "/" where FILE_PATH_PATTERN
|
|
|
|
|
// does not — every manifest in the wild says `base_directory: /`, so a shared
|
|
|
|
|
// "absolute path" rule that rejected it would refuse every manifest cast has.
|
|
|
|
|
it("accepts / as base_directory and a nested absolute publish_directory", () => {
|
|
|
|
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
|
|
|
|
build: { pack: static, base_directory: /, publish_directory: /apps/web/dist }
|
|
|
|
|
domains: ["https://api.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
const app = m.environments.prod.applications.core;
|
|
|
|
|
expect(app.build.base_directory).toBe("/");
|
|
|
|
|
expect(app.build.publish_directory).toBe("/apps/web/dist");
|
|
|
|
|
});
|
|
|
|
|
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"]
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
// #63: the working (hand-built) config for a static site in a workspace
|
|
|
|
|
// monorepo — is_static plus workspace-scoped install/build commands that build
|
|
|
|
|
// only the target app and serve its dist, instead of the repo root's start
|
|
|
|
|
// script booting a different workspace.
|
|
|
|
|
it("accepts install/build/start commands and static on a non-compose app, round-tripping them", () => {
|
|
|
|
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
landing:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
|
|
|
|
build:
|
|
|
|
|
pack: static
|
|
|
|
|
base_directory: /
|
|
|
|
|
publish_directory: /apps/landing-site/dist
|
|
|
|
|
install_command: npm ci
|
|
|
|
|
build_command: npm run build -w apps/landing-site
|
|
|
|
|
start_command: node server.js
|
|
|
|
|
static: true
|
|
|
|
|
domains: ["https://landing.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
const b = m.environments.prod.applications.landing.build;
|
|
|
|
|
expect(b.install_command).toBe("npm ci");
|
|
|
|
|
expect(b.build_command).toBe("npm run build -w apps/landing-site");
|
|
|
|
|
expect(b.start_command).toBe("node server.js");
|
|
|
|
|
expect(b.static).toBe(true);
|
|
|
|
|
expect(b.publish_directory).toBe("/apps/landing-site/dist");
|
|
|
|
|
});
|
|
|
|
|
it("rejects static: true with nothing to serve (no publish_directory)", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadManifest(`${FIX}manifest.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
project: widget
|
|
|
|
|
environments:
|
|
|
|
|
prod:
|
|
|
|
|
applications:
|
|
|
|
|
landing:
|
|
|
|
|
source: { repo: acme/widget, branch: main }
|
|
|
|
|
build: { pack: static, base_directory: /, static: true }
|
|
|
|
|
domains: ["https://landing.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/nothing to serve/);
|
|
|
|
|
});
|
|
|
|
|
it("rejects install/build/start commands and static on a dockercompose app", () => {
|
|
|
|
|
for (const field of [
|
|
|
|
|
"install_command: npm ci",
|
|
|
|
|
"build_command: npm run build",
|
|
|
|
|
"start_command: node server.js",
|
|
|
|
|
"static: true",
|
|
|
|
|
]) {
|
|
|
|
|
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, ${field} }
|
|
|
|
|
service_domains:
|
|
|
|
|
api: ["https://api.example.com"]
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/not allowed on a dockercompose app/);
|
|
|
|
|
}
|
|
|
|
|
});
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
feat: assert the token's team before touching Coolify (fail-closed)
Coolify API tokens are team-scoped, and a wrong-team token does not error:
the API resolves what it cannot see to `null` (getResourceByUuid walks
resource → environment → project → team_id and returns null on a mismatch).
To cast, `null` is indistinguishable from "this resource does not exist
yet" — an invitation to create it. So an apply with a token minted under the
wrong team would not fail loudly; it would provision a duplicate set of
resources into the wrong team, against whatever server that team owns.
Silent, mutating, discovered late. That makes this a correctness bug, not
hardening.
- environments.yaml carries a required `team:` per environment (id, name, or
both). Required is the point: an environment with no declared team is one
cast cannot verify it is pointed at.
- Every command that reaches a live Coolify (apply, diff, server add, smoke)
resolves GET /teams/current — the only endpoint that answers "what team
does this token act as?" — and aborts on mismatch before its first READ,
not merely its first write: a wrong-team diff reports "everything is
absent", which is the very lie an apply would then act on.
- server add and smoke take --env for this reason. A server belongs to
exactly one team forever (no pivot, no is_system_wide escape hatch), and
smoke writes env vars onto a live app.
- New read-only `cast team` prints the token's team, so the binding can be
filled in without a chicken-and-egg. With --env it also checks the
binding: the dry run for "would apply refuse?".
Team id 0 is a first-class value, not a falsy absent — it is the Root Team
that a single-admin instance keeps everything in (app/Models/User.php).
Also records the #4 investigation in docs/semantics.md: GithubApp
`is_system_wide` IS the supported way to serve every team — list_github_apps
scopes to `team_id = token's team OR is_system_wide`, and POST /github-apps
accepts the flag — so per-team App duplication is unnecessary. Corollary:
resolving a GitHub App by name is NOT a proxy for being in the right team,
which is the second reason the assert has to be explicit.
Closes #9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:55:04 +00:00
|
|
|
it("carries an environment's expected team through", () => {
|
|
|
|
|
const b = loadBindings(`${FIX}environments.yaml`);
|
|
|
|
|
expect(b.environments.prod.team).toEqual({ id: 1, name: "heavy-duty" });
|
|
|
|
|
});
|
|
|
|
|
// Fail-closed at the schema: an environment with no declared team is one
|
|
|
|
|
// whose token cannot be verified, and an unverifiable target is exactly the
|
|
|
|
|
// duplicate-into-the-wrong-team failure the binding exists to prevent.
|
|
|
|
|
it("rejects an environment with no team", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadBindings(`${FIX}environments.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
environments:
|
|
|
|
|
prod: { server: prod-box }
|
|
|
|
|
github_apps: { widget: my-github-app }
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/team/);
|
|
|
|
|
});
|
|
|
|
|
it("rejects a team that names neither id nor name", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
loadBindings(`${FIX}environments.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
environments:
|
|
|
|
|
prod: { server: prod-box, team: {} }
|
|
|
|
|
github_apps: { widget: my-github-app }
|
|
|
|
|
`,
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/at least one of/);
|
|
|
|
|
});
|
|
|
|
|
// Coolify's Root Team is id 0 (app/Models/User.php @ v4.1.2) — the team a
|
|
|
|
|
// single-admin instance keeps everything in. Rejecting it would make the id
|
|
|
|
|
// check unusable on exactly the topology that most needs it.
|
|
|
|
|
it("accepts team id 0, the Root Team", () => {
|
|
|
|
|
const b = loadBindings(`${FIX}environments.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
environments:
|
|
|
|
|
prod: { server: prod-box, team: { id: 0, name: Root Team } }
|
|
|
|
|
github_apps: { widget: my-github-app }
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
expect(b.environments.prod.team).toEqual({ id: 0, name: "Root Team" });
|
|
|
|
|
});
|
|
|
|
|
it("accepts a team given by id alone, or by name alone", () => {
|
|
|
|
|
const byId = loadBindings(`${FIX}environments.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
environments:
|
|
|
|
|
prod: { server: prod-box, team: { id: 2 } }
|
|
|
|
|
github_apps: { widget: my-github-app }
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
expect(byId.environments.prod.team).toEqual({ id: 2 });
|
|
|
|
|
const byName = loadBindings(`${FIX}environments.yaml`, {
|
|
|
|
|
overrideText: `
|
|
|
|
|
environments:
|
|
|
|
|
prod: { server: prod-box, team: { name: heavy-duty } }
|
|
|
|
|
github_apps: { widget: my-github-app }
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
expect(byName.environments.prod.team).toEqual({ name: "heavy-duty" });
|
|
|
|
|
});
|
feat: cast — the Coolify executor, extracted from the infra state repo
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>
2026-07-11 12:25:44 +00:00
|
|
|
});
|