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>
This commit is contained in:
parent
8f0abe2ab8
commit
cd1864aaad
9 changed files with 405 additions and 3 deletions
|
|
@ -221,6 +221,60 @@ softened by an implementation detail):
|
||||||
- **Reserved names:** cast never writes `SOURCE_COMMIT` or a `COOLIFY_*` var,
|
- **Reserved names:** cast never writes `SOURCE_COMMIT` or a `COOLIFY_*` var,
|
||||||
under any manifest, in any environment. See below.
|
under any manifest, in any environment. See below.
|
||||||
|
|
||||||
|
## Build settings (`install_command`, `build_command`, `start_command`, `static`)
|
||||||
|
|
||||||
|
A manifest application's `build` block carries, beyond `pack` and the checkout
|
||||||
|
paths, four optional settings Coolify accepts on both the create
|
||||||
|
(`POST /applications/private-github-app`) and update (`PATCH /applications/{uuid}`)
|
||||||
|
routes:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
landing:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build:
|
||||||
|
pack: nixpacks
|
||||||
|
base_directory: /
|
||||||
|
install_command: npm ci
|
||||||
|
build_command: npm run build -w apps/landing-site
|
||||||
|
publish_directory: /apps/landing-site/dist
|
||||||
|
static: true
|
||||||
|
domains: ["https://widget.example.com"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `install_command` / `build_command` / `start_command` are free-form strings,
|
||||||
|
passed through verbatim — cast does not parse the shell in them. They exist so
|
||||||
|
a **workspace monorepo** can scope the build to one app (`npm run build -w
|
||||||
|
apps/landing-site`) instead of letting nixpacks auto-detect from the repo-root
|
||||||
|
`package.json`, whose scripts may build and run a *different* workspace.
|
||||||
|
- `static: true` maps to Coolify's `is_static`: Coolify then **serves
|
||||||
|
`publish_directory` and runs no start command**. Without it a static site in a
|
||||||
|
monorepo gets built and then *run* as its root `package.json` — for the
|
||||||
|
incubator's `landing` that meant Coolify ran `npm run start -w apps/core`, the
|
||||||
|
API server, which crash-looped on a missing `DATABASE_URL` (#63).
|
||||||
|
|
||||||
|
**All four are rejected on a `dockercompose` app** — a compose app builds and
|
||||||
|
runs from its compose file, so Coolify never consults them — and `static: true`
|
||||||
|
is rejected without a `publish_directory` (nothing to serve). Both are parse-time
|
||||||
|
refusals, like the checkout-path rules above.
|
||||||
|
|
||||||
|
**Managing `is_static` is opt-in.** cast emits it only when the manifest declares
|
||||||
|
`static:` — like the three commands, not on every app. Emitting `is_static:false`
|
||||||
|
by default would make the first apply after this ships PATCH `is_static=false`
|
||||||
|
onto any static/SPA app configured in the UI whose manifest has not yet been
|
||||||
|
migrated — silently disabling static serving and re-creating the very crash, now
|
||||||
|
caused by cast; a `pack: static` app that Coolify couples to `is_static=true`
|
||||||
|
would drift-and-revert forever. So: declare `static: true` to serve, `static:
|
||||||
|
false` to actively guard against a UI flip to `true`, or omit it to leave the
|
||||||
|
field alone (Coolify keeps `pack` and `is_static` independent, which is why this
|
||||||
|
is an explicit field, not inferred from `pack`). The three commands are likewise
|
||||||
|
conditional (an unset command means "let the build pack decide"), diffed only
|
||||||
|
when declared. `projectLiveFields` reads `is_static` back on every app so it is
|
||||||
|
there to compare when a manifest does declare it. `draft` emits all four when the
|
||||||
|
live box carries them (and `static` only alongside a `publish_directory`, so the
|
||||||
|
draft always loads) — they used to sit in its `NO_HOME` list of settings a
|
||||||
|
rebuild silently dropped, and `is_static` was not even there, which is exactly
|
||||||
|
how a rebuilt static site came back wrong.
|
||||||
|
|
||||||
## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`)
|
## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`)
|
||||||
|
|
||||||
Coolify injects a set of values into an application's runtime environment
|
Coolify injects a set of values into an application's runtime environment
|
||||||
|
|
|
||||||
15
src/cli.ts
15
src/cli.ts
|
|
@ -326,6 +326,17 @@ export function projectLiveFields(
|
||||||
? { docker_compose_location: raw.docker_compose_location }
|
? { docker_compose_location: raw.docker_compose_location }
|
||||||
: {}),
|
: {}),
|
||||||
...(composeDomains ? { docker_compose_domains: composeDomains } : {}),
|
...(composeDomains ? { docker_compose_domains: composeDomains } : {}),
|
||||||
|
// is_static is read on every application so it is there to compare WHEN a
|
||||||
|
// manifest declares `static:`. computeDiff compares only fields the DESIRED
|
||||||
|
// side declares, so an app whose manifest omits `static` never diffs on it
|
||||||
|
// (which is what keeps this from PATCHing is_static off an un-migrated
|
||||||
|
// static app), and the three commands are the same — live carrying them
|
||||||
|
// here is safe and never reads as spurious drift. Coolify's Application
|
||||||
|
// model casts is_static to boolean; tolerate a 1/0 defensively.
|
||||||
|
is_static: raw.is_static === true || raw.is_static === 1,
|
||||||
|
...(raw.install_command ? { install_command: raw.install_command } : {}),
|
||||||
|
...(raw.build_command ? { build_command: raw.build_command } : {}),
|
||||||
|
...(raw.start_command ? { start_command: raw.start_command } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === "database") {
|
if (kind === "database") {
|
||||||
|
|
@ -2445,6 +2456,10 @@ export function applicationApiFields(
|
||||||
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
|
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
|
||||||
fields;
|
fields;
|
||||||
return {
|
return {
|
||||||
|
// is_static/install_command/build_command/start_command ride through `rest`
|
||||||
|
// unchanged: they are valid API params verbatim, accepted on both the create
|
||||||
|
// (POST /applications/private-github-app) and update (PATCH
|
||||||
|
// /applications/{uuid}) routes — verified against the vendored OpenAPI.
|
||||||
...rest,
|
...rest,
|
||||||
// ports_exposes wants a string; healthcheck -> health_check_path;
|
// ports_exposes wants a string; healthcheck -> health_check_path;
|
||||||
// domains wants a comma-separated string, not an array.
|
// domains wants a comma-separated string, not an array.
|
||||||
|
|
|
||||||
30
src/draft.ts
30
src/draft.ts
|
|
@ -435,9 +435,6 @@ function applicationSpec(
|
||||||
const NO_HOME: Array<[string, string]> = [
|
const NO_HOME: Array<[string, string]> = [
|
||||||
["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"],
|
["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"],
|
||||||
["ports_mappings", "host port mappings"],
|
["ports_mappings", "host port mappings"],
|
||||||
["install_command", "a custom install command"],
|
|
||||||
["build_command", "a custom build command"],
|
|
||||||
["start_command", "a custom start command"],
|
|
||||||
["pre_deployment_command", "a pre-deployment command"],
|
["pre_deployment_command", "a pre-deployment command"],
|
||||||
["post_deployment_command", "a post-deployment command"],
|
["post_deployment_command", "a post-deployment command"],
|
||||||
["dockerfile", "an inline Dockerfile"],
|
["dockerfile", "an inline Dockerfile"],
|
||||||
|
|
@ -483,6 +480,33 @@ function applicationSpec(
|
||||||
...(!compose && r.raw.publish_directory
|
...(!compose && r.raw.publish_directory
|
||||||
? { publish_directory: String(r.raw.publish_directory) }
|
? { publish_directory: String(r.raw.publish_directory) }
|
||||||
: {}),
|
: {}),
|
||||||
|
// The three build/run commands and the static flag now HAVE manifest
|
||||||
|
// fields (see manifest.ts), so a rebuild carries them instead of silently
|
||||||
|
// dropping them. `is_static` was previously not even in NO_HOME, so a
|
||||||
|
// rebuild lost it without a word — that is exactly the #63 crash: a static
|
||||||
|
// site whose is_static was true on the box came back as false, got built
|
||||||
|
// and RUN from the repo-root package.json, and crash-looped. draft now
|
||||||
|
// carries it. (Compose apps get none of these — see the else above.)
|
||||||
|
...(!compose && r.raw.install_command
|
||||||
|
? { install_command: String(r.raw.install_command) }
|
||||||
|
: {}),
|
||||||
|
...(!compose && r.raw.build_command
|
||||||
|
? { build_command: String(r.raw.build_command) }
|
||||||
|
: {}),
|
||||||
|
...(!compose && r.raw.start_command
|
||||||
|
? { start_command: String(r.raw.start_command) }
|
||||||
|
: {}),
|
||||||
|
// Gated on publish_directory as well: the schema refuses `static: true`
|
||||||
|
// with nothing to serve, and a draft must only ever emit a manifest that
|
||||||
|
// loads. Coolify's static apps carry a publish_directory (its default is
|
||||||
|
// `/`), so this drops `static` only for a box in a state the manifest could
|
||||||
|
// not express anyway — a loud, correct omission rather than an unloadable
|
||||||
|
// file.
|
||||||
|
...(!compose &&
|
||||||
|
(r.raw.is_static === true || r.raw.is_static === 1) &&
|
||||||
|
r.raw.publish_directory
|
||||||
|
? { static: true }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
...(compose
|
...(compose
|
||||||
? {}
|
? {}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,19 @@ const AppSpecSchema = z
|
||||||
base_directory: repoDirectoryPath("base_directory"),
|
base_directory: repoDirectoryPath("base_directory"),
|
||||||
publish_directory: repoDirectoryPath("publish_directory").optional(),
|
publish_directory: repoDirectoryPath("publish_directory").optional(),
|
||||||
compose_file: composeFilePath.optional(),
|
compose_file: composeFilePath.optional(),
|
||||||
|
// The three build/run commands and the static flag Coolify accepts on
|
||||||
|
// both the create (POST /applications/private-github-app) and the
|
||||||
|
// update (PATCH /applications/{uuid}) routes. They are free-form
|
||||||
|
// strings passed through verbatim — cast does not parse or validate the
|
||||||
|
// shell in them, only whether they belong on this pack (superRefine
|
||||||
|
// below). `static` maps to Coolify's `is_static`: it makes Coolify
|
||||||
|
// SERVE `publish_directory` and run NO start command, which is exactly
|
||||||
|
// the fix for a static site in a workspace monorepo that otherwise gets
|
||||||
|
// built and RUN from the repo-root package.json (#63).
|
||||||
|
install_command: z.string().optional(),
|
||||||
|
build_command: z.string().optional(),
|
||||||
|
start_command: z.string().optional(),
|
||||||
|
static: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
port: z.number().int().optional(),
|
port: z.number().int().optional(),
|
||||||
|
|
@ -75,6 +88,26 @@ const AppSpecSchema = z
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "publish_directory not allowed on a dockercompose app",
|
message: "publish_directory not allowed on a dockercompose app",
|
||||||
});
|
});
|
||||||
|
// A compose app builds and runs from its compose file — Coolify never
|
||||||
|
// consults these on it. Reject them at parse time rather than post them
|
||||||
|
// and have them silently ignored (the same reasoning as publish_directory
|
||||||
|
// and port above).
|
||||||
|
for (const k of [
|
||||||
|
"install_command",
|
||||||
|
"build_command",
|
||||||
|
"start_command",
|
||||||
|
] as const)
|
||||||
|
if (app.build[k] !== undefined)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `build.${k} not allowed on a dockercompose app (it builds from its compose file)`,
|
||||||
|
});
|
||||||
|
if (app.build.static !== undefined)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"build.static not allowed on a dockercompose app (a compose file decides what is served)",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!app.domains)
|
if (!app.domains)
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|
@ -87,6 +120,16 @@ const AppSpecSchema = z
|
||||||
message:
|
message:
|
||||||
"service_domains/compose_file only allowed with pack dockercompose",
|
"service_domains/compose_file only allowed with pack dockercompose",
|
||||||
});
|
});
|
||||||
|
// `static: true` tells Coolify to serve publish_directory and run no
|
||||||
|
// start command — so a static app with nothing to serve is almost
|
||||||
|
// certainly a mistake, and one that would deploy green while serving an
|
||||||
|
// empty site. Catch it in the file, once, not on a live box.
|
||||||
|
if (app.build.static === true && !app.build.publish_directory)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"build.static: true serves publish_directory and runs no start command — but no publish_directory is set, so there is nothing to serve",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,31 @@ export function desiredFromManifest(
|
||||||
...(app.port !== undefined ? { port: app.port } : {}),
|
...(app.port !== undefined ? { port: app.port } : {}),
|
||||||
...(app.healthcheck ? { healthcheck: app.healthcheck } : {}),
|
...(app.healthcheck ? { healthcheck: app.healthcheck } : {}),
|
||||||
domains: app.domains,
|
domains: app.domains,
|
||||||
|
// Emitted only when the manifest DECLARES `static:` — like the
|
||||||
|
// three commands, not unconditionally. Emitting `is_static:false`
|
||||||
|
// on every non-compose app would make the first apply after this
|
||||||
|
// ships PATCH `is_static=false` onto any static/SPA app configured
|
||||||
|
// in the UI whose manifest has not yet been migrated — silently
|
||||||
|
// disabling static serving and re-creating the #63 crash, now
|
||||||
|
// caused by cast. And a `pack: static` app that Coolify couples to
|
||||||
|
// is_static=true would drift-and-revert forever. So managing
|
||||||
|
// is_static is opt-in: declare `static: true` to serve, `static:
|
||||||
|
// false` to actively guard against a UI flip to true, or omit it to
|
||||||
|
// leave the field alone. (Coolify keeps pack and is_static
|
||||||
|
// independent, which is why this stays an explicit field, not a
|
||||||
|
// heuristic off `pack`.)
|
||||||
|
...(app.build.static !== undefined
|
||||||
|
? { is_static: app.build.static }
|
||||||
|
: {}),
|
||||||
|
...(app.build.install_command !== undefined
|
||||||
|
? { install_command: app.build.install_command }
|
||||||
|
: {}),
|
||||||
|
...(app.build.build_command !== undefined
|
||||||
|
? { build_command: app.build.build_command }
|
||||||
|
: {}),
|
||||||
|
...(app.build.start_command !== undefined
|
||||||
|
? { start_command: app.build.start_command }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
env: resolveEnvFile(name, app.env_template),
|
env: resolveEnvFile(name, app.env_template),
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,81 @@ describe("planDraft — the emitted shape", () => {
|
||||||
expect(env.generated_secrets).toEqual(["DATABASE_URL"]);
|
expect(env.generated_secrets).toEqual(["DATABASE_URL"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #63: is_static was previously not even in NO_HOME, so a rebuild silently
|
||||||
|
// lost it — the exact crash. draft now carries it, plus the install/build/
|
||||||
|
// start commands that used to be flagged as NO_HOME.
|
||||||
|
it("carries static + install/build/start commands, and does NOT flag them as uncaptured", () => {
|
||||||
|
const p = project({
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
kind: "application",
|
||||||
|
name: "Landing",
|
||||||
|
uuid: "a1",
|
||||||
|
raw: {
|
||||||
|
git_repository: "https://github.com/heavy-duty/incubator",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "static",
|
||||||
|
base_directory: "/",
|
||||||
|
publish_directory: "/apps/landing-site/dist",
|
||||||
|
fqdn: "https://landing.example.com",
|
||||||
|
is_static: true,
|
||||||
|
install_command: "npm ci",
|
||||||
|
build_command: "npm run build -w apps/landing-site",
|
||||||
|
},
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const plan = planDraft([p], ctx);
|
||||||
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||||
|
const path = join(dir, "manifest.yaml");
|
||||||
|
writeFileSync(path, manifest?.content ?? "");
|
||||||
|
const build =
|
||||||
|
loadManifest(path).environments.prod.applications.Landing.build;
|
||||||
|
expect(build.static).toBe(true);
|
||||||
|
expect(build.install_command).toBe("npm ci");
|
||||||
|
expect(build.build_command).toBe("npm run build -w apps/landing-site");
|
||||||
|
// These now have a manifest home, so they must NOT be reported as settings
|
||||||
|
// cast could see but not express.
|
||||||
|
for (const setting of ["is_static", "install_command", "build_command"]) {
|
||||||
|
expect(plan.uncaptured.some((u) => u.setting === setting)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A draft must only ever emit a manifest that LOADS. is_static true with no
|
||||||
|
// publish_directory would be `static: true` with nothing to serve, which the
|
||||||
|
// schema refuses — so draft omits `static` for that (rare, malformed) box
|
||||||
|
// rather than writing a file that throws on load.
|
||||||
|
it("does not emit static:true when the box has is_static but no publish_directory", () => {
|
||||||
|
const p = project({
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
kind: "application",
|
||||||
|
name: "Odd",
|
||||||
|
uuid: "a1",
|
||||||
|
raw: {
|
||||||
|
git_repository: "https://github.com/heavy-duty/incubator",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
base_directory: "/",
|
||||||
|
fqdn: "https://odd.example.com",
|
||||||
|
is_static: true,
|
||||||
|
},
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const plan = planDraft([p], ctx);
|
||||||
|
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
|
||||||
|
const path = join(dir, "manifest.yaml");
|
||||||
|
writeFileSync(path, manifest?.content ?? "");
|
||||||
|
// The whole point: it loads (does not throw), and simply carries no `static`.
|
||||||
|
const build = loadManifest(path).environments.prod.applications.Odd.build;
|
||||||
|
expect(build).not.toHaveProperty("static");
|
||||||
|
});
|
||||||
|
|
||||||
it("emits an env template every cast reader can parse", () => {
|
it("emits an env template every cast reader can parse", () => {
|
||||||
const plan = planDraft([project()], ctx);
|
const plan = planDraft([project()], ctx);
|
||||||
const tpl = plan.files.find((f) => f.path.endsWith(".env.template"));
|
const tpl = plan.files.find((f) => f.path.endsWith(".env.template"));
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,77 @@ environments:
|
||||||
}),
|
}),
|
||||||
).toThrow(/service_domains/);
|
).toThrow(/service_domains/);
|
||||||
});
|
});
|
||||||
|
// #63: the working (hand-built) config for a static site in a workspace
|
||||||
|
// monorepo — is_static plus workspace-scoped install/build commands that build
|
||||||
|
// only the target app and serve its dist, instead of the repo root's start
|
||||||
|
// script booting a different workspace.
|
||||||
|
it("accepts install/build/start commands and static on a non-compose app, round-tripping them", () => {
|
||||||
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: `
|
||||||
|
project: widget
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
applications:
|
||||||
|
landing:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build:
|
||||||
|
pack: static
|
||||||
|
base_directory: /
|
||||||
|
publish_directory: /apps/landing-site/dist
|
||||||
|
install_command: npm ci
|
||||||
|
build_command: npm run build -w apps/landing-site
|
||||||
|
start_command: node server.js
|
||||||
|
static: true
|
||||||
|
domains: ["https://landing.example.com"]
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
const b = m.environments.prod.applications.landing.build;
|
||||||
|
expect(b.install_command).toBe("npm ci");
|
||||||
|
expect(b.build_command).toBe("npm run build -w apps/landing-site");
|
||||||
|
expect(b.start_command).toBe("node server.js");
|
||||||
|
expect(b.static).toBe(true);
|
||||||
|
expect(b.publish_directory).toBe("/apps/landing-site/dist");
|
||||||
|
});
|
||||||
|
it("rejects static: true with nothing to serve (no publish_directory)", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: `
|
||||||
|
project: widget
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
applications:
|
||||||
|
landing:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build: { pack: static, base_directory: /, static: true }
|
||||||
|
domains: ["https://landing.example.com"]
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
).toThrow(/nothing to serve/);
|
||||||
|
});
|
||||||
|
it("rejects install/build/start commands and static on a dockercompose app", () => {
|
||||||
|
for (const field of [
|
||||||
|
"install_command: npm ci",
|
||||||
|
"build_command: npm run build",
|
||||||
|
"start_command: node server.js",
|
||||||
|
"static: true",
|
||||||
|
]) {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: `
|
||||||
|
project: widget
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
applications:
|
||||||
|
core:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml, ${field} }
|
||||||
|
service_domains:
|
||||||
|
api: ["https://api.example.com"]
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
).toThrow(/not allowed on a dockercompose app/);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadBindings", () => {
|
describe("loadBindings", () => {
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,64 @@ environments:
|
||||||
value: "secret-v",
|
value: "secret-v",
|
||||||
secret: true,
|
secret: true,
|
||||||
});
|
});
|
||||||
|
// None of the four build settings are emitted for an app that declares none:
|
||||||
|
// managing is_static is opt-in (declaring it would otherwise PATCH static
|
||||||
|
// serving OFF on an un-migrated app), and the commands default to "let the
|
||||||
|
// build pack decide".
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("is_static");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("install_command");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("build_command");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("start_command");
|
||||||
|
});
|
||||||
|
it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".infra", "manifest.yaml"),
|
||||||
|
`project: widget
|
||||||
|
environments:
|
||||||
|
staging:
|
||||||
|
applications:
|
||||||
|
core:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build: { pack: nixpacks, base_directory: /, static: false }
|
||||||
|
domains: ["https://c.example.com"]
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const { desired } = desiredFromManifest(dir, "staging", {});
|
||||||
|
expect(desired[0].fields.is_static).toBe(false);
|
||||||
|
});
|
||||||
|
// #63: the static-site build settings a workspace monorepo needs.
|
||||||
|
it("emits is_static:true and the three commands for a non-compose app that declares them", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
|
mkdirSync(join(dir, ".infra"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".infra", "manifest.yaml"),
|
||||||
|
`project: widget
|
||||||
|
environments:
|
||||||
|
staging:
|
||||||
|
applications:
|
||||||
|
landing:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build:
|
||||||
|
pack: static
|
||||||
|
base_directory: /
|
||||||
|
publish_directory: /apps/landing-site/dist
|
||||||
|
install_command: npm ci
|
||||||
|
build_command: npm run build -w apps/landing-site
|
||||||
|
start_command: node server.js
|
||||||
|
static: true
|
||||||
|
domains: ["https://landing.example.com"]
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const { desired } = desiredFromManifest(dir, "staging", {});
|
||||||
|
expect(desired[0].fields).toMatchObject({
|
||||||
|
is_static: true,
|
||||||
|
install_command: "npm ci",
|
||||||
|
build_command: "npm run build -w apps/landing-site",
|
||||||
|
start_command: "node server.js",
|
||||||
|
publish_directory: "/apps/landing-site/dist",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// The reverse of what this file used to assert. `backup` was deliberately
|
// The reverse of what this file used to assert. `backup` was deliberately
|
||||||
// routed AROUND `fields` into a side channel, because live Coolify was
|
// routed AROUND `fields` into a side channel, because live Coolify was
|
||||||
|
|
@ -345,6 +403,12 @@ environments:
|
||||||
expect(desired[0].fields).not.toHaveProperty("port");
|
expect(desired[0].fields).not.toHaveProperty("port");
|
||||||
expect(desired[0].fields).not.toHaveProperty("healthcheck");
|
expect(desired[0].fields).not.toHaveProperty("healthcheck");
|
||||||
expect(desired[0].fields).not.toHaveProperty("domains");
|
expect(desired[0].fields).not.toHaveProperty("domains");
|
||||||
|
// A compose app builds from its compose file — none of the static/command
|
||||||
|
// fields belong on it, not even is_static (which every NON-compose app gets).
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("is_static");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("install_command");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("build_command");
|
||||||
|
expect(desired[0].fields).not.toHaveProperty("start_command");
|
||||||
});
|
});
|
||||||
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
|
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,37 @@ describe("projectLiveFields", () => {
|
||||||
});
|
});
|
||||||
expect(out).not.toHaveProperty("docker_compose_domains");
|
expect(out).not.toHaveProperty("docker_compose_domains");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always reports is_static as a boolean, tolerating Coolify's 1/0", () => {
|
||||||
|
expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Absent on the wire reads as false (its real default), never undefined —
|
||||||
|
// so it compares against the desired side, which always emits it.
|
||||||
|
expect(projectLiveFields("application", {}).is_static).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("compose app idempotency (review finding #2)", () => {
|
describe("compose app idempotency (review finding #2)", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue