feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
import { execFileSync, spawn } from "node:child_process";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
import { createServer } from "node:http";
|
|
|
|
|
import type { AddressInfo } from "node:net";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
|
|
|
import { decryptSecrets, encryptSecrets } from "../src/secrets.js";
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
import { tmp } from "./helpers/tmp.js";
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
|
|
|
|
|
// End-to-end: the real CLI, a real age identity, a stub Coolify holding real
|
|
|
|
|
// live values. The point is the store that comes out the other side — it is
|
|
|
|
|
// decrypted and asserted on, so "exactly the names the manifest requires, no
|
|
|
|
|
// more and no fewer" is checked against the actual ciphertext rather than
|
|
|
|
|
// against cast's own console output.
|
|
|
|
|
|
|
|
|
|
const SECRETS = {
|
|
|
|
|
// Points at the SOURCE box. Must NOT be carried over.
|
|
|
|
|
DATABASE_URL: "postgres://user:pw@SOURCE-BOX-postgres:5432/app",
|
|
|
|
|
MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET",
|
|
|
|
|
OPENROUTER_API_KEY: "sk-or-REAL-OPENROUTER-SECRET",
|
|
|
|
|
// A real founder. Must NOT be carried over to staging.
|
|
|
|
|
ADMIN_EMAIL: "founder@real-company.com",
|
|
|
|
|
// Live on the box, but the manifest never asks for it.
|
|
|
|
|
UNRELATED_LIVE_VAR: "nobody-asked-for-this",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let keyFile: string;
|
|
|
|
|
let recipient: string;
|
|
|
|
|
|
|
|
|
|
beforeAll(() => {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const dir = tmp("cast-age-");
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
keyFile = join(dir, "age-staging.key");
|
|
|
|
|
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
|
|
|
|
const pub = execFileSync("age-keygen", ["-y", keyFile], { encoding: "utf8" });
|
|
|
|
|
recipient = pub.trim();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
type Stub = { url: string; close: () => Promise<void> };
|
|
|
|
|
const stubs: Stub[] = [];
|
|
|
|
|
|
|
|
|
|
// A Coolify with one project, one environment, one application carrying the
|
|
|
|
|
// live env above.
|
|
|
|
|
async function stubCoolify(): Promise<Stub> {
|
|
|
|
|
const server = createServer((req, res) => {
|
|
|
|
|
const path = (req.url ?? "").replace("/api/v1", "");
|
|
|
|
|
const json = (body: unknown) => {
|
|
|
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
|
|
|
res.end(JSON.stringify(body));
|
|
|
|
|
};
|
|
|
|
|
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
|
|
|
|
|
if (path === "/projects") return json([{ uuid: "p1", name: "incubator" }]);
|
|
|
|
|
if (path === "/projects/p1/staging")
|
|
|
|
|
return json({ applications: [{ name: "core", uuid: "a1" }] });
|
|
|
|
|
if (path === "/applications/a1/envs")
|
|
|
|
|
return json(
|
|
|
|
|
Object.entries(SECRETS).map(([key, real_value]) => ({
|
|
|
|
|
key,
|
|
|
|
|
real_value,
|
|
|
|
|
value: "REDACTED",
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
res.writeHead(404);
|
|
|
|
|
res.end("{}");
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((r) => {
|
|
|
|
|
server.listen(0, "127.0.0.1", r);
|
|
|
|
|
});
|
|
|
|
|
const stub: Stub = {
|
|
|
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
|
|
|
close: () =>
|
|
|
|
|
new Promise<void>((r) => {
|
|
|
|
|
server.close(() => r());
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
stubs.push(stub);
|
|
|
|
|
return stub;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(stubs.splice(0).map((s) => s.close()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const MANIFEST = `project: incubator
|
|
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
generated_secrets: [DATABASE_URL_STAGING]
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: heavy-duty/incubator, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: / }
|
|
|
|
|
domains: ["http://core.example.com"]
|
|
|
|
|
env_template: core.staging.env.template
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// NODE_ENV is a literal, not a secret — it must not reach the store.
|
|
|
|
|
const TEMPLATE = `NODE_ENV=production
|
|
|
|
|
DATABASE_URL=\${DATABASE_URL_STAGING}
|
|
|
|
|
MAILGUN_API_KEY=\${MAILGUN_API_KEY}
|
|
|
|
|
OPENROUTER_API_KEY=\${OPENROUTER_API_KEY}
|
|
|
|
|
ADMIN_EMAIL=\${ADMIN_EMAIL}
|
|
|
|
|
`;
|
|
|
|
|
|
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
|
|
|
function fixture(
|
|
|
|
|
url: string,
|
|
|
|
|
opts: { template?: string; manifest?: string } = {},
|
|
|
|
|
) {
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const checkout = tmp("cast-co-");
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
|
|
|
writeFileSync(
|
|
|
|
|
join(checkout, ".infra", "manifest.yaml"),
|
|
|
|
|
opts.manifest ?? MANIFEST,
|
|
|
|
|
);
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
writeFileSync(
|
|
|
|
|
join(checkout, ".infra", "env", "core.staging.env.template"),
|
|
|
|
|
opts.template ?? TEMPLATE,
|
|
|
|
|
);
|
|
|
|
|
|
fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
2026-07-19 23:38:53 +00:00
|
|
|
const state = tmp("cast-state-");
|
feat: cast capture — adopt a hand-built Coolify into the age secret store (#15)
cast was scoped to the steady state: manifest → Coolify, forever. It had no
adoption path — no way to bootstrap the age store from an instance built by
hand, before any manifest existed. The operator did it by hand: curl the envs,
assemble 17 name=value pairs into /dev/shm/prod.env, age -r, shred. Every input
to that pipeline is something cast already has, so a human was shuffling cast's
own inputs through a terminal, with the leak (scrollback, history, a tmp file
that never got shredded) and the silent miss both live.
cast capture <org>/<repo> --env <env> [--generated N] [--override N] [--force]
The required set comes from the MANIFEST, not the box: the ${...} refs in that
environment's env templates, read by the same parser apply uses to demand them.
resolveTemplate and templateRefs now share one grammar — a drift between them
would mean capture collects a different set than apply later requires, which is
exactly the "a name silently missed" failure this verb exists to remove.
The mapping is deliberately NOT mechanical. A DATABASE_URL read off the source
points at the SOURCE box's Postgres: confidently wrong, entirely plausible, and
the target's real URL does not exist until Coolify creates the resource. So the
manifest declares `generated_secrets:` and those names are written as the
literal `pending-coolify-generated`. staging's ADMIN_EMAIL must be the operator,
not the source's — staging and prod share a Mailgun domain, so a staging box
carrying the real address can mail real users; that is --override.
A "capture everything" verb would be wrong in ~4 of 17 entries, silently —
worse than being wrong in all of them. So every name is forced into a
disposition, and two of the four stop the run: a name required by a template but
absent from the source REFUSES (an empty substitutes to nothing and the app
boots misconfigured), as does one name carrying different values on two
resources.
generated_secrets is a manifest property rather than a flag the operator must
remember, because the manifest is what knows DATABASE_URL comes from a database
it declares. An entry no template refers to is a hard error: a guard standing
over nothing reads like a guard, and the likeliest cause is a typo whose real
name is then captured from the source instead of placeheld.
Secret hygiene, all covered by tests asserting on real values:
- the plan prints names and provenance, NEVER values
- an --override's value comes from $CAST_CAPTURE_<NAME>, never argv (`ps`)
- plaintext is piped to age on stdin — never a temp file, stdout, or history
- an existing store is not overwritten without --force: it may hold the only
copy of values the source no longer has (apply's never-delete, applied here)
capture inherits diff's absent-target refusal (D-237) — against a project that
isn't there it would report every secret as missing, an alarming report about
the wrong box — plus the team assert and the --path/--env prod ban. The last
gate is a typed confirmation of the environment's name; there is no --yes.
The end-to-end test decrypts the store cast wrote and asserts on its contents,
so "exactly the names the manifest requires, no more and no fewer" is checked
against real ciphertext rather than against cast's own console output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:51:43 +00:00
|
|
|
mkdirSync(join(state, "secrets"));
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, ".coolify.env"),
|
|
|
|
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
|
|
|
|
);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(state, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" staging:",
|
|
|
|
|
" server: staging-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
` age_recipient: ${recipient}`,
|
|
|
|
|
"github_apps:",
|
|
|
|
|
" incubator: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
checkout,
|
|
|
|
|
state,
|
|
|
|
|
store: join(state, "secrets", "incubator.staging.env.age"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runCapture(
|
|
|
|
|
args: string[],
|
|
|
|
|
opts: { stdin?: string; env?: Record<string, string> } = {},
|
|
|
|
|
): Promise<{ code: number; output: string }> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const child = spawn("node", ["dist/cli.js", "capture", ...args], {
|
|
|
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
|
|
|
env: { ...process.env, ...opts.env },
|
|
|
|
|
});
|
|
|
|
|
let output = "";
|
|
|
|
|
child.stdout.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stderr.on("data", (d) => {
|
|
|
|
|
output += String(d);
|
|
|
|
|
});
|
|
|
|
|
child.stdin.end(opts.stdin ?? "");
|
|
|
|
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const base = (f: ReturnType<typeof fixture>) => [
|
|
|
|
|
"heavy-duty/incubator",
|
|
|
|
|
"--env",
|
|
|
|
|
"staging",
|
|
|
|
|
"--state",
|
|
|
|
|
f.state,
|
|
|
|
|
"--path",
|
|
|
|
|
f.checkout,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("cast capture (end to end)", () => {
|
|
|
|
|
it("writes a store with exactly the manifest's names, and the right provenance", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(existsSync(f.store)).toBe(true);
|
|
|
|
|
|
|
|
|
|
const store = decryptSecrets(f.store, keyFile);
|
|
|
|
|
// No more, no fewer: the four ${...} refs. NODE_ENV is a literal, and
|
|
|
|
|
// UNRELATED_LIVE_VAR is live but unasked-for — neither belongs here.
|
|
|
|
|
expect(Object.keys(store).sort()).toEqual([
|
|
|
|
|
"ADMIN_EMAIL",
|
|
|
|
|
"DATABASE_URL_STAGING",
|
|
|
|
|
"MAILGUN_API_KEY",
|
|
|
|
|
"OPENROUTER_API_KEY",
|
|
|
|
|
]);
|
|
|
|
|
// Captured verbatim.
|
|
|
|
|
expect(store.MAILGUN_API_KEY).toBe(SECRETS.MAILGUN_API_KEY);
|
|
|
|
|
expect(store.OPENROUTER_API_KEY).toBe(SECRETS.OPENROUTER_API_KEY);
|
|
|
|
|
// Generated: the placeholder, NEVER the source box's own database URL.
|
|
|
|
|
expect(store.DATABASE_URL_STAGING).toBe("pending-coolify-generated");
|
|
|
|
|
expect(store.DATABASE_URL_STAGING).not.toContain("SOURCE-BOX");
|
|
|
|
|
// Overridden: the operator's value, not the real founder's address.
|
|
|
|
|
expect(store.ADMIN_EMAIL).toBe("operator@example.com");
|
|
|
|
|
expect(store.ADMIN_EMAIL).not.toBe(SECRETS.ADMIN_EMAIL);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// "No secret value is ever written to stdout" — checked against the real
|
|
|
|
|
// values the stub served, on the real console output of a real run.
|
|
|
|
|
it("never prints a secret value to the console", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
for (const value of Object.values(SECRETS)) {
|
|
|
|
|
expect(r.output).not.toContain(value);
|
|
|
|
|
}
|
|
|
|
|
expect(r.output).not.toContain("operator@example.com");
|
|
|
|
|
// It did print the NAMES, though — that is the plan.
|
|
|
|
|
expect(r.output).toContain("MAILGUN_API_KEY");
|
|
|
|
|
expect(r.output).toContain("captured");
|
|
|
|
|
expect(r.output).toContain("generated");
|
|
|
|
|
expect(r.output).toContain("overridden");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The plaintext exists only in memory and on age's stdin.
|
|
|
|
|
it("leaves no plaintext behind — the store is real ciphertext", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
const raw = readFileSync(f.store, "utf8");
|
|
|
|
|
expect(raw).toContain("age-encryption.org");
|
|
|
|
|
for (const value of Object.values(SECRETS)) {
|
|
|
|
|
expect(raw).not.toContain(value);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A name required by the template but absent from the source refuses the run
|
|
|
|
|
// — writing an empty would boot the app misconfigured, plausibly.
|
|
|
|
|
it("refuses when a required name is absent from the source", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url, {
|
|
|
|
|
template: `${TEMPLATE}TURNSTILE_SECRET=\${TURNSTILE_SECRET}\n`,
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/TURNSTILE_SECRET\s+MISSING/);
|
|
|
|
|
expect(r.output).toMatch(/refusing to write the store/);
|
|
|
|
|
expect(existsSync(f.store)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The confirmation is the last gate, and it is not "y".
|
|
|
|
|
it("aborts, writing nothing, when the confirmation does not name the env", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "y\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/aborted/);
|
|
|
|
|
expect(existsSync(f.store)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("aborts on a closed stdin rather than hanging", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/aborted/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// An override's VALUE never comes from argv — argv is visible in `ps`.
|
|
|
|
|
it("refuses an --override whose CAST_CAPTURE_<NAME> is unset", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/CAST_CAPTURE_ADMIN_EMAIL/);
|
|
|
|
|
expect(r.output).toMatch(/never from the command/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The store may hold the only copy of values the source box no longer has.
|
|
|
|
|
it("refuses to overwrite an existing store without --force", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
writeFileSync(f.store, "PRE-EXISTING");
|
|
|
|
|
const r = await runCapture([...base(f), "--override", "ADMIN_EMAIL"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: { CAST_CAPTURE_ADMIN_EMAIL: "operator@example.com" },
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/already exists/);
|
|
|
|
|
expect(r.output).toMatch(/--force/);
|
|
|
|
|
expect(readFileSync(f.store, "utf8")).toBe("PRE-EXISTING");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses an environment with no age_recipient", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
writeFileSync(
|
|
|
|
|
join(f.state, "environments.yaml"),
|
|
|
|
|
[
|
|
|
|
|
"environments:",
|
|
|
|
|
" staging:",
|
|
|
|
|
" server: staging-box",
|
|
|
|
|
" team: { id: 0, name: Root Team }",
|
|
|
|
|
"github_apps:",
|
|
|
|
|
" incubator: hdb-coolify",
|
|
|
|
|
"",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
const r = await runCapture(base(f), { stdin: "staging\n" });
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/age_recipient/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Same position as diff: capture is only ever a claim about something that
|
|
|
|
|
// already exists. Against an absent target it would call every secret
|
|
|
|
|
// "missing" — an alarming report about the wrong box.
|
|
|
|
|
it("refuses an absent target rather than reporting every secret missing", async () => {
|
|
|
|
|
const f = fixture((await stubCoolify()).url);
|
|
|
|
|
const r = await runCapture([...base(f), "--project", "typo"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/refusing to capture/);
|
|
|
|
|
expect(r.output).toMatch(/no project named "typo"/);
|
|
|
|
|
expect(r.output).not.toMatch(/MISSING/);
|
|
|
|
|
});
|
|
|
|
|
});
|
feat(capture): --generated-only, the bootstrap's missing pass 2
A manifest that declares `generated_secrets:` bootstraps in two passes by
construction: pass 1 `capture` placeholds those names (their values do not
exist yet), `apply` creates the database and Coolify generates the real URL —
and nothing then taught the store that value. The operator did it by hand:
decrypt a fourteen-name store, edit two lines, re-encrypt to the environment's
age recipient, against production, holding the prod key.
`capture --generated-only` inverts capture's disposition rule and changes
nothing else — it fills the generated names and leaves every other name in the
store exactly as it is, byte for byte. Same verb, same ceremony, same
store-writing code path.
- reads the value from the resource that OWNS it (`internal_db_url` on the
database), never from a consuming app's env, where a generated URL never
appears — the app's env holds the placeholder itself at this point.
- resolves the database inside the project+environment via
GET /projects/{uuid}/{env}, never the instance-wide GET /databases (which
lists other projects' databases and umami's bundled Postgres — #29's bug in
another hat). The scoping is structural, not a filter.
- refuses to guess which database a name comes from: nothing in the manifest,
the templates or the box carries that edge, so it infers only when it cannot
be wrong (one name, one database) and otherwise hands back `--from`.
- refuses to overwrite a generated name holding a real value without --force
(a silent credential rotation), a name absent from the store, and a
placeholder nothing fills.
- asserts the postcondition against the ciphertext on disk: zero
pending-coolify-generated remain, and the name count is unchanged. That
assertion was a line in a human runbook.
`apply` deliberately does NOT do this after a create — it would make the verb
that mutates Coolify also mutate the encrypted store, and hence the git repo.
Closes #48.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:32:07 +00:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Pass 2 — `cast capture --generated-only` (end to end)
|
|
|
|
|
//
|
|
|
|
|
// The bootstrap this closes: pass 1 wrote a store in which the generated names
|
|
|
|
|
// are placeholders, `apply` then created the database, and Coolify generated the
|
|
|
|
|
// real URL. Until this runs, the store's value is a lie while the live value is
|
|
|
|
|
// real — which is the state that makes the next routine apply overwrite a
|
|
|
|
|
// working secret (#47). Everything here runs against a real age identity and a
|
|
|
|
|
// real `dist/cli.js`; the store is decrypted afterwards and asserted on.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// The values Coolify generated when it created the resources. NOT reachable from
|
|
|
|
|
// any application's env — the app's env holds what the template resolved to,
|
|
|
|
|
// which at this point in the bootstrap is the placeholder itself.
|
|
|
|
|
const PG_URL = "postgres://postgres:GENERATED-PG-PASSWORD@abc123:5432/app";
|
|
|
|
|
const REDIS_URL = "redis://default:GENERATED-REDIS-PASSWORD@def456:6379/0";
|
|
|
|
|
// Another project's database entirely. `GET /databases` would list it right next
|
|
|
|
|
// to ours — it is umami's bundled Postgres, the row the hand-run `jq` had to be
|
|
|
|
|
// careful not to pick. A name-directed lookup across the instance eventually
|
|
|
|
|
// takes it, and what it writes is a well-formed URL to the wrong database.
|
|
|
|
|
const UMAMI_URL = "postgres://umami:UMAMI-PASSWORD@zzz999:5432/umami";
|
|
|
|
|
|
|
|
|
|
const GEN_MANIFEST = `project: incubator
|
|
|
|
|
environments:
|
|
|
|
|
staging:
|
|
|
|
|
generated_secrets: [DATABASE_URL, REDIS_URL]
|
|
|
|
|
applications:
|
|
|
|
|
core:
|
|
|
|
|
source: { repo: heavy-duty/incubator, branch: main }
|
|
|
|
|
build: { pack: nixpacks, base_directory: / }
|
|
|
|
|
domains: ["http://core.example.com"]
|
|
|
|
|
env_template: core.staging.env.template
|
|
|
|
|
databases:
|
|
|
|
|
incubator-db: { type: postgresql }
|
|
|
|
|
incubator-redis: { type: redis }
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const GEN_TEMPLATE = `NODE_ENV=production
|
|
|
|
|
DATABASE_URL=\${DATABASE_URL}
|
|
|
|
|
REDIS_URL=\${REDIS_URL}
|
|
|
|
|
MAILGUN_API_KEY=\${MAILGUN_API_KEY}
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
// What pass 1 left behind: the two generated names placeheld, the real secret
|
|
|
|
|
// captured. Pass 2 must fill the first two and not disturb the third.
|
|
|
|
|
const PASS1_STORE = {
|
|
|
|
|
DATABASE_URL: "pending-coolify-generated",
|
|
|
|
|
REDIS_URL: "pending-coolify-generated",
|
|
|
|
|
MAILGUN_API_KEY: "key-REAL-MAILGUN-SECRET",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Records every path the CLI asks for, so a test can assert on what cast did
|
|
|
|
|
// NOT call — `GET /databases` is instance-wide, and never reaching for it is the
|
|
|
|
|
// point, not an implementation detail.
|
|
|
|
|
async function stubCoolifyWithDatabases(): Promise<Stub & { hits: string[] }> {
|
|
|
|
|
const hits: string[] = [];
|
|
|
|
|
const server = createServer((req, res) => {
|
|
|
|
|
const path = (req.url ?? "").replace("/api/v1", "");
|
|
|
|
|
hits.push(path);
|
|
|
|
|
const json = (body: unknown) => {
|
|
|
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
|
|
|
res.end(JSON.stringify(body));
|
|
|
|
|
};
|
|
|
|
|
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
|
|
|
|
|
if (path === "/projects")
|
|
|
|
|
return json([
|
|
|
|
|
{ uuid: "p1", name: "incubator" },
|
|
|
|
|
{ uuid: "p2", name: "analytics" },
|
|
|
|
|
]);
|
|
|
|
|
// The project+environment document. Coolify's environment_details eager-loads
|
|
|
|
|
// these relations and serializes the models whole, so `internal_db_url` (an
|
|
|
|
|
// appended attribute on StandalonePostgresql / StandaloneRedis) rides along.
|
|
|
|
|
if (path === "/projects/p1/staging")
|
|
|
|
|
return json({
|
|
|
|
|
applications: [{ name: "core", uuid: "a1" }],
|
|
|
|
|
postgresqls: [
|
|
|
|
|
{ name: "incubator-db", uuid: "abc123", internal_db_url: PG_URL },
|
|
|
|
|
],
|
|
|
|
|
redis: [
|
|
|
|
|
{
|
|
|
|
|
name: "incubator-redis",
|
|
|
|
|
uuid: "def456",
|
|
|
|
|
internal_db_url: REDIS_URL,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
// Instance-wide: OURS and somebody else's, indistinguishable by name alone.
|
|
|
|
|
// Nothing in cast may read this.
|
|
|
|
|
if (path === "/databases")
|
|
|
|
|
return json([
|
|
|
|
|
{ name: "incubator-db", internal_db_url: PG_URL },
|
|
|
|
|
{ name: "incubator-redis", internal_db_url: REDIS_URL },
|
|
|
|
|
{ name: "incubator-db", internal_db_url: UMAMI_URL },
|
|
|
|
|
]);
|
|
|
|
|
// The app's env — it holds the PLACEHOLDER, which is exactly why the value
|
|
|
|
|
// must be read off the database instead.
|
|
|
|
|
if (path === "/applications/a1/envs")
|
|
|
|
|
return json([
|
|
|
|
|
{
|
|
|
|
|
key: "DATABASE_URL",
|
|
|
|
|
real_value: "pending-coolify-generated",
|
|
|
|
|
value: "REDACTED",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "MAILGUN_API_KEY",
|
|
|
|
|
real_value: "key-REAL-MAILGUN-SECRET",
|
|
|
|
|
value: "REDACTED",
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
res.writeHead(404);
|
|
|
|
|
res.end("{}");
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((r) => {
|
|
|
|
|
server.listen(0, "127.0.0.1", r);
|
|
|
|
|
});
|
|
|
|
|
const stub = {
|
|
|
|
|
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
|
|
|
|
close: () =>
|
|
|
|
|
new Promise<void>((r) => {
|
|
|
|
|
server.close(() => r());
|
|
|
|
|
}),
|
|
|
|
|
hits,
|
|
|
|
|
};
|
|
|
|
|
stubs.push(stub);
|
|
|
|
|
return stub;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A fixture whose store is already what pass 1 would have written.
|
|
|
|
|
function genFixture(url: string, store: Record<string, string> = PASS1_STORE) {
|
|
|
|
|
const f = fixture(url, { manifest: GEN_MANIFEST, template: GEN_TEMPLATE });
|
|
|
|
|
encryptSecrets(recipient, f.store, store);
|
|
|
|
|
return f;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const genBase = (f: ReturnType<typeof fixture>) => [
|
|
|
|
|
...base(f),
|
|
|
|
|
"--generated-only",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Pass 2 reads the store before it fills it, so it needs the age IDENTITY —
|
|
|
|
|
// not just the recipient pass 1 needed.
|
|
|
|
|
const withKey = () => ({ CAST_AGE_KEY_FILE_STAGING: keyFile });
|
|
|
|
|
|
|
|
|
|
const FROM = [
|
|
|
|
|
"--from",
|
|
|
|
|
"DATABASE_URL=incubator-db",
|
|
|
|
|
"--from",
|
|
|
|
|
"REDIS_URL=incubator-redis",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
describe("cast capture --generated-only (end to end)", () => {
|
|
|
|
|
it("fills the generated names from the databases that own them, and leaves the rest alone", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
|
|
|
|
|
const store = decryptSecrets(f.store, keyFile);
|
|
|
|
|
// The postcondition, asserted on the actual ciphertext: zero placeholders,
|
|
|
|
|
// and the name count unchanged.
|
|
|
|
|
expect(Object.keys(store).sort()).toEqual([
|
|
|
|
|
"DATABASE_URL",
|
|
|
|
|
"MAILGUN_API_KEY",
|
|
|
|
|
"REDIS_URL",
|
|
|
|
|
]);
|
|
|
|
|
expect(Object.values(store)).not.toContain("pending-coolify-generated");
|
|
|
|
|
// Each from the resource that OWNS it — and the redis one is a redis URL,
|
|
|
|
|
// not the Postgres URL under a redis name.
|
|
|
|
|
expect(store.DATABASE_URL).toBe(PG_URL);
|
|
|
|
|
expect(store.REDIS_URL).toBe(REDIS_URL);
|
|
|
|
|
// Carried over byte for byte. Pass 2 never re-read it from the box.
|
|
|
|
|
expect(store.MAILGUN_API_KEY).toBe("key-REAL-MAILGUN-SECRET");
|
|
|
|
|
// Never the other project's database.
|
|
|
|
|
expect(store.DATABASE_URL).not.toContain("UMAMI");
|
|
|
|
|
expect(r.output).toMatch(/zero pending-coolify-generated remaining/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The #29 hazard, structurally: the value is resolved inside the project and
|
|
|
|
|
// environment, so the instance-wide list is never even consulted.
|
|
|
|
|
it("never reads the instance-wide database list", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(stub.hits).toContain("/projects/p1/staging");
|
|
|
|
|
expect(stub.hits).not.toContain("/databases");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// capture.ts:166's rule holds in pass 2: the only value-shaped thing printed
|
|
|
|
|
// is the placeholder literal being replaced.
|
|
|
|
|
it("never prints a secret value to the console", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
for (const value of [PG_URL, REDIS_URL, "key-REAL-MAILGUN-SECRET"]) {
|
|
|
|
|
expect(r.output).not.toContain(value);
|
|
|
|
|
}
|
|
|
|
|
expect(r.output).not.toContain("GENERATED-PG-PASSWORD");
|
|
|
|
|
// The NAMES, and where each value came from, are the plan.
|
|
|
|
|
expect(r.output).toMatch(/DATABASE_URL\s+fill/);
|
|
|
|
|
expect(r.output).toContain("incubator-db (postgresql) internal_db_url");
|
|
|
|
|
expect(r.output).toMatch(/MAILGUN_API_KEY\s+keep/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Nothing in the manifest, the templates or the box says DATABASE_URL comes
|
|
|
|
|
// from the postgres one. cast will not guess — it hands back the flag.
|
|
|
|
|
it("refuses to guess which database a name comes from, and says how to say it", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture(genBase(f), {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/UNMAPPED/);
|
|
|
|
|
expect(r.output).toContain("--from DATABASE_URL=<database name>");
|
|
|
|
|
// Refused BEFORE the store was touched.
|
|
|
|
|
expect(decryptSecrets(f.store, keyFile)).toEqual(PASS1_STORE);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The refusal that keeps this from being a silent credential rotation.
|
|
|
|
|
it("refuses to overwrite a generated name that already holds a real value", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url, {
|
|
|
|
|
...PASS1_STORE,
|
|
|
|
|
DATABASE_URL: "postgres://set:BY-HAND@live:5432/app",
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/OCCUPIED/);
|
|
|
|
|
expect(r.output).toMatch(/rotate a live credential/);
|
|
|
|
|
expect(r.output).toMatch(/--force/);
|
|
|
|
|
// Untouched — including the name it COULD have filled. A refusal is a stop,
|
|
|
|
|
// not a partial write.
|
|
|
|
|
const store = decryptSecrets(f.store, keyFile);
|
|
|
|
|
expect(store.DATABASE_URL).toBe("postgres://set:BY-HAND@live:5432/app");
|
|
|
|
|
expect(store.REDIS_URL).toBe("pending-coolify-generated");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("--force rotates it deliberately", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url, {
|
|
|
|
|
...PASS1_STORE,
|
|
|
|
|
DATABASE_URL: "postgres://set:BY-HAND@live:5432/app",
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM, "--force"], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).toBe(0);
|
|
|
|
|
expect(decryptSecrets(f.store, keyFile).DATABASE_URL).toBe(PG_URL);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Pass 2 fills a store; it does not create one. A store written from here
|
|
|
|
|
// would hold the generated names and nothing else.
|
|
|
|
|
it("refuses when the store does not exist — pass 1 has not run", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = fixture(stub.url, {
|
|
|
|
|
manifest: GEN_MANIFEST,
|
|
|
|
|
template: GEN_TEMPLATE,
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/does not exist/);
|
|
|
|
|
expect(r.output).toMatch(/Pass 2 FILLS the generated names/);
|
|
|
|
|
expect(existsSync(f.store)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A placeholder nobody fills would leave the store still lying — and the next
|
|
|
|
|
// apply would push that literal at a live app.
|
|
|
|
|
it("refuses to leave a placeholder standing in a name it does not fill", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url, {
|
|
|
|
|
...PASS1_STORE,
|
|
|
|
|
MAILGUN_API_KEY: "pending-coolify-generated",
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "staging\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/MAILGUN_API_KEY\s+PENDING/);
|
|
|
|
|
expect(r.output).toMatch(/would still hold the/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The confirmation is the same last gate as pass 1, and it is not "y".
|
|
|
|
|
it("aborts, writing nothing, when the confirmation does not name the env", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture([...genBase(f), ...FROM], {
|
|
|
|
|
stdin: "y\n",
|
|
|
|
|
env: withKey(),
|
|
|
|
|
});
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/aborted/);
|
|
|
|
|
expect(decryptSecrets(f.store, keyFile)).toEqual(PASS1_STORE);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A flag pairing that can never be honored: pass 2 captures nothing, so there
|
|
|
|
|
// is nothing for an override to override.
|
|
|
|
|
it("refuses --override with --generated-only", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture(
|
|
|
|
|
[...genBase(f), ...FROM, "--override", "MAILGUN_API_KEY"],
|
|
|
|
|
{ stdin: "staging\n", env: withKey() },
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/refuses --override with --generated-only/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refuses --from without --generated-only", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = fixture(stub.url, {
|
|
|
|
|
manifest: GEN_MANIFEST,
|
|
|
|
|
template: GEN_TEMPLATE,
|
|
|
|
|
});
|
|
|
|
|
const r = await runCapture([...base(f), ...FROM], { stdin: "staging\n" });
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/refuses --from without --generated-only/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// A --from for a name that is not generated would be silently ignored, and
|
|
|
|
|
// the operator would walk away believing they had set a value.
|
|
|
|
|
it("refuses a --from naming something that is not a generated secret", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture(
|
|
|
|
|
[...genBase(f), "--from", "MAILGUN_API_KEY=incubator-db"],
|
|
|
|
|
{ stdin: "staging\n", env: withKey() },
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/is not a generated secret/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --resource maps manifest names to box names for the env-reading pass, and pass
|
|
|
|
|
// 2 reads no env. Accepted, it would be silently ignored.
|
|
|
|
|
describe("cast capture --generated-only (flag hygiene)", () => {
|
|
|
|
|
it("refuses --resource with --generated-only", async () => {
|
|
|
|
|
const stub = await stubCoolifyWithDatabases();
|
|
|
|
|
const f = genFixture(stub.url);
|
|
|
|
|
const r = await runCapture(
|
|
|
|
|
[...genBase(f), ...FROM, "--resource", "core=Core"],
|
|
|
|
|
{ stdin: "staging\n", env: withKey() },
|
|
|
|
|
);
|
|
|
|
|
expect(r.code).not.toBe(0);
|
|
|
|
|
expect(r.output).toMatch(/refuses --resource with --generated-only/);
|
|
|
|
|
});
|
|
|
|
|
});
|