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, vi } from "vitest";
|
|
|
|
|
import {
|
|
|
|
|
applicationApiFields,
|
|
|
|
|
buildExecutor,
|
|
|
|
|
databaseApiFields,
|
|
|
|
|
databaseVersionFromImage,
|
|
|
|
|
defaultDatabaseImage,
|
2026-07-15 15:46:50 +00:00
|
|
|
parseDockerComposeDomains,
|
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
|
|
|
projectLiveFields,
|
|
|
|
|
serviceApiFields,
|
|
|
|
|
} from "../src/cli.js";
|
|
|
|
|
import { CoolifyClient } from "../src/coolify.js";
|
|
|
|
|
import { computeDiff } from "../src/diff.js";
|
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
|
|
|
import { DERIVED_UNRESOLVED } from "../src/envtemplate.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
|
|
|
|
|
|
|
|
// Pure wire-translation helpers (Desired vocabulary <-> Coolify API
|
|
|
|
|
// vocabulary). Importing src/cli.ts here must not run the CLI — see the
|
|
|
|
|
// import.meta.url guard around main() at the bottom of that file.
|
|
|
|
|
|
|
|
|
|
describe("applicationApiFields", () => {
|
|
|
|
|
it("joins domains into a comma-separated string and renames port/healthcheck", () => {
|
|
|
|
|
const out = applicationApiFields({
|
|
|
|
|
port: 3000,
|
|
|
|
|
healthcheck: "/health",
|
|
|
|
|
domains: ["https://a.example.com", "https://b.example.com"],
|
|
|
|
|
});
|
|
|
|
|
expect(out).toEqual({
|
|
|
|
|
ports_exposes: "3000",
|
|
|
|
|
health_check_path: "/health",
|
|
|
|
|
domains: "https://a.example.com,https://b.example.com",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
it("maps a docker_compose_domains map to the wire array-of-{name,domain} shape", () => {
|
|
|
|
|
const out = applicationApiFields({
|
|
|
|
|
docker_compose_domains: {
|
|
|
|
|
api: ["https://a", "https://b"],
|
|
|
|
|
admin: ["https://c"],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(out).toEqual({
|
|
|
|
|
docker_compose_domains: [
|
|
|
|
|
{ name: "api", domain: "https://a,https://b" },
|
|
|
|
|
{ name: "admin", domain: "https://c" },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
// cast#76. The three basic-auth keys are already Coolify's own names, so the
|
|
|
|
|
// wire layer's job here is not translation — it is the presence rule, enforced
|
|
|
|
|
// before the request rather than discovered as a 422 halfway through a run.
|
|
|
|
|
describe("applicationApiFields — basic auth (#76)", () => {
|
|
|
|
|
it("passes the three basic-auth keys through untranslated", () => {
|
|
|
|
|
const out = applicationApiFields({
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
});
|
|
|
|
|
expect(out).toEqual({
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("passes a bare disable through — no credentials needed to turn it off", () => {
|
|
|
|
|
expect(applicationApiFields({ is_http_basic_auth_enabled: false })).toEqual(
|
|
|
|
|
{
|
|
|
|
|
is_http_basic_auth_enabled: false,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const [what, fields] of [
|
|
|
|
|
["no password", { http_basic_auth_username: "ops" }],
|
|
|
|
|
["no username", { http_basic_auth_password: "s3cret" }],
|
|
|
|
|
[
|
|
|
|
|
"an empty password",
|
|
|
|
|
{
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
http_basic_auth_password: "",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
["neither", {}],
|
|
|
|
|
] as const) {
|
|
|
|
|
it(`refuses to enable basic auth with ${what}`, () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
applicationApiFields({ is_http_basic_auth_enabled: true, ...fields }),
|
fix: complete the basic-auth triple on username-only drift, at both guards
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
2026-07-21 12:43:04 +00:00
|
|
|
).toThrow(/refusing a partial HTTP basic auth write/);
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
});
|
|
|
|
|
}
|
fix: complete the basic-auth triple on username-only drift, at both guards
All three reviewers found the same hole, and it contradicted this PR's own
documentation rather than merely being incomplete.
`completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` —
the toggle being present IN THE PAYLOAD. But an update body is assembled from
the field diffs, and the toggle is absent exactly when it MATCHES. So on the
real drift case — basic auth already on at both ends, username edited in the
UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
early, and the PATCH went out as a lone username. Coolify requires the whole
triple on any write that enables basic auth, so that is a 422 mid-run: the
precise failure the function exists to prevent, on the one path it was not
looking at.
The fix reads INTENT from the declared spec instead of from the payload, and
completes whenever the payload touches basic auth at all. Two properties are
kept deliberately:
- it still never MANUFACTURES a write — a payload mentioning no basic-auth
field is returned untouched, so the honest limit printed on every diff
still holds;
- a spec that does not enable basic auth completes nothing, so reading
intent from the declaration does not trade one silent wrong write for
another.
The toggle is now completed alongside the credentials: Coolify's presence rule
is about the write as a whole, and a credentials-only PATCH asks it to infer
what cast can state.
`applicationApiFields` shared the blind spot for the same reason — a lone
username has no toggle to be true, so the belt never tightened either. It now
refuses any partial basic-auth write, while still letting an explicit disable
travel alone and ignoring payloads that do not mention basic auth.
No documentation changed: docs/semantics.md:374 and the function's own comment
already promised the triple is completed "whenever it sends one of them". The
code simply did not do it. This makes them true.
Tests: the existing "only the username drifted" case passed the toggle in its
payload, so it never exercised the guard — which is why the hole survived
review-by-suite. Added the real shape (lone username, lone password, no
toggle), the spec-says-off case, three wire-level partial writes, and the two
non-write cases. Verified by mutation: restoring the payload-keyed guard fails
both new completion assertions.
2026-07-21 12:43:04 +00:00
|
|
|
|
|
|
|
|
// The hole the #76 review found, at the wire: a username-only drift produces
|
|
|
|
|
// a PATCH with no toggle at all, so a guard keyed on `=== true` never looked
|
|
|
|
|
// at it. These are the shapes apply must never hand over uncompleted.
|
|
|
|
|
for (const [what, fields] of [
|
|
|
|
|
["a lone username", { http_basic_auth_username: "ops" }],
|
|
|
|
|
["a lone password", { http_basic_auth_password: "s3cret" }],
|
|
|
|
|
[
|
|
|
|
|
"credentials with no toggle",
|
|
|
|
|
{ http_basic_auth_username: "ops", http_basic_auth_password: "s3cret" },
|
|
|
|
|
],
|
|
|
|
|
] as const) {
|
|
|
|
|
it(`refuses ${what} — no toggle is not an exemption`, () => {
|
|
|
|
|
expect(() => applicationApiFields({ ...fields })).toThrow(
|
|
|
|
|
/refusing a partial HTTP basic auth write/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A disable is a legitimate one-key write: it needs no credentials, and
|
|
|
|
|
// demanding them would make turning basic auth off impossible.
|
|
|
|
|
it("allows an explicit disable to travel alone", () => {
|
|
|
|
|
expect(() =>
|
|
|
|
|
applicationApiFields({ is_http_basic_auth_enabled: false }),
|
|
|
|
|
).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// And a payload that says nothing about basic auth is not a basic-auth write.
|
|
|
|
|
it("ignores a payload that does not mention basic auth at all", () => {
|
|
|
|
|
expect(() => applicationApiFields({ is_static: true })).not.toThrow();
|
|
|
|
|
});
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
});
|
|
|
|
|
|
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("databaseApiFields", () => {
|
|
|
|
|
it("maps type+version to an image and drops the type/version keys", () => {
|
|
|
|
|
const out = databaseApiFields({ type: "postgresql", version: "17" });
|
|
|
|
|
expect(out).toEqual({ image: "postgres:17-alpine" });
|
|
|
|
|
expect(out).not.toHaveProperty("type");
|
|
|
|
|
expect(out).not.toHaveProperty("version");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("serviceApiFields", () => {
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
// cast#72: a service's per-container hostnames go on the wire as `urls`
|
|
|
|
|
// ({name, url}[], url comma-joined) — matched to a ServiceApplication by name
|
|
|
|
|
// on create/PATCH. `service_domains` is the internal map that becomes it.
|
|
|
|
|
it("builds `urls` from service_domains", () => {
|
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 = serviceApiFields({
|
|
|
|
|
type: "plausible",
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
service_domains: {
|
|
|
|
|
web: ["https://stats.example.com", "https://alt.example.com"],
|
|
|
|
|
collector: ["https://collect.example.com"],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(out).toEqual({
|
|
|
|
|
type: "plausible",
|
|
|
|
|
urls: [
|
|
|
|
|
{
|
|
|
|
|
name: "web",
|
|
|
|
|
url: "https://stats.example.com,https://alt.example.com",
|
|
|
|
|
},
|
|
|
|
|
{ name: "collector", url: "https://collect.example.com" },
|
|
|
|
|
],
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
});
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
expect(out).not.toHaveProperty("service_domains");
|
|
|
|
|
});
|
|
|
|
|
it("sends no `urls` for a service with no service_domains", () => {
|
|
|
|
|
const out = serviceApiFields({ type: "plausible" });
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
expect(out).toEqual({ type: "plausible" });
|
feat(service): set and diff per-container service hostnames via `urls` (#72)
Services could not carry hostnames through cast: `desiredFromManifest`
dropped a service's `domains` and warned they were a manual Coolify UI act,
citing a re-checked "no flat `domains` on a 4.1.2 service, on any route."
The audit (#72) disproved that — the same failure mode #51 corrected for
backup schedules. The FLAT shape genuinely has no route; the per-container
CAPABILITY was there at 4.1.2 all along.
`POST /services` and `PATCH /services/{uuid}` both take a `urls` list
([{name, url}], url comma-joined) that `applyServiceUrls` matches to a
`ServiceApplication` by name and stores as its `fqdn`; `GET /services/{uuid}`
loads `applications` and returns each `fqdn` (verified against
ServicesController v4.1.2). So services now speak the SAME per-container
vocabulary a dockercompose app does:
- **Manifest:** `service_domains: { <container>: [url] }` replaces the flat,
unhonorable `domains` on a service (a flat list cannot name which container
a hostname belongs to — exactly what `urls` requires). Canonicalized (keys
and each URL array sorted) so container order never false-drifts.
- **Write:** `serviceApiFields` builds `urls` on create and update.
- **Read/diff:** a supplementary `GET /services/{uuid}` per service
(`attachServiceDomains`, gated to `diff`/`apply` like backups) projects
`applications[].fqdn` back into `service_domains`, so a declared hostname is
compared every run — no perpetual drift, no manual UI step.
- **Pre-flight:** a service create's `service_domains` joins
`desiredDomainsOfCreate`, the more important because a service create whose
domain conflicts is DELETED server-side before the 409 (rollback).
Two limits stated out loud: the read is fail-closed (an unreachable/
unrecognized `GET /services/{uuid}` aborts rather than projecting empty and
re-PATCHing forever), and `inventory --emit-draft` does not yet make the
per-service GET, so a drafted service's hostnames are still declared by hand
(same as backups) — draft/semantics say so.
`npm run check` clean · 514 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 15:14:29 +00:00
|
|
|
expect(out).not.toHaveProperty("urls");
|
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
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-15 15:46:50 +00:00
|
|
|
describe("parseDockerComposeDomains", () => {
|
|
|
|
|
// cast#68: the REAL read shape on a live Coolify 4.1.2 — a service-keyed
|
|
|
|
|
// object, NOT the array the OpenAPI implies. This exact string came off the
|
|
|
|
|
// wire. It must decode into cast's internal { service: string[] } map.
|
|
|
|
|
it("parses the real service-keyed object shape", () => {
|
|
|
|
|
const raw =
|
|
|
|
|
'{"api":{"domain":"https://api.heavyduty.builders"},"admin":{"domain":"https://admin.heavyduty.builders"},"intake":{"domain":"https://apply.heavyduty.builders"}}';
|
|
|
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
|
|
|
api: ["https://api.heavyduty.builders"],
|
|
|
|
|
admin: ["https://admin.heavyduty.builders"],
|
|
|
|
|
intake: ["https://apply.heavyduty.builders"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("splits a comma-joined domain string into the internal array", () => {
|
|
|
|
|
const raw =
|
|
|
|
|
'{"api":{"domain":"https://a.example.com,https://b.example.com"}}';
|
|
|
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
|
|
|
api: ["https://a.example.com", "https://b.example.com"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The write side (applicationApiFields) still emits the array shape, and the
|
|
|
|
|
// vendored OpenAPI documents it — keep tolerating it so the round-trip holds.
|
|
|
|
|
it("still parses the legacy array-of-{name,domain} shape", () => {
|
|
|
|
|
const raw = '[{"name":"api","domain":"https://api.example.com"}]';
|
|
|
|
|
expect(parseDockerComposeDomains(raw)).toEqual({
|
|
|
|
|
api: ["https://api.example.com"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("collapses a malformed string to undefined rather than throwing", () => {
|
|
|
|
|
expect(parseDockerComposeDomains("not json{")).toBeUndefined();
|
|
|
|
|
// A JSON scalar is neither shape.
|
|
|
|
|
expect(parseDockerComposeDomains("42")).toBeUndefined();
|
|
|
|
|
expect(parseDockerComposeDomains("")).toBeUndefined();
|
|
|
|
|
expect(parseDockerComposeDomains(null)).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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("projectLiveFields", () => {
|
|
|
|
|
it("projects a live application onto the Desired vocabulary", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
ports_exposes: "3000",
|
|
|
|
|
health_check_path: "/health",
|
|
|
|
|
fqdn: "https://a.example.com,https://b.example.com",
|
|
|
|
|
});
|
|
|
|
|
expect(out.domains).toEqual([
|
|
|
|
|
"https://a.example.com",
|
|
|
|
|
"https://b.example.com",
|
|
|
|
|
]);
|
|
|
|
|
expect(out.port).toBe(3000);
|
|
|
|
|
expect(out.healthcheck).toBe("/health");
|
|
|
|
|
});
|
|
|
|
|
|
feat: an application can declare HTTP basic auth, and apply sets it
UNCAPTURED.md has said since it existed that Basic Auth is "carried as raw
container labels. cast's manifest has no field for them, so a rebuilt resource
is UNPROTECTED where the original was not." For applications that is a cast
vocabulary gap, not a Coolify one: is_http_basic_auth_enabled,
http_basic_auth_username and http_basic_auth_password are in both the create
and the PATCH allowlists at v4.1.2 (ApplicationsController.php:914, :2368).
An application now declares `basic_auth: { enabled, username, password }`, with
the password a store ${REF} and only a ${REF} — the schema refuses a literal,
because a manifest is a committed file. It resolves out of the environment's
age store through the same mechanism every env-template ref uses, and a missing
or empty entry fails before anything is written.
Managing it is opt-in (the is_static rule): an unconditional `false` would have
the first apply after this ships strip protection off every app enabled by hand
in the UI. Enabling without both credentials is refused at parse time and again
at the wire — Coolify's own rule (:2446-2463), enforced before the request
rather than discovered as a mid-run 422.
The read side is fail-honest. The toggle and username are plain columns and are
compared, so a UI flip is caught. The password is gated behind a
sensitive-data-enabled token at 4.1.2 and read:sensitive on v4.2, and would have
to be printed as a field diff, so it is never projected into the comparison
vocabulary on any box — every diff of an app declaring basic_auth says the
password was NOT compared, in the backup schedule's voice: reported, not drift.
custom_labels stays deliberately unwired: enabling basic auth or changing
domains regenerates labels and overwrites it unless
is_container_label_readonly_enabled, which is not API-settable until v4.2.
The NO_API_COVERAGE row narrows to services, where it is a real API gap on both
releases, plus a separate row for custom_labels on applications.
Closes #76
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:43:14 +00:00
|
|
|
// cast#76 — the read side of basic auth, and the rule that the password never
|
|
|
|
|
// enters the comparison vocabulary at all.
|
|
|
|
|
it("projects the basic-auth toggle and username, and NEVER the password", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://admin.example.com",
|
|
|
|
|
is_http_basic_auth_enabled: true,
|
|
|
|
|
http_basic_auth_username: "ops",
|
|
|
|
|
// Even when the read DOES carry it — a sensitive-data-enabled token — it
|
|
|
|
|
// must not reach `fields`, because a field in `fields` is a field
|
|
|
|
|
// renderDiff prints.
|
|
|
|
|
http_basic_auth_password: "s3cret",
|
|
|
|
|
});
|
|
|
|
|
expect(out.is_http_basic_auth_enabled).toBe(true);
|
|
|
|
|
expect(out.http_basic_auth_username).toBe("ops");
|
|
|
|
|
expect(out).not.toHaveProperty("http_basic_auth_password");
|
|
|
|
|
expect(JSON.stringify(out)).not.toContain("s3cret");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("projects a real `false` toggle — off is an answer, not an absence", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://admin.example.com",
|
|
|
|
|
is_http_basic_auth_enabled: 0,
|
|
|
|
|
});
|
|
|
|
|
expect(out.is_http_basic_auth_enabled).toBe(false);
|
|
|
|
|
// No username on the row means no username IS set — a value to diff against,
|
|
|
|
|
// not a field cast failed to read.
|
|
|
|
|
expect(out.http_basic_auth_username).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("omits both when the read carries no toggle at all", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "nixpacks",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
fqdn: "https://admin.example.com",
|
|
|
|
|
});
|
|
|
|
|
expect(out).not.toHaveProperty("is_http_basic_auth_enabled");
|
|
|
|
|
expect(out).not.toHaveProperty("http_basic_auth_username");
|
|
|
|
|
});
|
|
|
|
|
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
it("normalizes a live database's database_type to the manifest vocabulary", () => {
|
|
|
|
|
const out = projectLiveFields("database", {
|
|
|
|
|
database_type: "standalone-postgresql",
|
|
|
|
|
image: "postgres:17-alpine",
|
|
|
|
|
});
|
|
|
|
|
expect(out).toEqual({ type: "postgresql", version: "17" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("projects both docker_compose_location and docker_compose_domains for a live compose app", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: JSON.stringify([
|
|
|
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
|
|
|
]),
|
|
|
|
|
});
|
|
|
|
|
expect(out.docker_compose_location).toBe("docker-compose.yaml");
|
|
|
|
|
expect(out.docker_compose_domains).toEqual({
|
|
|
|
|
api: ["https://api.widget.example.com"],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not choke on an absent/null docker_compose_domains", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: null,
|
|
|
|
|
});
|
|
|
|
|
expect(out).not.toHaveProperty("docker_compose_domains");
|
|
|
|
|
});
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
|
|
|
|
|
// #63: the static-site fields must read back off a live application so they
|
|
|
|
|
// diff against the manifest — otherwise a UI flip of is_static is invisible.
|
|
|
|
|
it("reads is_static and the build/run commands off a live application", () => {
|
|
|
|
|
const out = projectLiveFields("application", {
|
|
|
|
|
git_repository: "org/repo",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "static",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
is_static: true,
|
|
|
|
|
install_command: "npm ci",
|
|
|
|
|
build_command: "npm run build -w apps/landing-site",
|
|
|
|
|
start_command: "node server.js",
|
|
|
|
|
});
|
|
|
|
|
expect(out.is_static).toBe(true);
|
|
|
|
|
expect(out.install_command).toBe("npm ci");
|
|
|
|
|
expect(out.build_command).toBe("npm run build -w apps/landing-site");
|
|
|
|
|
expect(out.start_command).toBe("node server.js");
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-15 15:46:50 +00:00
|
|
|
it("reports a readable is_static as a boolean, tolerating Coolify's 1/0", () => {
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe(
|
|
|
|
|
true,
|
|
|
|
|
);
|
|
|
|
|
expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe(
|
|
|
|
|
false,
|
|
|
|
|
);
|
2026-07-15 15:46:50 +00:00
|
|
|
// A real `false` is readable and projected as false, so a UI flip off
|
|
|
|
|
// `static` still diffs against a manifest that declares `static: true`.
|
|
|
|
|
expect(
|
|
|
|
|
projectLiveFields("application", { is_static: false }).is_static,
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// cast#68: Coolify 4.1.2 returns is_static: null on the read path even for a
|
|
|
|
|
// genuinely-static app. An unreadable value must be OMITTED, not projected as
|
|
|
|
|
// `false` — projecting false diffed false→true and redeployed every run.
|
|
|
|
|
it("omits is_static when the live value is unreadable (null/absent)", () => {
|
|
|
|
|
expect(
|
|
|
|
|
projectLiveFields("application", { is_static: null }),
|
|
|
|
|
).not.toHaveProperty("is_static");
|
|
|
|
|
// Absent on the wire is the same unreadable case, not a real `false`.
|
|
|
|
|
expect(projectLiveFields("application", {})).not.toHaveProperty(
|
|
|
|
|
"is_static",
|
|
|
|
|
);
|
fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
apply created applications but dropped install_command, build_command, and
is_static — settings the manifest had no field for — so a static site in an
npm-workspace monorepo (landing) was built and RUN from the repo-root
package.json, booting the core API server, which crash-looped on a missing
DATABASE_URL.
The build block gains install_command / build_command / start_command
(free-form strings) and static (-> Coolify is_static). apply writes and diffs
them; draft emits them (they left its NO_HOME list, and is_static was never in
it — the silent loss that caused the crash), and only emits static alongside a
publish_directory so a draft always loads.
Managing is_static is opt-in: declaring `static:` is required to serve a static
app, and NOT emitting is_static by default avoids the first apply PATCHing
static serving OFF on an un-migrated app (or fighting a pack:static coupling
forever). static:true with no publish_directory, and any of the four on a
dockercompose app, are parse-time refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:46:14 +00:00
|
|
|
});
|
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("compose app idempotency (review finding #2)", () => {
|
|
|
|
|
it("produces zero field diffs when live docker_compose_location/domains match the manifest", () => {
|
|
|
|
|
const desired = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
fields: {
|
|
|
|
|
git_repository: "acme/widget",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: {
|
|
|
|
|
api: ["https://api.widget.example.com"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const liveRaw = {
|
|
|
|
|
git_repository: "acme/widget",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: JSON.stringify([
|
|
|
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
|
|
|
]),
|
|
|
|
|
};
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
uuid: "app-uuid",
|
|
|
|
|
fields: projectLiveFields("application", liveRaw),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(desired, live, "structural");
|
|
|
|
|
expect(report.clean).toBe(true);
|
|
|
|
|
});
|
2026-07-15 15:46:50 +00:00
|
|
|
|
|
|
|
|
// cast#68: the idempotency guarantee against the REAL live shape. When
|
|
|
|
|
// docker_compose_domains comes back as Coolify 4.1.2's service-keyed object
|
|
|
|
|
// string, a matching manifest must still produce ZERO field diff — before the
|
|
|
|
|
// fix the object bailed to undefined and cast diffed the map against nothing
|
|
|
|
|
// on every apply.
|
|
|
|
|
it("produces zero field diffs when live docker_compose_domains is the service-keyed object shape", () => {
|
|
|
|
|
const desired = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
fields: {
|
|
|
|
|
git_repository: "acme/widget",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains: {
|
|
|
|
|
api: ["https://api.heavyduty.builders"],
|
|
|
|
|
admin: ["https://admin.heavyduty.builders"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const liveRaw = {
|
|
|
|
|
git_repository: "acme/widget",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "dockercompose",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
docker_compose_location: "docker-compose.yaml",
|
|
|
|
|
docker_compose_domains:
|
|
|
|
|
'{"api":{"domain":"https://api.heavyduty.builders"},"admin":{"domain":"https://admin.heavyduty.builders"}}',
|
|
|
|
|
};
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
uuid: "app-uuid",
|
|
|
|
|
fields: projectLiveFields("application", liveRaw),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(desired, live, "structural");
|
|
|
|
|
expect(report.clean).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// cast#68: is_static is unreadable on Coolify 4.1.2's read path (returns null
|
|
|
|
|
// even for a static app). A manifest that declares `static: true` must NOT diff
|
|
|
|
|
// against that null forever — the comparison is skipped and a once-per-run warn
|
|
|
|
|
// is emitted. A real live boolean still diffs normally.
|
|
|
|
|
describe("is_static live-unreadable degradation (cast#68)", () => {
|
|
|
|
|
const baseFields = {
|
|
|
|
|
git_repository: "acme/site",
|
|
|
|
|
git_branch: "main",
|
|
|
|
|
build_pack: "static",
|
|
|
|
|
base_directory: "/",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("does not diff is_static when the live value is unreadable, and warns once", () => {
|
|
|
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
|
const desired = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "landing",
|
|
|
|
|
fields: { ...baseFields, is_static: true },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
// projectLiveFields omits is_static from a null live read; fetchLive flags
|
|
|
|
|
// the resource staticNotCompared (mirrored here).
|
|
|
|
|
const liveFields = projectLiveFields("application", {
|
|
|
|
|
...baseFields,
|
|
|
|
|
is_static: null,
|
|
|
|
|
});
|
|
|
|
|
expect(liveFields).not.toHaveProperty("is_static");
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "landing",
|
|
|
|
|
uuid: "app-uuid",
|
|
|
|
|
fields: liveFields,
|
|
|
|
|
staticNotCompared: true,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(desired, live, "structural");
|
|
|
|
|
const change = report.changes.find((c) => c.name === "landing");
|
|
|
|
|
expect(change?.fieldDiffs.some((f) => f.field === "is_static")).toBeFalsy();
|
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
|
|
|
warn.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still diffs is_static when the live value is a real boolean", () => {
|
|
|
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
|
const desired = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "landing",
|
|
|
|
|
fields: { ...baseFields, is_static: true },
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
// A UI flip off static: the live value is a genuine `false`, readable and
|
|
|
|
|
// staticNotCompared unset — cast must catch the drift, not suppress it.
|
|
|
|
|
const live = [
|
|
|
|
|
{
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "landing",
|
|
|
|
|
uuid: "app-uuid",
|
|
|
|
|
fields: projectLiveFields("application", {
|
|
|
|
|
...baseFields,
|
|
|
|
|
is_static: false,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
const report = computeDiff(desired, live, "structural");
|
|
|
|
|
const change = report.changes.find((c) => c.name === "landing");
|
|
|
|
|
const staticDiff = change?.fieldDiffs.find((f) => f.field === "is_static");
|
|
|
|
|
expect(staticDiff).toBeDefined();
|
|
|
|
|
expect(staticDiff?.desired).toBe(true);
|
|
|
|
|
expect(staticDiff?.live).toBe(false);
|
|
|
|
|
expect(warn).not.toHaveBeenCalled();
|
|
|
|
|
warn.mockRestore();
|
|
|
|
|
});
|
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("buildExecutor createResource (application, dockercompose)", () => {
|
|
|
|
|
function mockFetch(handler: (path: string, init?: RequestInit) => Response) {
|
|
|
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) =>
|
|
|
|
|
handler(new URL(String(url)).pathname, init),
|
|
|
|
|
) as unknown as typeof fetch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("sets connect_to_docker_network: true on a compose app create payload", async () => {
|
|
|
|
|
let createBody: Record<string, unknown> | undefined;
|
|
|
|
|
const fetchImpl = mockFetch((path, init) => {
|
|
|
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
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
|
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
|
|
|
createBody = JSON.parse(String(init?.body));
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "app-1" }), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return new Response("not found", { status: 404 });
|
|
|
|
|
});
|
|
|
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
|
|
|
const exec = buildExecutor(client, {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
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
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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 uuid = await exec.createResource({
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "dockercompose", updatable: false },
|
|
|
|
|
{
|
|
|
|
|
field: "docker_compose_location",
|
|
|
|
|
desired: "docker-compose.yaml",
|
|
|
|
|
updatable: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
field: "docker_compose_domains",
|
|
|
|
|
desired: { api: ["https://api.widget.example.com"] },
|
|
|
|
|
updatable: true,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
});
|
|
|
|
|
expect(uuid).toBe("app-1");
|
|
|
|
|
expect(createBody?.connect_to_docker_network).toBe(true);
|
|
|
|
|
expect(createBody?.docker_compose_domains).toEqual([
|
|
|
|
|
{ name: "api", domain: "https://api.widget.example.com" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not set connect_to_docker_network for a non-compose app", async () => {
|
|
|
|
|
let createBody: Record<string, unknown> | undefined;
|
|
|
|
|
const fetchImpl = mockFetch((path, init) => {
|
|
|
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
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
|
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
|
|
|
createBody = JSON.parse(String(init?.body));
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "app-2" }), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return new Response("not found", { status: 404 });
|
|
|
|
|
});
|
|
|
|
|
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
|
|
|
|
|
const exec = buildExecutor(client, {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
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
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.
Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.
Two changes were required to make it genuinely stateless and publishable:
- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
resolved against the working directory, silently reading the wrong file
from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
only product knowledge in the executor — becomes the generic, operator-
owned environments.<env>.forbidden_var_patterns. The guard now lives in
private state, so a product-side change cannot lower its own guard, and
it is a pattern rather than a list, so it catches unforeseen siblings.
Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.
Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.
69 tests green; bin/cast + curl installer mirror rig's shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
|
|
|
});
|
|
|
|
|
await exec.createResource({
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "core-api",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
|
|
|
{ field: "domains", desired: ["https://a"], updatable: true },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
});
|
|
|
|
|
expect(createBody).not.toHaveProperty("connect_to_docker_network");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-19 12:26:35 +00:00
|
|
|
// githubAppUuid is null when the desired state declares no applications (#103)
|
|
|
|
|
// — legal for an executor that will only ever create databases and services,
|
|
|
|
|
// and unreachable for an application create by construction. The guard exists
|
|
|
|
|
// so that IF the construction is ever broken, the failure is cast's own
|
|
|
|
|
// sentence and not a Coolify 422 about somebody else's field.
|
|
|
|
|
describe("buildExecutor createResource (no GitHub App resolved, #103)", () => {
|
|
|
|
|
type Call = { method: string; path: string; body?: unknown };
|
|
|
|
|
|
|
|
|
|
// Records every request, so a test can assert what was NOT sent — the
|
|
|
|
|
// silently-included github_app_uuid is the shape of bug this pins against.
|
|
|
|
|
function recorder(handler: (path: string) => Response): {
|
|
|
|
|
calls: Call[];
|
|
|
|
|
fetchImpl: typeof fetch;
|
|
|
|
|
} {
|
|
|
|
|
const calls: Call[] = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
calls.push({
|
|
|
|
|
method: init?.method ?? "GET",
|
|
|
|
|
path,
|
|
|
|
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
|
|
|
});
|
|
|
|
|
return handler(path);
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
return { calls, fetchImpl };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ctx = {
|
|
|
|
|
projectName: "drill-widget",
|
|
|
|
|
envName: "staging",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: null,
|
|
|
|
|
serverName: "drill-box",
|
|
|
|
|
orgRepo: "heavy-duty/drill-widget",
|
|
|
|
|
bindingEnv: "staging",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("creates a database without one, sending no github_app_uuid", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path) => {
|
|
|
|
|
if (path === "/api/v1/projects")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "drill-widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "staging" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "db-9" }), { status: 201 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
const uuid = await exec.createResource({
|
|
|
|
|
kind: "database",
|
|
|
|
|
name: "cache",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [{ field: "type", desired: "redis", updatable: false }],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
});
|
|
|
|
|
expect(uuid).toBe("db-9");
|
|
|
|
|
const create = calls.find((c) => c.path === "/api/v1/databases/redis");
|
|
|
|
|
expect(create?.body).not.toHaveProperty("github_app_uuid");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses an application create with cast's own internal error, not a wire 422", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path) => {
|
|
|
|
|
if (path === "/api/v1/projects")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "drill-widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "staging" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
exec.createResource({
|
|
|
|
|
kind: "application",
|
|
|
|
|
name: "web",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow(/internal: application create/);
|
|
|
|
|
// The refusal happened before anything went near the create route.
|
|
|
|
|
expect(
|
|
|
|
|
calls.some((c) => c.path === "/api/v1/applications/private-github-app"),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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
|
|
|
// Placement is create-time only, and every kind needs it: Coolify runs the same
|
|
|
|
|
// destination logic in ApplicationsController, DatabasesController and
|
|
|
|
|
// ServicesController, and 400s on a multi-destination server for whichever one
|
|
|
|
|
// omits it. Missing it on the database create alone would be enough to leave a
|
|
|
|
|
// project's Postgres on the shared default network.
|
|
|
|
|
describe("buildExecutor createResource (destination placement)", () => {
|
|
|
|
|
function captureCreates() {
|
|
|
|
|
const bodies: Record<string, Record<string, unknown>> = {};
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
// The environment a create names has to exist, so apply reads it first
|
|
|
|
|
// (#38). This project is an existing one and already carries `prod`.
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
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
|
|
|
if (
|
|
|
|
|
path === "/api/v1/applications/private-github-app" ||
|
|
|
|
|
path === "/api/v1/databases/postgresql" ||
|
|
|
|
|
path === "/api/v1/services"
|
|
|
|
|
) {
|
|
|
|
|
bodies[path] = JSON.parse(String(init?.body));
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "new-1" }), { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
return new Response("not found", { status: 404 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
return { bodies, fetchImpl };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const creates = [
|
|
|
|
|
{
|
|
|
|
|
path: "/api/v1/applications/private-github-app",
|
|
|
|
|
change: {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
|
|
|
{ field: "domains", desired: ["https://a"], updatable: true },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: "/api/v1/databases/postgresql",
|
|
|
|
|
change: {
|
|
|
|
|
kind: "database" as const,
|
|
|
|
|
name: "postgres",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
path: "/api/v1/services",
|
|
|
|
|
change: {
|
|
|
|
|
kind: "service" as const,
|
|
|
|
|
name: "umami",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
it.each(creates)(
|
|
|
|
|
"sends destination_uuid on the $path create",
|
|
|
|
|
async ({ path, change }) => {
|
|
|
|
|
const { bodies, fetchImpl } = captureCreates();
|
|
|
|
|
const client = new CoolifyClient(
|
|
|
|
|
"https://coolify.test",
|
|
|
|
|
"tok",
|
|
|
|
|
fetchImpl,
|
|
|
|
|
);
|
|
|
|
|
const exec = buildExecutor(client, {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
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
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
destinationUuid: "dest-abc",
|
|
|
|
|
});
|
|
|
|
|
await exec.createResource(change);
|
|
|
|
|
expect(bodies[path]?.destination_uuid).toBe("dest-abc");
|
|
|
|
|
// The server still has to be named — a destination belongs to one.
|
|
|
|
|
expect(bodies[path]?.server_uuid).toBe("srv-1");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Undeclared must mean ABSENT, not empty-string: Coolify branches on
|
|
|
|
|
// `$request->has('destination_uuid')`, so sending "" would take the
|
|
|
|
|
// "you gave me one" path and then fail to match any destination.
|
|
|
|
|
it.each(creates)(
|
|
|
|
|
"omits destination_uuid entirely when none is declared ($path)",
|
|
|
|
|
async ({ path, change }) => {
|
|
|
|
|
const { bodies, fetchImpl } = captureCreates();
|
|
|
|
|
const client = new CoolifyClient(
|
|
|
|
|
"https://coolify.test",
|
|
|
|
|
"tok",
|
|
|
|
|
fetchImpl,
|
|
|
|
|
);
|
|
|
|
|
const exec = buildExecutor(client, {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
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
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
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
|
|
|
});
|
|
|
|
|
await exec.createResource(change);
|
|
|
|
|
expect(bodies[path]).not.toHaveProperty("destination_uuid");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
// #38: the first apply against a project that does not exist yet. POST /projects
|
|
|
|
|
// hands the new project Coolify's OWN default environment ("production"), never
|
|
|
|
|
// ours — so a create that names `environment_name: prod` 404s with "Environment
|
|
|
|
|
// not found" and leaves the project behind, created and empty. Every environment
|
|
|
|
|
// cast had touched until then was hand-built in a UI and adopted, which is why
|
|
|
|
|
// the from-nothing path is the one that had never run.
|
|
|
|
|
describe("buildExecutor createResource (environment reconcile)", () => {
|
|
|
|
|
// A Coolify with ONE project's worth of state, driven by what the test hands
|
|
|
|
|
// it: `projects` is what GET /projects answers, `environments` what the
|
|
|
|
|
// project carries. Both mutate as cast writes, so the mock stays honest about
|
|
|
|
|
// what a second read would see.
|
|
|
|
|
function fakeCoolify(opts: {
|
|
|
|
|
projects?: Array<{ uuid: string; name: string }>;
|
|
|
|
|
environments?: string[];
|
|
|
|
|
envCreateStatus?: number;
|
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
|
|
|
// What an environment HOLDS, by name — the guard on #40's delete. A project
|
|
|
|
|
// cast just created cannot really hold anything, which is exactly why the
|
|
|
|
|
// mock has to be able to say otherwise: the guard is worth only as much as
|
|
|
|
|
// the case it refuses.
|
|
|
|
|
holds?: Record<string, unknown[]>;
|
|
|
|
|
envDeleteStatus?: number;
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
}) {
|
|
|
|
|
const projects = opts.projects ?? [];
|
|
|
|
|
let environments = opts.environments ?? [];
|
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
|
|
|
const holds = opts.holds ?? {};
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
const calls: string[] = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
const method = init?.method ?? "GET";
|
|
|
|
|
calls.push(`${method} ${path}`);
|
|
|
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
|
|
|
return new Response(JSON.stringify(projects), { status: 200 });
|
|
|
|
|
if (path === "/api/v1/projects" && method === "POST") {
|
|
|
|
|
projects.push({ uuid: "proj-new", name: "widget" });
|
|
|
|
|
// Coolify's doing, not ours: a brand-new project comes with this.
|
|
|
|
|
environments = ["production"];
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "proj-new" }), {
|
|
|
|
|
status: 201,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const envRoute = /^\/api\/v1\/projects\/([^/]+)\/environments$/.exec(
|
|
|
|
|
path,
|
|
|
|
|
);
|
|
|
|
|
if (envRoute && method === "GET")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify(environments.map((name) => ({ name }))),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
// CoolifyClient.environments falls back to the project show route when
|
|
|
|
|
// the list route answers empty — it carries the same names as a relation.
|
|
|
|
|
if (/^\/api\/v1\/projects\/[^/]+$/.test(path) && method === "GET")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
environments: environments.map((name) => ({ name })),
|
|
|
|
|
}),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (envRoute && method === "POST") {
|
|
|
|
|
const name = JSON.parse(String(init?.body)).name as string;
|
|
|
|
|
const status = opts.envCreateStatus ?? 201;
|
|
|
|
|
if (status === 409) {
|
|
|
|
|
// A 409 is Coolify saying the name is TAKEN — so in the world the
|
|
|
|
|
// mock is modelling it exists, created by whoever won the race
|
|
|
|
|
// between our read and our write. The environment is there; only our
|
|
|
|
|
// create lost. A 409 whose environment did not exist is not a state
|
|
|
|
|
// Coolify can be in, and pretending otherwise would test nothing.
|
|
|
|
|
if (!environments.includes(name)) environments.push(name);
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
message: "Environment with this name already exists.",
|
|
|
|
|
}),
|
|
|
|
|
{ status: 409 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (status !== 201)
|
|
|
|
|
return new Response(JSON.stringify({ message: "boom" }), { status });
|
|
|
|
|
environments.push(name);
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "env-1" }), { status: 201 });
|
|
|
|
|
}
|
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
|
|
|
// DELETE /projects/{uuid}/environments/{name} — #40. Three segments, so the
|
|
|
|
|
// two-segment details route below cannot swallow it.
|
|
|
|
|
const envDelete =
|
|
|
|
|
/^\/api\/v1\/projects\/([^/]+)\/environments\/([^/]+)$/.exec(path);
|
|
|
|
|
if (envDelete && method === "DELETE") {
|
|
|
|
|
const status = opts.envDeleteStatus ?? 200;
|
|
|
|
|
if (status !== 200)
|
|
|
|
|
return new Response(JSON.stringify({ message: "boom" }), { status });
|
|
|
|
|
environments = environments.filter((e) => e !== envDelete[2]);
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({ message: "Environment deleted." }),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// GET /projects/{uuid}/{environment} — the details route, the ONLY one that
|
|
|
|
|
// eager-loads an environment's resources, and so the only one that can
|
|
|
|
|
// answer "is it empty?". The list route's Environment objects carry no
|
|
|
|
|
// relations at all. Checked after the /environments routes above, which
|
|
|
|
|
// this pattern would otherwise match.
|
|
|
|
|
const envDetail = /^\/api\/v1\/projects\/([^/]+)\/([^/]+)$/.exec(path);
|
|
|
|
|
if (envDetail && method === "GET") {
|
|
|
|
|
const name = envDetail[2];
|
|
|
|
|
if (!environments.includes(name))
|
|
|
|
|
return new Response(JSON.stringify({ message: "Not found." }), {
|
|
|
|
|
status: 404,
|
|
|
|
|
});
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
id: 1,
|
|
|
|
|
name,
|
|
|
|
|
project_id: 1,
|
|
|
|
|
description: "",
|
|
|
|
|
applications: holds[name] ?? [],
|
|
|
|
|
postgresqls: [],
|
|
|
|
|
redis: [],
|
|
|
|
|
services: [],
|
|
|
|
|
}),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
}
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
if (path === "/api/v1/applications/private-github-app") {
|
|
|
|
|
// Coolify's actual rule, and the whole of #38: a create names an
|
|
|
|
|
// environment, and an environment that is not there is a 404. Without
|
|
|
|
|
// it this mock would happily accept the create that a real box refuses,
|
|
|
|
|
// and the test below would pass against the very bug it exists to catch.
|
|
|
|
|
const body = JSON.parse(String(init?.body)) as {
|
|
|
|
|
environment_name: string;
|
|
|
|
|
};
|
|
|
|
|
if (!environments.includes(body.environment_name))
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({ message: "Environment not found." }),
|
|
|
|
|
{ status: 404 },
|
|
|
|
|
);
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "app-1" }), { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
return new Response("not found", { status: 404 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
return { calls, fetchImpl, environments: () => environments };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const app = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
// envName is a parameter because #40's third guard is about it: an environment
|
|
|
|
|
// named `production` is Coolify's leftover default in every run EXCEPT the one
|
|
|
|
|
// that asked for `--environment production`, where it is the environment
|
|
|
|
|
// everything is about to live in.
|
|
|
|
|
function exec(fetchImpl: typeof fetch, envName = "prod") {
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
return buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
{
|
|
|
|
|
projectName: "widget",
|
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
|
|
|
envName,
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
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
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("creates the environment on a project it just created, and the create names it", async () => {
|
|
|
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(uuid).toBe("app-1");
|
|
|
|
|
// The fix: our environment is created BEFORE the resource that names it.
|
|
|
|
|
expect(coolify.calls).toContain(
|
|
|
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
|
|
|
);
|
|
|
|
|
expect(coolify.environments()).toContain("prod");
|
|
|
|
|
const order = coolify.calls.indexOf(
|
|
|
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
|
|
|
);
|
|
|
|
|
const create = coolify.calls.indexOf(
|
|
|
|
|
"POST /api/v1/applications/private-github-app",
|
|
|
|
|
);
|
|
|
|
|
expect(order).toBeGreaterThan(-1);
|
|
|
|
|
expect(order).toBeLessThan(create);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The half of idempotence that protects every apply that works today: an
|
|
|
|
|
// environment that already exists must not be written to at all.
|
|
|
|
|
it("never touches the create route when the environment already exists", async () => {
|
|
|
|
|
const coolify = fakeCoolify({
|
|
|
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
|
|
|
environments: ["prod", "staging"],
|
|
|
|
|
});
|
|
|
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(coolify.calls).toContain("GET /api/v1/projects/proj-1/environments");
|
|
|
|
|
expect(coolify.calls).not.toContain(
|
|
|
|
|
"POST /api/v1/projects/proj-1/environments",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Coolify 409s a duplicate environment name — the same answer as "present",
|
|
|
|
|
// reached when something else wins the race between our read and our write.
|
|
|
|
|
it("treats a 409 from the environment create as already-there", async () => {
|
|
|
|
|
const coolify = fakeCoolify({
|
|
|
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
|
|
|
environments: [],
|
|
|
|
|
envCreateStatus: 409,
|
|
|
|
|
});
|
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
expect(uuid).toBe("app-1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A 5xx is NOT "already there" — apply must not go on to create resources
|
|
|
|
|
// into an environment it has no reason to believe exists.
|
|
|
|
|
it("surfaces a non-409 failure from the environment create", async () => {
|
|
|
|
|
const coolify = fakeCoolify({
|
|
|
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
|
|
|
environments: [],
|
|
|
|
|
envCreateStatus: 500,
|
|
|
|
|
});
|
|
|
|
|
await expect(exec(coolify.fetchImpl).createResource(app)).rejects.toThrow(
|
|
|
|
|
/environments → 500/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Five creates in a run must reconcile the project and its environment once,
|
|
|
|
|
// not five times.
|
|
|
|
|
it("reconciles once across several creates", async () => {
|
|
|
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
|
|
|
const e = exec(coolify.fetchImpl);
|
|
|
|
|
await e.createResource(app);
|
|
|
|
|
await e.createResource({ ...app, name: "core-api" });
|
|
|
|
|
|
|
|
|
|
const envReads = coolify.calls.filter((c) => c.endsWith("/environments"));
|
|
|
|
|
expect(envReads).toHaveLength(2); // one GET + one POST, not two of each
|
|
|
|
|
expect(
|
|
|
|
|
coolify.calls.filter((c) => c === "POST /api/v1/projects").length,
|
|
|
|
|
).toBe(1);
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
// #40: what #39 left behind. POST /projects hands the new project Coolify's own
|
|
|
|
|
// default environment, and #39 then created OURS beside it — so every project cast
|
|
|
|
|
// creates from nothing carried a permanently-empty `production` next to the
|
|
|
|
|
// environment everything actually lives in. That is the shape that makes a box
|
|
|
|
|
// unreadable later; the box being migrated away from has an empty `production` and
|
|
|
|
|
// runs everything in `staging`, and "the obvious guess is the wrong one" is a note
|
|
|
|
|
// we had to write for ourselves.
|
|
|
|
|
//
|
|
|
|
|
// This is also the ONE delete cast performs, so the guards are the test: it happens
|
|
|
|
|
// only to a project cast made in this run, only to an environment that is empty, and
|
|
|
|
|
// only when the name is not the one we asked for.
|
|
|
|
|
describe("buildExecutor createResource (default environment removal, #40)", () => {
|
|
|
|
|
const DELETE_DEFAULT =
|
|
|
|
|
"DELETE /api/v1/projects/proj-new/environments/production";
|
|
|
|
|
|
|
|
|
|
it("removes the empty default environment from a project it just created", async () => {
|
|
|
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
|
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(uuid).toBe("app-1");
|
|
|
|
|
expect(coolify.calls).toContain(DELETE_DEFAULT);
|
|
|
|
|
// The point of the whole issue: what is left is ours, and only ours.
|
|
|
|
|
expect(coolify.environments()).toEqual(["prod"]);
|
|
|
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
|
|
|
/removed Coolify's default environment production/,
|
|
|
|
|
);
|
|
|
|
|
notes.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Guard 1. The rule cast does not get to break: a project someone built by hand
|
|
|
|
|
// is not cast's to tidy, whatever it happens to carry. `production` sitting empty
|
|
|
|
|
// next to `staging` on an ADOPTED project is exactly the live box we migrate from
|
|
|
|
|
// — and it stays untouched.
|
|
|
|
|
it("never removes an environment from a project it adopted", async () => {
|
|
|
|
|
const coolify = fakeCoolify({
|
|
|
|
|
projects: [{ uuid: "proj-1", name: "widget" }],
|
|
|
|
|
environments: ["production", "prod"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
|
|
|
expect(coolify.environments()).toContain("production");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Guard 2. Asked of Coolify, not inferred from "we just made this project". The
|
|
|
|
|
// mock is lying here — a fresh project cannot hold an application — and it must
|
|
|
|
|
// be able to, because a guard that is only ever handed the safe case is not a
|
|
|
|
|
// guard. cast declines, and says why.
|
|
|
|
|
it("leaves the default environment alone when it holds anything", async () => {
|
|
|
|
|
const coolify = fakeCoolify({
|
|
|
|
|
projects: [],
|
|
|
|
|
holds: { production: [{ uuid: "app-x", name: "legacy" }] },
|
|
|
|
|
});
|
|
|
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
|
|
|
expect(coolify.environments()).toContain("production");
|
|
|
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
|
|
|
/left Coolify's default environment production .* NOT empty/,
|
|
|
|
|
);
|
|
|
|
|
notes.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Guard 3. `production` is a leftover in every run except the one that asked for
|
|
|
|
|
// it, where it is the environment everything is about to live in. Deleting it
|
|
|
|
|
// there would delete the target of the very apply doing the deleting.
|
|
|
|
|
it("keeps the default environment when it is the one we asked for", async () => {
|
|
|
|
|
const coolify = fakeCoolify({ projects: [] });
|
|
|
|
|
|
|
|
|
|
await exec(coolify.fetchImpl, "production").createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(coolify.calls.some((c) => c.startsWith("DELETE"))).toBe(false);
|
|
|
|
|
expect(coolify.environments()).toEqual(["production"]);
|
|
|
|
|
// ...and it was never re-created either: it was already there.
|
|
|
|
|
expect(coolify.calls).not.toContain(
|
|
|
|
|
"POST /api/v1/projects/proj-new/environments",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Tidying is a courtesy, and a courtesy that can fail an apply is not one. The
|
|
|
|
|
// resource still gets created; the operator is told what was left behind.
|
|
|
|
|
it("does not fail the apply when the delete fails", async () => {
|
|
|
|
|
const coolify = fakeCoolify({ projects: [], envDeleteStatus: 500 });
|
|
|
|
|
const notes = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
|
|
|
|
|
|
|
|
const uuid = await exec(coolify.fetchImpl).createResource(app);
|
|
|
|
|
|
|
|
|
|
expect(uuid).toBe("app-1");
|
|
|
|
|
expect(coolify.environments()).toContain("production");
|
|
|
|
|
expect(notes.mock.calls.flat().join("\n")).toMatch(
|
|
|
|
|
/could not remove it .*500/s,
|
|
|
|
|
);
|
|
|
|
|
notes.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// #41: a first apply against a server with more than one destination 400s on the
|
|
|
|
|
// first create — with the project and the environment already made. Coolify's own
|
|
|
|
|
// message names neither the remedy nor the file it goes in, and cast cannot
|
|
|
|
|
// pre-flight the condition (4.1.2 serves no destinations API at all, so a server's
|
|
|
|
|
// destination count is unknowable until a create has been attempted). The diagnosis
|
|
|
|
|
// is the whole of what is fixable, so the diagnosis is what is tested.
|
|
|
|
|
describe("buildExecutor createResource (multi-destination 400, #41)", () => {
|
|
|
|
|
const app = {
|
|
|
|
|
kind: "application" as const,
|
|
|
|
|
name: "core",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "build_pack", desired: "nixpacks", updatable: false },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Coolify's real answer, verbatim, from all three create controllers.
|
|
|
|
|
function multiDestinationCoolify() {
|
|
|
|
|
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
const method = init?.method ?? "GET";
|
|
|
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
message:
|
|
|
|
|
"Server has multiple destinations and you do not set destination_uuid.",
|
|
|
|
|
}),
|
|
|
|
|
{ status: 400 },
|
|
|
|
|
);
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const exec = (fetchImpl: typeof fetch) =>
|
|
|
|
|
buildExecutor(new CoolifyClient("https://coolify.test", "tok", fetchImpl), {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
|
|
|
|
// The names the message has to be able to say back. None is on the wire:
|
|
|
|
|
// Coolify knows srv-1, the operator wrote prod-box.
|
|
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "heavy-duty/incubator",
|
|
|
|
|
bindingEnv: "prod",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const kinds = [
|
|
|
|
|
{ label: "application", change: app },
|
|
|
|
|
{
|
|
|
|
|
label: "database",
|
|
|
|
|
change: {
|
|
|
|
|
kind: "database" as const,
|
|
|
|
|
name: "postgres",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: "service",
|
|
|
|
|
change: {
|
|
|
|
|
kind: "service" as const,
|
|
|
|
|
name: "umami",
|
|
|
|
|
op: "create" as const,
|
|
|
|
|
fieldDiffs: [{ field: "type", desired: "umami", updatable: false }],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Every kind, because which one 400s first depends only on the order of the
|
|
|
|
|
// manifest — Coolify runs the same destination logic in all three controllers.
|
|
|
|
|
it.each(kinds)(
|
|
|
|
|
"answers the $label 400 with the state-file path the UUID goes in",
|
|
|
|
|
async ({ change }) => {
|
|
|
|
|
const err = await exec(multiDestinationCoolify())
|
|
|
|
|
.createResource(change)
|
|
|
|
|
.catch((e: Error) => e);
|
|
|
|
|
|
|
|
|
|
expect(err).toBeInstanceOf(Error);
|
|
|
|
|
const message = (err as Error).message;
|
|
|
|
|
// The remedy: the exact key, in the exact place, with the repo and the env
|
|
|
|
|
// the operator actually named.
|
|
|
|
|
expect(message).toContain(
|
|
|
|
|
"environments.prod.projects.heavy-duty/incubator.destination_uuid",
|
|
|
|
|
);
|
|
|
|
|
// The server, by the name the operator wrote — not srv-1.
|
|
|
|
|
expect(message).toContain("prod-box has multiple destinations");
|
|
|
|
|
// Which resource it died on, so a half-applied run can be read.
|
|
|
|
|
expect(message).toContain(`cannot create ${change.kind} ${change.name}`);
|
|
|
|
|
// Create-time: the part that decides whether they can fix this with an
|
|
|
|
|
// apply (they cannot) or a delete + recreate (they must).
|
|
|
|
|
expect(message).toMatch(/cannot be moved between networks later/);
|
|
|
|
|
// Coolify's own words survive — a translation that hides the original
|
|
|
|
|
// makes the next person's search fail.
|
|
|
|
|
expect(message).toContain(
|
|
|
|
|
"Server has multiple destinations and you do not set destination_uuid.",
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The translation must be about THIS 400, not about 400s. A create rejected for
|
|
|
|
|
// any other reason has to arrive unmolested, or the next bug gets a confident
|
|
|
|
|
// answer about a destination it has nothing to do with.
|
|
|
|
|
it("leaves every other failure exactly as Coolify sent it", async () => {
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
const method = init?.method ?? "GET";
|
|
|
|
|
if (path === "/api/v1/projects" && method === "GET")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments" && method === "GET")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({ message: "The name field is required." }),
|
|
|
|
|
{ status: 422 },
|
|
|
|
|
);
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const err = await exec(fetchImpl)
|
|
|
|
|
.createResource(app)
|
|
|
|
|
.catch((e: Error) => e);
|
|
|
|
|
|
|
|
|
|
expect((err as Error).message).toContain("The name field is required.");
|
|
|
|
|
expect((err as Error).message).not.toMatch(/destination/);
|
|
|
|
|
});
|
fix: apply creates the environment its resources name (#38)
POST /projects hands a new project Coolify's OWN default environment,
`production` — never ours. cast then created every resource with
`environment_name: <our --env>`, so the first apply against a project that
did not exist yet 404'd on its first resource ("Environment not found") and
left the project behind, created and empty.
Two comments in the source already asserted the behaviour as though it were
implemented (cli.ts:716, :808), and the README says it outright — the route
existed in the vendored 4.1.2 spec, cast just never called it. It went unseen
because every environment cast had touched until now was hand-built in a UI
and adopted, so it already existed under whatever name someone typed. The
genuinely-from-nothing apply is the one path nobody had run.
apply now reconciles project + environment once per run, before the first
create. Read-before-write: an environment that already exists is never written
to, so adoption is untouched and this cannot regress an apply that works today.
A 409 is read as "present" (the race between our read and our write).
Coolify's default environment is LEFT ALONE, per apply-never-deletes — deleting
it would be the first delete cast ever performs. An empty `production` beside
the environment everything lives in is reported, the same courtesy an orphan
gets, and removed by hand or not at all.
The regression test drives the real failure, not a call count: the fake Coolify
404s a create whose environment_name it does not carry, exactly as a live box
does — against the old executor it reproduces the reported error verbatim.
2026-07-14 16:43:12 +00:00
|
|
|
});
|
|
|
|
|
|
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("databaseVersionFromImage / defaultDatabaseImage", () => {
|
|
|
|
|
it("round-trips through defaultDatabaseImage for postgres", () => {
|
|
|
|
|
const image = defaultDatabaseImage("postgresql", "17");
|
|
|
|
|
expect(databaseVersionFromImage(image)).toBe("17");
|
|
|
|
|
});
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
// The write half of #51. Backup schedules live on their own route
|
|
|
|
|
// (/databases/{uuid}/backups), so `apply` has to read that route to know
|
|
|
|
|
// whether to POST or PATCH — and used to do neither outside the create branch.
|
|
|
|
|
describe("buildExecutor backup schedules", () => {
|
|
|
|
|
type Call = { method: string; path: string; body?: unknown };
|
|
|
|
|
|
|
|
|
|
// Records every request so a test can assert not just what was written, but
|
|
|
|
|
// what was NOT — a schedule silently skipped is the whole defect.
|
|
|
|
|
function recorder(handler: (path: string, init?: RequestInit) => Response): {
|
|
|
|
|
calls: Call[];
|
|
|
|
|
fetchImpl: typeof fetch;
|
|
|
|
|
} {
|
|
|
|
|
const calls: Call[] = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
calls.push({
|
|
|
|
|
method: init?.method ?? "GET",
|
|
|
|
|
path,
|
|
|
|
|
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
|
|
|
});
|
|
|
|
|
return handler(path, init);
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
return { calls, fetchImpl };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ctx = {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
|
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
|
|
|
|
s3DestinationUuid: "s3-1",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const schedule = { frequency: "0 3 * * *", retention: 7 };
|
|
|
|
|
const liveRow = {
|
|
|
|
|
uuid: "sched-1",
|
|
|
|
|
frequency: "0 5 * * *",
|
|
|
|
|
database_backup_retention_amount_locally: 3,
|
|
|
|
|
enabled: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// THE FIX. A database that exists and has no schedule gets one on update —
|
|
|
|
|
// before this, `apply` wrote a schedule only inside the create branch, so
|
|
|
|
|
// adding `backup:` to a live database was a clean run and zero backups.
|
|
|
|
|
it("CREATES the schedule on update when the database has none", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "POST")
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "sched-new" }), {
|
|
|
|
|
status: 201,
|
|
|
|
|
});
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups")
|
|
|
|
|
return new Response(JSON.stringify([]), { status: 200 }); // read: none
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
|
|
|
const post = calls.find((c) => c.method === "POST");
|
|
|
|
|
expect(post?.path).toBe("/api/v1/databases/db-1/backups");
|
|
|
|
|
expect(post?.body).toEqual({
|
|
|
|
|
frequency: "0 3 * * *",
|
|
|
|
|
database_backup_retention_amount_locally: 7,
|
|
|
|
|
save_s3: true,
|
|
|
|
|
s3_storage_uuid: "s3-1",
|
|
|
|
|
enabled: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("PATCHES the existing schedule rather than adding a second one", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
|
|
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
|
|
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
|
|
|
|
const patch = calls.find((c) => c.method === "PATCH");
|
|
|
|
|
expect(patch?.path).toBe("/api/v1/databases/db-1/backups/sched-1");
|
|
|
|
|
expect(patch?.body).toMatchObject({
|
|
|
|
|
frequency: "0 3 * * *",
|
|
|
|
|
database_backup_retention_amount_locally: 7,
|
|
|
|
|
// Re-enabled: declaring `backup:` asks for backups, not for a disabled
|
|
|
|
|
// row that looks like backups.
|
|
|
|
|
enabled: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not PATCH the database itself for a backup-only change", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
|
|
|
return new Response(JSON.stringify([liveRow]), { status: 200 });
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.updateFields("db-1", "database", { backup: schedule });
|
|
|
|
|
// `backup` is not a column on the database — it must never reach the
|
|
|
|
|
// database's own update body, and an empty body is not worth a write.
|
|
|
|
|
expect(calls.some((c) => c.path === "/api/v1/databases/db-1")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Degrade honestly: an apply that promised a backup schedule and cannot tell
|
|
|
|
|
// whether one already exists must STOP, not guess. POSTing blind would
|
|
|
|
|
// duplicate an existing schedule; skipping is the silent no-op being fixed.
|
|
|
|
|
it("refuses to guess when the schedule read fails", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path, init) => {
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
|
|
|
return new Response("gateway timeout", { status: 504 });
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
|
|
|
).rejects.toThrow(/cannot set the declared backup schedule/);
|
|
|
|
|
expect(calls.filter((c) => c.method === "POST")).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses to guess which of several schedules the manifest meant", async () => {
|
|
|
|
|
const { fetchImpl } = recorder((path, init) => {
|
|
|
|
|
if (path === "/api/v1/databases/db-1/backups" && init?.method === "GET")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([liveRow, { ...liveRow, uuid: "sched-2" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
return new Response("{}", { status: 200 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
|
|
|
).rejects.toThrow(/holds 2 backup schedules/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves an undeclared schedule alone (apply never removes)", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder(
|
|
|
|
|
() => new Response("{}", { status: 200 }),
|
|
|
|
|
);
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.updateFields("db-1", "database", { version: "17" });
|
|
|
|
|
expect(calls.some((c) => c.path.includes("/backups"))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still POSTs the schedule on create, without a read", async () => {
|
|
|
|
|
const { calls, fetchImpl } = recorder((path) => {
|
|
|
|
|
if (path === "/api/v1/projects")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/environments")
|
|
|
|
|
return new Response(JSON.stringify([{ name: "prod" }]), {
|
|
|
|
|
status: 200,
|
|
|
|
|
});
|
|
|
|
|
if (path === "/api/v1/databases/postgresql")
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "db-9" }), { status: 201 });
|
|
|
|
|
return new Response(JSON.stringify({ uuid: "sched-9" }), { status: 201 });
|
|
|
|
|
});
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.createResource({
|
|
|
|
|
kind: "database",
|
|
|
|
|
name: "postgres",
|
|
|
|
|
op: "create",
|
|
|
|
|
fieldDiffs: [
|
|
|
|
|
{ field: "type", desired: "postgresql", updatable: false },
|
|
|
|
|
{ field: "backup", desired: schedule, updatable: true },
|
|
|
|
|
],
|
|
|
|
|
envDiffs: [],
|
|
|
|
|
});
|
|
|
|
|
// A database created a moment ago provably has no schedule: POST straight
|
|
|
|
|
// out, with no read that could fail and abort the create.
|
|
|
|
|
expect(
|
|
|
|
|
calls.some((c) => c.method === "GET" && c.path.includes("/backups")),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
const post = calls.find((c) => c.path === "/api/v1/databases/db-9/backups");
|
|
|
|
|
expect(post?.body).toMatchObject({ frequency: "0 3 * * *", save_s3: true });
|
|
|
|
|
// And `backup` never reaches the database's own create body.
|
|
|
|
|
const create = calls.find((c) => c.path === "/api/v1/databases/postgresql");
|
|
|
|
|
expect(create?.body).not.toHaveProperty("backup");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses a declared schedule with no s3_destination configured", async () => {
|
|
|
|
|
const { fetchImpl } = recorder(
|
|
|
|
|
() => new Response(JSON.stringify([]), { status: 200 }),
|
|
|
|
|
);
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
{ ...ctx, s3DestinationUuid: undefined },
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
exec.updateFields("db-1", "database", { backup: schedule }),
|
|
|
|
|
).rejects.toThrow(/no s3_destination UUID/);
|
|
|
|
|
});
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
// The from-nothing half of derived resource URLs (#60): at plan time the
|
|
|
|
|
// database did not exist, so the app's ${resource:postgres.url} is still
|
|
|
|
|
// unresolved when apply goes to write its env. syncEnv reads the URL back from
|
|
|
|
|
// the environment_details route (the database was created earlier in the same
|
|
|
|
|
// apply) and writes the real value — or refuses, rather than writing a blank.
|
|
|
|
|
describe("buildExecutor syncEnv (derived resource URLs, #60)", () => {
|
|
|
|
|
const ctx = {
|
|
|
|
|
projectName: "widget",
|
|
|
|
|
envName: "prod",
|
|
|
|
|
serverUuid: "srv-1",
|
|
|
|
|
githubAppUuid: "gh-1",
|
|
|
|
|
serverName: "prod-box",
|
|
|
|
|
orgRepo: "acme/widget",
|
|
|
|
|
bindingEnv: "prod",
|
|
|
|
|
};
|
|
|
|
|
const derivedEnv = {
|
|
|
|
|
vars: {
|
|
|
|
|
DATABASE_URL: {
|
|
|
|
|
value: DERIVED_UNRESOLVED,
|
|
|
|
|
secret: true,
|
|
|
|
|
derived: { resource: "postgres", attr: "url" as const },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const URL_ = "postgres://u:p@pg-uuid:5432/widget";
|
|
|
|
|
|
|
|
|
|
// Serves /projects and the environment_details route; records the envs/bulk
|
|
|
|
|
// write so a test can assert what value landed (or that none did).
|
|
|
|
|
function box(internalDbUrl?: string) {
|
|
|
|
|
const bulkBodies: unknown[] = [];
|
|
|
|
|
const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
|
|
|
const path = new URL(String(url)).pathname;
|
|
|
|
|
if (path === "/api/v1/projects")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/projects/proj-1/prod")
|
|
|
|
|
return new Response(
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
postgresqls: [
|
|
|
|
|
internalDbUrl === undefined
|
|
|
|
|
? { name: "postgres" }
|
|
|
|
|
: { name: "postgres", internal_db_url: internalDbUrl },
|
|
|
|
|
],
|
|
|
|
|
}),
|
|
|
|
|
{ status: 200 },
|
|
|
|
|
);
|
|
|
|
|
if (path === "/api/v1/applications/app-1/envs/bulk") {
|
|
|
|
|
bulkBodies.push(JSON.parse(String(init?.body)));
|
|
|
|
|
return new Response(JSON.stringify([]), { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
return new Response("not found", { status: 404 });
|
|
|
|
|
}) as unknown as typeof fetch;
|
|
|
|
|
return { fetchImpl, bulkBodies };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("resolves the ref from the live database and writes the real URL", async () => {
|
|
|
|
|
const { fetchImpl, bulkBodies } = box(URL_);
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.syncEnv("app-1", "application", derivedEnv);
|
|
|
|
|
expect(bulkBodies).toHaveLength(1);
|
|
|
|
|
expect(bulkBodies[0]).toEqual({
|
|
|
|
|
data: [
|
|
|
|
|
{
|
|
|
|
|
key: "DATABASE_URL",
|
|
|
|
|
value: URL_,
|
|
|
|
|
is_buildtime: false,
|
|
|
|
|
is_preview: false,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses — and writes nothing — when the database has no URL yet", async () => {
|
|
|
|
|
const { fetchImpl, bulkBodies } = box(undefined);
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await expect(
|
|
|
|
|
exec.syncEnv("app-1", "application", derivedEnv),
|
|
|
|
|
).rejects.toThrow(/cannot resolve derived env var/);
|
|
|
|
|
expect(bulkBodies).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not read the box at all when there is nothing derived to resolve", async () => {
|
|
|
|
|
const { fetchImpl, bulkBodies } = box(URL_);
|
|
|
|
|
const exec = buildExecutor(
|
|
|
|
|
new CoolifyClient("https://coolify.test", "tok", fetchImpl),
|
|
|
|
|
ctx,
|
|
|
|
|
);
|
|
|
|
|
await exec.syncEnv("app-1", "application", {
|
|
|
|
|
vars: { PORT: { value: "3000", secret: false } },
|
|
|
|
|
});
|
|
|
|
|
// Straight to the write — no /projects lookup for a derived URL.
|
|
|
|
|
expect(bulkBodies).toHaveLength(1);
|
|
|
|
|
expect(
|
|
|
|
|
(
|
|
|
|
|
fetchImpl as unknown as { mock: { calls: unknown[][] } }
|
|
|
|
|
).mock.calls.some(([u]) => String(u).endsWith("/projects")),
|
|
|
|
|
).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|