docs: add semantics.md — the apply contract and Coolify 4.1.2 limits
This should have shipped with the extraction PR and did not: the file was written after that commit was already pushed, so it landed nowhere. The infra PR meanwhile removed the same content from that repo's README, so between the two merges these notes existed only in git history. They are the expensive part of the executor — apply's guarantees (never deletes, never recreates a database, fails loudly on un-updatable drift), the dockercompose build pack, hostname-overlay shapes, and each place Coolify 4.1.2 misbehaves, with citations verified against coollabsio/coolify v4.1.2 and the vendored OpenAPI rather than its published docs. Linked from the README so it is found before someone changes apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4a0cfbef7
commit
75fdd7cfe4
2 changed files with 139 additions and 0 deletions
|
|
@ -72,6 +72,13 @@ cast smoke
|
||||||
`--hostname-overlay` swaps domains for a pre-flight run against temporary
|
`--hostname-overlay` swaps domains for a pre-flight run against temporary
|
||||||
hostnames; re-applying **without** it is the cutover.
|
hostnames; re-applying **without** it is the cutover.
|
||||||
|
|
||||||
|
**[docs/semantics.md](docs/semantics.md)** is the contract behind those
|
||||||
|
commands: what `apply` guarantees (never deletes, never recreates a database,
|
||||||
|
fails loudly rather than recreating on un-updatable drift), the `dockercompose`
|
||||||
|
build pack, the hostname-overlay shapes, and the places Coolify 4.1.2 does not
|
||||||
|
cooperate — each citation verified against `coollabsio/coolify` v4.1.2 and the
|
||||||
|
vendored OpenAPI in `reference/`. Read it before changing `apply`.
|
||||||
|
|
||||||
## Secrets, and attended applies
|
## Secrets, and attended applies
|
||||||
|
|
||||||
An environment's age identity is resolved in exactly two ways:
|
An environment's age identity is resolved in exactly two ways:
|
||||||
|
|
|
||||||
132
docs/semantics.md
Normal file
132
docs/semantics.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
The guarantees `cast apply` makes, the shapes it accepts, and the places
|
||||||
|
Coolify 4.1.2 does not cooperate. Extracted from the substrate repo this tool
|
||||||
|
was born in; the Coolify-source citations were verified against
|
||||||
|
`coollabsio/coolify` v4.1.2 and the vendored OpenAPI in `reference/`.
|
||||||
|
|
||||||
|
|
||||||
|
The command surface itself is in the README; this file is the behavior behind it.
|
||||||
|
|
||||||
|
**`dockercompose` build pack** (compose apps, box-B parity): a manifest
|
||||||
|
application whose `build.pack` is `dockercompose` declares
|
||||||
|
`build.compose_file` (path to the compose file in the checkout) and
|
||||||
|
`service_domains` (map of compose service name → `string[]` of URLs) instead
|
||||||
|
of the plain-app `port`/`healthcheck`/`domains` trio — those three live in the
|
||||||
|
compose file itself and are rejected by the manifest schema on a compose app;
|
||||||
|
conversely `service_domains`/`compose_file` are rejected on a non-compose app.
|
||||||
|
`domains` is schema-optional at the top level for this reason, but still
|
||||||
|
required (via a `superRefine`) for every non-compose app — no existing
|
||||||
|
manifest needs to change.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
core:
|
||||||
|
source: { repo: acme/widget, branch: main }
|
||||||
|
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
|
||||||
|
service_domains:
|
||||||
|
api: ["https://api.widget.example.com"]
|
||||||
|
env_template: core.prod.env.template
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally cast keeps the map vocabulary (`docker_compose_domains:
|
||||||
|
{service: string[]}`) all the way through `resolve.ts`/`apply.ts`/diffing;
|
||||||
|
only `cli.ts`'s wire-translation layer (`applicationApiFields`) flattens it to
|
||||||
|
the Coolify request shape — an array of `{name, domain}` where `domain` is
|
||||||
|
that service's URLs comma-joined (verified against the
|
||||||
|
`/applications/private-github-app` and `PATCH /applications/{uuid}` request
|
||||||
|
schemas in `reference/coolify-openapi-4.1.2.json`). A compose app's create
|
||||||
|
payload also sets `connect_to_docker_network: true` — without it the stack
|
||||||
|
cannot reach the environment's managed Postgres/Redis resources at all, which
|
||||||
|
fails at runtime rather than at apply time.
|
||||||
|
|
||||||
|
Live-state projection (`projectLiveFields`) reads both fields back for a
|
||||||
|
compose app: `docker_compose_location` (a plain string on the GET model) and
|
||||||
|
`docker_compose_domains`, which the GET model documents as a nullable
|
||||||
|
**string**, not the structured array the write side accepts — parsed
|
||||||
|
defensively as JSON back into the internal map, degrading to "field omitted"
|
||||||
|
(not a crash) on anything that doesn't parse as a well-formed array of
|
||||||
|
`{name, domain}`. Projecting both is what keeps a matching re-apply a true
|
||||||
|
no-op instead of a spurious PATCH + stack redeploy every run. **This parsing
|
||||||
|
path is unverified against a real Coolify instance** — the read-back is only
|
||||||
|
confirmed by an overlay apply followed by an overlay-edit re-apply against a
|
||||||
|
live instance, which has not yet happened. If a live instance turns out not to expose
|
||||||
|
`docker_compose_domains` on read, apply's idempotency guarantee for the
|
||||||
|
domains half breaks and needs the same warn-and-drop treatment as service
|
||||||
|
domains below — that also removes the cutover mechanism (domains no longer
|
||||||
|
flip via re-apply), so it would need a runbook amendment, not a silent fix.
|
||||||
|
|
||||||
|
**Hostname overlay, compose apps:** `--hostname-overlay <file>` accepts a
|
||||||
|
per-service map value for a compose app's entry instead of the plain-app
|
||||||
|
`string[]`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
core:
|
||||||
|
api: ["http://api.<PROD-IP>.sslip.io"]
|
||||||
|
landing: ["http://landing.<PROD-IP>.sslip.io"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the named services' domain lists are replaced; other services in the
|
||||||
|
same app keep their manifest values. A map value naming an unknown service
|
||||||
|
errors, listing the app's known services; a map value against a non-compose
|
||||||
|
app errors (`hostname overlay gave a service map for non-compose app <name>`)
|
||||||
|
— the plain `string[]` shape keeps working unchanged for non-compose apps.
|
||||||
|
|
||||||
|
**Structural vs. full diff** are tied to what the configured token can do,
|
||||||
|
not a flag alone: **structural** diff needs only a standing read-only token
|
||||||
|
and never compares env values (output says so explicitly); **full** diff
|
||||||
|
(and `apply`, which always requires full) needs a session token with
|
||||||
|
`read:sensitive` and compares secret values too — but the output only ever
|
||||||
|
says `secret FOO differs`, never the value on either side.
|
||||||
|
|
||||||
|
**Apply semantics** (verbatim from the spec's Global Constraints — never
|
||||||
|
softened by an implementation detail):
|
||||||
|
|
||||||
|
- Apply never deletes. Resource removal or rename is a manual runbook act;
|
||||||
|
`diff` reports the orphan as such until that act happens.
|
||||||
|
- Apply never recreates a database resource under any circumstances.
|
||||||
|
- On drift in a field the API cannot update in place (`build_pack`, a
|
||||||
|
database's `type`/`version`, a service's `type`), apply **fails loudly
|
||||||
|
naming the field** rather than recreating.
|
||||||
|
- Apply ends every mutated resource with a restart/redeploy
|
||||||
|
(`instant_deploy` on create, `/deploy` or `/restart` on update) — API
|
||||||
|
mutations land in Coolify's DB, not in running containers, so a create or
|
||||||
|
update that skipped this would silently not take effect.
|
||||||
|
- Direction is one-way, manifest → Coolify, always.
|
||||||
|
- **Environment guards:** `apply` refuses if a var matching that environment's
|
||||||
|
`forbidden_var_patterns` is present in the resolved env **at all, regardless
|
||||||
|
of value** — "off" means absent, not `false` (see the README). `apply` also
|
||||||
|
refuses `--path` combined with `--env prod`: prod always reads the default
|
||||||
|
branch, so a feature-branch checkout can never reach it.
|
||||||
|
|
||||||
|
**Known limitations, not defects:**
|
||||||
|
|
||||||
|
- **Backup schedules are create-time only.** A manifest database's `backup`
|
||||||
|
block (`frequency`, `retention`) is applied only when the database is
|
||||||
|
first created; it is deliberately kept out of the diffed `fields` (live
|
||||||
|
Coolify state doesn't expose it back, so diffing it would flag spurious
|
||||||
|
drift every run — breaking idempotency). Changing a schedule on an
|
||||||
|
existing database is a runbook act, done by hand in the Coolify UI.
|
||||||
|
- **A service's `domains` cannot be applied via the API in Coolify 4.1.2,
|
||||||
|
and is deliberately kept out of the diffed `fields` for the same
|
||||||
|
idempotency reason as backup schedules above.** The `/services`
|
||||||
|
create/update payload takes a structured per-container `urls` list, not
|
||||||
|
the manifest's flat `domains: string[]`, and the manifest has no
|
||||||
|
per-container name to build that list correctly from — so cast
|
||||||
|
drops it rather than send a malformed payload. Live Coolify service state
|
||||||
|
doesn't expose a flat `domains` back either, so if it stayed in `fields`
|
||||||
|
every domain-bearing service would diff as a perpetual update and every
|
||||||
|
`apply` would needlessly restart it — `desiredFromManifest` drops
|
||||||
|
`domains` from the service's `fields` and **warns**
|
||||||
|
(`service <name> declares domains (...), but apply cannot set them on
|
||||||
|
Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`)
|
||||||
|
once per run for every service that declared any. Set service hostnames
|
||||||
|
in the Coolify UI by hand.
|
||||||
|
- **The redis default image is an unverified extrapolation.** Coolify's
|
||||||
|
"New Resource" wizard drives PostgreSQL version selection through a
|
||||||
|
verified `postgres:<version>-alpine` image string; Redis has no version
|
||||||
|
picker in that same wizard, so cast's `redis:<version>-alpine`
|
||||||
|
guess for a manifest-declared `version` is the same Docker Hub tag
|
||||||
|
convention applied by analogy, **not confirmed against a live Coolify
|
||||||
|
instance**. Recommendation: leave a manifest database's `version` unset
|
||||||
|
for `redis` until this has been verified once against bootstrap, letting
|
||||||
|
Coolify pick its own default image instead of risking a bad tag.
|
||||||
Loading…
Reference in a new issue