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,
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
completeBasicAuth,
|
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
|
|
|
} from "../src/apply.js";
|
fix(apply): refuse to write the generated-secret placeholder over a live value
The bootstrap is two-pass and only the first pass was ever safe to repeat.
The store holds `pending-coolify-generated` for a provider-generated secret;
the first apply sends it, Coolify creates the Postgres/Redis and replaces it
with the real URL. From that moment the store is known-wrong — and `diff` and
`apply` had never heard of the literal cast itself invented to say so.
`diff` printed `secret DATABASE_URL differs`, which is word for word what a
legitimate rotation prints, and `apply` stood ready to PATCH the placeholder
back over the live URL and redeploy every consumer onto it. Coolify's bulk env
endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found
and its value overwritten), so nothing on the far side stopped it either.
- diffEnv gives the placeholder its own state, `placeholder-conflict`, when the
store holds it and the live resource holds anything else. Live-also-
placeholder, absent live, and the create path are unchanged.
- renderDiff says it in words no rotation prints, and counts it in the summary.
- applyPlan REFUSES on it, before any resource is touched — same fail-closed
shape as the not-updatable refusal. The message names the key and the
resource, never the live value, and points at the remedy (#48).
Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that
list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var
key (DATABASE_URL). Matching the list against these keys would have sailed past
the very case that motivated the issue.
Closes #47.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
|
|
|
import { GENERATED_PLACEHOLDER } from "../src/capture.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
|
|
|
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
// Wrap plain live values as Coolify's {value, realValue} pairs (here the two
|
|
|
|
|
// agree); computeDiff reads them per LiveEnvVar. See diffEnv / #78.
|
|
|
|
|
const liveEnv = (
|
|
|
|
|
m: Record<string, string>,
|
|
|
|
|
): Record<string, { value: string }> =>
|
|
|
|
|
Object.fromEntries(Object.entries(m).map(([k, v]) => [k, { value: v }]));
|
|
|
|
|
|
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"] },
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv({ PORT: "3000" }),
|
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
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
await expect(
|
|
|
|
|
applyPlan(computeDiff(desired, live, "full"), desired, exec),
|
|
|
|
|
).rejects.toThrow(/build_pack.*core-api|core-api.*build_pack/s);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
fix(apply): refuse to write the generated-secret placeholder over a live value
The bootstrap is two-pass and only the first pass was ever safe to repeat.
The store holds `pending-coolify-generated` for a provider-generated secret;
the first apply sends it, Coolify creates the Postgres/Redis and replaces it
with the real URL. From that moment the store is known-wrong — and `diff` and
`apply` had never heard of the literal cast itself invented to say so.
`diff` printed `secret DATABASE_URL differs`, which is word for word what a
legitimate rotation prints, and `apply` stood ready to PATCH the placeholder
back over the live URL and redeploy every consumer onto it. Coolify's bulk env
endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found
and its value overwritten), so nothing on the far side stopped it either.
- diffEnv gives the placeholder its own state, `placeholder-conflict`, when the
store holds it and the live resource holds anything else. Live-also-
placeholder, absent live, and the create path are unchanged.
- renderDiff says it in words no rotation prints, and counts it in the summary.
- applyPlan REFUSES on it, before any resource is touched — same fail-closed
shape as the not-updatable refusal. The message names the key and the
resource, never the live value, and points at the remedy (#48).
Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that
list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var
key (DATABASE_URL). Matching the list against these keys would have sailed past
the very case that motivated the issue.
Closes #47.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
|
|
|
// #47 — the whole product of the guard. Every assertion here is about a
|
|
|
|
|
// routine `cast apply` against a box that has ALREADY been applied once: the
|
|
|
|
|
// store still holds `pending-coolify-generated` for the secrets Coolify was
|
|
|
|
|
// asked to generate, and Coolify has since generated them.
|
|
|
|
|
describe("generated-secret placeholder", () => {
|
|
|
|
|
const REAL_URL = "postgres://real:hunter2@db:5432/app";
|
|
|
|
|
const REAL_REDIS = "redis://real:hunter2@redis:6379";
|
|
|
|
|
const generated: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: { build_pack: "nixpacks" },
|
|
|
|
|
env: {
|
|
|
|
|
vars: {
|
|
|
|
|
DATABASE_URL: { value: GENERATED_PLACEHOLDER, secret: true },
|
|
|
|
|
REDIS_URL: { value: GENERATED_PLACEHOLDER, secret: true },
|
|
|
|
|
PORT: { value: "3000", secret: false },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const liveApp = (env: Record<string, string>) => [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { build_pack: "nixpacks" },
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv(env),
|
fix(apply): refuse to write the generated-secret placeholder over a live value
The bootstrap is two-pass and only the first pass was ever safe to repeat.
The store holds `pending-coolify-generated` for a provider-generated secret;
the first apply sends it, Coolify creates the Postgres/Redis and replaces it
with the real URL. From that moment the store is known-wrong — and `diff` and
`apply` had never heard of the literal cast itself invented to say so.
`diff` printed `secret DATABASE_URL differs`, which is word for word what a
legitimate rotation prints, and `apply` stood ready to PATCH the placeholder
back over the live URL and redeploy every consumer onto it. Coolify's bulk env
endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found
and its value overwritten), so nothing on the far side stopped it either.
- diffEnv gives the placeholder its own state, `placeholder-conflict`, when the
store holds it and the live resource holds anything else. Live-also-
placeholder, absent live, and the create path are unchanged.
- renderDiff says it in words no rotation prints, and counts it in the summary.
- applyPlan REFUSES on it, before any resource is touched — same fail-closed
shape as the not-updatable refusal. The message names the key and the
resource, never the live value, and points at the remedy (#48).
Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that
list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var
key (DATABASE_URL). Matching the list against these keys would have sailed past
the very case that motivated the issue.
Closes #47.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it("REFUSES before any mutation — no syncEnv, no redeploy, nothing touched", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
generated,
|
|
|
|
|
liveApp({
|
|
|
|
|
DATABASE_URL: REAL_URL,
|
|
|
|
|
REDIS_URL: REAL_REDIS,
|
|
|
|
|
PORT: "3000",
|
|
|
|
|
}),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
await expect(applyPlan(report, generated, exec)).rejects.toThrow(
|
|
|
|
|
/refusing apply/,
|
|
|
|
|
);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("names every conflicted key and its resource", async () => {
|
|
|
|
|
const { exec } = recorder();
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
generated,
|
|
|
|
|
liveApp({ DATABASE_URL: REAL_URL, REDIS_URL: REAL_REDIS }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
const err = await applyPlan(report, generated, exec).catch(
|
|
|
|
|
(e: Error) => e.message,
|
|
|
|
|
);
|
|
|
|
|
expect(err).toContain("DATABASE_URL on application core-api");
|
|
|
|
|
expect(err).toContain("REDIS_URL on application core-api");
|
|
|
|
|
expect(err).toContain(GENERATED_PLACEHOLDER);
|
|
|
|
|
});
|
|
|
|
|
it("never prints the live value it is protecting", async () => {
|
|
|
|
|
const { exec } = recorder();
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
generated,
|
|
|
|
|
liveApp({ DATABASE_URL: REAL_URL, REDIS_URL: REAL_REDIS }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
const err = await applyPlan(report, generated, exec).catch(
|
|
|
|
|
(e: Error) => e.message,
|
|
|
|
|
);
|
|
|
|
|
expect(err).not.toContain(REAL_URL);
|
|
|
|
|
expect(err).not.toContain(REAL_REDIS);
|
|
|
|
|
expect(err).not.toContain("hunter2");
|
|
|
|
|
});
|
|
|
|
|
it("tells the operator what to do about it (#48)", async () => {
|
|
|
|
|
const { exec } = recorder();
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
generated,
|
|
|
|
|
liveApp({ DATABASE_URL: REAL_URL, REDIS_URL: REAL_REDIS }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
const err = await applyPlan(report, generated, exec).catch(
|
|
|
|
|
(e: Error) => e.message,
|
|
|
|
|
);
|
|
|
|
|
expect(err).toContain("cast capture --generated-only");
|
|
|
|
|
expect(err).toContain("#48");
|
|
|
|
|
expect(err).toContain("generated_secrets:");
|
|
|
|
|
});
|
|
|
|
|
it("refuses even when the conflict rides along with legitimate drift", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const withDomain: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
...generated[0],
|
|
|
|
|
fields: { build_pack: "nixpacks", domains: ["https://new.example"] },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
withDomain,
|
|
|
|
|
liveApp({
|
|
|
|
|
DATABASE_URL: REAL_URL,
|
|
|
|
|
REDIS_URL: REAL_REDIS,
|
|
|
|
|
PORT: "3000",
|
|
|
|
|
}),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
await expect(applyPlan(report, withDomain, exec)).rejects.toThrow(
|
|
|
|
|
/refusing apply/,
|
|
|
|
|
);
|
|
|
|
|
// The updatable field drift is real and would otherwise have been applied.
|
|
|
|
|
// The refusal is not a filter: nothing at all goes out.
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("refuses on a conflict carried by a SECOND resource, after a clean first one", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const two: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "web",
|
|
|
|
|
fields: { build_pack: "nixpacks" },
|
|
|
|
|
env: { vars: { PORT: { value: "3000", secret: false } } },
|
|
|
|
|
},
|
|
|
|
|
...generated,
|
|
|
|
|
];
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "web",
|
|
|
|
|
uuid: "u0",
|
|
|
|
|
fields: { build_pack: "static" },
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv({ PORT: "3000" }),
|
fix(apply): refuse to write the generated-secret placeholder over a live value
The bootstrap is two-pass and only the first pass was ever safe to repeat.
The store holds `pending-coolify-generated` for a provider-generated secret;
the first apply sends it, Coolify creates the Postgres/Redis and replaces it
with the real URL. From that moment the store is known-wrong — and `diff` and
`apply` had never heard of the literal cast itself invented to say so.
`diff` printed `secret DATABASE_URL differs`, which is word for word what a
legitimate rotation prints, and `apply` stood ready to PATCH the placeholder
back over the live URL and redeploy every consumer onto it. Coolify's bulk env
endpoint is a plain upsert (create_bulk_envs, v4.1.2: an existing key is found
and its value overwritten), so nothing on the far side stopped it either.
- diffEnv gives the placeholder its own state, `placeholder-conflict`, when the
store holds it and the live resource holds anything else. Live-also-
placeholder, absent live, and the create path are unchanged.
- renderDiff says it in words no rotation prints, and counts it in the summary.
- applyPlan REFUSES on it, before any resource is touched — same fail-closed
shape as the not-updatable refusal. The message names the key and the
resource, never the live value, and points at the remedy (#48).
Keyed on the store's VALUE, not the manifest's `generated_secrets:` list: that
list names store refs (DATABASE_URL_PROD) while an env diff is keyed by env var
key (DATABASE_URL). Matching the list against these keys would have sailed past
the very case that motivated the issue.
Closes #47.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:09 +00:00
|
|
|
},
|
|
|
|
|
...liveApp({ DATABASE_URL: REAL_URL, REDIS_URL: REAL_REDIS }),
|
|
|
|
|
];
|
|
|
|
|
// `web` also carries non-updatable drift, so if the refusals were ordered
|
|
|
|
|
// the other way this would throw for the wrong reason — the data-loss
|
|
|
|
|
// write is the one an operator must be told about first.
|
|
|
|
|
const report = computeDiff(two, live, "full");
|
|
|
|
|
await expect(applyPlan(report, two, exec)).rejects.toThrow(
|
|
|
|
|
/refusing apply.*DATABASE_URL/s,
|
|
|
|
|
);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
// The FIRST apply, which must keep working: the placeholder is what cast is
|
|
|
|
|
// supposed to send, because Coolify replaces it when it creates the resource.
|
|
|
|
|
it("still sends the placeholder on a create", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const r = await applyPlan(
|
|
|
|
|
computeDiff(generated, [], "full"),
|
|
|
|
|
generated,
|
|
|
|
|
exec,
|
|
|
|
|
);
|
|
|
|
|
expect(calls).toEqual([
|
|
|
|
|
"create core-api",
|
|
|
|
|
"env new-uuid",
|
|
|
|
|
"redeploy new-uuid",
|
|
|
|
|
]);
|
|
|
|
|
expect(r.mutated).toEqual(["core-api"]);
|
|
|
|
|
});
|
|
|
|
|
// Applied once, resources not yet generated (or generated as the placeholder
|
|
|
|
|
// — same thing to cast). Nothing differs, so there is nothing to refuse.
|
|
|
|
|
it("proceeds when the live value is the placeholder too", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
generated,
|
|
|
|
|
liveApp({
|
|
|
|
|
DATABASE_URL: GENERATED_PLACEHOLDER,
|
|
|
|
|
REDIS_URL: GENERATED_PLACEHOLDER,
|
|
|
|
|
PORT: "3000",
|
|
|
|
|
}),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
const r = await applyPlan(report, generated, exec);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
expect(r.mutated).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
// A var the manifest declares and the live resource has never had: writing
|
|
|
|
|
// the placeholder is the only thing cast can do, and it is what the first
|
|
|
|
|
// apply's second pass needs.
|
|
|
|
|
it("proceeds when the generated var is absent live", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const report = computeDiff(generated, liveApp({ PORT: "3000" }), "full");
|
|
|
|
|
const r = await applyPlan(report, generated, exec);
|
|
|
|
|
expect(calls).toEqual(["env u1", "redeploy u1"]);
|
|
|
|
|
expect(r.mutated).toEqual(["core-api"]);
|
|
|
|
|
});
|
|
|
|
|
// The guard must not turn every secret rotation into a refusal — that is the
|
|
|
|
|
// failure that gets a guard disabled.
|
|
|
|
|
it("still applies an ordinary secret rotation", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const rotated: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: { build_pack: "nixpacks" },
|
|
|
|
|
env: { vars: { MAILGUN_KEY: { value: "mk-NEW", secret: true } } },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(
|
|
|
|
|
rotated,
|
|
|
|
|
liveApp({ MAILGUN_KEY: "mk-OLD" }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
const r = await applyPlan(report, rotated, exec);
|
|
|
|
|
expect(calls).toEqual(["env u1", "redeploy u1"]);
|
|
|
|
|
expect(r.mutated).toEqual(["core-api"]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
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"],
|
|
|
|
|
},
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv({ PORT: "3000" }),
|
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 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"],
|
|
|
|
|
},
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv({ PORT: "3000", LEGACY_VAR: "keep-me" }),
|
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 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
|
fix(diff): compare non-secret env vars against fresh `value`, not stale `real_value` (#78)
`cast diff` re-proposed an env var that was updated in place and is
correct on the box: a flag flipped false→true, applied, and redeployed
still showed `env … : change` on every subsequent diff, while created-once
vars did not. A false drift that never clears also masks real drift.
Root cause: `fetchEnv` collapsed each live var to `real_value ?? value`,
and Coolify leaves `real_value` at the pre-update value after an in-place
PATCH of `value` (a redeploy does not refresh it either). So the diff read
the stale `real_value` and compared "false" against the manifest's "true".
The `real_value ?? value` choice is deliberate for SECRETS — `value` is
masked to a plain token, so `real_value` is the only plaintext to compare —
so the fix is per-var, not a blanket switch. `fetchEnv` now carries both
forms through as `LiveEnvVar {value, realValue}` and `diffEnv` picks per the
desired side's `secret` flag it already knows: `value` for non-secrets
(always fresh), `real_value ?? value` for secrets (unchanged). Capture and
draft, which want the decrypted plaintext and compare against no manifest
literal, keep the old flattening via `flattenEnv`.
Tests: a non-secret flipped in place with stale `realValue` reads clean; a
masked secret still diffs via `realValue` so a genuine rotation is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:53:00 +00:00
|
|
|
env: liveEnv({ DATABASE_URL: "postgres://x" }),
|
2026-07-14 22:22:30 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
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"]);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
|
|
|
|
|
// cast#76. An update body is built from the fields that CHANGED, and basic auth
|
|
|
|
|
// cannot be written that way: Coolify requires both credentials on any write
|
|
|
|
|
// that enables it, and the password never shows up as a change because it is
|
|
|
|
|
// never read back. So the payload is completed from the declared spec — and
|
|
|
|
|
// only when a write was already happening.
|
|
|
|
|
describe("completeBasicAuth", () => {
|
|
|
|
|
const spec: Desired = {
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "admin",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("fills in the credentials when only the toggle drifted", () => {
|
|
|
|
|
expect(
|
|
|
|
|
completeBasicAuth({ is_http_basic_auth_enabled: true }, spec),
|
|
|
|
|
).toEqual({
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fills in the password when only the username drifted", () => {
|
|
|
|
|
expect(
|
|
|
|
|
completeBasicAuth(
|
|
|
|
|
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
|
|
|
|
|
spec,
|
|
|
|
|
).http_basic_auth_password,
|
|
|
|
|
).toBe("s3cret");
|
|
|
|
|
});
|
|
|
|
|
|
fix: complete the basic-auth triple on username-only drift, at both guards
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
2026-07-21 12:43:04 +00:00
|
|
|
// The case the #76 review found, and the one the test above only LOOKED like
|
|
|
|
|
// it covered: that payload carries the toggle, so it never exercised the
|
|
|
|
|
// guard. When basic auth is already on at both ends and only the username is
|
|
|
|
|
// edited in the UI, the toggle MATCHES — so computeDiff emits no fieldDiff
|
|
|
|
|
// for it and the payload arrives as a lone username. The old guard keyed on
|
|
|
|
|
// the toggle being present and returned early, and the PATCH went out
|
|
|
|
|
// incomplete: a 422 mid-run, which is the exact failure this function exists
|
|
|
|
|
// to prevent.
|
|
|
|
|
it("completes the whole triple from a lone username — no toggle in the payload", () => {
|
2026-07-21 12:56:00 +00:00
|
|
|
expect(
|
|
|
|
|
completeBasicAuth({ http_basic_auth_username: "ops" }, spec),
|
|
|
|
|
).toEqual({
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
});
|
fix: complete the basic-auth triple on username-only drift, at both guards
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
2026-07-21 12:43:04 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Same shape, other credential: a stored-password rotation riding along.
|
|
|
|
|
it("completes from a lone password too", () => {
|
|
|
|
|
expect(
|
|
|
|
|
completeBasicAuth({ http_basic_auth_password: "rotated" }, spec),
|
|
|
|
|
).toEqual({
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "rotated",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Intent comes from the SPEC, so a spec that does not enable basic auth must
|
|
|
|
|
// not have credentials completed into its payload — otherwise reading intent
|
|
|
|
|
// from the declaration would trade one silent wrong write for another.
|
|
|
|
|
it("does not complete when the spec does not enable basic auth", () => {
|
|
|
|
|
const off: Desired = {
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "admin",
|
|
|
|
|
fields: { is_http_basic_auth_enabled: false },
|
|
|
|
|
};
|
2026-07-21 12:56:00 +00:00
|
|
|
expect(completeBasicAuth({ http_basic_auth_username: "ops" }, off)).toEqual(
|
|
|
|
|
{
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
},
|
|
|
|
|
);
|
fix: complete the basic-auth triple on username-only drift, at both guards
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
2026-07-21 12:43:04 +00:00
|
|
|
});
|
|
|
|
|
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
it("leaves a payload that is not enabling basic auth completely alone", () => {
|
|
|
|
|
// The load-bearing half: this must not MANUFACTURE a write. A run where
|
|
|
|
|
// nothing about basic auth drifted sends nothing about basic auth.
|
|
|
|
|
expect(completeBasicAuth({ domains: ["https://a"] }, spec)).toEqual({
|
|
|
|
|
domains: ["https://a"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds no credentials to a disable", () => {
|
|
|
|
|
expect(
|
|
|
|
|
completeBasicAuth({ is_http_basic_auth_enabled: false }, spec),
|
|
|
|
|
).toEqual({ is_http_basic_auth_enabled: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not invent values the spec does not carry", () => {
|
|
|
|
|
// Then applicationApiFields refuses at the wire — one clear error, rather
|
|
|
|
|
// than a request Coolify 422s halfway through a run.
|
|
|
|
|
expect(
|
|
|
|
|
completeBasicAuth({ is_http_basic_auth_enabled: true }, undefined),
|
|
|
|
|
).toEqual({ is_http_basic_auth_enabled: true });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The honest limit, asserted rather than described: a password rotated in the
|
|
|
|
|
// store with nothing else changed produces NO write, because there is no field
|
|
|
|
|
// diff to carry it. `cast diff` prints "NOT compared" on that run — the failure
|
|
|
|
|
// is visible, not silent — and this test exists so the day someone makes the
|
|
|
|
|
// password diffable, it goes red and they read the comment.
|
|
|
|
|
describe("applyPlan — a password-only rotation writes nothing (#76)", () => {
|
|
|
|
|
it("makes no call at all when the readable halves agree", async () => {
|
|
|
|
|
const { calls, exec } = recorder();
|
|
|
|
|
const declared: Desired[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "admin",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "the-NEW-password",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const live: Live[] = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "admin",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: {
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
},
|
|
|
|
|
basicAuthNotCompared: "password is never read back",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(declared, live, "full");
|
|
|
|
|
await applyPlan(report, declared, exec);
|
|
|
|
|
expect(calls).toEqual([]);
|
|
|
|
|
// …and the run says so, rather than reading as a verified match.
|
|
|
|
|
expect(report.basicAuthNotCompared).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
});
|