diff --git a/README.md b/README.md index a602198..23a3a4c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,13 @@ cast smoke `--hostname-overlay` swaps domains for a pre-flight run against temporary 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 An environment's age identity is resolved in exactly two ways: diff --git a/docs/semantics.md b/docs/semantics.md new file mode 100644 index 0000000..726426c --- /dev/null +++ b/docs/semantics.md @@ -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 ` accepts a +per-service map value for a compose app's entry instead of the plain-app +`string[]`: + +```yaml +core: + api: ["http://api..sslip.io"] +landing: ["http://landing..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 `) +— 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 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:-alpine` image string; Redis has no version + picker in that same wizard, so cast's `redis:-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.