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 {
|
|
|
|
|
type Executor,
|
2026-07-14 22:22:30 +00:00
|
|
|
KIND_ORDER,
|
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
|
|
|
applyHostnameOverlay,
|
|
|
|
|
applyPlan,
|
|
|
|
|
} from "../src/apply.js";
|
2026-07-14 22:22:30 +00:00
|
|
|
import { type Desired, type Live, computeDiff } from "../src/diff.js";
|
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
|
|
|
|
|
|
|
|
const desired: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
|
|
|
|
|
env: { vars: { PORT: { value: "3000", secret: false } } },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-07-14 22:22:30 +00:00
|
|
|
function recorder(uuidFor: (name: string) => string = () => "new-uuid") {
|
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
|
|
|
const calls: string[] = [];
|
|
|
|
|
const exec: Executor = {
|
|
|
|
|
createResource: async (c) => {
|
|
|
|
|
calls.push(`create ${c.name}`);
|
2026-07-14 22:22:30 +00:00
|
|
|
return uuidFor(c.name);
|
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
|
|
|
},
|
|
|
|
|
updateFields: async (uuid, _k, fields) => {
|
|
|
|
|
calls.push(`update ${uuid} ${Object.keys(fields).join(",")}`);
|
|
|
|
|
},
|
|
|
|
|
syncEnv: async (uuid) => {
|
|
|
|
|
calls.push(`env ${uuid}`);
|
|
|
|
|
},
|
|
|
|
|
redeploy: async (uuid) => {
|
|
|
|
|
calls.push(`redeploy ${uuid}`);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return { calls, exec };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("applyPlan", () => {
|
|
|
|
|
it("creates, syncs env, then redeploys", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const r = await applyPlan(computeDiff(desired, [], "full"), desired, exec);
|
|
|
|
|
expect(calls).toEqual([
|
|
|
|
|
"create core-api",
|
|
|
|
|
"env new-uuid",
|
|
|
|
|
"redeploy new-uuid",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.mutated).toEqual(["core-api"]);
|
|
|
|
|
});
|
|
|
|
|
it("refuses a structural report before any mutation", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
await expect(
|
|
|
|
|
applyPlan(computeDiff(desired, [], "structural"), desired, exec),
|
|
|
|
|
).rejects.toThrow(/full diff/);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("refuses non-updatable drift before any mutation, naming the field", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { build_pack: "static", domains: ["https://api.example.com"] },
|
|
|
|
|
env: { PORT: "3000" },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
await expect(
|
|
|
|
|
applyPlan(computeDiff(desired, live, "full"), desired, exec),
|
|
|
|
|
).rejects.toThrow(/build_pack.*core-api|core-api.*build_pack/s);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("does nothing on a clean report", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
domains: ["https://api.example.com"],
|
|
|
|
|
},
|
|
|
|
|
env: { PORT: "3000" },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const r = await applyPlan(
|
|
|
|
|
computeDiff(desired, live, "full"),
|
|
|
|
|
desired,
|
|
|
|
|
exec,
|
|
|
|
|
);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
expect(r.mutated).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("does nothing when the only drift is a remove-candidate env var", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
domains: ["https://api.example.com"],
|
|
|
|
|
},
|
|
|
|
|
env: { PORT: "3000", LEGACY_VAR: "keep-me" },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(desired, live, "full");
|
|
|
|
|
expect(report.changes).toEqual([
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
op: "update",
|
|
|
|
|
fieldDiffs: [],
|
|
|
|
|
envDiffs: [
|
|
|
|
|
{ key: "LEGACY_VAR", state: "remove-candidate", secret: false },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
const r = await applyPlan(report, desired, exec);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
expect(r.mutated).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-14 22:22:30 +00:00
|
|
|
// The order resolve.ts actually emits (`desiredFromManifest`: applications,
|
|
|
|
|
// then databases, then services) and `computeDiff` faithfully preserves. This
|
|
|
|
|
// is the input that used to build and deploy `core` against nothing.
|
|
|
|
|
const manifestOrder: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
fields: { build_pack: "dockercompose" },
|
|
|
|
|
env: { vars: { DATABASE_URL: { value: "postgres://x", secret: true } } },
|
|
|
|
|
},
|
|
|
|
|
{ kind: "database", name: "postgres", fields: { type: "postgresql" } },
|
|
|
|
|
{ kind: "database", name: "redis", fields: { type: "redis" } },
|
|
|
|
|
{ kind: "service", name: "metabase", fields: { type: "metabase" } },
|
|
|
|
|
];
|
|
|
|
|
const named = (name: string) => `${name}-uuid`;
|
|
|
|
|
|
|
|
|
|
describe("applyPlan ordering (#45)", () => {
|
|
|
|
|
it("creates databases, then services, then applications — never the manifest's order", async () => {
|
|
|
|
|
const { calls, exec } = recorder(named);
|
|
|
|
|
const report = computeDiff(manifestOrder, [], "full");
|
|
|
|
|
// The report itself reads in manifest order: application first.
|
|
|
|
|
expect(report.changes.map((c) => c.name)).toEqual([
|
|
|
|
|
"core",
|
|
|
|
|
"postgres",
|
|
|
|
|
"redis",
|
|
|
|
|
"metabase",
|
|
|
|
|
]);
|
|
|
|
|
const r = await applyPlan(report, manifestOrder, exec);
|
|
|
|
|
// …and apply ACTS in dependency order. `core` is created and deployed last,
|
|
|
|
|
// by which point both databases and the service exist. Within a kind the
|
|
|
|
|
// manifest's order survives (postgres before redis) — the sort is stable.
|
|
|
|
|
expect(calls).toEqual([
|
|
|
|
|
"create postgres",
|
|
|
|
|
"redeploy postgres-uuid",
|
|
|
|
|
"create redis",
|
|
|
|
|
"redeploy redis-uuid",
|
|
|
|
|
"create metabase",
|
|
|
|
|
"redeploy metabase-uuid",
|
|
|
|
|
"create core",
|
|
|
|
|
"env core-uuid",
|
|
|
|
|
"redeploy core-uuid",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.mutated).toEqual(["postgres", "redis", "metabase", "core"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("orders updates too, not only creates", async () => {
|
|
|
|
|
// The apply that adds a Redis and points an existing app at it: the
|
|
|
|
|
// database must be created and started before the app redeploys onto it.
|
|
|
|
|
const { calls, exec } = recorder(named);
|
|
|
|
|
const withRedis: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
fields: { build_pack: "dockercompose" },
|
|
|
|
|
env: {
|
|
|
|
|
vars: { REDIS_URL: { value: "redis://redis:6379", secret: false } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ kind: "database", name: "redis", fields: { type: "redis" } },
|
|
|
|
|
];
|
|
|
|
|
const live: Live[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
uuid: "u-core",
|
|
|
|
|
fields: { build_pack: "dockercompose" },
|
|
|
|
|
env: {},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const r = await applyPlan(
|
|
|
|
|
computeDiff(withRedis, live, "full"),
|
|
|
|
|
withRedis,
|
|
|
|
|
exec,
|
|
|
|
|
);
|
|
|
|
|
expect(calls).toEqual([
|
|
|
|
|
"create redis",
|
|
|
|
|
"redeploy redis-uuid",
|
|
|
|
|
"env u-core",
|
|
|
|
|
"redeploy u-core",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.mutated).toEqual(["redis", "core"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still refuses non-updatable drift before ANY mutation, even one that now sorts first", async () => {
|
|
|
|
|
// The regression the reorder could have introduced: the database sorts
|
|
|
|
|
// ahead of the application, so a check folded into the ordered walk would
|
|
|
|
|
// create postgres and only then refuse. The refusal is a full scan first.
|
|
|
|
|
const { calls, exec } = recorder(named);
|
|
|
|
|
const live: Live[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
uuid: "u-core",
|
|
|
|
|
fields: { build_pack: "nixpacks" }, // NON_UPDATABLE drift
|
|
|
|
|
env: { DATABASE_URL: "postgres://x" },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
await expect(
|
|
|
|
|
applyPlan(computeDiff(manifestOrder, live, "full"), manifestOrder, exec),
|
|
|
|
|
).rejects.toThrow(/build_pack/);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not reorder the report itself — the diff reads in manifest order", async () => {
|
|
|
|
|
// renderDiff and the fleet summary read `report.changes`; sorting it in
|
|
|
|
|
// place would silently reshuffle what the operator sees.
|
|
|
|
|
const { exec } = recorder(named);
|
|
|
|
|
const report = computeDiff(manifestOrder, [], "full");
|
|
|
|
|
await applyPlan(report, manifestOrder, exec);
|
|
|
|
|
expect(report.changes.map((c) => c.name)).toEqual([
|
|
|
|
|
"core",
|
|
|
|
|
"postgres",
|
|
|
|
|
"redis",
|
|
|
|
|
"metabase",
|
|
|
|
|
]);
|
|
|
|
|
// and the rest of the report is untouched by ordering
|
|
|
|
|
expect(report.clean).toBe(false);
|
|
|
|
|
expect(report.orphans).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("exports the forward kind-order, whose reverse is the teardown order", () => {
|
|
|
|
|
expect(KIND_ORDER).toEqual(["database", "service", "application"]);
|
|
|
|
|
// `cast destroy` (#43) is the exact reverse — up in dependency order, down
|
|
|
|
|
// in reverse. It defines its own constant today; a follow-up unifies them.
|
|
|
|
|
expect([...KIND_ORDER].reverse()).toEqual([
|
|
|
|
|
"application",
|
|
|
|
|
"service",
|
|
|
|
|
"database",
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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("applyHostnameOverlay", () => {
|
|
|
|
|
it("replaces only domains of named apps", () => {
|
|
|
|
|
const out = applyHostnameOverlay(desired, {
|
|
|
|
|
"core-api": ["http://tmp.example.net"],
|
|
|
|
|
});
|
|
|
|
|
expect(out[0].fields.domains).toEqual(["http://tmp.example.net"]);
|
|
|
|
|
expect(out[0].fields.build_pack).toBe("nixpacks");
|
|
|
|
|
expect(desired[0].fields.domains).toEqual(["https://api.example.com"]); // input untouched
|
|
|
|
|
});
|
|
|
|
|
it("throws on unknown app names", () => {
|
|
|
|
|
expect(() => applyHostnameOverlay(desired, { nope: ["http://x"] })).toThrow(
|
|
|
|
|
/unknown.*nope/i,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const composeDesired: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: {
|
|
|
|
|
api: ["http://api.<PROD-IP>.sslip.io"],
|
|
|
|
|
landing: ["http://landing.<PROD-IP>.sslip.io"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it("rewrites docker_compose_domains per-service when the overlay value is a map", () => {
|
|
|
|
|
const out = applyHostnameOverlay(composeDesired, {
|
|
|
|
|
core: { api: ["http://api.override.example.net"] },
|
|
|
|
|
});
|
|
|
|
|
expect(out[0].fields.docker_compose_domains).toEqual({
|
|
|
|
|
api: ["http://api.override.example.net"],
|
|
|
|
|
landing: ["http://landing.<PROD-IP>.sslip.io"],
|
|
|
|
|
});
|
|
|
|
|
// input untouched
|
|
|
|
|
expect(composeDesired[0].fields.docker_compose_domains).toEqual({
|
|
|
|
|
api: ["http://api.<PROD-IP>.sslip.io"],
|
|
|
|
|
landing: ["http://landing.<PROD-IP>.sslip.io"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
it("throws on an unknown service key in a map overlay, listing known services", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
applyHostnameOverlay(composeDesired, {
|
|
|
|
|
core: { bogus: ["http://x"] },
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/bogus.*(api|landing)/is);
|
|
|
|
|
});
|
|
|
|
|
it("throws when a map-shaped overlay value names a non-compose app", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
applyHostnameOverlay(desired, {
|
|
|
|
|
"core-api": { api: ["http://x"] },
|
|
|
|
|
}),
|
|
|
|
|
).toThrow(/service map for non-compose app core-api/);
|
|
|
|
|
});
|
|
|
|
|
it("keeps today's behavior for a string[]-shaped entry on a plain app", () => {
|
|
|
|
|
const out = applyHostnameOverlay(desired, {
|
|
|
|
|
"core-api": ["http://plain.example.net"],
|
|
|
|
|
});
|
|
|
|
|
expect(out[0].fields.domains).toEqual(["http://plain.example.net"]);
|
|
|
|
|
});
|
|
|
|
|
});
|