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";
|
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";
|
|
|
|
|
import { computeDiff, placeholderConflicts, renderDiff } 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 — the ordinary case, where nothing was updated in place — so only
|
|
|
|
|
// `value` is set; diffEnv reads it per LiveEnvVar. The stale-`realValue` case
|
|
|
|
|
// (#78) is exercised with explicit pairs in its own test below.
|
|
|
|
|
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 desiredApp = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
|
|
|
|
|
env: {
|
|
|
|
|
vars: {
|
|
|
|
|
PORT: { value: "3000", secret: false },
|
|
|
|
|
MAILGUN_KEY: { value: "mk-123", secret: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe("computeDiff", () => {
|
|
|
|
|
it("plans a create when live is missing", () => {
|
|
|
|
|
const r = computeDiff([desiredApp], [], "full");
|
|
|
|
|
expect(r.changes).toHaveLength(1);
|
|
|
|
|
expect(r.changes[0].op).toBe("create");
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
it("is clean when live matches", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[desiredApp],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
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", MAILGUN_KEY: "mk-123" }),
|
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
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
it("marks build_pack drift as non-updatable", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[desiredApp],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { build_pack: "static", domains: desiredApp.fields.domains },
|
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", MAILGUN_KEY: "mk-123" }),
|
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
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].fieldDiffs).toEqual([
|
|
|
|
|
{
|
|
|
|
|
field: "build_pack",
|
|
|
|
|
desired: "nixpacks",
|
|
|
|
|
live: "static",
|
|
|
|
|
updatable: false,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
it("full mode diffs env; structural mode does not", () => {
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
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", MAILGUN_KEY: "OLD", EXTRA: "x" }),
|
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 full = computeDiff([desiredApp], live, "full");
|
|
|
|
|
expect(full.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "MAILGUN_KEY", state: "change", secret: true },
|
|
|
|
|
{ key: "EXTRA", state: "remove-candidate", secret: false },
|
|
|
|
|
]);
|
|
|
|
|
expect(computeDiff([desiredApp], live, "structural").clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
it("reports orphans, never plans deletion", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[],
|
|
|
|
|
[{ kind: "service", name: "old-thing", uuid: "u9", fields: {} }],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes).toHaveLength(0);
|
|
|
|
|
expect(r.orphans).toEqual([
|
|
|
|
|
{ kind: "service", name: "old-thing", uuid: "u9" },
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
});
|
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
|
|
|
// #78. Coolify leaves a non-secret var's `realValue` at the pre-update value
|
|
|
|
|
// after an in-place PATCH, while `value` is fresh. A flip that landed and
|
|
|
|
|
// redeployed must read clean, not re-propose forever — compare non-secrets
|
|
|
|
|
// against `value`.
|
|
|
|
|
it("does not re-propose a non-secret var flipped in place (stale realValue)", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[desiredApp],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
|
|
|
|
env: {
|
|
|
|
|
PORT: { value: "3000", realValue: "3000" },
|
|
|
|
|
// Manifest wants MAILGUN_KEY = mk-123 (a secret); realValue carries
|
|
|
|
|
// the plaintext and agrees, so the secret stays clean too.
|
|
|
|
|
MAILGUN_KEY: { value: "mk-123", realValue: "mk-123" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes).toHaveLength(0);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
// The same reproduction with the flip the incubator prod cutover hit: manifest
|
|
|
|
|
// "true", live value already "true", but realValue still the stale "false".
|
|
|
|
|
it("reads a flipped-in-place flag clean even when realValue is stale", () => {
|
|
|
|
|
const flag = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: {},
|
|
|
|
|
env: { vars: { REPORTING_ENABLED: { value: "true", secret: false } } },
|
|
|
|
|
};
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[flag],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: {},
|
|
|
|
|
env: { REPORTING_ENABLED: { value: "true", realValue: "false" } },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes).toHaveLength(0);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
// The regression guard the fix must not trip: a SECRET whose `value` is masked
|
|
|
|
|
// to a plain token still compares via `realValue`, so a genuine rotation is
|
|
|
|
|
// still caught (and a masked value is never mistaken for the desired plaintext).
|
|
|
|
|
it("still diffs a secret via realValue when value is masked", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[desiredApp],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
|
|
|
|
env: {
|
|
|
|
|
PORT: { value: "3000" },
|
|
|
|
|
MAILGUN_KEY: { value: "**********", realValue: "mk-OLD" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "MAILGUN_KEY", state: "change", secret: true },
|
|
|
|
|
]);
|
|
|
|
|
});
|
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(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
|
|
|
// The second pass of the two-pass bootstrap (#47). The store holds the literal
|
|
|
|
|
// `pending-coolify-generated` for a provider-generated secret; once the first
|
|
|
|
|
// apply has run, Coolify holds the real one. Every fixture here is that state.
|
|
|
|
|
const REAL_URL = "postgres://real:secret@db:5432/app";
|
|
|
|
|
const generatedApp = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: {},
|
|
|
|
|
env: {
|
|
|
|
|
vars: {
|
|
|
|
|
// The env var KEY. In the real manifest the store REF behind it is
|
|
|
|
|
// `DATABASE_URL_PROD` — the two differ, which is why the guard is keyed
|
|
|
|
|
// on the store's VALUE and not on the `generated_secrets:` name list.
|
|
|
|
|
DATABASE_URL: { value: GENERATED_PLACEHOLDER, secret: true },
|
|
|
|
|
PORT: { value: "3000", secret: false },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const liveGenerated = (env: Record<string, string>) => [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: {},
|
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
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("computeDiff generated-secret placeholder", () => {
|
|
|
|
|
it("flags a placeholder-in-store vs real-value-live as a conflict, not a change", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[generatedApp],
|
|
|
|
|
liveGenerated({ DATABASE_URL: REAL_URL, PORT: "3000" }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "DATABASE_URL", state: "placeholder-conflict", secret: true },
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([
|
|
|
|
|
{ kind: "application", name: "core-api", key: "DATABASE_URL" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
it("is clean when the live value is the placeholder too (first apply landed, resource not created yet)", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[generatedApp],
|
|
|
|
|
liveGenerated({ DATABASE_URL: GENERATED_PLACEHOLDER, PORT: "3000" }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("is a plain add — not a conflict — when the var is absent live (the FIRST apply's path)", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[generatedApp],
|
|
|
|
|
liveGenerated({ PORT: "3000" }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "DATABASE_URL", state: "add", secret: true },
|
|
|
|
|
]);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("is a plain add on a create — Coolify replaces the placeholder when it makes the resource", () => {
|
|
|
|
|
const r = computeDiff([generatedApp], [], "full");
|
|
|
|
|
expect(r.changes[0].op).toBe("create");
|
|
|
|
|
expect(r.changes[0].envDiffs).toContainEqual({
|
|
|
|
|
key: "DATABASE_URL",
|
|
|
|
|
state: "add",
|
|
|
|
|
secret: true,
|
|
|
|
|
});
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("leaves an ordinary secret rotation a plain change", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[desiredApp],
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
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", MAILGUN_KEY: "mk-OLD" }),
|
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
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "MAILGUN_KEY", state: "change", secret: true },
|
|
|
|
|
]);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
// A non-secret template literal that happens to read `pending-coolify-generated`
|
|
|
|
|
// came from the template, not the store — nothing generated it, and writing it
|
|
|
|
|
// is what the manifest asked for.
|
|
|
|
|
it("does not flag a non-secret var whose literal value happens to be the placeholder", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: {},
|
|
|
|
|
env: {
|
|
|
|
|
vars: { NOTE: { value: GENERATED_PLACEHOLDER, secret: false } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
liveGenerated({ NOTE: "something-else" }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].envDiffs).toEqual([
|
|
|
|
|
{ key: "NOTE", state: "change", secret: false },
|
|
|
|
|
]);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
it("is invisible to a structural diff, which reads no env at all", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[generatedApp],
|
|
|
|
|
liveGenerated({ DATABASE_URL: REAL_URL }),
|
|
|
|
|
"structural",
|
|
|
|
|
);
|
|
|
|
|
expect(placeholderConflicts(r)).toEqual([]);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
Add a ${resource:<name>.url} env-template ref that resolves to the internal URL
of a database the same manifest declares, read back from the live resource's
internal_db_url — never stored in the age store, never decrypted, never printed.
This deletes the two-pass generated-secret bootstrap for a database's own URL
rather than automating it: no placeholder, no stored copy to drift or overwrite,
and a rotated password is simply followed on the next apply.
Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff
time against databases already on the box (so a matching app shows no drift —
killing the "secret DATABASE_URL differs" noise that ran on every plan), and in
the executor at apply time against a database created earlier in the same run
(the from-nothing case; apply acts databases-before-applications, #45). The
unresolved sentinel is never written — the executor refuses, rather than write a
blank that boots the app pointed at nothing, and re-running once the database is
up resolves it as an ordinary update.
A ${resource:X.url} naming a database the manifest does not declare, or an
attribute other than .url, is a hard plan-time error refused by every verb that
opens a template (apply, diff, capture). generated_secrets and the two-pass
bootstrap remain for the residual class — a provider-generated value that
genuinely is not derivable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
|
|
|
describe("derived resource refs (#60)", () => {
|
|
|
|
|
const DERIVED_URL = "postgres://u:p@pg-uuid:5432/widget";
|
|
|
|
|
const derivedApp = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
|
|
|
|
|
env: {
|
|
|
|
|
vars: {
|
|
|
|
|
DATABASE_URL: {
|
|
|
|
|
value: DERIVED_URL,
|
|
|
|
|
secret: true,
|
|
|
|
|
derived: { resource: "postgres", attr: "url" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const liveApp = (env: Record<string, string>) => [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...derivedApp.fields },
|
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),
|
feat(resolve): derive DATABASE_URL/REDIS_URL from the database cast created (#60)
Add a ${resource:<name>.url} env-template ref that resolves to the internal URL
of a database the same manifest declares, read back from the live resource's
internal_db_url — never stored in the age store, never decrypted, never printed.
This deletes the two-pass generated-secret bootstrap for a database's own URL
rather than automating it: no placeholder, no stored copy to drift or overwrite,
and a rotated password is simply followed on the next apply.
Resolution runs in one function (fillDerivedEnv) against two URL maps: at diff
time against databases already on the box (so a matching app shows no drift —
killing the "secret DATABASE_URL differs" noise that ran on every plan), and in
the executor at apply time against a database created earlier in the same run
(the from-nothing case; apply acts databases-before-applications, #45). The
unresolved sentinel is never written — the executor refuses, rather than write a
blank that boots the app pointed at nothing, and re-running once the database is
up resolves it as an ordinary update.
A ${resource:X.url} naming a database the manifest does not declare, or an
attribute other than .url, is a hard plan-time error refused by every verb that
opens a template (apply, diff, capture). generated_secrets and the two-pass
bootstrap remain for the residual class — a provider-generated value that
genuinely is not derivable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:26 +00:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it("is clean when the app's live URL already equals the database's — no perpetual drift", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[derivedApp],
|
|
|
|
|
liveApp({ DATABASE_URL: DERIVED_URL }),
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders a drift as derived — not as a secret rotation — and never prints the value", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff(
|
|
|
|
|
[derivedApp],
|
|
|
|
|
liveApp({ DATABASE_URL: "postgres://old" }),
|
|
|
|
|
"full",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toContain(
|
|
|
|
|
"DATABASE_URL: derived from database postgres — live differs, apply will follow it",
|
|
|
|
|
);
|
|
|
|
|
expect(out).not.toContain("secret DATABASE_URL differs");
|
|
|
|
|
expect(out).not.toContain(DERIVED_URL);
|
|
|
|
|
expect(out).not.toContain("postgres://old");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders a create as a derived add", () => {
|
|
|
|
|
const out = renderDiff(computeDiff([derivedApp], [], "full"));
|
|
|
|
|
expect(out).toContain(
|
|
|
|
|
"DATABASE_URL: derived from database postgres — apply will set it",
|
|
|
|
|
);
|
|
|
|
|
expect(out).not.toContain(DERIVED_URL);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
describe("renderDiff generated-secret placeholder", () => {
|
|
|
|
|
it("says it in words no rotation prints, and never prints the live value", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff(
|
|
|
|
|
[generatedApp],
|
|
|
|
|
liveGenerated({ DATABASE_URL: REAL_URL, PORT: "3000" }),
|
|
|
|
|
"full",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toContain(
|
|
|
|
|
"secret DATABASE_URL: store holds the generated-secret PLACEHOLDER, live holds a real value — apply would OVERWRITE it",
|
|
|
|
|
);
|
|
|
|
|
// The old line — the one a rotation prints — must NOT be what this reports.
|
|
|
|
|
expect(out).not.toContain("secret DATABASE_URL differs");
|
|
|
|
|
// Loud in the tail as well: the summary is the line read before typing apply.
|
|
|
|
|
expect(out).toContain(
|
|
|
|
|
"1 generated-secret PLACEHOLDER conflict(s) — apply will REFUSE",
|
|
|
|
|
);
|
|
|
|
|
// Names the key, never the value — capture's rule (a secret printed to a
|
|
|
|
|
// terminal is a secret in a scrollback buffer).
|
|
|
|
|
expect(out).not.toContain(REAL_URL);
|
|
|
|
|
expect(out).not.toContain("real:secret");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
|
|
|
// The destination can never be diffed the way a field is: Coolify 4.1.2 takes
|
|
|
|
|
// destination_uuid on write and returns destination_id on read, with nothing
|
|
|
|
|
// mapping between them. So it is REPORTED rather than compared — and the one
|
|
|
|
|
// thing that IS comparable (a project's live resources against each other)
|
|
|
|
|
// carries the check that matters.
|
|
|
|
|
//
|
|
|
|
|
// Placement is measured against the live side ALONE, so these fixtures pair each
|
|
|
|
|
// live resource with a matching desired one: otherwise every resource is an
|
|
|
|
|
// orphan, and `clean` would be false for reasons that have nothing to do with
|
|
|
|
|
// the destination.
|
|
|
|
|
const want = (name: string) => ({
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name,
|
|
|
|
|
fields: {},
|
|
|
|
|
});
|
|
|
|
|
const got = (name: string, destinationId?: number) => ({
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name,
|
|
|
|
|
uuid: `u-${name}`,
|
|
|
|
|
fields: {},
|
|
|
|
|
destinationId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("computeDiff placement", () => {
|
|
|
|
|
it("is not split when every resource shares one destination", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[want("a"), want("b")],
|
|
|
|
|
[got("a", 3), got("b", 3)],
|
|
|
|
|
"structural",
|
|
|
|
|
);
|
|
|
|
|
expect(r.placement.split).toBe(false);
|
|
|
|
|
expect(r.placement.groups).toEqual([
|
|
|
|
|
{ destinationId: 3, resources: ["application a", "application b"] },
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A project whose resources straddle two networks is a project whose
|
|
|
|
|
// isolation is broken — drift, not silence. Same disposition as an orphan:
|
|
|
|
|
// reported, counted, never repaired.
|
|
|
|
|
it("reports a split project as drift, and is not clean", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[want("a"), want("b")],
|
|
|
|
|
[got("a", 3), got("b", 7)],
|
|
|
|
|
"structural",
|
|
|
|
|
);
|
|
|
|
|
expect(r.placement.split).toBe(true);
|
|
|
|
|
expect(r.placement.groups).toEqual([
|
|
|
|
|
{ destinationId: 3, resources: ["application a"] },
|
|
|
|
|
{ destinationId: 7, resources: ["application b"] },
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
// ...and apply is not offered a way to "fix" it.
|
|
|
|
|
expect(r.changes).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A resource Coolify reports no destination for is no evidence of a split.
|
|
|
|
|
it("ignores resources with no destination rather than grouping them", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[want("a"), want("b")],
|
|
|
|
|
[got("a", 3), got("b")],
|
|
|
|
|
"structural",
|
|
|
|
|
);
|
|
|
|
|
expect(r.placement.split).toBe(false);
|
|
|
|
|
expect(r.placement.groups).toEqual([
|
|
|
|
|
{ destinationId: 3, resources: ["application a"] },
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("carries the declared destination through without comparing it", () => {
|
|
|
|
|
const r = computeDiff([want("a")], [got("a", 3)], "structural", {
|
|
|
|
|
declaredDestination: "dest-abc",
|
|
|
|
|
});
|
|
|
|
|
expect(r.placement.declared).toBe("dest-abc");
|
|
|
|
|
// Declaring one does not make the project dirty — there is nothing to
|
|
|
|
|
// compare it against, and a phantom "update" would never clear.
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
expect(r.changes).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("renderDiff placement", () => {
|
|
|
|
|
it("says out loud that a declared destination was NOT compared", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff([want("a")], [got("a", 3)], "structural", {
|
|
|
|
|
declaredDestination: "dest-abc",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toContain("dest-abc");
|
|
|
|
|
expect(out).toMatch(/NOT compared/);
|
|
|
|
|
expect(out).toMatch(/placement: all resources on destination 3/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The whole reason placement is in the report at all: a destination that read
|
|
|
|
|
// back as absent rather than wrong is the failure shape #12/#14/#17/#18 are
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
// about.
|
|
|
|
|
//
|
|
|
|
|
// This test used to assert the opposite — that an undeclared, unsplit box says
|
|
|
|
|
// NOTHING about placement, on the grounds that a line on every diff is how a
|
|
|
|
|
// report stops being read. #41 reversed it. Declaring nothing is not the absence
|
|
|
|
|
// of a placement decision, it is a placement decision: cast sends no
|
|
|
|
|
// destination_uuid and Coolify picks. Leaving that inference in a source comment
|
|
|
|
|
// is what made it invisible until the day it was wrong — a first apply against a
|
|
|
|
|
// multi-destination server, 400ing after the run had already created the project
|
|
|
|
|
// and the environment. It is a fact about what the next create will do, so it is
|
|
|
|
|
// on screen while it is still true.
|
|
|
|
|
it("says out loud that an undeclared placement is the server's default", () => {
|
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff([want("a")], [got("a", 3)], "structural"),
|
|
|
|
|
);
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
expect(out).toMatch(
|
|
|
|
|
/placement: server's default destination \(none declared\)/,
|
|
|
|
|
);
|
|
|
|
|
// ...and the consequence, which is the only part that can hurt: on a server
|
|
|
|
|
// with more than one destination this is not a default, it is a 400.
|
|
|
|
|
expect(out).toMatch(/refuses the create/);
|
|
|
|
|
// Still clean: an undeclared destination is an assumption, not drift.
|
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
|
|
|
expect(out).toContain("clean");
|
|
|
|
|
});
|
|
|
|
|
|
fix: the first apply against a fresh multi-destination box (#40, #41)
Both of these were found by the same run — the genuinely-from-nothing apply that
#38 was also hiding in, against a box that shares its server with another project.
Neither is a bug in what apply DOES; both are bugs in what it leaves behind and
what it says.
#40 — cast removes the default environment it made Coolify create.
POST /projects hands a new project Coolify's OWN default environment, `production`.
#39 taught apply to create the environment its resources actually name, so a project
cast creates from nothing now ends up carrying two: ours, holding everything, and an
empty `production` that nothing will ever use. That is precisely the shape that makes
a box unreadable later, and we have the live example — on the box being migrated away
from, `production` is empty and everything runs in `staging`, and "the obvious guess
is the wrong one" is a note we had to write down for ourselves. Shipping more of those
is not neutrality.
This is the only delete cast performs, so it argues for itself against apply-never-
deletes: what that rule protects is things cast did not make, and this is a byproduct
of cast's own POST /projects seconds earlier, holding nothing and having never held
anything. Three conditions, jointly, or nothing is touched — cast created the project
in THIS run (never a project someone built by hand), the environment is EMPTY (asked
of Coolify via the details route, the only one that eager-loads resources — not
inferred from the first condition), and its name is NOT ours (an --environment
production keeps its production, since that is where everything is about to live).
Best-effort: a delete that fails is reported and never fails an apply that worked.
#41 — the multi-destination 400 says what to do, and the plan says what it assumed.
A create against a server with more than one destination that names none is rejected
with "Server has multiple destinations and you do not set destination_uuid." — a
message that names neither the remedy nor the file it goes in, arriving at the FIRST
create, after apply has already made the project and the environment.
cast cannot pre-flight it and that half is not fixable: 4.1.2 serves no destinations
API at all, and GET /servers/{uuid} does not carry them either, so a server's
destination COUNT is unknowable until a create has been attempted. The diagnosis is
what is fixable. The 400 is now answered with the failing resource, the server by the
name the operator wrote (not its UUID), the exact path the UUID goes in
(environments.<env>.projects.<org>/<repo>.destination_uuid), the create-time warning —
placement is repaired by delete + recreate, never by a later apply — and Coolify's own
words kept verbatim, so the next person's search still works.
And the assumption behind an undeclared destination is now on screen at the moment it
is made: `placement: server's default destination (none declared)`. This reverses a
judgment cast held explicitly ("a line on every diff that says nothing is how a report
stops being read" — the test it replaces). The line does not say nothing; it says which
network the next create lands on. It stays on a clean run that creates nothing, too,
because the trap is set for projects that are already built: the day their server gains
a second destination, every one of them that declared no destination stops being able
to create, and nothing will have warned them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:29 +00:00
|
|
|
// The claim above is about the UNDECLARED case only. A declared destination
|
|
|
|
|
// makes the opposite statement (it was sent, and cannot be verified) and must
|
|
|
|
|
// never make both.
|
|
|
|
|
it("does not call a declared destination the server's default", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff([want("a")], [got("a", 3)], "structural", {
|
|
|
|
|
declaredDestination: "dest-abc",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toMatch(/NOT compared/);
|
|
|
|
|
expect(out).not.toMatch(/none declared/);
|
|
|
|
|
});
|
|
|
|
|
|
feat: place a resource on a destination — and a state file that can say which (#21)
A destination is the Docker network a resource is created on. cast never sent
one, so everything landed on the server's default — invisible and harmless while
each server hosts one project, and neither the moment a server hosts two.
The state file had nowhere to say otherwise, either. A destination is scoped
project × environment, and `environments.<env>` is scoped by environment alone:
a `destination:` key there would mean "one network shared by every project in
this environment", which is the isolation it is meant to provide, inverted.
So:
- `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full
`<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like
`github_apps`. It carries `destination_uuid` and `smoke_target`.
- `smoke_target` moves there. It was state-file-scoped: it named ONE project's
app (`core`) from a key that could not tell two projects apart — or even prod's
app from staging's. The old key is still read (with a warning), so an unmigrated
state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`.
- `apply` sends `destination_uuid` on create, for applications, databases and
services alike — Coolify runs identical destination logic in all three.
The API turns out to be worse than the issue assumed, in a way that changes what
"diff should compare the destination" can honestly mean. Verified against
coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and
written up in reference/README.md:
- There is NO destinations API. Zero routes. A destination cannot be listed, read
or resolved by name — only a raw UUID from the UI identifies one, exactly as
with `s3_destination`. Hence `destination_uuid:` and not `destination:`.
- The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns
`destination_id` (an integer PK) on read, with nothing mapping between them.
- On a server with >1 destination, a create that OMITS it is a hard 400. So cast
could not deploy onto a shared box at all — it did not silently misplace there,
it simply failed. On a single-destination server the uuid is ignored entirely
and never validated, so a wrong one is invisible until a second one exists.
A declared UUID therefore cannot be verified against the resource it was sent
for — by cast or by anything else. Diffing it as a field would compare a UUID to
an int and report drift that never clears, so it is reported rather than compared,
and the limit is stated out loud: every diff that declares a destination says it
did not verify it. Silence would make an unverified setting read as a verified
one, which is the failure shape #12/#14/#17/#18 are all about.
What IS comparable is the live side to itself. `diff` groups live resources by the
`destination_id` Coolify does report, and a project whose resources do not all
share one network is drift — non-clean, both sides named, and never repaired
(apply moves nothing between networks). That catches the thing actually worth
catching, including on a box whose destinations were made by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
|
|
|
it("names every resource on each side of a split", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff(
|
|
|
|
|
[want("core"), want("landing")],
|
|
|
|
|
[got("core", 3), got("landing", 7)],
|
|
|
|
|
"structural",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toMatch(/split placement: these resources sit on 2 different/);
|
|
|
|
|
expect(out).toContain("destination 3: application core");
|
|
|
|
|
expect(out).toContain("destination 7: application landing");
|
|
|
|
|
expect(out).toMatch(/apply never moves a live resource between networks/);
|
|
|
|
|
expect(out).toMatch(/split placement$/m);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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("renderDiff", () => {
|
|
|
|
|
it("never prints secret values", () => {
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core-api",
|
|
|
|
|
uuid: "u1",
|
|
|
|
|
fields: { ...desiredApp.fields },
|
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", MAILGUN_KEY: "OLD-SECRET" }),
|
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 out = renderDiff(computeDiff([desiredApp], live, "full"));
|
|
|
|
|
expect(out).toContain("secret MAILGUN_KEY differs");
|
|
|
|
|
expect(out).not.toContain("mk-123");
|
|
|
|
|
expect(out).not.toContain("OLD-SECRET");
|
|
|
|
|
});
|
|
|
|
|
it("structural mode says env was not compared", () => {
|
|
|
|
|
const out = renderDiff(computeDiff([desiredApp], [], "structural"));
|
|
|
|
|
expect(out).toMatch(/env vars not compared \(structural mode/);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat: diff and apply a database's backup schedule (#51)
Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
2026-07-14 22:37:01 +00:00
|
|
|
|
|
|
|
|
// A database's backup schedule is a field like any other — the whole point of
|
|
|
|
|
// #51. These pin the four answers a live read can give, and above all pin the
|
|
|
|
|
// two that must never be confused: "there is no schedule" (drift, fixable) and
|
|
|
|
|
// "cast could not read the schedule" (not drift, not clean, said out loud).
|
|
|
|
|
describe("backup schedules", () => {
|
|
|
|
|
const wantBackup = {
|
|
|
|
|
kind: "database" as const,
|
|
|
|
|
name: "postgres",
|
|
|
|
|
fields: {
|
|
|
|
|
type: "postgresql",
|
|
|
|
|
backup: { frequency: "0 3 * * *", retention: 7 },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const liveDb = (fields: Record<string, unknown>, extra = {}) => ({
|
|
|
|
|
kind: "database" as const,
|
|
|
|
|
name: "postgres",
|
|
|
|
|
uuid: "db-1",
|
|
|
|
|
fields: { type: "postgresql", ...fields },
|
|
|
|
|
...extra,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("is clean when the live schedule matches", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[wantBackup],
|
|
|
|
|
[liveDb({ backup: { frequency: "0 3 * * *", retention: 7 } })],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.clean).toBe(true);
|
|
|
|
|
expect(r.backupsNotCompared).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports drift when the schedule differs", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[wantBackup],
|
|
|
|
|
[liveDb({ backup: { frequency: "0 5 * * *", retention: 3 } })],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.changes[0].fieldDiffs).toEqual([
|
|
|
|
|
{
|
|
|
|
|
field: "backup",
|
|
|
|
|
desired: { frequency: "0 3 * * *", retention: 7 },
|
|
|
|
|
live: { frequency: "0 5 * * *", retention: 3 },
|
|
|
|
|
updatable: true,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The defect in one test: a live database with NO schedule, declared in the
|
|
|
|
|
// manifest, used to be invisible. It is drift, and apply can fix it.
|
|
|
|
|
it("reports drift when the database has no schedule at all", () => {
|
|
|
|
|
const r = computeDiff([wantBackup], [liveDb({})], "full");
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
expect(r.changes[0].fieldDiffs[0]).toMatchObject({
|
|
|
|
|
field: "backup",
|
|
|
|
|
live: undefined,
|
|
|
|
|
updatable: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A schedule row that exists but is switched off backs nothing up. It must
|
|
|
|
|
// not read as clean, and it must not read as absent (apply PATCHes it rather
|
|
|
|
|
// than POSTing a second one).
|
|
|
|
|
it("reports drift when the schedule exists but is disabled", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[wantBackup],
|
|
|
|
|
[
|
|
|
|
|
liveDb({
|
|
|
|
|
backup: { frequency: "0 3 * * *", retention: 7, enabled: false },
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
expect(r.clean).toBe(false);
|
|
|
|
|
expect(r.changes[0].fieldDiffs[0].field).toBe("backup");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The shape-mismatch path — the one that must not lie in EITHER direction.
|
|
|
|
|
it("invents no drift when the live schedule could not be read", () => {
|
|
|
|
|
const r = computeDiff(
|
|
|
|
|
[wantBackup],
|
|
|
|
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
|
|
|
|
"full",
|
|
|
|
|
);
|
|
|
|
|
// Not drift: cast read nothing, so it may claim nothing. In particular it
|
|
|
|
|
// must NOT diff the declared block against `undefined` and report a
|
|
|
|
|
// confident change on a database that may be perfectly backed up.
|
|
|
|
|
expect(r.changes).toEqual([]);
|
|
|
|
|
// And not silence either.
|
|
|
|
|
expect(r.backupsNotCompared).toEqual([
|
|
|
|
|
{ name: "postgres", reason: "unrecognized shape" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("says so on screen, on a run that is otherwise clean", () => {
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff(
|
|
|
|
|
[wantBackup],
|
|
|
|
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
|
|
|
|
"full",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(out).toContain(
|
|
|
|
|
"backup schedule for database postgres declared, NOT compared — verify in the Coolify UI",
|
|
|
|
|
);
|
|
|
|
|
expect(out).toContain("(unrecognized shape)");
|
|
|
|
|
// Reported, but not counted as drift — an absence of evidence is not
|
|
|
|
|
// evidence of drift, and a run that fails on it is a run operators learn
|
|
|
|
|
// to force past.
|
|
|
|
|
expect(out).toMatch(/^clean$/m);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("says nothing about backups when none is declared", () => {
|
|
|
|
|
// An undeclared schedule is uncompared, not deleted: a live schedule on a
|
|
|
|
|
// database whose manifest is silent is left alone, and unremarked.
|
|
|
|
|
const out = renderDiff(
|
|
|
|
|
computeDiff(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
kind: "database" as const,
|
|
|
|
|
name: "postgres",
|
|
|
|
|
fields: { type: "postgresql" },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[liveDb({}, { backupNotCompared: "unrecognized shape" })],
|
|
|
|
|
"full",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(out).not.toContain("backup");
|
|
|
|
|
expect(out).toMatch(/^clean$/m);
|
|
|
|
|
});
|
|
|
|
|
});
|