Merge pull request #125 from dan-claude-bot/feat/app-basic-auth
feat: an application can declare HTTP basic auth, and apply sets it
This commit is contained in:
commit
f2c2bb3470
14 changed files with 1651 additions and 14 deletions
80
CHANGELOG.md
80
CHANGELOG.md
|
|
@ -7,6 +7,72 @@ actually cutting it, and this file starts there.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **An application can declare HTTP basic auth, and `apply` sets it**
|
||||||
|
(#76) — `UNCAPTURED.md` has said for as long as it has existed that
|
||||||
|
Basic Auth is "carried as raw container labels. cast's manifest has no
|
||||||
|
field for them, so a rebuilt resource is UNPROTECTED where the original
|
||||||
|
was not." The #72 audit found that half of that is a **cast vocabulary
|
||||||
|
gap, not a Coolify one**: `is_http_basic_auth_enabled`,
|
||||||
|
`http_basic_auth_username` and `http_basic_auth_password` are in both
|
||||||
|
the create and the PATCH allowlists at v4.1.2
|
||||||
|
(`ApplicationsController.php:914`, `:2368`). So an application now
|
||||||
|
declares it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
admin:
|
||||||
|
basic_auth:
|
||||||
|
enabled: true
|
||||||
|
username: ops
|
||||||
|
password: ${ADMIN_BASIC_AUTH_PROD}
|
||||||
|
```
|
||||||
|
|
||||||
|
The password is a **store ref and only a store ref** — the schema
|
||||||
|
refuses a literal rather than warning about one, because a manifest is
|
||||||
|
a reviewed, committed artifact and a literal there is a live password
|
||||||
|
in git forever. It resolves out of the environment's age store through
|
||||||
|
the same mechanism every env-template `${REF}` uses, and a missing or
|
||||||
|
empty entry fails the run before anything is written.
|
||||||
|
|
||||||
|
Managing it is **opt-in**, the `is_static` rule for the same reason one
|
||||||
|
notch sharper: an unconditional `is_http_basic_auth_enabled: false`
|
||||||
|
would make the first apply after this ships strip the protection off
|
||||||
|
every app somebody enabled by hand in the UI. Omit the block to say
|
||||||
|
nothing, `enabled: false` to assert it is off. Enabling without both
|
||||||
|
credentials is refused at parse time *and* at the wire — Coolify's own
|
||||||
|
rule (`:2446-2463`), enforced before the request rather than discovered
|
||||||
|
as a 422 halfway through a run.
|
||||||
|
|
||||||
|
**The read side is fail-honest, because the three fields do not read
|
||||||
|
back alike.** The toggle and the username are plain columns and are
|
||||||
|
compared like anything else — somebody turning basic auth off in the UI
|
||||||
|
*is* caught, which is most of the value. The password is gated behind a
|
||||||
|
sensitive-data-enabled token at 4.1.2 and behind the `read:sensitive`
|
||||||
|
ability on v4.2 (#77), so whether it arrives depends on the token, the
|
||||||
|
route and the release — and it would have to be *printed*, since a
|
||||||
|
field diff renders as `field: <live> → <desired>`. So cast never
|
||||||
|
projects it into the comparison vocabulary on any box, and every diff
|
||||||
|
of an app declaring `basic_auth:` prints `http_basic_auth_password NOT
|
||||||
|
compared — verify in the Coolify UI`. Same disposition as an
|
||||||
|
unverifiable backup schedule: reported, not counted as drift. A read
|
||||||
|
returning none of the three names all three on that line instead, and
|
||||||
|
claims nothing at all.
|
||||||
|
|
||||||
|
The limit that follows is stated rather than hidden: rotating *only*
|
||||||
|
the password in the store produces no field diff and therefore no
|
||||||
|
write. It lands on the next apply that touches basic auth for any other
|
||||||
|
reason — Coolify requires both credentials on any write that enables
|
||||||
|
it, so cast completes the whole triple whenever it sends one.
|
||||||
|
|
||||||
|
`custom_labels` is **deliberately still absent**, though it is equally
|
||||||
|
settable: enabling basic auth or changing domains makes Coolify
|
||||||
|
regenerate an application's labels and overwrite `custom_labels` unless
|
||||||
|
`is_container_label_readonly_enabled`, which is itself not API-settable
|
||||||
|
until v4.2. Declaring both on one app would have cast silently destroy
|
||||||
|
what it was told to write. Basic-auth-only is the safe slice until
|
||||||
|
then.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`state:needs-human` no longer waits on the cron to become true** (#131)
|
- **`state:needs-human` no longer waits on the cron to become true** (#131)
|
||||||
|
|
@ -81,6 +147,20 @@ actually cutting it, and this file starts there.
|
||||||
`blocker:unrequested`: `MISSING` (nobody reviewed) and `STALE` (everybody
|
`blocker:unrequested`: `MISSING` (nobody reviewed) and `STALE` (everybody
|
||||||
reviewed an older head). Fixtures 51 → 72.
|
reviewed an older head). Fixtures 51 → 72.
|
||||||
|
|
||||||
|
- **The `NO_API_COVERAGE` row for Basic Auth now says *services*** (#76)
|
||||||
|
— the blanket row covered applications and services alike, and became
|
||||||
|
wrong for half of them the moment applications could say `basic_auth:`.
|
||||||
|
What survives is a real API gap rather than a vocabulary one:
|
||||||
|
`ServicesController` carries no basic-auth fields and no
|
||||||
|
`custom_labels`, on v4.1.2 or on the v4.2 train, so no manifest field
|
||||||
|
could ever set them. A separate row now covers `custom_labels` on
|
||||||
|
applications — writable, deliberately unwired, with the overwrite
|
||||||
|
caveat spelled out — so neither row implies cast can express something
|
||||||
|
it cannot. `inventory --emit-draft` also reports, per application, an
|
||||||
|
app whose basic auth is enabled on the box: it emits no `basic_auth:`
|
||||||
|
block, because the password cannot be read and a block a rebuild cannot
|
||||||
|
honour is exactly the failure `UNCAPTURED.md` exists to prevent.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
- **A PR that deletes a shipped release heading is now CI-red** (#133,
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -397,6 +397,19 @@ standing over nothing is worse than no guard, because it reads like one.
|
||||||
> prints in a diff like any literal. Applications only. See
|
> prints in a diff like any literal. Applications only. See
|
||||||
> [semantics.md](docs/semantics.md) → *Derived domains*.
|
> [semantics.md](docs/semantics.md) → *Derived domains*.
|
||||||
|
|
||||||
|
> **An application can declare HTTP basic auth, and its password is a `${REF}`
|
||||||
|
> like any other secret.** Write
|
||||||
|
> `basic_auth: { enabled: true, username: ops, password: ${ADMIN_PW_PROD} }` on
|
||||||
|
> the application, and `apply` sets it — closing the "a rebuilt resource comes
|
||||||
|
> back UNPROTECTED" hole for applications (services have no API for it at all, on
|
||||||
|
> 4.1.2 or v4.2). The schema **refuses a literal password**: a manifest is a
|
||||||
|
> committed file, so a literal there is a password in git forever. What cast
|
||||||
|
> cannot do is *verify* it — the password reads back only to a token with
|
||||||
|
> sensitive-data reads, so `diff` compares the toggle and the username (a UI flip
|
||||||
|
> is still caught) and says on every run that the password was not compared,
|
||||||
|
> rather than implying it matches. See [semantics.md](docs/semantics.md) → *HTTP
|
||||||
|
> Basic Auth on an application*.
|
||||||
|
|
||||||
An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the
|
An **`--override`**'s value is read from `$CAST_CAPTURE_<NAME>`, never from the
|
||||||
command line: argv is visible in `ps` to every process on the box. It exists for
|
command line: argv is visible in `ps` to every process on the box. It exists for
|
||||||
values that must not survive the copy — staging and prod sharing a Mailgun
|
values that must not survive the copy — staging and prod sharing a Mailgun
|
||||||
|
|
@ -569,8 +582,10 @@ whose secrets were silently skipped looks complete and holds not one value.
|
||||||
**2. Silent losses.** cast cannot express everything a Coolify holds:
|
**2. Silent losses.** cast cannot express everything a Coolify holds:
|
||||||
destinations (which Docker network a resource sits on — no API at all in 4.1.2),
|
destinations (which Docker network a resource sits on — no API at all in 4.1.2),
|
||||||
service hostnames (they live per-container on `service.applications[].fqdn`),
|
service hostnames (they live per-container on `service.applications[].fqdn`),
|
||||||
Basic Auth and custom Traefik labels, the *Include Source Commit in Build*
|
Basic Auth on a *service* and custom Traefik labels anywhere (an application's
|
||||||
toggle, whole database kinds (a MySQL is invisible to cast's manifest), backup
|
Basic Auth is expressible — see below — but its **password** is not readable, so
|
||||||
|
a draft reports it instead of emitting a block a rebuild could not honour), the
|
||||||
|
*Include Source Commit in Build* toggle, whole database kinds (a MySQL is invisible to cast's manifest), backup
|
||||||
schedules, and anything else configured in the UI with no manifest field.
|
schedules, and anything else configured in the UI with no manifest field.
|
||||||
|
|
||||||
A blueprint that omits these **without saying so** is worse than no blueprint,
|
A blueprint that omits these **without saying so** is worse than no blueprint,
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,105 @@ 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
|
rebuild silently dropped, and `is_static` was not even there, which is exactly
|
||||||
how a rebuilt static site came back wrong.
|
how a rebuilt static site came back wrong.
|
||||||
|
|
||||||
|
## HTTP Basic Auth on an application (`basic_auth:`)
|
||||||
|
|
||||||
|
An application can declare HTTP basic auth, and `apply` sets it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
admin:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build: { pack: nixpacks, base_directory: / }
|
||||||
|
domains: ["https://admin.widget.example.com"]
|
||||||
|
basic_auth:
|
||||||
|
enabled: true
|
||||||
|
username: ops
|
||||||
|
password: ${ADMIN_BASIC_AUTH_PROD}
|
||||||
|
```
|
||||||
|
|
||||||
|
This closes the hole `UNCAPTURED.md` used to describe as *"a rebuilt resource is
|
||||||
|
UNPROTECTED where the original was not"* — for **applications**. It stays open
|
||||||
|
for **services**, and that is an API gap rather than a vocabulary one:
|
||||||
|
`ServicesController` carries no basic-auth fields and no `custom_labels`, on
|
||||||
|
v4.1.2 or on the v4.2 train, so no manifest field could set them (cast#72
|
||||||
|
finding 7). The `NO_API_COVERAGE` row now says *services* specifically.
|
||||||
|
|
||||||
|
**The password is a `${REF}`, and only a `${REF}`.** The schema refuses a
|
||||||
|
literal — not discourages it. A manifest is a reviewed, committed artifact, so a
|
||||||
|
literal there is a live password in git forever, in the file everyone reads to
|
||||||
|
understand the system. The value lives in the environment's age store under that
|
||||||
|
name, resolved by the same mechanism every env-template `${REF}` uses, and a
|
||||||
|
missing or empty entry fails the run **before** anything is written, naming the
|
||||||
|
ref and the store.
|
||||||
|
|
||||||
|
**Managing basic auth is opt-in**, exactly like `is_static`. An omitted
|
||||||
|
`basic_auth:` block says *nothing* about basic auth, and that is deliberate: an
|
||||||
|
unconditional `is_http_basic_auth_enabled: false` would make the first apply
|
||||||
|
after this ships **strip the protection off every application somebody enabled by
|
||||||
|
hand in the UI** whose manifest had not yet been migrated. So: `enabled: true` to
|
||||||
|
protect, `enabled: false` to actively assert it is off, omit to leave it alone.
|
||||||
|
Enabling requires both credentials and disabling forbids them — both are
|
||||||
|
parse-time refusals, and the first is also Coolify's own rule
|
||||||
|
(`ApplicationsController.php:2446-2463` @ v4.1.2 rejects an enable without them),
|
||||||
|
enforced again at the wire so no path can reach a mid-run 422.
|
||||||
|
|
||||||
|
### What the diff can and cannot see
|
||||||
|
|
||||||
|
The three fields do not read back alike, and the report says which is which
|
||||||
|
rather than averaging them into a single confident answer:
|
||||||
|
|
||||||
|
| field | read back? | consequence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `is_http_basic_auth_enabled` | ✅ a plain column | compared — a toggle flipped in the UI **is** caught |
|
||||||
|
| `http_basic_auth_username` | ✅ a plain column | compared — a changed username **is** caught |
|
||||||
|
| `http_basic_auth_password` | ❌ | never compared, always **written** on any basic-auth write |
|
||||||
|
|
||||||
|
The password is gated behind a sensitive-data-enabled token at 4.1.2, and on
|
||||||
|
v4.2 behind the `read:sensitive` token ability (cast#72, #77) — so whether it
|
||||||
|
arrives depends on the token, the route *and* the release. cast therefore never
|
||||||
|
projects it into the comparison vocabulary at all, on any box: a field that means
|
||||||
|
different things on different instances is worse than a field that means one
|
||||||
|
thing everywhere. (It would also have to be *printed* — `renderDiff` renders
|
||||||
|
every field diff as `field: <live> → <desired>` — and cast prints no secret
|
||||||
|
anywhere. The field name is redacted in the renderer as a backstop; not
|
||||||
|
projecting it is the actual guarantee.)
|
||||||
|
|
||||||
|
So `cast diff` prints, on every run against an application that declares
|
||||||
|
`basic_auth:`:
|
||||||
|
|
||||||
|
```
|
||||||
|
basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Same disposition as an unverifiable backup schedule or a declared
|
||||||
|
`destination_uuid`: reported, **not** counted as drift (an absence of evidence is
|
||||||
|
not evidence of drift, and a run that fails on it is a run operators learn to
|
||||||
|
force past). A read that returns *none* of the three — a Coolify or a token that
|
||||||
|
does not serve those columns — names all three on that line instead, and cast
|
||||||
|
claims nothing at all about that application's basic auth.
|
||||||
|
|
||||||
|
**The honest limit that follows:** rotating *only* the password in the store
|
||||||
|
produces no field diff, therefore no PATCH. The rotation lands on the next apply
|
||||||
|
that writes basic auth for any other reason (the toggle or username drifting, or
|
||||||
|
a create) — because Coolify requires both credentials on any write that enables
|
||||||
|
basic auth, so cast completes the whole triple whenever it sends one of them.
|
||||||
|
Until then the diff says the password was not compared rather than implying it
|
||||||
|
matches. To force a rotation today, flip `enabled` off, apply, flip it back on,
|
||||||
|
apply — or set it in the UI.
|
||||||
|
|
||||||
|
### Why `custom_labels` is deliberately absent
|
||||||
|
|
||||||
|
`custom_labels` **is** API-settable at 4.1.2 (base64-validated,
|
||||||
|
`ApplicationsController.php:3836-3854`, in both allowlists), and cast still has
|
||||||
|
no field for it. Enabling basic auth or changing domains makes Coolify regenerate
|
||||||
|
an application's proxy labels via `generateLabelsApplication()`, which
|
||||||
|
**overwrites `custom_labels`** unless `is_container_label_readonly_enabled` — and
|
||||||
|
*that* flag is itself not API-settable until v4.2. A manifest that declared both
|
||||||
|
`custom_labels` and domains or basic auth on one application would therefore have
|
||||||
|
cast silently destroy the labels it was just told to write. Basic-auth-only is
|
||||||
|
the safe slice until v4.2; raw labels wait for a real use and for the readonly
|
||||||
|
flag (cast#76, #77). Hand-written labels on a live box stay reported per resource
|
||||||
|
in `UNCAPTURED.md`, never silently dropped.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
@ -1063,8 +1162,11 @@ the per-service `GET /services/{uuid}` that `diff`/`apply` have made since
|
||||||
projection the diff's read-back uses, so a drafted service diffs clean once
|
projection the diff's read-back uses, so a drafted service diffs clean once
|
||||||
applied; a service whose GET is unreachable or unrecognized is reported per
|
applied; a service whose GET is unreachable or unrecognized is reported per
|
||||||
resource instead — where a one-project diff fails closed, a whole-instance sweep
|
resource instead — where a one-project diff fails closed, a whole-instance sweep
|
||||||
reports and keeps going), Basic Auth / custom Traefik labels,
|
reports and keeps going), Basic Auth on a SERVICE and custom Traefik labels
|
||||||
build and deploy command overrides, what a backup schedule cannot fully say
|
anywhere (an application's basic auth **is** expressible since #76 — what a draft
|
||||||
|
still cannot carry is its PASSWORD, which no read returns, so an enabled app is
|
||||||
|
reported per resource rather than emitted as a `basic_auth:` block a rebuild
|
||||||
|
could not honour), build and deploy command overrides, what a backup schedule cannot fully say
|
||||||
(the schedule itself **is captured** since #75 — the draft reads
|
(the schedule itself **is captured** since #75 — the draft reads
|
||||||
`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51,
|
`GET /databases/{uuid}/backups`, the route `diff`/`apply` have used since #51,
|
||||||
and a single enabled schedule becomes a real `backup: { frequency, retention }`
|
and a single enabled schedule becomes a real `backup: { frequency, retention }`
|
||||||
|
|
|
||||||
72
src/apply.ts
72
src/apply.ts
|
|
@ -92,6 +92,73 @@ export function applyHostnameOverlay(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Put the whole basic-auth triple back into an UPDATE payload that carries only
|
||||||
|
// part of it.
|
||||||
|
//
|
||||||
|
// An update body is assembled from the field DIFFS — the fields that actually
|
||||||
|
// changed — and that is wrong for basic auth in both directions:
|
||||||
|
//
|
||||||
|
// - Coolify requires username AND password on any write that enables basic
|
||||||
|
// auth (ApplicationsController.php:2446-2463 @ v4.1.2). So a drift in the
|
||||||
|
// toggle alone, or in the username alone, would PATCH an enable with a
|
||||||
|
// missing credential and 422 mid-run.
|
||||||
|
// - The password is never read back (see projectLiveFields), so it never
|
||||||
|
// appears as a diff on its own. Sending it alongside every basic-auth write
|
||||||
|
// is what makes a store-side rotation land at all: it rides on the next
|
||||||
|
// apply that touches basic auth for any reason.
|
||||||
|
//
|
||||||
|
// What it deliberately does NOT do is manufacture a write. A run where nothing
|
||||||
|
// about basic auth drifted still sends nothing — this only completes a payload
|
||||||
|
// that was already going to be sent. So the honest limit stands and is printed
|
||||||
|
// on every diff: rotating ONLY the password in the store produces no field diff,
|
||||||
|
// therefore no PATCH, and `cast diff` says the password was not compared rather
|
||||||
|
// than implying it matches.
|
||||||
|
const BASIC_AUTH_KEYS = [
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
"http_basic_auth_username",
|
||||||
|
"http_basic_auth_password",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function completeBasicAuth(
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
spec: Desired | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const declared = spec?.fields ?? {};
|
||||||
|
|
||||||
|
// Only complete a payload that is ALREADY touching basic auth. This is what
|
||||||
|
// keeps the function from manufacturing a write, and it is the reason the
|
||||||
|
// honest limit above still holds.
|
||||||
|
if (!BASIC_AUTH_KEYS.some((k) => fields[k] !== undefined)) return fields;
|
||||||
|
|
||||||
|
// Read the INTENT from the declared spec, not from the payload. Keying on
|
||||||
|
// `fields.is_http_basic_auth_enabled === true` was the bug (cast#76 review):
|
||||||
|
// the toggle is absent from an update body exactly when it already MATCHES,
|
||||||
|
// so on username-only drift — auth on at both ends, username edited in the
|
||||||
|
// UI — computeDiff emits `http_basic_auth_username` alone, the guard returned
|
||||||
|
// early, and the PATCH went out as a lone username. Coolify requires the
|
||||||
|
// whole triple on any write that enables basic auth, so that is a 422
|
||||||
|
// mid-run: the failure this function exists to prevent, on the one path it
|
||||||
|
// was not looking at.
|
||||||
|
//
|
||||||
|
// A payload that explicitly DISABLES (toggle === false) is left alone —
|
||||||
|
// completing it with credentials would be manufacturing the opposite write.
|
||||||
|
const enabled =
|
||||||
|
fields.is_http_basic_auth_enabled === true ||
|
||||||
|
(fields.is_http_basic_auth_enabled === undefined &&
|
||||||
|
declared.is_http_basic_auth_enabled === true);
|
||||||
|
if (!enabled) return fields;
|
||||||
|
|
||||||
|
const completed = { ...fields };
|
||||||
|
// The toggle is completed too, not just the credentials: Coolify's presence
|
||||||
|
// rule is about the write as a whole, and a username+password PATCH with no
|
||||||
|
// toggle asks it to infer what cast can simply state.
|
||||||
|
for (const k of BASIC_AUTH_KEYS) {
|
||||||
|
if (completed[k] === undefined && declared[k] !== undefined)
|
||||||
|
completed[k] = declared[k];
|
||||||
|
}
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyPlan(
|
export async function applyPlan(
|
||||||
report: DiffReport,
|
report: DiffReport,
|
||||||
desired: Desired[],
|
desired: Desired[],
|
||||||
|
|
@ -181,8 +248,9 @@ export async function applyPlan(
|
||||||
uuid = await exec.createResource(c);
|
uuid = await exec.createResource(c);
|
||||||
} else {
|
} else {
|
||||||
uuid = c.uuid as string;
|
uuid = c.uuid as string;
|
||||||
const fields = Object.fromEntries(
|
const fields = completeBasicAuth(
|
||||||
c.fieldDiffs.map((f) => [f.field, f.desired]),
|
Object.fromEntries(c.fieldDiffs.map((f) => [f.field, f.desired])),
|
||||||
|
spec,
|
||||||
);
|
);
|
||||||
if (Object.keys(fields).length > 0) {
|
if (Object.keys(fields).length > 0) {
|
||||||
await exec.updateFields(uuid, c.kind, fields);
|
await exec.updateFields(uuid, c.kind, fields);
|
||||||
|
|
|
||||||
124
src/cli.ts
124
src/cli.ts
|
|
@ -391,6 +391,53 @@ export function projectLiveFields(
|
||||||
...(raw.is_static == null
|
...(raw.is_static == null
|
||||||
? {}
|
? {}
|
||||||
: { is_static: raw.is_static === true || raw.is_static === 1 }),
|
: { is_static: raw.is_static === true || raw.is_static === 1 }),
|
||||||
|
// Basic auth, read back as far as the read path will say (cast#76).
|
||||||
|
//
|
||||||
|
// `is_http_basic_auth_enabled` and `http_basic_auth_username` are ordinary
|
||||||
|
// `applications` columns and come back on this route; they are projected
|
||||||
|
// whenever they are actually THERE, and omitted when they are not, so a
|
||||||
|
// Coolify (or a token) that hides them produces "not compared" rather than
|
||||||
|
// a phantom `undefined`. Same three-way discipline as `is_static` above,
|
||||||
|
// and note that "absent" and "false" are different answers: a real `false`
|
||||||
|
// is projected and diffs normally, which is what catches somebody turning
|
||||||
|
// basic auth off in the UI.
|
||||||
|
//
|
||||||
|
// `http_basic_auth_password` is NEVER projected, whatever the read
|
||||||
|
// returned, and that is a deliberate policy rather than a limitation:
|
||||||
|
//
|
||||||
|
// - It is gated. `ApplicationsController@removeSensitiveData` hides it
|
||||||
|
// from a token without sensitive-data reads at 4.1.2, and on `next`
|
||||||
|
// the hiding moves to the model behind the `read:sensitive` ability
|
||||||
|
// (cast#72, #77) — so whether it arrives depends on the token AND the
|
||||||
|
// route AND the release, and a diff must not silently mean different
|
||||||
|
// things on different boxes.
|
||||||
|
// - Even where it DOES arrive, putting a plaintext password into
|
||||||
|
// `fields` puts it into the diff report, and cast prints no secret,
|
||||||
|
// anywhere. (renderDiff redacts the field name as a backstop; not
|
||||||
|
// projecting it is the actual guarantee.)
|
||||||
|
//
|
||||||
|
// The consequence is stated rather than hidden: `fetchLive` flags the app
|
||||||
|
// `basicAuthNotCompared`, computeDiff skips the password, and every diff of
|
||||||
|
// an app declaring basic auth prints a line saying the password was not
|
||||||
|
// compared. A store-side password rotation therefore needs an apply that
|
||||||
|
// has some other reason to write — see completeBasicAuth in apply.ts, and
|
||||||
|
// the caveat in semantics.md.
|
||||||
|
// The username rides on the toggle's readability rather than on its own
|
||||||
|
// presence: the two are plain columns on the same row, serialized (or
|
||||||
|
// hidden) together, so a readable toggle means the username was readable
|
||||||
|
// too — and a NULL one then means "no username is set", a real value worth
|
||||||
|
// diffing against, not an unreadable one. Projecting it as absent instead
|
||||||
|
// would turn "somebody cleared the username" into "cast could not look".
|
||||||
|
...(raw.is_http_basic_auth_enabled == null
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
is_http_basic_auth_enabled:
|
||||||
|
raw.is_http_basic_auth_enabled === true ||
|
||||||
|
raw.is_http_basic_auth_enabled === 1,
|
||||||
|
http_basic_auth_username: String(
|
||||||
|
raw.http_basic_auth_username ?? "",
|
||||||
|
),
|
||||||
|
}),
|
||||||
...(raw.install_command ? { install_command: raw.install_command } : {}),
|
...(raw.install_command ? { install_command: raw.install_command } : {}),
|
||||||
...(raw.build_command ? { build_command: raw.build_command } : {}),
|
...(raw.build_command ? { build_command: raw.build_command } : {}),
|
||||||
...(raw.start_command ? { start_command: raw.start_command } : {}),
|
...(raw.start_command ? { start_command: raw.start_command } : {}),
|
||||||
|
|
@ -659,6 +706,20 @@ export async function fetchLive(
|
||||||
...(kind === "application" && i.is_static == null
|
...(kind === "application" && i.is_static == null
|
||||||
? { staticNotCompared: true }
|
? { staticNotCompared: true }
|
||||||
: {}),
|
: {}),
|
||||||
|
// Why this application's basic auth could not be fully verified (cast#76).
|
||||||
|
// Set on EVERY application, not only the ones a manifest protects —
|
||||||
|
// computeDiff decides whether it is relevant, because only it knows what
|
||||||
|
// the desired side declared. The password half is unconditional at 4.1.2
|
||||||
|
// (projectLiveFields never projects it, on purpose); the whole-block half
|
||||||
|
// fires when the read returned no toggle at all.
|
||||||
|
...(kind === "application"
|
||||||
|
? {
|
||||||
|
basicAuthNotCompared:
|
||||||
|
i.is_http_basic_auth_enabled == null
|
||||||
|
? "this read returned no is_http_basic_auth_enabled for this application, so cast saw none of its basic-auth state — a Coolify or a token that does not serve these columns on GET /projects/{uuid}/{env}"
|
||||||
|
: "http_basic_auth_password is never read back: Coolify 4.1.2 hides it from a token without sensitive-data reads, v4.2 moves it behind the read:sensitive ability, and cast prints no secret — so the toggle and the username are compared and the password is written, not verified (cast#76)",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}));
|
}));
|
||||||
const live = [
|
const live = [
|
||||||
...map("application", env.applications),
|
...map("application", env.applications),
|
||||||
|
|
@ -1175,7 +1236,17 @@ async function runProject(
|
||||||
let secrets: Record<string, string>;
|
let secrets: Record<string, string>;
|
||||||
if (existsSync(store)) {
|
if (existsSync(store)) {
|
||||||
secrets = decryptSecrets(store, keyFileFor(ctx.envName));
|
secrets = decryptSecrets(store, keyFileFor(ctx.envName));
|
||||||
} else if (requiredSecrets(checkout, ctx.envName).required.length > 0) {
|
} else if (
|
||||||
|
// `manifestRefs` alongside `required` (cast#76): a `basic_auth.password`
|
||||||
|
// ref is read from the store exactly like a template's, but it is not a
|
||||||
|
// RequiredSecret (see requiredSecrets). Asking only about `required` would
|
||||||
|
// let a manifest whose one secret is a basic-auth password proceed on `{}`
|
||||||
|
// — straight into desiredFromManifest's refusal, with a worse message.
|
||||||
|
(() => {
|
||||||
|
const s = requiredSecrets(checkout, ctx.envName);
|
||||||
|
return s.required.length > 0 || s.manifestRefs.length > 0;
|
||||||
|
})()
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
[
|
[
|
||||||
`no secret store for ${orgRepo} in ${ctx.envName}`,
|
`no secret store for ${orgRepo} in ${ctx.envName}`,
|
||||||
|
|
@ -2751,6 +2822,57 @@ export function applicationApiFields(
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
|
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
|
||||||
fields;
|
fields;
|
||||||
|
// Coolify's presence rule, enforced at the wire (cast#76). PATCH
|
||||||
|
// /applications/{uuid} rejects an enable without both credentials
|
||||||
|
// (ApplicationsController.php:2446-2463 @ v4.1.2) and the create allowlist
|
||||||
|
// takes the same three keys (:914, :2368).
|
||||||
|
//
|
||||||
|
// The manifest schema already refuses a half-declared block, so this is the
|
||||||
|
// BELT, not the braces — and it is worth having because it guards the paths
|
||||||
|
// the schema cannot see: apply's own field completion, a hostname overlay, and
|
||||||
|
// any future caller assembling a payload by hand. Failing here costs one
|
||||||
|
// exception; failing at Coolify costs a 422 in the middle of a run that has
|
||||||
|
// already created a project, an environment and possibly a database.
|
||||||
|
// Any write that TOUCHES basic auth without disabling it must carry the whole
|
||||||
|
// triple. Keying this on `=== true` alone shared apply's blind spot: a PATCH
|
||||||
|
// body of `{http_basic_auth_username}` — the username-only drift — has no
|
||||||
|
// toggle to be true, so the belt never tightened either. An explicit
|
||||||
|
// `false` is a legitimate disable and needs no credentials.
|
||||||
|
const touchesBasicAuth = (
|
||||||
|
[
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
"http_basic_auth_username",
|
||||||
|
"http_basic_auth_password",
|
||||||
|
] as const
|
||||||
|
).some((k) => rest[k] !== undefined);
|
||||||
|
if (touchesBasicAuth && rest.is_http_basic_auth_enabled !== false) {
|
||||||
|
const missing = (
|
||||||
|
[
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
"http_basic_auth_username",
|
||||||
|
"http_basic_auth_password",
|
||||||
|
] as const
|
||||||
|
).filter((k) => {
|
||||||
|
const v = rest[k];
|
||||||
|
if (k === "is_http_basic_auth_enabled") return v !== true;
|
||||||
|
return typeof v !== "string" || v === "";
|
||||||
|
});
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`refusing a partial HTTP basic auth write — missing ${missing.join(" and ")}`,
|
||||||
|
"",
|
||||||
|
"Coolify requires the toggle, a username AND a password on any write that",
|
||||||
|
"enables basic auth, and would answer 422 mid-run. A write carrying only some",
|
||||||
|
"of the three is that 422 waiting to happen — including a lone username, which",
|
||||||
|
"is what a username-only drift produces if nothing completes it. Half-configured",
|
||||||
|
"basic auth protects nothing anyway: declare the pair under the application's",
|
||||||
|
"`basic_auth:` (the password as a ${REF} held by the environment's age store),",
|
||||||
|
"or set `basic_auth.enabled: false`.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
// is_static/install_command/build_command/start_command ride through `rest`
|
// is_static/install_command/build_command/start_command ride through `rest`
|
||||||
// unchanged: they are valid API params verbatim, accepted on both the create
|
// unchanged: they are valid API params verbatim, accepted on both the create
|
||||||
|
|
|
||||||
98
src/diff.ts
98
src/diff.ts
|
|
@ -79,6 +79,27 @@ export type Live = {
|
||||||
// a create-time setting; a real boolean from a future Coolify (staticNotCompared
|
// a create-time setting; a real boolean from a future Coolify (staticNotCompared
|
||||||
// unset) is projected and diffed normally.
|
// unset) is projected and diffed normally.
|
||||||
staticNotCompared?: boolean;
|
staticNotCompared?: boolean;
|
||||||
|
// Why one or more of this application's basic-auth fields could not be read
|
||||||
|
// back this run — printed verbatim. Third of the same family as
|
||||||
|
// backupNotCompared and staticNotCompared, and the one whose absence would be
|
||||||
|
// most dangerous, because the field it hides is a protection.
|
||||||
|
//
|
||||||
|
// Which fields it covers is not fixed: it is exactly the BASIC_AUTH_FIELDS
|
||||||
|
// that are ABSENT from `fields` (projectLiveFields omits what it could not
|
||||||
|
// read). Two shapes occur at 4.1.2, and the reason string says which:
|
||||||
|
//
|
||||||
|
// - the password alone is unreadable — the usual case. `enabled` and
|
||||||
|
// `username` are ordinary columns and diff normally, so a UI flip of the
|
||||||
|
// toggle or a changed username IS still caught; only a store-side password
|
||||||
|
// rotation is invisible.
|
||||||
|
// - nothing is readable — a read path or a token that returns none of the
|
||||||
|
// three. Then all three are skipped, and cast claims nothing at all about
|
||||||
|
// this app's basic auth.
|
||||||
|
//
|
||||||
|
// As with the other two, leaving the fields merely absent from `fields` is NOT
|
||||||
|
// equivalent: computeDiff would diff `true` against `undefined` and report
|
||||||
|
// confident drift, and apply would rewrite a protection it never read.
|
||||||
|
basicAuthNotCompared?: string;
|
||||||
};
|
};
|
||||||
export type FieldDiff = {
|
export type FieldDiff = {
|
||||||
field: string;
|
field: string;
|
||||||
|
|
@ -161,9 +182,38 @@ export type DiffReport = {
|
||||||
// against `clean` — but printed on every run that has any, because the whole
|
// against `clean` — but printed on every run that has any, because the whole
|
||||||
// point is that the assumption goes on screen at the moment it is made.
|
// point is that the assumption goes on screen at the moment it is made.
|
||||||
backupsNotCompared: { name: string; reason: string }[];
|
backupsNotCompared: { name: string; reason: string }[];
|
||||||
|
// Applications whose declared `basic_auth:` cast could not fully verify this
|
||||||
|
// run, with the fields it had to skip. Same disposition as
|
||||||
|
// backupsNotCompared — a finding, printed always, NOT counted against `clean`:
|
||||||
|
// it is an absence of evidence, not evidence of drift, and a run that failed
|
||||||
|
// because a read failed is a run operators learn to force past.
|
||||||
|
basicAuthNotCompared: { name: string; fields: string[]; reason: string }[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The three Coolify fields an application's `basic_auth:` block becomes. Named
|
||||||
|
// once, here, because three separate places have to agree on the set: the
|
||||||
|
// not-compared skip below, the redaction in renderDiff, and apply's
|
||||||
|
// completeBasicAuth.
|
||||||
|
export const BASIC_AUTH_FIELDS = [
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
"http_basic_auth_username",
|
||||||
|
"http_basic_auth_password",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Field names whose VALUES never reach the terminal, on either side of a diff.
|
||||||
|
//
|
||||||
|
// renderDiff prints every field diff as `field: <live> → <desired>`, so an
|
||||||
|
// ordinary field carrying a password would print it twice — into a scrollback
|
||||||
|
// buffer, and into the CI log of every run. That is the same rule cast already
|
||||||
|
// holds for env vars (`secret X differs`, never the value) and for capture's
|
||||||
|
// disposition table; this is it, extended to the first RESOURCE FIELD that is a
|
||||||
|
// secret. The diff still says the field changed — what is withheld is only what
|
||||||
|
// it changed from and to.
|
||||||
|
export const REDACTED_FIELDS: ReadonlySet<string> = new Set([
|
||||||
|
"http_basic_auth_password",
|
||||||
|
]);
|
||||||
|
|
||||||
export const NON_UPDATABLE: Record<ResourceKind, string[]> = {
|
export const NON_UPDATABLE: Record<ResourceKind, string[]> = {
|
||||||
application: ["build_pack"],
|
application: ["build_pack"],
|
||||||
database: ["type", "version"],
|
database: ["type", "version"],
|
||||||
|
|
@ -311,6 +361,7 @@ export function computeDiff(
|
||||||
): DiffReport {
|
): DiffReport {
|
||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
const backupsNotCompared: { name: string; reason: string }[] = [];
|
const backupsNotCompared: { name: string; reason: string }[] = [];
|
||||||
|
const basicAuthNotCompared: DiffReport["basicAuthNotCompared"] = [];
|
||||||
// is_static is unreadable on Coolify 4.1.2's read path (cast#68); warn once
|
// is_static is unreadable on Coolify 4.1.2's read path (cast#68); warn once
|
||||||
// per run when the degradation actually bites (a manifest declares `static:`
|
// per run when the degradation actually bites (a manifest declares `static:`
|
||||||
// on an app whose live value cast could not read), not per application.
|
// on an app whose live value cast could not read), not per application.
|
||||||
|
|
@ -350,8 +401,32 @@ export function computeDiff(
|
||||||
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
|
backupsNotCompared.push({ name: d.name, reason: l.backupNotCompared });
|
||||||
}
|
}
|
||||||
const skipBackup = l.backupNotCompared !== undefined;
|
const skipBackup = l.backupNotCompared !== undefined;
|
||||||
|
// The basic-auth escape hatch, third sibling of the backup and is_static
|
||||||
|
// ones. A field is skipped when the live read could not supply it (it is
|
||||||
|
// absent from `l.fields`) AND the read said why (`basicAuthNotCompared`) —
|
||||||
|
// never merely because it is absent, which would silently swallow a real
|
||||||
|
// "this app has no basic auth" into "cast could not tell".
|
||||||
|
//
|
||||||
|
// Recorded per APPLICATION, once, with the fields it covers, and only when
|
||||||
|
// the desired side declares basic auth at all: an app whose manifest is
|
||||||
|
// silent about it must not produce a line about something it never asked
|
||||||
|
// for.
|
||||||
|
const skippedBasicAuth =
|
||||||
|
l.basicAuthNotCompared === undefined
|
||||||
|
? []
|
||||||
|
: BASIC_AUTH_FIELDS.filter(
|
||||||
|
(f) => f in d.fields && !(f in l.fields),
|
||||||
|
).map(String);
|
||||||
|
if (skippedBasicAuth.length > 0) {
|
||||||
|
basicAuthNotCompared.push({
|
||||||
|
name: d.name,
|
||||||
|
fields: skippedBasicAuth,
|
||||||
|
reason: l.basicAuthNotCompared as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
|
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
|
||||||
.filter(([field]) => !(skipBackup && field === "backup"))
|
.filter(([field]) => !(skipBackup && field === "backup"))
|
||||||
|
.filter(([field]) => !skippedBasicAuth.includes(field))
|
||||||
.filter(([field]) => {
|
.filter(([field]) => {
|
||||||
// The unreadable-is_static escape hatch (cast#68), sibling to the
|
// The unreadable-is_static escape hatch (cast#68), sibling to the
|
||||||
// backup one above. is_static lives on the ApplicationSetting relation,
|
// backup one above. is_static lives on the ApplicationSetting relation,
|
||||||
|
|
@ -404,6 +479,7 @@ export function computeDiff(
|
||||||
reserved,
|
reserved,
|
||||||
placement,
|
placement,
|
||||||
backupsNotCompared,
|
backupsNotCompared,
|
||||||
|
basicAuthNotCompared,
|
||||||
// A split project is drift, and drift is not clean — the same disposition
|
// A split project is drift, and drift is not clean — the same disposition
|
||||||
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
// as an orphan: reported, counted, and NOT repaired (apply moves nothing
|
||||||
// between networks; see renderDiff).
|
// between networks; see renderDiff).
|
||||||
|
|
@ -454,6 +530,16 @@ export function renderDiff(report: DiffReport): string {
|
||||||
for (const c of report.changes) {
|
for (const c of report.changes) {
|
||||||
lines.push(`${c.op} ${c.kind} ${c.name}`);
|
lines.push(`${c.op} ${c.kind} ${c.name}`);
|
||||||
for (const f of c.fieldDiffs) {
|
for (const f of c.fieldDiffs) {
|
||||||
|
// A redacted field says THAT it changes and never what to or from — see
|
||||||
|
// REDACTED_FIELDS. `f.live` is undefined here whenever the read could not
|
||||||
|
// see it, which is the common case, so even the shape of the old value
|
||||||
|
// would be a claim cast cannot make.
|
||||||
|
if (REDACTED_FIELDS.has(f.field)) {
|
||||||
|
lines.push(
|
||||||
|
` ${f.field}: differs — apply will set it (secret; value not printed)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
lines.push(
|
lines.push(
|
||||||
` ${f.field}: ${JSON.stringify(f.live)} → ${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
|
` ${f.field}: ${JSON.stringify(f.live)} → ${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
|
||||||
);
|
);
|
||||||
|
|
@ -516,6 +602,18 @@ export function renderDiff(report: DiffReport): string {
|
||||||
` (${b.reason})`,
|
` (${b.reason})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The same honest fallback as the backup line above, for the field where
|
||||||
|
// silence is most expensive: a diff that said "clean" over an unreadable basic
|
||||||
|
// auth would be a tool reporting an admin panel as protected without having
|
||||||
|
// looked. It names the fields it skipped so the line distinguishes "only the
|
||||||
|
// password" (the routine 4.1.2 case, where the toggle and username ARE
|
||||||
|
// compared) from "all of it" (a read that told cast nothing).
|
||||||
|
for (const b of report.basicAuthNotCompared) {
|
||||||
|
lines.push(
|
||||||
|
`basic_auth on application ${b.name} declared, ${b.fields.join(", ")} NOT compared — verify in the Coolify UI`,
|
||||||
|
` (${b.reason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const { placement } = report;
|
const { placement } = report;
|
||||||
if (placement.split) {
|
if (placement.split) {
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|
|
||||||
39
src/draft.ts
39
src/draft.ts
|
|
@ -424,7 +424,10 @@ function applicationSpec(
|
||||||
// for. Each one changes what the application IS, and each would be silently
|
// for. Each one changes what the application IS, and each would be silently
|
||||||
// absent from a rebuild.
|
// absent from a rebuild.
|
||||||
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 has its own fields, and its own manifest block since cast#76 — these are the hand-written labels beside it)",
|
||||||
|
],
|
||||||
["ports_mappings", "host port mappings"],
|
["ports_mappings", "host port mappings"],
|
||||||
["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"],
|
||||||
|
|
@ -439,6 +442,26 @@ function applicationSpec(
|
||||||
flag(field, `${what} is set on the box. The manifest has no field for it.`);
|
flag(field, `${what} is set on the box. The manifest has no field for it.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Basic auth is EXPRESSIBLE now (cast#76) and still not CAPTURABLE, and those
|
||||||
|
// are different sentences. The manifest has a `basic_auth:` block, so a draft
|
||||||
|
// could emit the toggle and the username — but the password reads back only to
|
||||||
|
// a token with sensitive-data reads (4.1.2; `read:sensitive` on v4.2) and a
|
||||||
|
// draft that emitted `enabled: true` with a password cast never saw would
|
||||||
|
// refuse to apply, or worse, apply with the wrong one.
|
||||||
|
//
|
||||||
|
// So the draft emits NOTHING here and says so per application, which is the
|
||||||
|
// same disposition as a generated secret: the name is reported, the value is
|
||||||
|
// the operator's to supply. Not emitting a half-block is the point — an
|
||||||
|
// `enabled: true` a rebuild cannot honour is the failure this whole file exists
|
||||||
|
// to prevent, and an unprotected admin panel is the one that costs most.
|
||||||
|
if (r.raw.is_http_basic_auth_enabled) {
|
||||||
|
const user = r.raw.http_basic_auth_username;
|
||||||
|
flag(
|
||||||
|
"basic_auth",
|
||||||
|
`HTTP basic auth is ENABLED on this application${typeof user === "string" && user !== "" ? ` (username ${user})` : ""}, and its PASSWORD cannot be read back — so no \`basic_auth:\` block was written and a rebuilt application would be PUBLIC. Add one by hand: \`basic_auth: { enabled: true, username: …, password: \${REF} }\`, with the value in the environment's age store.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// `port` is one number in a manifest and a comma-separated list on the wire.
|
// `port` is one number in a manifest and a comma-separated list on the wire.
|
||||||
// The draft writes the first and says so — a rebuilt app exposing one of the
|
// The draft writes the first and says so — a rebuilt app exposing one of the
|
||||||
// three ports it used to is the kind of difference that surfaces as a broken
|
// three ports it used to is the kind of difference that surfaces as a broken
|
||||||
|
|
@ -876,9 +899,19 @@ const NO_API_COVERAGE: Array<[string, string]> = [
|
||||||
"destinations",
|
"destinations",
|
||||||
"Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21).",
|
"Coolify 4.1.2 serves no destinations endpoint. A resource's `destination_id` comes back; the UUID that names it never does. Placement must be read from the UI (#21).",
|
||||||
],
|
],
|
||||||
|
// Narrowed to SERVICES (cast#76). Applications can now say `basic_auth:`, so
|
||||||
|
// the blanket row overstated the gap for half the resources it covered — and
|
||||||
|
// the half it still covers is a real API gap, not a cast vocabulary one:
|
||||||
|
// `ServicesController` has no basic-auth fields and no `custom_labels`, on
|
||||||
|
// v4.1.2 or on `next` (cast#72, finding 7). The row must not imply cast could
|
||||||
|
// express this for a service if only someone wrote the field.
|
||||||
[
|
[
|
||||||
"Basic Auth / custom Traefik labels",
|
"Basic Auth / custom Traefik labels on SERVICES",
|
||||||
"carried as raw container labels. cast's manifest has no field for them, so a rebuilt resource is UNPROTECTED where the original was not.",
|
"no API surface at all — Coolify's ServicesController carries neither basic-auth fields nor custom_labels, on 4.1.2 or on the v4.2 train, so no manifest field could set them. A service that was protected on the source box comes back UNPROTECTED and must be re-protected by hand. (Applications are a different story: they take `basic_auth:` in the manifest since cast#76 — what is uncapturable there is the PASSWORD, reported per application above.)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"custom Traefik/Docker labels on applications",
|
||||||
|
"`custom_labels` IS writable at 4.1.2, but cast deliberately has no field for it: enabling basic auth or changing domains makes Coolify regenerate an application's labels and overwrite whatever was there, unless `is_container_label_readonly_enabled` — which is itself not API-settable until v4.2. Declaring both would be a footgun, so labels set by hand stay uncaptured and are reported per application above (cast#72, #76).",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'"Include Source Commit in Build"',
|
'"Include Source Commit in Build"',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,74 @@ const repoDirectoryPath = (field: string) =>
|
||||||
`${field} must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /apps/core, not apps/core; the checkout root is /`,
|
`${field} must be an absolute path inside the repo checkout (Coolify 4.1.2 rejects the create otherwise) — write /apps/core, not apps/core; the checkout root is /`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A store REF — `${NAME}` and nothing else. The one syntax cast already uses for
|
||||||
|
// a secret, in env templates (envtemplate.ts), reused verbatim rather than
|
||||||
|
// invented a second time: the value lives in the environment's age store, keyed
|
||||||
|
// by NAME, and the manifest carries only the name.
|
||||||
|
//
|
||||||
|
// This is a REFUSAL, not a preference. `http_basic_auth_password` is the first
|
||||||
|
// secret cast writes that is a resource FIELD rather than an env var, and a
|
||||||
|
// manifest is a reviewed, committed artifact — a literal here is a password in
|
||||||
|
// git, permanently, in the file everyone reads to understand the system. There is
|
||||||
|
// no ergonomic case that outweighs that, so the schema makes the mistake
|
||||||
|
// unrepresentable rather than warning about it.
|
||||||
|
const STORE_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
||||||
|
|
||||||
|
// HTTP Basic Auth on an application, as Coolify 4.1.2 can actually set it:
|
||||||
|
// `is_http_basic_auth_enabled`, `http_basic_auth_username` and
|
||||||
|
// `http_basic_auth_password` are in the create allowlist
|
||||||
|
// (`ApplicationsController.php:914`) and the PATCH allowlist (`:2368`), and PATCH
|
||||||
|
// enforces username/password presence when enabling (`:2446-2463`).
|
||||||
|
//
|
||||||
|
// `enabled` is explicit rather than inferred from the block's presence, because
|
||||||
|
// the two halves of the vocabulary are not symmetric: `enabled: true` needs
|
||||||
|
// credentials, `enabled: false` must have none (a password ref standing over a
|
||||||
|
// disabled auth is dead config that reads like a guard). Spelling it out is also
|
||||||
|
// what lets the presence rule below fail with a message about the field the
|
||||||
|
// operator got wrong, instead of a union mismatch about two shapes.
|
||||||
|
//
|
||||||
|
// OMITTING the block leaves basic auth alone entirely — the `is_static` rule
|
||||||
|
// (see resolve.ts), for the same reason: emitting `is_http_basic_auth_enabled:
|
||||||
|
// false` on every application would make the first apply after this ships strip
|
||||||
|
// basic auth off every app protected by hand in the UI whose manifest has not yet
|
||||||
|
// been migrated. Protection removed, silently, by an upgrade. So: declare
|
||||||
|
// `enabled: true` to protect, `enabled: false` to actively assert it is off, omit
|
||||||
|
// to say nothing.
|
||||||
|
const BasicAuthSchema = z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
STORE_REF,
|
||||||
|
"basic_auth.password must be a store ref (${NAME}) whose value lives in the environment's age store — never a literal, which would be a password committed to git",
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((auth, ctx) => {
|
||||||
|
// Coolify's own rule, enforced HERE so it fails in the file rather than as a
|
||||||
|
// bare 422 from a PATCH that has already half-applied a run
|
||||||
|
// (ApplicationsController.php:2446-2463 @ v4.1.2 requires both when
|
||||||
|
// enabling). Same reasoning as the checkout-path patterns above.
|
||||||
|
if (auth.enabled) {
|
||||||
|
for (const k of ["username", "password"] as const)
|
||||||
|
if (auth[k] === undefined || auth[k] === "")
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `basic_auth.${k} is required when basic_auth.enabled is true (Coolify rejects the write otherwise, and half-protected basic auth protects nothing)`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const k of ["username", "password"] as const)
|
||||||
|
if (auth[k] !== undefined)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `basic_auth.${k} is not allowed when basic_auth.enabled is false — a credential declared for a disabled auth is dead config that reads like a guard`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const AppSpecSchema = z
|
const AppSpecSchema = z
|
||||||
.object({
|
.object({
|
||||||
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
|
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
|
||||||
|
|
@ -62,6 +130,7 @@ const AppSpecSchema = z
|
||||||
healthcheck: z.string().optional(),
|
healthcheck: z.string().optional(),
|
||||||
domains: z.array(z.string()).optional(),
|
domains: z.array(z.string()).optional(),
|
||||||
service_domains: z.record(z.array(z.string())).optional(),
|
service_domains: z.record(z.array(z.string())).optional(),
|
||||||
|
basic_auth: BasicAuthSchema.optional(),
|
||||||
env_template: z.string().optional(),
|
env_template: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
@ -189,6 +258,13 @@ const ManifestSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
// The NAME inside a `${NAME}` store ref, or undefined if this is not one. The
|
||||||
|
// single reader of STORE_REF outside the schema, so the syntax the manifest
|
||||||
|
// ACCEPTS and the syntax resolution UNDERSTANDS cannot drift apart.
|
||||||
|
export function storeRefName(value: string): string | undefined {
|
||||||
|
return STORE_REF.exec(value)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
export type AppSpec = z.infer<typeof AppSpecSchema>;
|
export type AppSpec = z.infer<typeof AppSpecSchema>;
|
||||||
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
|
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
|
||||||
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
|
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import {
|
||||||
templateRefs,
|
templateRefs,
|
||||||
templateResourceRefs,
|
templateResourceRefs,
|
||||||
} from "./envtemplate.js";
|
} from "./envtemplate.js";
|
||||||
import type { EnvironmentSpec } from "./manifest.js";
|
import type { AppSpec, EnvironmentSpec } from "./manifest.js";
|
||||||
import { loadManifest } from "./manifest.js";
|
import { loadManifest, storeRefName } from "./manifest.js";
|
||||||
import {
|
import {
|
||||||
type ReservedHit,
|
type ReservedHit,
|
||||||
assertNoReservedEnvNames,
|
assertNoReservedEnvNames,
|
||||||
|
|
@ -376,7 +376,11 @@ function assertDomainRefs(
|
||||||
export function requiredSecrets(
|
export function requiredSecrets(
|
||||||
checkoutDir: string,
|
checkoutDir: string,
|
||||||
envName: string,
|
envName: string,
|
||||||
): { required: RequiredSecret[]; generated: string[] } {
|
): {
|
||||||
|
required: RequiredSecret[];
|
||||||
|
generated: string[];
|
||||||
|
manifestRefs: string[];
|
||||||
|
} {
|
||||||
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
|
||||||
const envSpec = manifest.environments[envName];
|
const envSpec = manifest.environments[envName];
|
||||||
if (!envSpec) {
|
if (!envSpec) {
|
||||||
|
|
@ -452,7 +456,26 @@ export function requiredSecrets(
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { required, generated };
|
// Store refs the MANIFEST itself carries, as opposed to the ones its env
|
||||||
|
// templates carry. Today that is exactly `basic_auth.password`.
|
||||||
|
//
|
||||||
|
// Kept OUT of `required`, deliberately. A RequiredSecret is `{ref, resource,
|
||||||
|
// key}` where `key` is a live ENV VAR name — that triple is what `capture`
|
||||||
|
// reads the source box's value from — and a basic-auth password is not an env
|
||||||
|
// var on any resource. Putting it in `required` would have capture look for an
|
||||||
|
// env var named after a field, fail to find it, and refuse the whole run as
|
||||||
|
// "missing". It is returned separately so the one caller that asks a different
|
||||||
|
// question — "would anything at all be read from the store?", the gate on
|
||||||
|
// whether a missing store is fatal (cli.ts, #104) — gets the right answer for
|
||||||
|
// a manifest whose only secret is a basic-auth password.
|
||||||
|
const manifestRefs = Object.values(envSpec.applications).flatMap((app) => {
|
||||||
|
const ref =
|
||||||
|
app.basic_auth?.enabled && app.basic_auth.password
|
||||||
|
? storeRefName(app.basic_auth.password)
|
||||||
|
: undefined;
|
||||||
|
return ref ? [ref] : [];
|
||||||
|
});
|
||||||
|
return { required, generated, manifestRefs };
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the manifest declares for an environment, as names only — no secrets, no
|
// What the manifest declares for an environment, as names only — no secrets, no
|
||||||
|
|
@ -527,6 +550,65 @@ export function canonicalizeServiceDomains(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Coolify fields an application's `basic_auth:` block becomes, with the
|
||||||
|
// password resolved out of the age store.
|
||||||
|
//
|
||||||
|
// Empty when the manifest declares nothing: managing basic auth is OPT-IN, the
|
||||||
|
// same rule as `is_static` (see the comment on that field below) and for a
|
||||||
|
// sharper reason — an unconditional `is_http_basic_auth_enabled: false` would
|
||||||
|
// have the first apply after this ships REMOVE the protection from every
|
||||||
|
// application somebody enabled by hand in the UI. A tool that silently
|
||||||
|
// unprotects an admin panel during a routine apply is worse than one that cannot
|
||||||
|
// protect it at all.
|
||||||
|
//
|
||||||
|
// The password is resolved HERE, at plan time, from the same store every
|
||||||
|
// `${REF}` in an env template resolves against — so a missing ref fails before
|
||||||
|
// anything is written, naming the ref and the store, rather than 422ing
|
||||||
|
// mid-apply or (worse) writing an empty password over a working one.
|
||||||
|
export function basicAuthFields(
|
||||||
|
envName: string,
|
||||||
|
appName: string,
|
||||||
|
app: Pick<AppSpec, "basic_auth">,
|
||||||
|
secrets: Record<string, string>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const auth = app.basic_auth;
|
||||||
|
if (!auth) return {};
|
||||||
|
if (!auth.enabled) return { is_http_basic_auth_enabled: false };
|
||||||
|
// Both are guaranteed present by the schema's superRefine; the checks are
|
||||||
|
// repeated at the value level because THIS is where an empty store entry
|
||||||
|
// becomes an empty password, which the schema cannot see.
|
||||||
|
const ref = storeRefName(String(auth.password));
|
||||||
|
if (ref === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`manifest environment ${envName}: application ${appName} basic_auth.password is not a store ref (\${NAME})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = secrets[ref];
|
||||||
|
if (value === undefined || value === "") {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`manifest environment ${envName}: application ${appName} declares basic_auth.password \${${ref}}, and the age store ${value === "" ? "holds an EMPTY value for it" : "does not hold it"}`,
|
||||||
|
"",
|
||||||
|
" the store is the environment's `secrets/<repo>.<env>.env.age` — the same one",
|
||||||
|
" every env-template ${REF} resolves against. Add the name to it (a store is a",
|
||||||
|
" KEY=value file, encrypted to the environment's age recipient) and re-run.",
|
||||||
|
"",
|
||||||
|
"Writing an empty password would enable basic auth on a public URL and protect",
|
||||||
|
"nothing, and Coolify would accept it — so cast refuses before it writes anything.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: auth.username,
|
||||||
|
// The PLAINTEXT, in the desired field bag — the only place it exists in this
|
||||||
|
// process besides the decrypted store. It is never printed: renderDiff
|
||||||
|
// redacts this field name by name (see REDACTED_FIELDS in diff.ts), which is
|
||||||
|
// the same contract every secret env var already has.
|
||||||
|
http_basic_auth_password: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function desiredFromManifest(
|
export function desiredFromManifest(
|
||||||
checkoutDir: string,
|
checkoutDir: string,
|
||||||
envName: string,
|
envName: string,
|
||||||
|
|
@ -644,6 +726,12 @@ export function desiredFromManifest(
|
||||||
? { start_command: app.build.start_command }
|
? { start_command: app.build.start_command }
|
||||||
: {}),
|
: {}),
|
||||||
}),
|
}),
|
||||||
|
// Outside the pack branch: basic auth is a property of the APPLICATION
|
||||||
|
// (Coolify sets it on the app's proxy labels, not on anything the build
|
||||||
|
// pack decides), so it is equally declarable on a compose app and a
|
||||||
|
// nixpacks one. The three keys are already Coolify's own names, so
|
||||||
|
// applicationApiFields passes them through untranslated.
|
||||||
|
...basicAuthFields(envName, name, app, secrets),
|
||||||
},
|
},
|
||||||
env: resolveEnvFile(name, app.env_template),
|
env: resolveEnvFile(name, app.env_template),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
KIND_ORDER,
|
KIND_ORDER,
|
||||||
applyHostnameOverlay,
|
applyHostnameOverlay,
|
||||||
applyPlan,
|
applyPlan,
|
||||||
|
completeBasicAuth,
|
||||||
} from "../src/apply.js";
|
} from "../src/apply.js";
|
||||||
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
import { GENERATED_PLACEHOLDER } from "../src/capture.js";
|
||||||
import { type Desired, type Live, computeDiff } from "../src/diff.js";
|
import { type Desired, type Live, computeDiff } from "../src/diff.js";
|
||||||
|
|
@ -539,3 +540,148 @@ describe("applyHostnameOverlay", () => {
|
||||||
expect(out[0].fields.domains).toEqual(["http://plain.example.net"]);
|
expect(out[0].fields.domains).toEqual(["http://plain.example.net"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cast#76. An update body is built from the fields that CHANGED, and basic auth
|
||||||
|
// cannot be written that way: Coolify requires both credentials on any write
|
||||||
|
// that enables it, and the password never shows up as a change because it is
|
||||||
|
// never read back. So the payload is completed from the declared spec — and
|
||||||
|
// only when a write was already happening.
|
||||||
|
describe("completeBasicAuth", () => {
|
||||||
|
const spec: Desired = {
|
||||||
|
kind: "application",
|
||||||
|
name: "admin",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fills in the credentials when only the toggle drifted", () => {
|
||||||
|
expect(
|
||||||
|
completeBasicAuth({ is_http_basic_auth_enabled: true }, spec),
|
||||||
|
).toEqual({
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills in the password when only the username drifted", () => {
|
||||||
|
expect(
|
||||||
|
completeBasicAuth(
|
||||||
|
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
|
||||||
|
spec,
|
||||||
|
).http_basic_auth_password,
|
||||||
|
).toBe("s3cret");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The case the #76 review found, and the one the test above only LOOKED like
|
||||||
|
// it covered: that payload carries the toggle, so it never exercised the
|
||||||
|
// guard. When basic auth is already on at both ends and only the username is
|
||||||
|
// edited in the UI, the toggle MATCHES — so computeDiff emits no fieldDiff
|
||||||
|
// for it and the payload arrives as a lone username. The old guard keyed on
|
||||||
|
// the toggle being present and returned early, and the PATCH went out
|
||||||
|
// incomplete: a 422 mid-run, which is the exact failure this function exists
|
||||||
|
// to prevent.
|
||||||
|
it("completes the whole triple from a lone username — no toggle in the payload", () => {
|
||||||
|
expect(
|
||||||
|
completeBasicAuth({ http_basic_auth_username: "ops" }, spec),
|
||||||
|
).toEqual({
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same shape, other credential: a stored-password rotation riding along.
|
||||||
|
it("completes from a lone password too", () => {
|
||||||
|
expect(
|
||||||
|
completeBasicAuth({ http_basic_auth_password: "rotated" }, spec),
|
||||||
|
).toEqual({
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "rotated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intent comes from the SPEC, so a spec that does not enable basic auth must
|
||||||
|
// not have credentials completed into its payload — otherwise reading intent
|
||||||
|
// from the declaration would trade one silent wrong write for another.
|
||||||
|
it("does not complete when the spec does not enable basic auth", () => {
|
||||||
|
const off: Desired = {
|
||||||
|
kind: "application",
|
||||||
|
name: "admin",
|
||||||
|
fields: { is_http_basic_auth_enabled: false },
|
||||||
|
};
|
||||||
|
expect(completeBasicAuth({ http_basic_auth_username: "ops" }, off)).toEqual(
|
||||||
|
{
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a payload that is not enabling basic auth completely alone", () => {
|
||||||
|
// The load-bearing half: this must not MANUFACTURE a write. A run where
|
||||||
|
// nothing about basic auth drifted sends nothing about basic auth.
|
||||||
|
expect(completeBasicAuth({ domains: ["https://a"] }, spec)).toEqual({
|
||||||
|
domains: ["https://a"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no credentials to a disable", () => {
|
||||||
|
expect(
|
||||||
|
completeBasicAuth({ is_http_basic_auth_enabled: false }, spec),
|
||||||
|
).toEqual({ is_http_basic_auth_enabled: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent values the spec does not carry", () => {
|
||||||
|
// Then applicationApiFields refuses at the wire — one clear error, rather
|
||||||
|
// than a request Coolify 422s halfway through a run.
|
||||||
|
expect(
|
||||||
|
completeBasicAuth({ is_http_basic_auth_enabled: true }, undefined),
|
||||||
|
).toEqual({ is_http_basic_auth_enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The honest limit, asserted rather than described: a password rotated in the
|
||||||
|
// store with nothing else changed produces NO write, because there is no field
|
||||||
|
// diff to carry it. `cast diff` prints "NOT compared" on that run — the failure
|
||||||
|
// is visible, not silent — and this test exists so the day someone makes the
|
||||||
|
// password diffable, it goes red and they read the comment.
|
||||||
|
describe("applyPlan — a password-only rotation writes nothing (#76)", () => {
|
||||||
|
it("makes no call at all when the readable halves agree", async () => {
|
||||||
|
const { calls, exec } = recorder();
|
||||||
|
const declared: Desired[] = [
|
||||||
|
{
|
||||||
|
kind: "application",
|
||||||
|
name: "admin",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "the-NEW-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const live: Live[] = [
|
||||||
|
{
|
||||||
|
kind: "application",
|
||||||
|
name: "admin",
|
||||||
|
uuid: "u1",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
},
|
||||||
|
basicAuthNotCompared: "password is never read back",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const report = computeDiff(declared, live, "full");
|
||||||
|
await applyPlan(report, declared, exec);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
// …and the run says so, rather than reading as a verified match.
|
||||||
|
expect(report.basicAuthNotCompared).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
319
test/basic-auth-cli.test.ts
Normal file
319
test/basic-auth-cli.test.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// HTTP basic auth on an application, end to end: manifest -> the real binary ->
|
||||||
|
// what goes on the wire and what reaches the terminal (cast#76).
|
||||||
|
//
|
||||||
|
// The unit tests prove each half (the schema refuses a literal password;
|
||||||
|
// projectLiveFields never projects one; computeDiff skips what it could not
|
||||||
|
// read; completeBasicAuth puts the triple back into a partial payload). This
|
||||||
|
// proves they are wired to each other, and pins the two facts that only a real
|
||||||
|
// request can show: that the CREATE body carries all three keys, and that a
|
||||||
|
// PATCH triggered by a drifted TOGGLE still carries the credentials Coolify
|
||||||
|
// requires alongside it.
|
||||||
|
//
|
||||||
|
// BOUNDARY, stated because it matters: the Coolify here is a stub of this
|
||||||
|
// repo's own making. These tests prove what cast SENDS. They cannot prove that a
|
||||||
|
// real 4.1.2 accepts it, that label regeneration behaves as cast#72 read it, or
|
||||||
|
// that a sensitive-data token returns the password on any given route — every
|
||||||
|
// one of those is a claim about Coolify, sourced from reading Coolify, and none
|
||||||
|
// has been run against a live instance.
|
||||||
|
|
||||||
|
let recipient: string;
|
||||||
|
let keyFile: string;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
|
||||||
|
keyFile = join(dir, "age.key");
|
||||||
|
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
|
||||||
|
recipient = execFileSync("age-keygen", ["-y", keyFile], {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Stub = {
|
||||||
|
url: string;
|
||||||
|
hits: string[];
|
||||||
|
bodies: Record<string, Record<string, unknown>>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
const stubs: Stub[] = [];
|
||||||
|
|
||||||
|
// `app` is the knob: what the live application row looks like, or `null` for an
|
||||||
|
// environment with nothing in it (so the plan is a create). Everything else on
|
||||||
|
// the row matches the manifest below, so anything the diff reports is basic auth
|
||||||
|
// and nothing else.
|
||||||
|
async function stubCoolify(app: Record<string, unknown> | null): Promise<Stub> {
|
||||||
|
const hits: string[] = [];
|
||||||
|
const bodies: Record<string, Record<string, unknown>> = {};
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const path = new URL(req.url ?? "", "http://x").pathname.replace(
|
||||||
|
"/api/v1",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
hits.push(`${req.method} ${path}`);
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (d) => {
|
||||||
|
raw += String(d);
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
if (raw !== "") {
|
||||||
|
try {
|
||||||
|
bodies[`${req.method} ${path}`] = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* not JSON — not a body this test asks about */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 === "/servers")
|
||||||
|
return json([{ uuid: "s1", name: "shared-box" }]);
|
||||||
|
if (path === "/github-apps")
|
||||||
|
return json([{ uuid: "gh1", name: "hdb-coolify" }]);
|
||||||
|
if (path === "/projects" && req.method === "GET")
|
||||||
|
return json([{ uuid: "p1", name: "incubator" }]);
|
||||||
|
if (path === "/projects/p1/environments")
|
||||||
|
return json([{ name: "staging" }]);
|
||||||
|
if (path === "/projects/p1/staging")
|
||||||
|
return json({ applications: app === null ? [] : [app] });
|
||||||
|
if (path === "/applications" && req.method === "GET") return json([]);
|
||||||
|
if (path === "/applications/private-github-app" && req.method === "POST")
|
||||||
|
return json({ uuid: "app-1" });
|
||||||
|
if (path === "/applications/app-1" && req.method === "PATCH")
|
||||||
|
return json({ uuid: "app-1" });
|
||||||
|
if (path === "/applications/app-1/envs") return json([]);
|
||||||
|
if (path === "/deploy") return json({});
|
||||||
|
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}`,
|
||||||
|
hits,
|
||||||
|
bodies,
|
||||||
|
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()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A live application row as Coolify's environment_details serializes one. Note
|
||||||
|
// what is NOT here on purpose: `http_basic_auth_password`. That is the whole
|
||||||
|
// read-side story — the column is hidden from an ordinary token at 4.1.2, and
|
||||||
|
// cast would not project it even if it arrived.
|
||||||
|
const liveApp = (over: Record<string, unknown> = {}) => ({
|
||||||
|
name: "admin",
|
||||||
|
uuid: "app-1",
|
||||||
|
git_repository: "heavy-duty/incubator",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
base_directory: "/",
|
||||||
|
fqdn: "https://admin.example.com",
|
||||||
|
destination_id: 1,
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PASSWORD = "correct-horse-battery-staple";
|
||||||
|
|
||||||
|
const MANIFEST = `project: incubator
|
||||||
|
environments:
|
||||||
|
staging:
|
||||||
|
applications:
|
||||||
|
admin:
|
||||||
|
source: { repo: heavy-duty/incubator, branch: main }
|
||||||
|
build: { pack: nixpacks, base_directory: / }
|
||||||
|
domains: ["https://admin.example.com"]
|
||||||
|
basic_auth:
|
||||||
|
enabled: true
|
||||||
|
username: ops
|
||||||
|
password: \${ADMIN_BASIC_AUTH}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function fixture(url: string, store = `ADMIN_BASIC_AUTH=${PASSWORD}\n`) {
|
||||||
|
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
|
||||||
|
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
|
||||||
|
writeFileSync(join(checkout, ".infra", "manifest.yaml"), MANIFEST);
|
||||||
|
|
||||||
|
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
|
||||||
|
mkdirSync(join(state, "secrets"));
|
||||||
|
writeFileSync(
|
||||||
|
join(state, ".coolify.env"),
|
||||||
|
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
|
||||||
|
);
|
||||||
|
execFileSync("age", ["-r", recipient, "-o", "incubator.staging.env.age"], {
|
||||||
|
input: store,
|
||||||
|
cwd: join(state, "secrets"),
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
writeFileSync(
|
||||||
|
join(state, "environments.yaml"),
|
||||||
|
[
|
||||||
|
"environments:",
|
||||||
|
" staging:",
|
||||||
|
" server: shared-box",
|
||||||
|
" team: { id: 0, name: Root Team }",
|
||||||
|
"github_apps:",
|
||||||
|
" incubator: hdb-coolify",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
return { checkout, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(
|
||||||
|
verb: "diff" | "apply",
|
||||||
|
f: { checkout: string; state: string },
|
||||||
|
): Promise<{ code: number; output: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(
|
||||||
|
"node",
|
||||||
|
[
|
||||||
|
"dist/cli.js",
|
||||||
|
verb,
|
||||||
|
"heavy-duty/incubator",
|
||||||
|
"--env",
|
||||||
|
"staging",
|
||||||
|
"--path",
|
||||||
|
f.checkout,
|
||||||
|
"--state",
|
||||||
|
f.state,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (d) => {
|
||||||
|
output += String(d);
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (d) => {
|
||||||
|
output += String(d);
|
||||||
|
});
|
||||||
|
child.on("close", (code) => resolve({ code: code ?? 0, output }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cast apply — basic auth reaches the wire (#76)", () => {
|
||||||
|
it("sends all three keys on the CREATE, with the password out of the age store", async () => {
|
||||||
|
const stub = await stubCoolify(null);
|
||||||
|
const r = await run("apply", fixture(stub.url));
|
||||||
|
expect(r.code).toBe(0);
|
||||||
|
const body = stub.bodies["POST /applications/private-github-app"];
|
||||||
|
expect(body.is_http_basic_auth_enabled).toBe(true);
|
||||||
|
expect(body.http_basic_auth_username).toBe("ops");
|
||||||
|
// The value came from the encrypted store via the manifest's ${REF} — the
|
||||||
|
// manifest itself holds only the name.
|
||||||
|
expect(body.http_basic_auth_password).toBe(PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The case an update body assembled from field diffs alone would get wrong:
|
||||||
|
// only the toggle drifted, and Coolify rejects an enable without credentials.
|
||||||
|
it("sends the credentials alongside a toggle-only PATCH", async () => {
|
||||||
|
const stub = await stubCoolify(
|
||||||
|
liveApp({ is_http_basic_auth_enabled: false }),
|
||||||
|
);
|
||||||
|
const r = await run("apply", fixture(stub.url));
|
||||||
|
expect(r.code).toBe(0);
|
||||||
|
const body = stub.bodies["PATCH /applications/app-1"];
|
||||||
|
expect(body.is_http_basic_auth_enabled).toBe(true);
|
||||||
|
expect(body.http_basic_auth_username).toBe("ops");
|
||||||
|
expect(body.http_basic_auth_password).toBe(PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other half of the same rule: no drift, no write. cast does not PATCH
|
||||||
|
// basic auth onto every apply just because it cannot verify the password.
|
||||||
|
it("writes nothing when the readable halves already agree", async () => {
|
||||||
|
const stub = await stubCoolify(liveApp());
|
||||||
|
const r = await run("apply", fixture(stub.url));
|
||||||
|
expect(r.code).toBe(0);
|
||||||
|
expect(stub.hits).not.toContain("PATCH /applications/app-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses before touching Coolify when the store does not hold the ref", async () => {
|
||||||
|
const stub = await stubCoolify(null);
|
||||||
|
const r = await run("apply", fixture(stub.url, "SOMETHING_ELSE=x\n"));
|
||||||
|
expect(r.code).not.toBe(0);
|
||||||
|
expect(r.output).toContain("does not hold it");
|
||||||
|
// Nothing was created on the way to finding out.
|
||||||
|
expect(stub.hits).not.toContain("POST /applications/private-github-app");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cast diff — basic auth is honest about the password (#76)", () => {
|
||||||
|
it("says the password was NOT compared, on a run it still calls clean", async () => {
|
||||||
|
const r = await run("diff", fixture((await stubCoolify(liveApp())).url));
|
||||||
|
expect(r.code).toBe(0);
|
||||||
|
expect(r.output).toContain(
|
||||||
|
"basic_auth on application admin declared, http_basic_auth_password NOT compared",
|
||||||
|
);
|
||||||
|
expect(r.output).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never prints the password", async () => {
|
||||||
|
const r = await run("diff", fixture((await stubCoolify(null)).url));
|
||||||
|
expect(r.output).not.toContain(PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The defect this feature closes, from the other direction: somebody turned
|
||||||
|
// basic auth off on the box. An unreadable password must not make that
|
||||||
|
// invisible.
|
||||||
|
it("reports drift when the toggle was flipped off in the UI", async () => {
|
||||||
|
const r = await run(
|
||||||
|
"diff",
|
||||||
|
fixture(
|
||||||
|
(await stubCoolify(liveApp({ is_http_basic_auth_enabled: false }))).url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(r.code).toBe(1);
|
||||||
|
expect(r.output).toContain("is_http_basic_auth_enabled: false → true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports drift when the username was changed on the box", async () => {
|
||||||
|
const r = await run(
|
||||||
|
"diff",
|
||||||
|
fixture(
|
||||||
|
(await stubCoolify(liveApp({ http_basic_auth_username: "someone" })))
|
||||||
|
.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(r.code).toBe(1);
|
||||||
|
expect(r.output).toContain("http_basic_auth_username");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Coolify (or a token) that serves none of these columns must produce
|
||||||
|
// neither a clean bill nor invented drift.
|
||||||
|
it("claims nothing at all when the read carried no basic-auth state", async () => {
|
||||||
|
const stub = await stubCoolify(
|
||||||
|
liveApp({
|
||||||
|
is_http_basic_auth_enabled: undefined,
|
||||||
|
http_basic_auth_username: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const r = await run("diff", fixture(stub.url));
|
||||||
|
expect(r.output).toContain(
|
||||||
|
"is_http_basic_auth_enabled, http_basic_auth_username, http_basic_auth_password NOT compared",
|
||||||
|
);
|
||||||
|
expect(r.output).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -690,3 +690,250 @@ describe("backup schedules", () => {
|
||||||
expect(out).toMatch(/^clean$/m);
|
expect(out).toMatch(/^clean$/m);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Basic auth, read-side (cast#76). The password is a field cast WRITES and
|
||||||
|
// cannot READ, and the three ways that could go wrong are all worse than saying
|
||||||
|
// so: reporting a false "no change", reporting drift cast has no evidence for,
|
||||||
|
// or printing the value it does have.
|
||||||
|
describe("computeDiff — basic auth is fail-honest about what it could read", () => {
|
||||||
|
const wantAuth = {
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// What projectLiveFields produces at 4.1.2: the toggle and the username, never
|
||||||
|
// the password — plus the reason, which fetchLive attaches.
|
||||||
|
const liveApp = (
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
over: Record<string, unknown> = {},
|
||||||
|
) => ({
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
uuid: "app-1",
|
||||||
|
fields: { build_pack: "nixpacks", ...fields },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const PW_REASON = "http_basic_auth_password is never read back";
|
||||||
|
|
||||||
|
it("compares the toggle and the username, and skips only the password", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantAuth],
|
||||||
|
[
|
||||||
|
liveApp(
|
||||||
|
{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" },
|
||||||
|
{ basicAuthNotCompared: PW_REASON },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
// The two readable halves agree, so there is no drift to report — and the
|
||||||
|
// password does NOT become a phantom change against `undefined`.
|
||||||
|
expect(r.changes).toEqual([]);
|
||||||
|
expect(r.basicAuthNotCompared).toEqual([
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
fields: ["http_basic_auth_password"],
|
||||||
|
reason: PW_REASON,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The case the whole feature exists for: somebody turned basic auth off in the
|
||||||
|
// UI. The password being unreadable must not make cast blind to that.
|
||||||
|
it("still catches a toggle flipped off on the box", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantAuth],
|
||||||
|
[
|
||||||
|
liveApp(
|
||||||
|
{
|
||||||
|
is_http_basic_auth_enabled: false,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
},
|
||||||
|
{ basicAuthNotCompared: PW_REASON },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.changes).toHaveLength(1);
|
||||||
|
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
]);
|
||||||
|
expect(r.clean).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a username changed on the box", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantAuth],
|
||||||
|
[
|
||||||
|
liveApp(
|
||||||
|
{
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "someone-else",
|
||||||
|
},
|
||||||
|
{ basicAuthNotCompared: PW_REASON },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.changes[0].fieldDiffs.map((f) => f.field)).toEqual([
|
||||||
|
"http_basic_auth_username",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other shape: a read that returned none of it. Then cast claims nothing
|
||||||
|
// about any of the three — not "clean", not "drifted".
|
||||||
|
it("skips all three when the read returned no basic-auth state at all", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[wantAuth],
|
||||||
|
[liveApp({}, { basicAuthNotCompared: "no toggle on this read" })],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.changes).toEqual([]);
|
||||||
|
expect(r.basicAuthNotCompared[0].fields).toEqual([
|
||||||
|
"is_http_basic_auth_enabled",
|
||||||
|
"http_basic_auth_username",
|
||||||
|
"http_basic_auth_password",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so on screen, on a run it still calls clean", () => {
|
||||||
|
const out = renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[wantAuth],
|
||||||
|
[
|
||||||
|
liveApp(
|
||||||
|
{
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
},
|
||||||
|
{ basicAuthNotCompared: PW_REASON },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).toContain(
|
||||||
|
"basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI",
|
||||||
|
);
|
||||||
|
// Absence of evidence, not evidence of drift — the backup precedent.
|
||||||
|
expect(out).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about basic auth for an application that declares none", () => {
|
||||||
|
const out = renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
fields: { build_pack: "nixpacks" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[liveApp({}, { basicAuthNotCompared: PW_REASON })],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("basic_auth");
|
||||||
|
expect(out).toMatch(/^clean$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A live resource that reports the fields fine (a future Coolify, or a
|
||||||
|
// sensitive-token read path) must NOT be told it was uncompared.
|
||||||
|
it("reports nothing uncompared when the read supplied everything it needed", () => {
|
||||||
|
const r = computeDiff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
fields: { build_pack: "nixpacks", is_http_basic_auth_enabled: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
liveApp(
|
||||||
|
{ is_http_basic_auth_enabled: false },
|
||||||
|
{ basicAuthNotCompared: PW_REASON },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
);
|
||||||
|
expect(r.basicAuthNotCompared).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The password must not reach a terminal, on either side of the arrow, on any
|
||||||
|
// path — including a CREATE, where every desired field becomes a field diff.
|
||||||
|
describe("renderDiff — the basic-auth password is redacted (#76)", () => {
|
||||||
|
const create = () =>
|
||||||
|
renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("never prints the value, on a create plan", () => {
|
||||||
|
expect(create()).not.toContain("s3cret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still says the field is being set — redacted is not silent", () => {
|
||||||
|
expect(create()).toContain(
|
||||||
|
"http_basic_auth_password: differs — apply will set it (secret; value not printed)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the username in the clear — it is not a secret", () => {
|
||||||
|
expect(create()).toContain("http_basic_auth_username");
|
||||||
|
expect(create()).toContain("ops");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never prints the value on an update plan either", () => {
|
||||||
|
const out = renderDiff(
|
||||||
|
computeDiff(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
http_basic_auth_password: "s3cret-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// No basicAuthNotCompared: this live side CAN see the password (a future
|
||||||
|
// Coolify), so it is compared — and still not printed.
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "application" as const,
|
||||||
|
name: "admin",
|
||||||
|
uuid: "app-1",
|
||||||
|
fields: {
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
http_basic_auth_password: "the-old-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"full",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("s3cret-value");
|
||||||
|
expect(out).not.toContain("the-old-one");
|
||||||
|
expect(out).toContain("http_basic_auth_password: differs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,124 @@ environments:
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HTTP basic auth on an application (cast#76). The schema carries three rules,
|
||||||
|
// and each is here because breaking it is expensive somewhere else: the password
|
||||||
|
// must be a store ref (a literal is a password in git, forever), enabling needs
|
||||||
|
// both credentials (Coolify 422s mid-run otherwise, and half-configured basic
|
||||||
|
// auth protects nothing), and a disabled block must carry none (a credential
|
||||||
|
// standing over a disabled auth reads like a guard and is not one).
|
||||||
|
describe("loadManifest — basic_auth (#76)", () => {
|
||||||
|
const app = (basicAuth: string) => `
|
||||||
|
project: x
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
applications:
|
||||||
|
admin:
|
||||||
|
source: { repo: o/r, branch: main }
|
||||||
|
build: { pack: nixpacks, base_directory: / }
|
||||||
|
domains: ["https://admin.example.com"]
|
||||||
|
basic_auth: ${basicAuth}
|
||||||
|
`;
|
||||||
|
|
||||||
|
it("accepts an enabled block whose password is a ${REF}", () => {
|
||||||
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app(
|
||||||
|
"{ enabled: true, username: ops, password: '${ADMIN_PW}' }",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(m.environments.prod.applications.admin.basic_auth).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
username: "ops",
|
||||||
|
password: "${ADMIN_PW}",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bare `enabled: false` — the way to assert basic auth is OFF", () => {
|
||||||
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app("{ enabled: false }"),
|
||||||
|
});
|
||||||
|
expect(m.environments.prod.applications.admin.basic_auth).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an omitted block as saying nothing at all", () => {
|
||||||
|
const m = loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: `
|
||||||
|
project: x
|
||||||
|
environments:
|
||||||
|
prod:
|
||||||
|
applications:
|
||||||
|
admin:
|
||||||
|
source: { repo: o/r, branch: main }
|
||||||
|
build: { pack: nixpacks, base_directory: / }
|
||||||
|
domains: ["https://admin.example.com"]
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
expect(m.environments.prod.applications.admin.basic_auth).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The non-negotiable. A literal here would be a live password in a reviewed,
|
||||||
|
// committed file — so it is unrepresentable, not discouraged.
|
||||||
|
it("REFUSES a literal password", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app(
|
||||||
|
"{ enabled: true, username: ops, password: hunter2 }",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
).toThrow(/must be a store ref/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a password that is a ref with anything around it", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app(
|
||||||
|
"{ enabled: true, username: ops, password: 'pre-${ADMIN_PW}' }",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
).toThrow(/must be a store ref/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coolify's own presence rule, failing in the FILE rather than as a 422 from a
|
||||||
|
// PATCH in the middle of a run.
|
||||||
|
it("refuses enabling without a password", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app("{ enabled: true, username: ops }"),
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
/basic_auth.password is required when basic_auth.enabled is true/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses enabling without a username", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app("{ enabled: true, password: '${ADMIN_PW}' }"),
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
/basic_auth.username is required when basic_auth.enabled is true/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses credentials declared alongside `enabled: false`", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app("{ enabled: false, username: ops }"),
|
||||||
|
}),
|
||||||
|
).toThrow(/not allowed when basic_auth.enabled is false/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a block with no `enabled` at all — the toggle is never inferred", () => {
|
||||||
|
expect(() =>
|
||||||
|
loadManifest(`${FIX}manifest.yaml`, {
|
||||||
|
overrideText: app("{ username: ops, password: '${ADMIN_PW}' }"),
|
||||||
|
}),
|
||||||
|
).toThrow(/enabled/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("loadBindings", () => {
|
describe("loadBindings", () => {
|
||||||
it("parses bindings", () => {
|
it("parses bindings", () => {
|
||||||
const b = loadBindings(`${FIX}environments.yaml`);
|
const b = loadBindings(`${FIX}environments.yaml`);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,82 @@ describe("applicationApiFields", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cast#76. The three basic-auth keys are already Coolify's own names, so the
|
||||||
|
// wire layer's job here is not translation — it is the presence rule, enforced
|
||||||
|
// before the request rather than discovered as a 422 halfway through a run.
|
||||||
|
describe("applicationApiFields — basic auth (#76)", () => {
|
||||||
|
it("passes the three basic-auth keys through untranslated", () => {
|
||||||
|
const out = applicationApiFields({
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
});
|
||||||
|
expect(out).toEqual({
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a bare disable through — no credentials needed to turn it off", () => {
|
||||||
|
expect(applicationApiFields({ is_http_basic_auth_enabled: false })).toEqual(
|
||||||
|
{
|
||||||
|
is_http_basic_auth_enabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [what, fields] of [
|
||||||
|
["no password", { http_basic_auth_username: "ops" }],
|
||||||
|
["no username", { http_basic_auth_password: "s3cret" }],
|
||||||
|
[
|
||||||
|
"an empty password",
|
||||||
|
{
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
http_basic_auth_password: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["neither", {}],
|
||||||
|
] as const) {
|
||||||
|
it(`refuses to enable basic auth with ${what}`, () => {
|
||||||
|
expect(() =>
|
||||||
|
applicationApiFields({ is_http_basic_auth_enabled: true, ...fields }),
|
||||||
|
).toThrow(/refusing a partial HTTP basic auth write/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hole the #76 review found, at the wire: a username-only drift produces
|
||||||
|
// a PATCH with no toggle at all, so a guard keyed on `=== true` never looked
|
||||||
|
// at it. These are the shapes apply must never hand over uncompleted.
|
||||||
|
for (const [what, fields] of [
|
||||||
|
["a lone username", { http_basic_auth_username: "ops" }],
|
||||||
|
["a lone password", { http_basic_auth_password: "s3cret" }],
|
||||||
|
[
|
||||||
|
"credentials with no toggle",
|
||||||
|
{ http_basic_auth_username: "ops", http_basic_auth_password: "s3cret" },
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
it(`refuses ${what} — no toggle is not an exemption`, () => {
|
||||||
|
expect(() => applicationApiFields({ ...fields })).toThrow(
|
||||||
|
/refusing a partial HTTP basic auth write/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A disable is a legitimate one-key write: it needs no credentials, and
|
||||||
|
// demanding them would make turning basic auth off impossible.
|
||||||
|
it("allows an explicit disable to travel alone", () => {
|
||||||
|
expect(() =>
|
||||||
|
applicationApiFields({ is_http_basic_auth_enabled: false }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// And a payload that says nothing about basic auth is not a basic-auth write.
|
||||||
|
it("ignores a payload that does not mention basic auth at all", () => {
|
||||||
|
expect(() => applicationApiFields({ is_static: true })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("databaseApiFields", () => {
|
describe("databaseApiFields", () => {
|
||||||
it("maps type+version to an image and drops the type/version keys", () => {
|
it("maps type+version to an image and drops the type/version keys", () => {
|
||||||
const out = databaseApiFields({ type: "postgresql", version: "17" });
|
const out = databaseApiFields({ type: "postgresql", version: "17" });
|
||||||
|
|
@ -145,6 +221,55 @@ describe("projectLiveFields", () => {
|
||||||
expect(out.healthcheck).toBe("/health");
|
expect(out.healthcheck).toBe("/health");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cast#76 — the read side of basic auth, and the rule that the password never
|
||||||
|
// enters the comparison vocabulary at all.
|
||||||
|
it("projects the basic-auth toggle and username, and NEVER the password", () => {
|
||||||
|
const out = projectLiveFields("application", {
|
||||||
|
git_repository: "org/repo",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
base_directory: "/",
|
||||||
|
fqdn: "https://admin.example.com",
|
||||||
|
is_http_basic_auth_enabled: true,
|
||||||
|
http_basic_auth_username: "ops",
|
||||||
|
// Even when the read DOES carry it — a sensitive-data-enabled token — it
|
||||||
|
// must not reach `fields`, because a field in `fields` is a field
|
||||||
|
// renderDiff prints.
|
||||||
|
http_basic_auth_password: "s3cret",
|
||||||
|
});
|
||||||
|
expect(out.is_http_basic_auth_enabled).toBe(true);
|
||||||
|
expect(out.http_basic_auth_username).toBe("ops");
|
||||||
|
expect(out).not.toHaveProperty("http_basic_auth_password");
|
||||||
|
expect(JSON.stringify(out)).not.toContain("s3cret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects a real `false` toggle — off is an answer, not an absence", () => {
|
||||||
|
const out = projectLiveFields("application", {
|
||||||
|
git_repository: "org/repo",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
base_directory: "/",
|
||||||
|
fqdn: "https://admin.example.com",
|
||||||
|
is_http_basic_auth_enabled: 0,
|
||||||
|
});
|
||||||
|
expect(out.is_http_basic_auth_enabled).toBe(false);
|
||||||
|
// No username on the row means no username IS set — a value to diff against,
|
||||||
|
// not a field cast failed to read.
|
||||||
|
expect(out.http_basic_auth_username).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits both when the read carries no toggle at all", () => {
|
||||||
|
const out = projectLiveFields("application", {
|
||||||
|
git_repository: "org/repo",
|
||||||
|
git_branch: "main",
|
||||||
|
build_pack: "nixpacks",
|
||||||
|
base_directory: "/",
|
||||||
|
fqdn: "https://admin.example.com",
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty("is_http_basic_auth_enabled");
|
||||||
|
expect(out).not.toHaveProperty("http_basic_auth_username");
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes a live database's database_type to the manifest vocabulary", () => {
|
it("normalizes a live database's database_type to the manifest vocabulary", () => {
|
||||||
const out = projectLiveFields("database", {
|
const out = projectLiveFields("database", {
|
||||||
database_type: "standalone-postgresql",
|
database_type: "standalone-postgresql",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue