feat: cast — the Coolify executor, extracted from the infra state repo

Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.

Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.

Two changes were required to make it genuinely stateless and publishable:

- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
  resolved against the working directory, silently reading the wrong file
  from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
  only product knowledge in the executor — becomes the generic, operator-
  owned environments.<env>.forbidden_var_patterns. The guard now lives in
  private state, so a product-side change cannot lower its own guard, and
  it is a pattern rather than a list, so it catches unforeseen siblings.

Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.

Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.

69 tests green; bin/cast + curl installer mirror rig's shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-11 12:25:44 +00:00
parent c64c339b19
commit a10349d835
39 changed files with 18491 additions and 0 deletions

24
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
# the secrets tests round-trip a real age identity
- run: sudo apt-get update && sudo apt-get install -y age
- run: npm ci
- run: npm run check
- run: npm run build
- run: npm test
- name: installer is valid bash
run: bash -n install.sh bin/cast scripts/*.sh

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.coolify.env

118
README.md
View file

@ -1 +1,119 @@
# cast
Point it at a repo and a state directory; it makes a **Coolify** instance match
what the repo declares. One-way, idempotent, never deletes.
Philosophy (shared with [rig](https://github.com/heavy-duty/rig) and
[claudebox](https://github.com/heavy-duty/claudebox)): **public tool, private
state.** cast holds no hostnames, no bindings, no secrets, nothing about *your*
infrastructure. It reads what you point it at and stores nothing, ever.
`rig` builds the boxes. `cast` fills them.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/cast/main/install.sh | bash
```
Needs `node` >= 22.12 and [`age`](https://github.com/FiloSottile/age) (secrets
are decrypted by shelling out to it). Re-run any time to upgrade. Unlike rig —
which is pure bash so it can run on a bare box — cast runs on **your** machine:
it is an API client, and a server should never install it.
## The two inputs
cast joins a **manifest** (what to deploy) with **state** (where, and with what
values). Neither knows about the other, which is the whole point: a manifest can
live in a product repo without leaking your infrastructure, and your
infrastructure can be re-pointed at a new Coolify without touching a product.
**1. The product repo's `.infra/`** — committed, instance-blind:
```
.infra/
manifest.yaml # applications, databases, services, per environment
env/<app>.<env>.env.template # var NAMES + non-secret values; ${SECRET} placeholders
```
**2. A state directory** — private, yours:
```
environments.yaml # bindings: which server each env deploys onto, the S3
# destination, GitHub App name, smoke target, guards
secrets/<repo>.<env>.env.age # age-encrypted values for the ${…} placeholders
.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit)
```
Pass it with `--state <dir>`, or set `CAST_STATE`. Defaults to the cwd.
## Commands
```sh
cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast server add <name> --ip <ip> --key <file> [--user root] [--port 22]
cast smoke
```
- **`apply`** — idempotent create-or-update of every manifest resource, then
redeploy what changed. One-way: it never deletes a resource that Coolify has
and the manifest doesn't. Clones the repo's default branch unless `--path`
points at a local checkout (refused with `--env prod` — prod always reads the
default branch).
- **`diff`** — reports drift, manifest → Coolify. Structural by default; `--full`
also compares env vars. Exits non-zero when dirty, so CI can gate on it.
- **`server add`** — uploads a server's private key and registers it with Coolify.
- **`smoke`** — contract test against `smoke_target`: proves Coolify's bulk env
endpoint still *upserts* rather than replacing. Run it after every Coolify
upgrade — `apply`'s never-delete guarantee rests on that behavior, and the
published OpenAPI does not describe it accurately.
`--hostname-overlay` swaps domains for a pre-flight run against temporary
hostnames; re-applying **without** it is the cutover.
## Secrets, and attended applies
An environment's age identity is resolved in exactly two ways:
1. `$CAST_AGE_KEY_FILE_<ENV>` — injected for this invocation
2. `~/.config/cast/age-<env>.key` — a standing key on this machine
That is the whole mechanism behind attended vs unattended applies: **an
environment whose key you never leave on disk can only be applied by someone who
injects it.** Keep a standing key for staging if you like; keep prod's in a
password manager and pass it per apply.
The state directory holds ciphertext. It must never hold the identity that opens
it.
## Guarding an environment
An environment may refuse variables by name pattern:
```yaml
environments:
prod:
server: prod-box
forbidden_var_patterns: ["^ALLOW_"]
```
`apply` then refuses if any such var is **present** on any resource, regardless
of value. `ALLOW_SEED=false` still fails: a var that exists can be flipped on
later in the Coolify UI without touching a manifest, so "off" has to mean absent.
This guard lives in your private state deliberately — not in the product's
manifest. A product-side change must not be able to lower its own guard.
## Scripts
Operational helpers, all argument-driven (`scripts/`): register a GitHub App with
Coolify, dump the Coolify control-plane database age-encrypted to S3, restore a
database backup into a target container.
## Development
```sh
npm ci && npm run build && npm test
npm run check # biome
```

20
bin/cast Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
# Thin launcher — the CLI itself is dist/cli.js (built from src/ by tsc).
# Kept as a shim so `cast` lands on PATH the same way `rig` does, without
# requiring a global npm install.
ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
command -v node >/dev/null 2>&1 || {
printf 'cast: node (>=22.12) is required but was not found.\n' >&2
exit 1
}
if [ ! -f "$ROOT/dist/cli.js" ]; then
printf 'cast: %s/dist/cli.js is missing — run the installer, or `npm ci && npm run build` in %s\n' "$ROOT" "$ROOT" >&2
exit 1
fi
exec node "$ROOT/dist/cli.js" "$@"

29
biome.jsonc Normal file
View file

@ -0,0 +1,29 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"include": ["**"],
"ignore": ["package-lock.json", "reference/coolify-openapi-4.1.2.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

86
install.sh Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the cast repo tarball, installs the tree under $DEST, builds it,
# and puts a `cast` symlink on PATH via $BINDIR. Re-run any time to upgrade.
#
# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and
# needs node — it is an API client, never something a server installs.
REPO="${CAST_REPO:-heavy-duty/cast}"
REF="${CAST_REF:-main}"
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}"
else
BINDIR="${CAST_BIN:-$HOME/.local/bin}"
fi
log() { printf 'cast-install: %s\n' "$*"; }
warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; }
# --- prerequisites -----------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
command -v npm >/dev/null 2>&1 || die "npm is required but was not found."
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
# age is what decrypts the state repo's secrets — apply/diff shell out to it.
command -v age >/dev/null 2>&1 || warn "age not found — 'cast apply' will fail until it is installed."
# --- temp workspace ----------------------------------------------------------
TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing cast ($REPO@$REF)"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz" \
|| die "failed to download $URL"
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# GitHub archives extract to a single top-level dir like cast-<ref>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'cast-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted cast-* directory in archive"
[ -f "$EXTRACTED/bin/cast" ] || die "archive does not contain bin/cast — is $REPO@$REF correct?"
# --- build (deps + tsc), then drop the dev deps -------------------------------
log "building (npm ci && npm run build)"
( cd "$EXTRACTED" && npm ci --silent && npm run build --silent ) \
|| die "build failed"
( cd "$EXTRACTED" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
# --- atomically replace $DEST --------------------------------------------------
log "installing into $DEST"
rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
chmod +x "$DEST/bin/cast" "$DEST"/scripts/*.sh
# --- put cast on PATH ----------------------------------------------------------
mkdir -p "$BINDIR"
ln -sf "$DEST/bin/cast" "$BINDIR/cast"
log "linked $BINDIR/cast -> $DEST/bin/cast"
# --- PATH check ----------------------------------------------------------------
case ":$PATH:" in
*":$BINDIR:"*) : ;;
*)
warn "$BINDIR is not on your PATH."
warn " add: export PATH=\"$BINDIR:\$PATH\""
;;
esac
log "done — try: cast --help"

1653
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "cast",
"version": "0.1.0",
"type": "module",
"private": true,
"engines": { "node": ">=22.12.0" },
"bin": { "cast": "./dist/cli.js" },
"scripts": {
"build": "tsc",
"test": "vitest run",
"format": "biome format --write .",
"check": "biome check --error-on-warnings ."
},
"dependencies": {
"yaml": "^2.5.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@types/node": "^22.10.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
}

30
reference/README.md Normal file
View file

@ -0,0 +1,30 @@
# API reference (pinned)
`coolify-openapi-4.1.2.json` is the OpenAPI spec at the pinned Coolify tag.
The executor is written against THIS file plus controller behavior verified
2026-07-10 (the published spec omits `is_buildtime`/`is_runtime` on env
write endpoints; the controllers accept them — `infra smoke` guards this).
On any Coolify upgrade: re-vendor at the new tag, diff, re-run `infra smoke`.
## Known gap: S3 storage destinations have no API surface (verified 2026-07-10)
Coolify 4.1.2 exposes **no endpoint at all** — not list, not create, not
read — for S3 storage destinations (the backup-target resource referenced
by `s3_storage_uuid` on database-backup payloads). Verified two ways:
- The vendored spec has zero `/storages`-as-a-resource paths; the only
`storages` paths are `/applications/{uuid}/storages`,
`/databases/{uuid}/storages`, `/services/{uuid}/storages` — these are
per-resource Docker volume mounts, a different Coolify concept.
- Upstream `routes/api.php` at tag `v4.1.2` imports only these Api
controllers: Applications, CloudProviderTokens, Databases, Deploy,
Github, Hetzner, Other, Project, Resources, ScheduledTasks, Security,
Sentinel, Servers, Services, Team. There is no Storage/S3 controller —
the route table has zero routes matching `storage` or `s3` outside the
three per-resource-type volume-mount groups above.
S3 storage destinations are UI-only in this Coolify version (Settings → S3
Storages). No S3 storage API exists in 4.1.2 — the destination UUID is
recorded in environments.yaml by the bootstrap runbook; the client
deliberately has no storage resolver. Re-check on any Coolify upgrade
re-vendor.

File diff suppressed because it is too large Load diff

12
scripts/dump-coolify-db.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Nightly on the coolify box: dump Coolify's own Postgres, age-encrypt
# client-side (dump holds GitHub App key, server SSH keys, all env values),
# ship to S3. Forensics only — a fresh instance is recreated, never restored.
set -euo pipefail
: "${AGE_RECIPIENT:?age public key for the backup identity}"
: "${S3_BUCKET:?s3 bucket, e.g. s3://my-backups/coolify-db}"
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
OUT="/tmp/coolify-db-${STAMP}.sql.age"
docker exec coolify-db pg_dump -U coolify coolify | age -r "$AGE_RECIPIENT" -o "$OUT"
aws s3 cp "$OUT" "${S3_BUCKET}/" --endpoint-url "${S3_ENDPOINT:?hetzner s3 endpoint}"
rm -f "$OUT"

36
scripts/register-github-app.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Register a GitHub App (created via the manifest flow on your org) with a
# Coolify instance, so cast can create applications from private repos.
#
# usage: CAST_STATE=~/infra ./register-github-app.sh
#
# Takes everything as input — the app's identity is yours, not this tool's.
# APP_NAME must match the `github_apps.<repo>` value in your environments.yaml:
# that is the name cast resolves when it creates an application.
set -euo pipefail
STATE="${CAST_STATE:-.}"
# shellcheck disable=SC1091
source "${STATE}/.coolify.env"
: "${APP_NAME:?the Coolify-facing GitHub App name (must match github_apps.<repo> in environments.yaml)}"
: "${ORG:?the GitHub org or user the App is installed on}"
: "${APP_ID:?}"; : "${INSTALLATION_ID:?}"; : "${CLIENT_ID:?}"; : "${CLIENT_SECRET:?}"
: "${WEBHOOK_SECRET:?}"; : "${PRIVATE_KEY_FILE:?path to the App private key PEM}"
api() { curl -fsS -H "Authorization: Bearer ${COOLIFY_ACCESS_TOKEN}" -H "Content-Type: application/json" "$@"; }
KEY_UUID=$(api -X POST "${COOLIFY_BASE_URL}/api/v1/security/keys" \
-d "$(jq -n --arg name "${APP_NAME}-key" --rawfile pk "$PRIVATE_KEY_FILE" \
'{name:$name, private_key:$pk}')" | jq -r .uuid)
api -X POST "${COOLIFY_BASE_URL}/api/v1/github-apps" -d "$(jq -n \
--arg name "$APP_NAME" --arg org "$ORG" \
--arg app_id "$APP_ID" --arg inst "$INSTALLATION_ID" --arg cid "$CLIENT_ID" \
--arg csec "$CLIENT_SECRET" --arg wh "$WEBHOOK_SECRET" --arg key "$KEY_UUID" \
--arg api_url "https://api.github.com" --arg html_url "https://github.com" \
'{name:$name, organization:$org, api_url:$api_url, html_url:$html_url,
app_id:($app_id|tonumber), installation_id:($inst|tonumber), client_id:$cid, client_secret:$csec,
webhook_secret:$wh, private_key_uuid:$key}')"
echo "github app registered as ${APP_NAME}"

12
scripts/restore-db.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# usage: ./scripts/restore-db.sh <artifact.sql.gz> <target-host> <postgres-container>
# Streams a Coolify Postgres backup artifact into the target container over
# tailnet SSH. Refuses to run without explicit confirmation of the target.
set -euo pipefail
ARTIFACT="${1:?backup artifact (.sql.gz)}"; HOST="${2:?target host (tailnet name)}"; CONTAINER="${3:?postgres container name}"
echo "About to RESTORE ${ARTIFACT} into ${CONTAINER} on ${HOST} — this overwrites that database."
read -r -p "Type the target host to confirm: " CONFIRM
[ "$CONFIRM" = "$HOST" ] || { echo "confirmation mismatch; aborting"; exit 1; }
# shellcheck disable=SC2029 # intentional: $CONTAINER is a local var, expand client-side before it reaches the remote shell
gunzip -c "$ARTIFACT" | ssh "root@${HOST}" "docker exec -i ${CONTAINER} psql -U postgres"
echo "restore complete — run the verification checks in runbooks/restore-drill.md step 4"

110
src/apply.ts Normal file
View file

@ -0,0 +1,110 @@
import type { Change, Desired, DiffReport, ResourceKind } from "./diff.js";
import type { ResolvedEnv } from "./envtemplate.js";
export type Executor = {
createResource(change: Change): Promise<string>;
updateFields(
uuid: string,
kind: ResourceKind,
fields: Record<string, unknown>,
): Promise<void>;
syncEnv(uuid: string, kind: ResourceKind, env: ResolvedEnv): Promise<void>;
redeploy(uuid: string, kind: ResourceKind): Promise<void>;
};
export function applyHostnameOverlay(
desired: Desired[],
overlay: Record<string, string[] | Record<string, string[]>>,
): Desired[] {
const unknown = Object.keys(overlay).filter(
(n) => !desired.some((d) => d.name === n),
);
if (unknown.length > 0)
throw new Error(
`hostname overlay names unknown apps: ${unknown.join(", ")}`,
);
return desired.map((d) => {
const entry = overlay[d.name];
if (!entry) return d;
if (Array.isArray(entry)) {
return { ...d, fields: { ...d.fields, domains: entry } };
}
// Map-shaped overlay entry: per-service domains for a dockercompose app.
const composeDomains = d.fields.docker_compose_domains as
| Record<string, string[]>
| undefined;
if (!composeDomains) {
throw new Error(
`hostname overlay gave a service map for non-compose app ${d.name}`,
);
}
const unknownServices = Object.keys(entry).filter(
(s) => !(s in composeDomains),
);
if (unknownServices.length > 0) {
throw new Error(
`hostname overlay names unknown service(s) ${unknownServices.join(", ")} for app ${d.name} (known: ${Object.keys(composeDomains).join(", ")})`,
);
}
return {
...d,
fields: {
...d.fields,
docker_compose_domains: { ...composeDomains, ...entry },
},
};
});
}
export async function applyPlan(
report: DiffReport,
desired: Desired[],
exec: Executor,
): Promise<{ mutated: string[] }> {
if (report.mode !== "full") {
throw new Error(
"apply requires a full diff (session token with read:sensitive) — refusing on a structural report",
);
}
for (const c of report.changes) {
const blocked = c.fieldDiffs.filter(
(f) => !f.updatable && c.op === "update",
);
if (blocked.length > 0) {
throw new Error(
`cannot update in place: ${c.kind} ${c.name} field(s) ${blocked.map((f) => f.field).join(", ")} — apply never recreates resources; resolve manually (runbook act)`,
);
}
}
const mutated: string[] = [];
for (const c of report.changes) {
const spec = desired.find((d) => d.kind === c.kind && d.name === c.name);
let uuid: string;
let didMutate = c.op === "create";
if (c.op === "create") {
uuid = await exec.createResource(c);
} else {
uuid = c.uuid as string;
const fields = Object.fromEntries(
c.fieldDiffs.map((f) => [f.field, f.desired]),
);
if (Object.keys(fields).length > 0) {
await exec.updateFields(uuid, c.kind, fields);
didMutate = true;
}
}
const needsEnv =
c.op === "create"
? spec?.env !== undefined
: c.envDiffs.some((e) => e.state !== "remove-candidate");
if (needsEnv && spec?.env) {
await exec.syncEnv(uuid, c.kind, spec.env);
didMutate = true;
}
if (didMutate) {
await exec.redeploy(uuid, c.kind);
mutated.push(c.name);
}
}
return { mutated };
}

32
src/bindings.ts Normal file
View file

@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { z } from "zod";
const BindingsSchema = z
.object({
environments: z.record(
z
.object({
server: z.string(),
s3_destination: z.string().optional(),
// Var-name patterns this environment refuses outright (see
// assertEnvVarPolicy). Operator-owned guard: prod typically bans
// whatever family of flags enables destructive tooling.
forbidden_var_patterns: z.array(z.string()).optional(),
})
.strict(),
),
github_apps: z.record(z.string()),
smoke_target: z.string().optional(),
})
.strict();
export type Bindings = z.infer<typeof BindingsSchema>;
export function loadBindings(path: string): Bindings {
const result = BindingsSchema.safeParse(parse(readFileSync(path, "utf8")));
if (!result.success) {
throw new Error(`invalid bindings ${path}: ${result.error.message}`);
}
return result.data;
}

600
src/cli.ts Normal file
View file

@ -0,0 +1,600 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { parse as parseYaml } from "yaml";
import { type Executor, applyHostnameOverlay, applyPlan } from "./apply.js";
import { loadBindings } from "./bindings.js";
import { loadCoolifyEnv } from "./config.js";
import { CoolifyClient, HttpError } from "./coolify.js";
import {
type Live,
type ResourceKind,
computeDiff,
renderDiff,
} from "./diff.js";
import { assertEnvVarPolicy } from "./envtemplate.js";
import { desiredFromManifest, resolveCheckout } from "./resolve.js";
import { decryptSecrets, keyFileFor, secretsFileFor } from "./secrets.js";
import { serverAdd } from "./server.js";
import { smoke } from "./smoke.js";
const USAGE = `usage: cast apply <org>/<repo> --env <env> [--path <dir>] [--hostname-overlay <file>]
cast diff <org>/<repo> --env <env> [--full]
cast server add <name> --ip <ip> --key <file> [--user root] [--port 22]
cast smoke
--state <dir> the state checkout holding environments.yaml, secrets/ and
.coolify.env (default: $CAST_STATE, else the cwd)`;
// cast is stateless: every instance-scoped input is read from the state
// directory it is pointed at, never from a location the tool itself knows.
function stateDirFrom(flag: string | undefined): string {
return flag ?? process.env.CAST_STATE ?? ".";
}
// Live Coolify objects use their own field vocabulary; computeDiff compares
// by the DESIRED vocabulary, so each live resource must be projected onto it
// or every run reports spurious drift (breaks idempotency, criterion 2).
// Source keys per reference/coolify-openapi-4.1.2.json, cross-checked
// against the coollabsio/coolify v4.1.2 controller/model source where the
// vendored doc is silent or wrong (see task-8-report.md for the full list).
const DATABASE_TYPE_ALIASES: Record<string, string> = {
"standalone-postgresql": "postgresql",
"standalone-redis": "redis",
};
export function databaseVersionFromImage(image: unknown): string | undefined {
if (typeof image !== "string") return undefined;
const tag = image.split(":")[1];
const m = tag?.match(/^(\d+(?:\.\d+)*)/);
return m?.[1];
}
// Coolify's GET application model exposes `docker_compose_domains` as a
// nullable string (reference/coolify-openapi-4.1.2.json ~line 12689), not
// the structured array the create/update request bodies accept (~line 353) —
// the live value is the same array-of-{name,domain} shape, JSON-encoded.
// Parses defensively: anything that isn't a JSON-encoded array of well-formed
// {name, domain} entries collapses to `undefined` rather than throwing, so a
// live instance that turns out not to expose this (unverified until Task 8
// step 6) degrades to "field omitted", not a crash.
export function parseDockerComposeDomains(
raw: unknown,
): Record<string, string[]> | undefined {
if (typeof raw !== "string" || raw.length === 0) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return undefined;
}
if (!Array.isArray(parsed)) return undefined;
const map: Record<string, string[]> = {};
for (const entry of parsed) {
const name = (entry as { name?: unknown } | null)?.name;
const domain = (entry as { domain?: unknown } | null)?.domain;
if (typeof name === "string" && typeof domain === "string") {
map[name] = domain.split(",").filter(Boolean);
}
}
return map;
}
export function projectLiveFields(
kind: ResourceKind,
raw: Record<string, unknown>,
): Record<string, unknown> {
if (kind === "application") {
const composeDomains = parseDockerComposeDomains(
raw.docker_compose_domains,
);
return {
git_repository: raw.git_repository,
git_branch: raw.git_branch,
build_pack: raw.build_pack,
base_directory: raw.base_directory,
...(raw.publish_directory
? { publish_directory: raw.publish_directory }
: {}),
...(raw.ports_exposes ? { port: Number(raw.ports_exposes) } : {}),
...(raw.health_check_path ? { healthcheck: raw.health_check_path } : {}),
domains: String(raw.fqdn ?? "")
.split(",")
.filter(Boolean),
...(raw.docker_compose_location
? { docker_compose_location: raw.docker_compose_location }
: {}),
...(composeDomains ? { docker_compose_domains: composeDomains } : {}),
};
}
if (kind === "database") {
// GET /projects/{uuid}/{env} returns raw Postgresql/Redis Eloquent
// models (see fetchLive) — the vendored OpenAPI documents no schema for
// these at all ("Content is very complex. Will be implemented later.").
// `database_type` is a model accessor (app/Models/StandalonePostgresql.php
// / StandaloneRedis.php @ v4.1.2) returning "standalone-postgresql" /
// "standalone-redis"; normalized here to the manifest's plain
// "postgresql"/"redis" vocabulary. There is no `version` field on the
// wire — we best-effort recover it from the leading digits of the
// `image` tag, mirroring the convention Coolify's own "New Resource"
// wizard writes on create (see defaultDatabaseImage below).
const rawType = String(raw.database_type ?? raw.type ?? "");
const type = DATABASE_TYPE_ALIASES[rawType] ?? rawType;
const version = databaseVersionFromImage(raw.image);
return { type, ...(version ? { version } : {}) };
}
return {
type: raw.type ?? raw.service_type,
// Coolify's live `Service` model carries no flat `fqdn` — hostnames
// live per-container on service.applications[].fqdn
// (app/Models/Service.php @ v4.1.2), which this environment-list call
// doesn't eager-load. We deliberately don't fabricate a `domains` value
// here; see serviceApiFields below for the matching create/update-side
// limitation.
};
}
async function fetchLive(
client: CoolifyClient,
projectName: string,
envName: string,
): Promise<Live[]> {
// List resources inside <projectName>/<envName>; tolerate a missing
// project (first apply creates it) or a missing environment (first apply
// into a project that doesn't have this environment yet) by returning [].
const projects = (await client.get("/projects")) as Array<{
uuid: string;
name: string;
}>;
const project = projects.find((p) => p.name === projectName);
if (!project) return [];
// GET /projects/{uuid}/{environment_name_or_uuid} eager-loads exactly
// these relations (app/Http/Controllers/Api/ProjectController.php
// @environment_details, coollabsio/coolify v4.1.2): applications,
// postgresqls, redis, mongodbs, mysqls, mariadbs, services. The vendored
// OpenAPI's `Environment` schema response omits all of them (published
// doc gap — the brief's `env.databases` shape does not exist on the
// wire). We only map postgresql/redis: the two database types
// manifest.ts's DatabaseSpecSchema supports.
const env = (await client
.get(`/projects/${project.uuid}/${envName}`)
.catch((err) => {
// Missing environment (first apply into a project without it yet) is
// a 404 and means "no live resources"; anything else (401, 5xx,
// network) must surface, not be silently treated as an empty diff —
// that would cause createResource to attempt duplicate resources.
if (err instanceof HttpError && err.status === 404) {
return null;
}
throw err;
})) as {
applications?: Array<Record<string, unknown>>;
postgresqls?: Array<Record<string, unknown>>;
redis?: Array<Record<string, unknown>>;
services?: Array<Record<string, unknown>>;
} | null;
if (!env) return [];
const map = (
kind: ResourceKind,
items: Array<Record<string, unknown>> = [],
): Live[] =>
items.map((i) => ({
kind,
name: String(i.name),
uuid: String(i.uuid),
fields: projectLiveFields(kind, i),
env: undefined, // populated per-resource below only in full mode by caller
}));
return [
...map("application", env.applications),
...map("database", env.postgresqls),
...map("database", env.redis),
...map("service", env.services),
];
}
async function main(): Promise<number> {
const [command, ...rest] = process.argv.slice(2);
if (command === "-h" || command === "--help" || command === "help") {
console.log(USAGE);
return 0;
}
if (command === "apply" || command === "diff") {
const { values, positionals } = parseArgs({
args: rest,
allowPositionals: true,
options: {
env: { type: "string" },
path: { type: "string" },
state: { type: "string" },
"hostname-overlay": { type: "string" },
full: { type: "boolean", default: false },
},
});
const orgRepo = positionals[0];
const envName = values.env;
if (!orgRepo || !envName) {
console.error(USAGE);
return 2;
}
const stateDir = stateDirFrom(values.state);
const repoShort = orgRepo.split("/")[1];
const checkout = resolveCheckout(orgRepo, {
env: envName,
path: values.path,
});
const secrets = decryptSecrets(
secretsFileFor(stateDir, repoShort, envName),
keyFileFor(envName),
);
let { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
checkout,
envName,
secrets,
);
const bindings = loadBindings(join(stateDir, "environments.yaml"));
const binding = bindings.environments[envName];
if (!binding) {
console.error(`environment ${envName} not in environments.yaml`);
return 2;
}
assertEnvVarPolicy(envName, resolvedEnvs, binding.forbidden_var_patterns);
if (values["hostname-overlay"]) {
desired = applyHostnameOverlay(
desired,
parseYaml(readFileSync(values["hostname-overlay"], "utf8")),
);
}
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
const client = new CoolifyClient(baseUrl, token);
const mode = command === "apply" || values.full ? "full" : "structural";
const live = await fetchLive(client, repoShort, envName);
if (mode === "full") {
for (const l of live) {
const envs = (await client
.get(
`/${l.kind === "database" ? "databases" : `${l.kind}s`}/${l.uuid}/envs`,
)
.catch((err) => {
// Same policy as fetchLive's environment fetch: a 404 (a
// resource we just listed no longer having an envs endpoint —
// not expected in practice, but consistent with treating
// "gone" as "no env vars") collapses to []; anything else
// (401, 5xx, network) must surface. Swallowing it here would
// make a live resource's env look empty and turn every one of
// its vars into a spurious create in the diff.
if (err instanceof HttpError && err.status === 404) return [];
throw err;
})) as Array<{
key: string;
real_value?: string;
value: string;
}>;
l.env = Object.fromEntries(
envs.map((e) => [e.key, e.real_value ?? e.value]),
);
}
}
const report = computeDiff(desired, live, mode);
console.log(renderDiff(report));
if (command === "diff") return report.clean ? 0 : 1;
const serverUuid = await client.serverUuid(binding.server);
const githubAppUuid = await client.githubAppUuid(
bindings.github_apps[repoShort],
);
const exec = buildExecutor(client, {
projectName: repoShort,
envName,
serverUuid,
githubAppUuid,
s3DestinationUuid: binding.s3_destination,
backupSchedules,
});
const { mutated } = await applyPlan(report, desired, exec);
console.log(
mutated.length === 0
? "no-op (clean)"
: `applied + redeployed: ${mutated.join(", ")}`,
);
return 0;
}
if (command === "server" && rest[0] === "add") {
const { values, positionals } = parseArgs({
args: rest.slice(1),
allowPositionals: true,
options: {
ip: { type: "string" },
key: { type: "string" },
user: { type: "string" },
port: { type: "string" },
state: { type: "string" },
},
});
if (!positionals[0] || !values.ip || !values.key) {
console.error(USAGE);
return 2;
}
const { baseUrl, token } = loadCoolifyEnv(
join(stateDirFrom(values.state), ".coolify.env"),
);
await serverAdd(new CoolifyClient(baseUrl, token), {
name: positionals[0],
ip: values.ip,
keyFile: values.key,
user: values.user,
port: values.port ? Number(values.port) : undefined,
});
return 0;
}
if (command === "smoke") {
const { values } = parseArgs({
args: rest,
allowPositionals: true,
options: { state: { type: "string" } },
});
const stateDir = stateDirFrom(values.state);
const { baseUrl, token } = loadCoolifyEnv(join(stateDir, ".coolify.env"));
const client = new CoolifyClient(baseUrl, token);
const bindings = loadBindings(join(stateDir, "environments.yaml"));
if (!bindings.smoke_target) {
console.error(
"environments.yaml: smoke_target (app name) required for smoke",
);
return 2;
}
// resolve app uuid by name across the project list
const apps = (await client.get("/applications")) as Array<{
uuid: string;
name: string;
}>;
const target = apps.find((a) => a.name === bindings.smoke_target);
if (!target) {
console.error(`smoke_target ${bindings.smoke_target} not found`);
return 2;
}
await smoke(client, target.uuid);
return 0;
}
console.error(USAGE);
return 2;
}
async function resolveOrCreateProject(
client: CoolifyClient,
name: string,
): Promise<string> {
try {
return await client.projectUuid(name);
} catch (err) {
// projectUuid's resolver-miss (CoolifyClient.resolve) throws this exact
// message with no `status` — that's the only case we treat as "create
// it"; a 401/5xx/network failure must surface, not fall through to a
// duplicate-create attempt.
if (
err instanceof Error &&
err.message === `not found in Coolify: project ${name}`
) {
const p = (await client.post("/projects", { name })) as { uuid: string };
return p.uuid;
}
throw err;
}
}
// --- Desired-vocabulary -> Coolify wire-vocabulary field mapping ---
//
// `fields` (from Desired/Change) speaks the internal vocabulary used for
// diffing (see resolve.ts / diff.ts): `port`, `healthcheck`, `domains`
// (array), `type`, `version`. Coolify's actual create/update payloads use
// different field names and shapes for some of these (verified against
// reference/coolify-openapi-4.1.2.json requestBody schemas for
// /applications/private-github-app, PATCH /applications/{uuid},
// /databases/postgresql, /databases/redis, PATCH /databases/{uuid},
// POST/PATCH /services) — spreading `fields` straight into the request body
// (as an earlier draft of this executor did) would silently drop
// healthcheck/domains updates and leak unrecognized type/version keys into
// database creates. These helpers do the translation once, shared by
// createResource and updateFields.
export function applicationApiFields(
fields: Record<string, unknown>,
): Record<string, unknown> {
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
fields;
return {
...rest,
// ports_exposes wants a string; healthcheck -> health_check_path;
// domains wants a comma-separated string, not an array.
...(port !== undefined ? { ports_exposes: String(port) } : {}),
...(healthcheck !== undefined ? { health_check_path: healthcheck } : {}),
...(domains !== undefined
? { domains: Array.isArray(domains) ? domains.join(",") : domains }
: {}),
// docker_compose_domains speaks the internal map vocabulary
// (service -> string[]); the wire shape is an array of
// {name, domain} where domain is that array comma-joined (verified
// against the /applications/private-github-app + PATCH /applications
// request schemas, ~line 353 of the vendored OpenAPI).
...(docker_compose_domains !== undefined
? {
docker_compose_domains: Object.entries(
docker_compose_domains as Record<string, string[]>,
).map(([name, urls]) => ({ name, domain: urls.join(",") })),
}
: {}),
};
}
export function defaultDatabaseImage(type: string, version: string): string {
// Verified against coollabsio/coolify v4.1.2 source
// (resources/views/livewire/project/new/select.blade.php +
// app/Livewire/Project/New/Select.php): the "New Resource" wizard's
// PostgreSQL version picker calls setPostgresqlType('postgres:{v}-alpine')
// for each offered version. Redis has no version picker in that wizard —
// this half of the mapping extrapolates the same Docker Hub tag
// convention and is UNVERIFIED against a live instance (see task-8-report.md).
const repo = type === "postgresql" ? "postgres" : "redis";
return `${repo}:${version}-alpine`;
}
export function databaseApiFields(
fields: Record<string, unknown>,
): Record<string, unknown> {
// /databases/postgresql and /databases/redis accept no `type` param (the
// endpoint path already encodes it) and no `version` param at all — only
// `image`, a literal Docker image string.
const { type, version, ...rest } = fields;
return {
...rest,
...(typeof version === "string"
? { image: defaultDatabaseImage(String(type), version) }
: {}),
};
}
export function serviceApiFields(
fields: Record<string, unknown>,
): Record<string, unknown> {
// /services accepts `urls`, a structured per-container list
// ({name, url}[]), not the flat `domains` string list the manifest
// speaks. manifest.ts's ServiceSpecSchema has no notion of per-container
// name, so we can't build a correct `urls` payload from `domains` alone —
// dropped rather than sent malformed. Known limitation: service hostnames
// need manual Coolify UI configuration (see README, Task 10).
const { domains: _domains, ...rest } = fields;
return rest;
}
export function buildExecutor(
client: CoolifyClient,
ctx: {
projectName: string;
envName: string;
serverUuid: string;
githubAppUuid: string;
s3DestinationUuid?: string; // raw UUID from environments.yaml — no storage API exists to resolve names
backupSchedules: Record<string, { frequency: string; retention: number }>;
},
): Executor {
return {
async createResource(change) {
// Field payloads assembled from change.fieldDiffs (desired values):
const fields = Object.fromEntries(
change.fieldDiffs.map((f) => [f.field, f.desired]),
);
const projectUuid = await resolveOrCreateProject(client, ctx.projectName);
if (change.kind === "application") {
const res = (await client.post("/applications/private-github-app", {
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
github_app_uuid: ctx.githubAppUuid,
name: change.name,
instant_deploy: false,
...applicationApiFields(fields),
// A compose stack must reach the managed Postgres/Redis resources
// (the box-B lesson, DEPLOY.md §0/§3) — Coolify only wires that up
// when this flag is set on create.
...(fields.build_pack === "dockercompose"
? { connect_to_docker_network: true }
: {}),
})) as { uuid: string };
return res.uuid;
}
if (change.kind === "database") {
const type = String(fields.type);
const res = (await client.post(
`/databases/${type === "postgresql" ? "postgresql" : "redis"}`,
{
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
name: change.name,
...databaseApiFields(fields),
},
)) as { uuid: string };
const schedule = ctx.backupSchedules[change.name];
if (schedule) {
if (!ctx.s3DestinationUuid) {
throw new Error(
`database ${change.name} declares a backup schedule but environments.yaml has no s3_destination UUID for this environment`,
);
}
await client.post(`/databases/${res.uuid}/backups`, {
frequency: schedule.frequency,
database_backup_retention_amount_locally: schedule.retention,
save_s3: true,
s3_storage_uuid: ctx.s3DestinationUuid,
});
}
return res.uuid;
}
const res = (await client.post("/services", {
project_uuid: projectUuid,
environment_name: ctx.envName,
server_uuid: ctx.serverUuid,
name: change.name,
...serviceApiFields(fields),
})) as { uuid: string };
return res.uuid;
},
async updateFields(uuid, kind, fields) {
const base =
kind === "application"
? "applications"
: kind === "database"
? "databases"
: "services";
const apiFields =
kind === "application"
? applicationApiFields(fields)
: kind === "service"
? serviceApiFields(fields)
: databaseApiFields(fields);
await client.patch(`/${base}/${uuid}`, apiFields);
},
async syncEnv(uuid, kind, env) {
// Bulk env update is an UPSERT of listed keys, not a full replace —
// verified against app/Http/Controllers/Api/{Applications,Databases,
// Services}Controller.php@create_bulk_envs (coollabsio/coolify
// v4.1.2): each item is found-by-key-and-updated or created; no
// deletion of unlisted keys occurs (audit event is literally named
// "*.env_bulk_upserted"). Safe under the iron rule that apply never
// deletes — no need to fall back to per-key create-or-update calls.
const base =
kind === "application"
? "applications"
: kind === "database"
? "databases"
: "services";
await client.patch(`/${base}/${uuid}/envs/bulk`, {
data: Object.entries(env.vars).map(([key, v]) => ({
key,
value: v.value,
is_buildtime: false,
is_preview: false,
})),
});
},
async redeploy(uuid, kind) {
if (kind === "service") await client.restart(uuid);
else await client.deploy(uuid);
},
};
}
// Guard the entrypoint so `test/wire.test.ts` can import the pure
// translation helpers above without executing the CLI (parseArgs against
// vitest's argv, process.exit mid-test-run, etc). Only runs main() when
// this file is the process entrypoint (`node dist/cli.js ...`), not when
// imported as a module.
if (import.meta.url === `file://${process.argv[1]}`) {
main().then(
(code) => process.exit(code),
(err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
},
);
}

25
src/config.ts Normal file
View file

@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
// Coolify's base URL + API token, read from <state>/.coolify.env — the one
// file in the state repo that is never committed (it is a live credential).
export function loadCoolifyEnv(path: string): {
baseUrl: string;
token: string;
} {
const vars: Record<string, string> = {};
for (const raw of readFileSync(path, "utf8").split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
vars[line.slice(0, eq)] = line.slice(eq + 1).replace(/^"|"$/g, "");
}
const baseUrl = vars.COOLIFY_BASE_URL;
const token = vars.COOLIFY_ACCESS_TOKEN;
if (!baseUrl || !token) {
throw new Error(
`${path}: COOLIFY_BASE_URL and COOLIFY_ACCESS_TOKEN are required`,
);
}
return { baseUrl, token };
}

92
src/coolify.ts Normal file
View file

@ -0,0 +1,92 @@
type Json = Record<string, unknown> | unknown[] | null;
// Thrown by req/reqText on a non-2xx response. `status` lets callers narrow
// handling (e.g. "treat 404 as absent, rethrow everything else") via
// `instanceof HttpError` without parsing the message string; the message
// format itself is unchanged (asserted by coolify.test.ts).
export class HttpError extends Error {
constructor(
method: string,
path: string,
public readonly status: number,
body: string,
) {
super(`${method} ${path}${status}: ${body}`);
this.name = "HttpError";
}
}
export class CoolifyClient {
constructor(
private readonly baseUrl: string,
private readonly token: string,
private readonly fetchImpl: typeof fetch = fetch,
) {}
private async req(
method: string,
path: string,
body?: unknown,
): Promise<Json> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
throw new HttpError(method, path, res.status, await res.text());
}
return res.status === 204 ? null : ((await res.json()) as Json);
}
private async reqText(method: string, path: string): Promise<string> {
const res = await this.fetchImpl(`${this.baseUrl}/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
},
});
if (!res.ok) {
throw new HttpError(method, path, res.status, await res.text());
}
return res.text();
}
get = (path: string) => this.req("GET", path);
post = (path: string, body?: unknown) => this.req("POST", path, body);
patch = (path: string, body?: unknown) => this.req("PATCH", path, body);
delete_ = (path: string) => this.req("DELETE", path);
async version(): Promise<string> {
return this.reqText("GET", "/version");
}
private async resolve(
kind: string,
listPath: string,
name: string,
): Promise<string> {
const items = (await this.get(listPath)) as Array<{
uuid: string;
name: string;
}>;
const hit = items.find((i) => i.name === name);
if (!hit) throw new Error(`not found in Coolify: ${kind} ${name}`);
return hit.uuid;
}
serverUuid = (name: string) => this.resolve("server", "/servers", name);
githubAppUuid = (name: string) =>
this.resolve("github app", "/github-apps", name);
projectUuid = (name: string) => this.resolve("project", "/projects", name);
async deploy(uuid: string): Promise<void> {
await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`);
}
async restart(uuid: string): Promise<void> {
await this.post(`/services/${encodeURIComponent(uuid)}/restart`);
}
}

165
src/diff.ts Normal file
View file

@ -0,0 +1,165 @@
import type { ResolvedEnv } from "./envtemplate.js";
export type ResourceKind = "application" | "database" | "service";
export type Desired = {
kind: ResourceKind;
name: string;
fields: Record<string, unknown>;
env?: ResolvedEnv;
};
export type Live = {
kind: ResourceKind;
name: string;
uuid: string;
fields: Record<string, unknown>;
env?: Record<string, string>;
};
export type FieldDiff = {
field: string;
desired: unknown;
live?: unknown;
updatable: boolean;
};
export type EnvDiff = {
key: string;
state: "add" | "change" | "remove-candidate";
secret: boolean;
};
export type Change = {
kind: ResourceKind;
name: string;
uuid?: string;
op: "create" | "update";
fieldDiffs: FieldDiff[];
envDiffs: EnvDiff[];
};
export type DiffReport = {
mode: "structural" | "full";
changes: Change[];
orphans: { kind: ResourceKind; name: string; uuid: string }[];
clean: boolean;
};
export const NON_UPDATABLE: Record<ResourceKind, string[]> = {
application: ["build_pack"],
database: ["type", "version"],
service: ["type"],
};
function eq(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function diffEnv(
desired: ResolvedEnv,
live: Record<string, string>,
): EnvDiff[] {
const diffs: EnvDiff[] = [];
for (const [key, v] of Object.entries(desired.vars)) {
if (!(key in live)) diffs.push({ key, state: "add", secret: v.secret });
else if (live[key] !== v.value)
diffs.push({ key, state: "change", secret: v.secret });
}
for (const key of Object.keys(live)) {
if (!(key in desired.vars))
diffs.push({ key, state: "remove-candidate", secret: false });
}
return diffs;
}
export function computeDiff(
desired: Desired[],
live: Live[],
mode: "structural" | "full",
): DiffReport {
const changes: Change[] = [];
for (const d of desired) {
const l = live.find((x) => x.kind === d.kind && x.name === d.name);
if (!l) {
changes.push({
kind: d.kind,
name: d.name,
op: "create",
fieldDiffs: Object.entries(d.fields).map(([field, value]) => ({
field,
desired: value,
updatable: !NON_UPDATABLE[d.kind].includes(field),
})),
envDiffs:
mode === "full" && d.env
? Object.entries(d.env.vars).map(([key, v]) => ({
key,
state: "add" as const,
secret: v.secret,
}))
: [],
});
continue;
}
const fieldDiffs: FieldDiff[] = Object.entries(d.fields)
.filter(([field, value]) => !eq(value, l.fields[field]))
.map(([field, value]) => ({
field,
desired: value,
live: l.fields[field],
updatable: !NON_UPDATABLE[d.kind].includes(field),
}));
const envDiffs =
mode === "full" && d.env ? diffEnv(d.env, l.env ?? {}) : [];
if (fieldDiffs.length > 0 || envDiffs.length > 0) {
changes.push({
kind: d.kind,
name: d.name,
uuid: l.uuid,
op: "update",
fieldDiffs,
envDiffs,
});
}
}
const orphans = live
.filter((l) => !desired.some((d) => d.kind === l.kind && d.name === l.name))
.map((l) => ({ kind: l.kind, name: l.name, uuid: l.uuid }));
return {
mode,
changes,
orphans,
clean: changes.length === 0 && orphans.length === 0,
};
}
export function renderDiff(report: DiffReport): string {
const lines: string[] = [];
if (report.mode === "structural") {
lines.push(
"env vars not compared (structural mode — full diff needs a session token with read:sensitive)",
);
}
for (const c of report.changes) {
lines.push(`${c.op} ${c.kind} ${c.name}`);
for (const f of c.fieldDiffs) {
lines.push(
` ${f.field}: ${JSON.stringify(f.live)}${JSON.stringify(f.desired)}${f.updatable ? "" : " [NOT UPDATABLE IN PLACE]"}`,
);
}
for (const e of c.envDiffs) {
if (e.state === "remove-candidate")
lines.push(
` env ${e.key}: live-only (orphan var — apply never removes)`,
);
else if (e.secret) lines.push(` secret ${e.key} differs`);
else lines.push(` env ${e.key}: ${e.state}`);
}
}
for (const o of report.orphans) {
lines.push(
`orphan ${o.kind} ${o.name} (live, not in manifest — removal is a manual runbook act)`,
);
}
lines.push(
report.clean
? "clean"
: `${report.changes.length} change(s), ${report.orphans.length} orphan(s)`,
);
return lines.join("\n");
}

64
src/envtemplate.ts Normal file
View file

@ -0,0 +1,64 @@
export type ResolvedEnv = {
vars: Record<string, { value: string; secret: boolean }>;
};
export function resolveTemplate(
text: string,
secrets: Record<string, string>,
): ResolvedEnv {
const vars: ResolvedEnv["vars"] = {};
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === "" || line.startsWith("#")) continue;
const m = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
if (!m)
throw new Error(
`env template line ${i + 1}: expected KEY=value, got "${line}"`,
);
const [, key, rhs] = m;
const placeholder = rhs.match(/^\$\{([A-Z][A-Z0-9_]*)\}$/);
if (placeholder) {
const value = secrets[placeholder[1]];
if (value === undefined) {
throw new Error(
`secret ${placeholder[1]} (for ${key}) missing from the age store`,
);
}
vars[key] = { value, secret: true };
} else {
vars[key] = { value: rhs, secret: false };
}
}
return { vars };
}
// An environment may forbid variables by name pattern, declared as
// `environments.<env>.forbidden_var_patterns` in the state repo. The rule is
// PRESENCE, not value: a forbidden var set to "false" still refuses the apply,
// because a var that exists can be flipped on later in the Coolify UI without
// touching a manifest — "off" has to mean absent.
//
// The policy lives in the operator's private state, never in a product's
// manifest: a product-side change must not be able to lower its own guard.
export function assertEnvVarPolicy(
envName: string,
resolved: Record<string, ResolvedEnv>,
forbiddenPatterns: string[] | undefined,
): void {
if (!forbiddenPatterns?.length) return;
const patterns = forbiddenPatterns.map((p) => ({
src: p,
re: new RegExp(p),
}));
for (const [resource, env] of Object.entries(resolved)) {
for (const key of Object.keys(env.vars)) {
const hit = patterns.find((p) => p.re.test(key));
if (hit) {
throw new Error(
`refusing ${envName} apply: ${key} is present on ${resource} regardless of value — forbidden by forbidden_var_patterns /${hit.src}/ ("off" means absent, not false)`,
);
}
}
}
}

113
src/manifest.ts Normal file
View file

@ -0,0 +1,113 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { z } from "zod";
const AppSpecSchema = z
.object({
source: z.object({ repo: z.string(), branch: z.string() }).strict(),
build: z
.object({
pack: z.enum(["nixpacks", "static", "dockerfile", "dockercompose"]),
base_directory: z.string(),
publish_directory: z.string().optional(),
compose_file: z.string().optional(),
})
.strict(),
port: z.number().int().optional(),
healthcheck: z.string().optional(),
domains: z.array(z.string()).optional(),
service_domains: z.record(z.array(z.string())).optional(),
env_template: z.string().optional(),
})
.strict()
.superRefine((app, ctx) => {
if (app.build.pack === "dockercompose") {
if (!app.build.compose_file)
ctx.addIssue({
code: "custom",
message: "dockercompose apps require build.compose_file",
});
if (!app.service_domains)
ctx.addIssue({
code: "custom",
message: "dockercompose apps require service_domains",
});
for (const k of ["port", "healthcheck", "domains"] as const)
if (app[k] !== undefined)
ctx.addIssue({
code: "custom",
message: `${k} not allowed on a dockercompose app (lives in the compose file)`,
});
if (app.build.publish_directory)
ctx.addIssue({
code: "custom",
message: "publish_directory not allowed on a dockercompose app",
});
} else {
if (!app.domains)
ctx.addIssue({
code: "custom",
message: "domains required (non-compose app)",
});
if (app.service_domains || app.build.compose_file)
ctx.addIssue({
code: "custom",
message:
"service_domains/compose_file only allowed with pack dockercompose",
});
}
});
const DatabaseSpecSchema = z
.object({
type: z.enum(["postgresql", "redis"]),
version: z.string().optional(),
backup: z
.object({ frequency: z.string(), retention: z.number().int() })
.strict()
.optional(),
})
.strict();
const ServiceSpecSchema = z
.object({
type: z.string(),
domains: z.array(z.string()).optional(),
env_template: z.string().optional(),
})
.strict();
const EnvironmentSpecSchema = z
.object({
applications: z.record(AppSpecSchema),
databases: z.record(DatabaseSpecSchema).optional(),
services: z.record(ServiceSpecSchema).optional(),
})
.strict();
const ManifestSchema = z
.object({
project: z.string(),
environments: z.record(EnvironmentSpecSchema),
})
.strict();
export type AppSpec = z.infer<typeof AppSpecSchema>;
export type DatabaseSpec = z.infer<typeof DatabaseSpecSchema>;
export type ServiceSpec = z.infer<typeof ServiceSpecSchema>;
export type EnvironmentSpec = z.infer<typeof EnvironmentSpecSchema>;
export type Manifest = z.infer<typeof ManifestSchema>;
export function loadManifest(
path: string,
opts: { overrideText?: string } = {},
): Manifest {
const text = opts.overrideText ?? readFileSync(path, "utf8");
const result = ManifestSchema.safeParse(parse(text));
if (!result.success) {
throw new Error(
`invalid manifest ${path}: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
);
}
return result.data;
}

127
src/resolve.ts Normal file
View file

@ -0,0 +1,127 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Desired } from "./diff.js";
import { type ResolvedEnv, resolveTemplate } from "./envtemplate.js";
import { loadManifest } from "./manifest.js";
export function resolveCheckout(
orgRepo: string,
opts: { env: string; path?: string },
): string {
if (opts.path && opts.env === "prod") {
throw new Error(
"apply refuses --path with --env prod: prod always reads the default branch",
);
}
if (opts.path) return opts.path;
const dir = mkdtempSync(join(tmpdir(), "infra-checkout-"));
execFileSync(
"git",
["clone", "--depth", "1", `https://github.com/${orgRepo}.git`, dir],
{
stdio: "pipe",
},
);
return dir;
}
export function desiredFromManifest(
checkoutDir: string,
envName: string,
secrets: Record<string, string>,
): {
desired: Desired[];
resolvedEnvs: Record<string, ResolvedEnv>;
backupSchedules: Record<string, { frequency: string; retention: number }>;
} {
const manifest = loadManifest(join(checkoutDir, ".infra", "manifest.yaml"));
const envSpec = manifest.environments[envName];
if (!envSpec) {
throw new Error(
`environment ${envName} not in manifest (has: ${Object.keys(manifest.environments).join(", ") || "none"})`,
);
}
const desired: Desired[] = [];
const resolvedEnvs: Record<string, ResolvedEnv> = {};
const backupSchedules: Record<
string,
{ frequency: string; retention: number }
> = {};
const resolveEnvFile = (
name: string,
template?: string,
): ResolvedEnv | undefined => {
if (!template) return undefined;
const file = join(checkoutDir, ".infra", "env", template);
if (!existsSync(file))
throw new Error(`env template missing: ${file} (referenced by ${name})`);
const env = resolveTemplate(readFileSync(file, "utf8"), secrets);
resolvedEnvs[name] = env;
return env;
};
for (const [name, app] of Object.entries(envSpec.applications)) {
desired.push({
kind: "application",
name,
fields: {
git_repository: app.source.repo,
git_branch: app.source.branch,
build_pack: app.build.pack,
base_directory: app.build.base_directory,
...(app.build.publish_directory
? { publish_directory: app.build.publish_directory }
: {}),
...(app.build.pack === "dockercompose"
? {
docker_compose_location: app.build.compose_file,
docker_compose_domains: app.service_domains,
}
: {
...(app.port !== undefined ? { port: app.port } : {}),
...(app.healthcheck ? { healthcheck: app.healthcheck } : {}),
domains: app.domains,
}),
},
env: resolveEnvFile(name, app.env_template),
});
}
for (const [name, db] of Object.entries(envSpec.databases ?? {})) {
desired.push({
kind: "database",
name,
fields: { type: db.type, ...(db.version ? { version: db.version } : {}) },
});
if (db.backup)
backupSchedules[name] = {
frequency: db.backup.frequency,
retention: db.backup.retention,
};
}
for (const [name, svc] of Object.entries(envSpec.services ?? {})) {
if (svc.domains && svc.domains.length > 0) {
// Coolify 4.1.2's service executor has no flat `domains` concept —
// hostnames live per-container on `urls` (see serviceApiFields in
// cli.ts) — so a manifest-declared service `domains` list is silently
// unhonorable by apply. Warn at build time, once per run, while the
// service name is still in scope.
console.warn(
`service ${name} declares domains (${svc.domains.join(", ")}), but apply cannot set them on Coolify 4.1.2 services — configure hostnames manually in the Coolify UI`,
);
}
desired.push({
kind: "service",
name,
// domains dropped from fields, same as database `backup` above: the
// live side (projectLiveFields in cli.ts) can't read service domains
// and the write side (serviceApiFields) drops them, so keeping
// domains in fields makes every domain-bearing service diff as a
// perpetual update. Hostnames stay a manual Coolify UI act (warned
// above).
fields: { type: svc.type },
env: resolveEnvFile(name, svc.env_template),
});
}
return { desired, resolvedEnvs, backupSchedules };
}

51
src/secrets.ts Normal file
View file

@ -0,0 +1,51 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export function decryptSecrets(
file: string,
keyFile: string,
): Record<string, string> {
const out = execFileSync("age", ["-d", "-i", keyFile, file], {
encoding: "utf8",
});
const secrets: Record<string, string> = {};
for (const raw of out.split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1)
throw new Error("age store: malformed line (expected KEY=value)");
secrets[line.slice(0, eq)] = line.slice(eq + 1);
}
return secrets;
}
// The age identity for an environment, resolved without cast knowing anything
// about your environment names:
//
// 1. $CAST_AGE_KEY_FILE_<ENV> — injected for this invocation
// 2. ~/.config/cast/age-<env>.key — a standing key on this machine
//
// This is the whole mechanism behind attended vs unattended applies: an
// environment whose key you never leave on disk can only be applied by an
// operator who injects it. Keep the key OUT of the state repo — the state repo
// holds ciphertext, never the identity that opens it.
export function keyFileFor(envName: string): string {
const injected = process.env[`CAST_AGE_KEY_FILE_${envName.toUpperCase()}`];
if (injected) return injected;
const standing = join(homedir(), ".config", "cast", `age-${envName}.key`);
if (existsSync(standing)) return standing;
throw new Error(
`no age key for ${envName}: set CAST_AGE_KEY_FILE_${envName.toUpperCase()} (attended apply) or place a standing key at ${standing}`,
);
}
export function secretsFileFor(
stateDir: string,
repoShortName: string,
envName: string,
): string {
return join(stateDir, "secrets", `${repoShortName}.${envName}.env.age`);
}

29
src/server.ts Normal file
View file

@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import type { CoolifyClient } from "./coolify.js";
export async function serverAdd(
client: CoolifyClient,
opts: {
name: string;
ip: string;
keyFile: string;
user?: string;
port?: number;
},
): Promise<void> {
const key = (await client.post("/security/keys", {
name: `${opts.name}-root`,
private_key: readFileSync(opts.keyFile, "utf8"),
})) as { uuid: string };
await client.post("/servers", {
name: opts.name,
ip: opts.ip,
port: opts.port ?? 22,
user: opts.user ?? "root",
private_key_uuid: key.uuid,
instant_validate: true,
});
console.log(
`server ${opts.name} registered (${opts.ip}); check validation in Coolify`,
);
}

66
src/smoke.ts Normal file
View file

@ -0,0 +1,66 @@
import type { CoolifyClient } from "./coolify.js";
export async function smoke(
client: CoolifyClient,
targetAppUuid: string,
): Promise<void> {
const KEEP_KEY = "INFRA_SMOKE_KEEP";
const PROBE_KEY = "INFRA_SMOKE_PROBE";
const envsPath = `/applications/${targetAppUuid}/envs`;
type EnvVar = {
key: string;
value: string;
is_buildtime?: boolean;
uuid: string;
};
const readEnvs = async (): Promise<EnvVar[]> =>
(await client.get(envsPath)) as EnvVar[];
// First var goes in via the singular envs endpoint — this is the
// never-delete canary the bulk write below must not disturb.
await client.post(envsPath, {
key: KEEP_KEY,
value: "1",
is_buildtime: false,
is_preview: false,
});
// Second var goes in via the bulk endpoint (the one apply's syncEnv uses,
// see cli.ts) with a payload containing ONLY the second var. The bulk
// envs endpoint is documented/verified as UPSERT-only (never deletes
// unlisted keys) — that's the load-bearing guarantee behind the iron rule
// that apply never deletes. If a Coolify upgrade regresses it to
// full-replace, KEEP_KEY will vanish from the read-back below.
await client.patch(`${envsPath}/bulk`, {
data: [
{
key: PROBE_KEY,
value: "1",
is_buildtime: false,
is_preview: false,
},
],
});
const envs = await readEnvs();
const keep = envs.find((e) => e.key === KEEP_KEY);
const probe = envs.find((e) => e.key === PROBE_KEY);
if (!keep) {
// Clean up whatever did survive before failing loudly.
if (probe) await client.delete_(`${envsPath}/${probe.uuid}`);
throw new Error(
"smoke FAIL: bulk env write is destructive (full-replace) — never-delete broken; do not apply with this Coolify version",
);
}
if (!probe) throw new Error("smoke FAIL: probe var not readable back");
if (probe.is_buildtime !== false) {
throw new Error(
`smoke FAIL: is_buildtime round-trip broken (got ${probe.is_buildtime}) — Coolify upgrade regression?`,
);
}
await client.delete_(`${envsPath}/${keep.uuid}`);
await client.delete_(`${envsPath}/${probe.uuid}`);
console.log(`smoke OK against Coolify ${await client.version()}`);
}

191
test/apply.test.ts Normal file
View file

@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import {
type Executor,
applyHostnameOverlay,
applyPlan,
} from "../src/apply.js";
import { type Desired, computeDiff } from "../src/diff.js";
const desired: Desired[] = [
{
kind: "application",
name: "core-api",
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
env: { vars: { PORT: { value: "3000", secret: false } } },
},
];
function recorder() {
const calls: string[] = [];
const exec: Executor = {
createResource: async (c) => {
calls.push(`create ${c.name}`);
return "new-uuid";
},
updateFields: async (uuid, _k, fields) => {
calls.push(`update ${uuid} ${Object.keys(fields).join(",")}`);
},
syncEnv: async (uuid) => {
calls.push(`env ${uuid}`);
},
redeploy: async (uuid) => {
calls.push(`redeploy ${uuid}`);
},
};
return { calls, exec };
}
describe("applyPlan", () => {
it("creates, syncs env, then redeploys", async () => {
const { calls, exec } = recorder();
const r = await applyPlan(computeDiff(desired, [], "full"), desired, exec);
expect(calls).toEqual([
"create core-api",
"env new-uuid",
"redeploy new-uuid",
]);
expect(r.mutated).toEqual(["core-api"]);
});
it("refuses a structural report before any mutation", async () => {
const { calls, exec } = recorder();
await expect(
applyPlan(computeDiff(desired, [], "structural"), desired, exec),
).rejects.toThrow(/full diff/);
expect(calls).toEqual([]);
});
it("refuses non-updatable drift before any mutation, naming the field", async () => {
const { calls, exec } = recorder();
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { build_pack: "static", domains: ["https://api.example.com"] },
env: { PORT: "3000" },
},
];
await expect(
applyPlan(computeDiff(desired, live, "full"), desired, exec),
).rejects.toThrow(/build_pack.*core-api|core-api.*build_pack/s);
expect(calls).toEqual([]);
});
it("does nothing on a clean report", async () => {
const { calls, exec } = recorder();
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: {
build_pack: "nixpacks",
domains: ["https://api.example.com"],
},
env: { PORT: "3000" },
},
];
const r = await applyPlan(
computeDiff(desired, live, "full"),
desired,
exec,
);
expect(calls).toEqual([]);
expect(r.mutated).toEqual([]);
});
it("does nothing when the only drift is a remove-candidate env var", async () => {
const { calls, exec } = recorder();
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: {
build_pack: "nixpacks",
domains: ["https://api.example.com"],
},
env: { PORT: "3000", LEGACY_VAR: "keep-me" },
},
];
const report = computeDiff(desired, live, "full");
expect(report.changes).toEqual([
{
kind: "application",
name: "core-api",
uuid: "u1",
op: "update",
fieldDiffs: [],
envDiffs: [
{ key: "LEGACY_VAR", state: "remove-candidate", secret: false },
],
},
]);
const r = await applyPlan(report, desired, exec);
expect(calls).toEqual([]);
expect(r.mutated).toEqual([]);
});
});
describe("applyHostnameOverlay", () => {
it("replaces only domains of named apps", () => {
const out = applyHostnameOverlay(desired, {
"core-api": ["http://tmp.example.net"],
});
expect(out[0].fields.domains).toEqual(["http://tmp.example.net"]);
expect(out[0].fields.build_pack).toBe("nixpacks");
expect(desired[0].fields.domains).toEqual(["https://api.example.com"]); // input untouched
});
it("throws on unknown app names", () => {
expect(() => applyHostnameOverlay(desired, { nope: ["http://x"] })).toThrow(
/unknown.*nope/i,
);
});
const composeDesired: Desired[] = [
{
kind: "application",
name: "core",
fields: {
build_pack: "dockercompose",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: {
api: ["http://api.<PROD-IP>.sslip.io"],
landing: ["http://landing.<PROD-IP>.sslip.io"],
},
},
},
];
it("rewrites docker_compose_domains per-service when the overlay value is a map", () => {
const out = applyHostnameOverlay(composeDesired, {
core: { api: ["http://api.override.example.net"] },
});
expect(out[0].fields.docker_compose_domains).toEqual({
api: ["http://api.override.example.net"],
landing: ["http://landing.<PROD-IP>.sslip.io"],
});
// input untouched
expect(composeDesired[0].fields.docker_compose_domains).toEqual({
api: ["http://api.<PROD-IP>.sslip.io"],
landing: ["http://landing.<PROD-IP>.sslip.io"],
});
});
it("throws on an unknown service key in a map overlay, listing known services", () => {
expect(() =>
applyHostnameOverlay(composeDesired, {
core: { bogus: ["http://x"] },
}),
).toThrow(/bogus.*(api|landing)/is);
});
it("throws when a map-shaped overlay value names a non-compose app", () => {
expect(() =>
applyHostnameOverlay(desired, {
"core-api": { api: ["http://x"] },
}),
).toThrow(/service map for non-compose app core-api/);
});
it("keeps today's behavior for a string[]-shaped entry on a plain app", () => {
const out = applyHostnameOverlay(desired, {
"core-api": ["http://plain.example.net"],
});
expect(out[0].fields.domains).toEqual(["http://plain.example.net"]);
});
});

35
test/cli.test.ts Normal file
View file

@ -0,0 +1,35 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
function runCli(args: string[]): { code: number; output: string } {
try {
const output = execFileSync("node", ["dist/cli.js", ...args], {
encoding: "utf8",
stdio: "pipe",
});
return { code: 0, output };
} catch (e) {
const err = e as { status: number; stderr: string; stdout: string };
return { code: err.status, output: `${err.stdout}${err.stderr}` };
}
}
describe("infra cli", () => {
it("refuses apply --path with --env prod, exit non-zero", () => {
const r = runCli([
"apply",
"acme/widget",
"--env",
"prod",
"--path",
"/tmp/x",
]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/--path.*prod/);
});
it("prints usage on unknown command", () => {
const r = runCli(["frobnicate"]);
expect(r.code).not.toBe(0);
expect(r.output).toMatch(/usage: cast (apply|diff)/i);
});
});

46
test/coolify.test.ts Normal file
View file

@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { CoolifyClient } from "../src/coolify.js";
function mockFetch(routes: Record<string, unknown>) {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${new URL(String(url)).pathname}`;
if (!(key in routes)) return new Response("not found", { status: 404 });
return new Response(JSON.stringify(routes[key]), { status: 200 });
}) as unknown as typeof fetch;
}
describe("CoolifyClient", () => {
it("sends bearer auth and resolves servers by name", async () => {
const fetchImpl = mockFetch({
"GET /api/v1/servers": [{ uuid: "srv-1", name: "prod-box" }],
});
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
expect(await c.serverUuid("prod-box")).toBe("srv-1");
const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect((call[1].headers as Record<string, string>).Authorization).toBe(
"Bearer tok",
);
});
it("throws a named error when a resolver misses", async () => {
const c = new CoolifyClient(
"https://coolify.test",
"tok",
mockFetch({ "GET /api/v1/servers": [] }),
);
await expect(c.serverUuid("nope")).rejects.toThrow(
/not found in Coolify: server nope/,
);
});
it("surfaces API errors with method, path and status", async () => {
const c = new CoolifyClient("https://coolify.test", "tok", mockFetch({}));
await expect(c.get("/projects")).rejects.toThrow(/GET \/projects → 404/);
});
it("reads version as plain text, not JSON", async () => {
const fetchImpl = vi.fn(
async () => new Response("4.1.2", { status: 200 }),
) as unknown as typeof fetch;
const c = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
await expect(c.version()).resolves.toBe("4.1.2");
});
});

113
test/diff.test.ts Normal file
View file

@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { computeDiff, renderDiff } from "../src/diff.js";
const desiredApp = {
kind: "application" as const,
name: "core-api",
fields: { build_pack: "nixpacks", domains: ["https://api.example.com"] },
env: {
vars: {
PORT: { value: "3000", secret: false },
MAILGUN_KEY: { value: "mk-123", secret: true },
},
},
};
describe("computeDiff", () => {
it("plans a create when live is missing", () => {
const r = computeDiff([desiredApp], [], "full");
expect(r.changes).toHaveLength(1);
expect(r.changes[0].op).toBe("create");
expect(r.clean).toBe(false);
});
it("is clean when live matches", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: { PORT: "3000", MAILGUN_KEY: "mk-123" },
},
],
"full",
);
expect(r.clean).toBe(true);
});
it("marks build_pack drift as non-updatable", () => {
const r = computeDiff(
[desiredApp],
[
{
kind: "application",
name: "core-api",
uuid: "u1",
fields: { build_pack: "static", domains: desiredApp.fields.domains },
env: { PORT: "3000", MAILGUN_KEY: "mk-123" },
},
],
"full",
);
expect(r.changes[0].fieldDiffs).toEqual([
{
field: "build_pack",
desired: "nixpacks",
live: "static",
updatable: false,
},
]);
});
it("full mode diffs env; structural mode does not", () => {
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: { PORT: "3000", MAILGUN_KEY: "OLD", EXTRA: "x" },
},
];
const full = computeDiff([desiredApp], live, "full");
expect(full.changes[0].envDiffs).toEqual([
{ key: "MAILGUN_KEY", state: "change", secret: true },
{ key: "EXTRA", state: "remove-candidate", secret: false },
]);
expect(computeDiff([desiredApp], live, "structural").clean).toBe(true);
});
it("reports orphans, never plans deletion", () => {
const r = computeDiff(
[],
[{ kind: "service", name: "old-thing", uuid: "u9", fields: {} }],
"full",
);
expect(r.changes).toHaveLength(0);
expect(r.orphans).toEqual([
{ kind: "service", name: "old-thing", uuid: "u9" },
]);
expect(r.clean).toBe(false);
});
});
describe("renderDiff", () => {
it("never prints secret values", () => {
const live = [
{
kind: "application" as const,
name: "core-api",
uuid: "u1",
fields: { ...desiredApp.fields },
env: { PORT: "3000", MAILGUN_KEY: "OLD-SECRET" },
},
];
const out = renderDiff(computeDiff([desiredApp], live, "full"));
expect(out).toContain("secret MAILGUN_KEY differs");
expect(out).not.toContain("mk-123");
expect(out).not.toContain("OLD-SECRET");
});
it("structural mode says env was not compared", () => {
const out = renderDiff(computeDiff([desiredApp], [], "structural"));
expect(out).toMatch(/env vars not compared \(structural mode/);
});
});

55
test/envtemplate.test.ts Normal file
View file

@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { assertEnvVarPolicy, resolveTemplate } from "../src/envtemplate.js";
describe("resolveTemplate", () => {
it("classifies literals as non-secret and ${…} as secret", () => {
const r = resolveTemplate(
"PORT=3000\nMAILGUN_KEY=${MAILGUN_KEY}\n# comment\n\n",
{
MAILGUN_KEY: "mk-123",
},
);
expect(r.vars.PORT).toEqual({ value: "3000", secret: false });
expect(r.vars.MAILGUN_KEY).toEqual({ value: "mk-123", secret: true });
});
it("throws on a missing secret, naming key and placeholder", () => {
expect(() => resolveTemplate("API_KEY=${NOPE}", {})).toThrow(
/NOPE.*API_KEY|API_KEY.*NOPE/,
);
});
it("throws on malformed lines with the line number", () => {
expect(() => resolveTemplate("PORT=3000\nnot a line", {})).toThrow(
/line 2/,
);
});
});
describe("assertEnvVarPolicy", () => {
const withFlag = { vars: { ALLOW_SEED: { value: "false", secret: false } } };
const banAllow = ["^ALLOW_"];
it("refuses when a forbidden var is PRESENT, even set false", () => {
expect(() =>
assertEnvVarPolicy("prod", { "core-api": withFlag }, banAllow),
).toThrow(/ALLOW_SEED.*core-api.*off.*absent/s);
});
it("allows the same env where the policy is not declared", () => {
expect(() =>
assertEnvVarPolicy("staging", { "core-api": withFlag }, undefined),
).not.toThrow();
});
it("is a pattern, not a fixed list — it catches unforeseen siblings", () => {
const future = {
vars: { ALLOW_WIPE_EVERYTHING: { value: "true", secret: false } },
};
expect(() =>
assertEnvVarPolicy("prod", { worker: future }, banAllow),
).toThrow(/ALLOW_WIPE_EVERYTHING/);
});
it("leaves vars that do not match the pattern alone", () => {
const ok = { vars: { PORT: { value: "3000", secret: false } } };
expect(() =>
assertEnvVarPolicy("prod", { "core-api": ok }, banAllow),
).not.toThrow();
});
});

8
test/fixtures/environments.yaml vendored Normal file
View file

@ -0,0 +1,8 @@
environments:
prod:
server: prod-box
s3_destination: s3-backups
forbidden_var_patterns: ["^ALLOW_"]
staging: { server: staging-vm }
github_apps:
widget: my-github-app

31
test/fixtures/manifest.yaml vendored Normal file
View file

@ -0,0 +1,31 @@
project: widget
environments:
prod:
applications:
core-api:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
port: 3000
healthcheck: /health
domains: ["https://api.example.com"]
env_template: core-api.prod.env.template
databases:
postgres:
type: postgresql
version: "17"
backup: { frequency: "0 3 * * *", retention: 7 }
redis:
type: redis
services:
metabase:
type: metabase
domains: ["https://metabase.example.com"]
staging:
applications:
core-api:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
port: 3000
healthcheck: /health
domains: ["http://api.staging.example.com"]
env_template: core-api.staging.env.template

138
test/manifest.test.ts Normal file
View file

@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { loadBindings } from "../src/bindings.js";
import { loadManifest } from "../src/manifest.js";
const FIX = new URL("./fixtures/", import.meta.url).pathname;
describe("loadManifest", () => {
it("parses a valid manifest", () => {
const m = loadManifest(`${FIX}manifest.yaml`);
expect(m.project).toBe("widget");
expect(m.environments.prod.applications["core-api"].build.pack).toBe(
"nixpacks",
);
expect(m.environments.prod.databases?.postgres.backup?.retention).toBe(7);
});
it("rejects unknown build packs", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: x
environments:
prod:
applications:
a:
source: { repo: o/r, branch: main }
build: { pack: docker-compose, base_directory: / }
domains: []
`,
}),
).toThrow(/pack/);
});
it("rejects instance identity in manifests (no uuid-like fields)", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: x
environments:
prod:
applications:
a:
source: { repo: o/r, branch: main }
build: { pack: static, base_directory: / }
domains: []
server_uuid: abc123
`,
}),
).toThrow(/unrecognized|server_uuid/i);
});
it("accepts a dockercompose app with compose_file + service_domains and no port/healthcheck/domains", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
service_domains:
api: ["https://api.example.com"]
env_template: core.prod.env.template
`,
});
const app = m.environments.prod.applications.core;
expect(app.build.pack).toBe("dockercompose");
expect(app.build.compose_file).toBe("docker-compose.yaml");
expect(app.service_domains).toEqual({ api: ["https://api.example.com"] });
expect(app.port).toBeUndefined();
expect(app.healthcheck).toBeUndefined();
expect(app.domains).toBeUndefined();
});
it("rejects a dockercompose app without compose_file", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: / }
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/compose_file/);
});
it("rejects a dockercompose app with top-level domains", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: docker-compose.yaml }
service_domains:
api: ["https://api.example.com"]
domains: ["https://api.example.com"]
`,
}),
).toThrow(/domains/);
});
it("rejects service_domains on a nixpacks app", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["https://api.example.com"]
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/service_domains/);
});
});
describe("loadBindings", () => {
it("parses bindings", () => {
const b = loadBindings(`${FIX}environments.yaml`);
expect(b.environments.prod.server).toBe("prod-box");
expect(b.github_apps.widget).toBe("my-github-app");
});
it("carries an environment's forbidden_var_patterns through", () => {
const b = loadBindings(`${FIX}environments.yaml`);
expect(b.environments.prod.forbidden_var_patterns).toEqual(["^ALLOW_"]);
expect(b.environments.staging.forbidden_var_patterns).toBeUndefined();
});
});

246
test/resolve.test.ts Normal file
View file

@ -0,0 +1,246 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { computeDiff } from "../src/diff.js";
import { desiredFromManifest, resolveCheckout } from "../src/resolve.js";
describe("resolveCheckout", () => {
it("hard-refuses --path with prod", () => {
expect(() =>
resolveCheckout("acme/widget", { env: "prod", path: "/tmp/x" }),
).toThrow(/--path.*prod/);
});
it("returns --path for non-prod", () => {
expect(
resolveCheckout("acme/widget", {
env: "staging",
path: "/tmp/x",
}),
).toBe("/tmp/x");
});
});
describe("desiredFromManifest", () => {
it("maps manifest + templates to Desired[] with resolved env", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core-api:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /apps/core }
port: 3000
healthcheck: /health
domains: ["http://api.staging.example.com"]
env_template: core-api.staging.env.template
`,
);
writeFileSync(
join(dir, ".infra", "env", "core-api.staging.env.template"),
"PORT=3000\nMG=${MG}\n",
);
const { desired, resolvedEnvs, backupSchedules } = desiredFromManifest(
dir,
"staging",
{
MG: "secret-v",
},
);
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core-api",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/apps/core",
port: 3000,
healthcheck: "/health",
domains: ["http://api.staging.example.com"],
},
});
expect(resolvedEnvs["core-api"].vars.MG).toEqual({
value: "secret-v",
secret: true,
});
expect(backupSchedules).toEqual({});
});
it("routes a database backup block into backupSchedules, not fields", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
databases:
postgres:
type: postgresql
version: "17"
backup: { frequency: "0 3 * * *", retention: 7 }
`,
);
const { desired, backupSchedules } = desiredFromManifest(
dir,
"staging",
{},
);
expect(desired[0].fields).toEqual({ type: "postgresql", version: "17" });
expect(backupSchedules).toEqual({
postgres: { frequency: "0 3 * * *", retention: 7 },
});
});
it("warns when a service declares domains (unhonorable by apply on Coolify 4.1.2)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
services:
plausible:
type: plausible
domains: ["https://stats.staging.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0]).toMatchObject({ kind: "service", name: "plausible" });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toMatch(/plausible/);
expect(warn.mock.calls[0][0]).toMatch(/domains/);
warn.mockRestore();
});
it("drops domains from a domain-bearing service's fields (mirrors database backup handling)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
domains: ["https://analytics.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {});
expect(desired[0].fields).toEqual({ type: "umami" });
warn.mockRestore();
});
it("computeDiff is clean for a domain-bearing service against a matching live service (no perpetual update)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications: {}
services:
umami:
type: umami
domains: ["https://analytics.example.com"]
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { desired } = desiredFromManifest(dir, "prod", {});
warn.mockRestore();
const report = computeDiff(
desired,
[
{
kind: "service",
name: "umami",
uuid: "svc-uuid",
fields: { type: "umami" },
},
],
"structural",
);
expect(report.clean).toBe(true);
});
it("does not warn for a service with no domains", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications: {}
services:
plausible:
type: plausible
`,
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
desiredFromManifest(dir, "staging", {});
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("resolves a dockercompose app to docker_compose_location/docker_compose_domains and no port/healthcheck/domains keys", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra", "env"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
prod:
applications:
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
`,
);
writeFileSync(
join(dir, ".infra", "env", "core.prod.env.template"),
"PORT=3000\n",
);
const { desired } = desiredFromManifest(dir, "prod", {});
expect(desired).toHaveLength(1);
expect(desired[0]).toMatchObject({
kind: "application",
name: "core",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: {
api: ["https://api.widget.example.com"],
},
},
});
expect(desired[0].fields).not.toHaveProperty("port");
expect(desired[0].fields).not.toHaveProperty("healthcheck");
expect(desired[0].fields).not.toHaveProperty("domains");
});
it("throws when the env is missing from the manifest", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
"project: x\nenvironments: {}\n",
);
expect(() => desiredFromManifest(dir, "prod", {})).toThrow(
/environment prod not in manifest/,
);
});
});

60
test/secrets.test.ts Normal file
View file

@ -0,0 +1,60 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { decryptSecrets, keyFileFor, secretsFileFor } from "../src/secrets.js";
describe("decryptSecrets", () => {
it("round-trips an env file through age", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-age-"));
const keyFile = join(dir, "key.txt");
execFileSync("age-keygen", ["-o", keyFile]);
const recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
const plain = join(dir, "s.env");
writeFileSync(plain, "MAILGUN_KEY=mk-123\nOPENROUTER_KEY=or-456\n");
const enc = join(dir, "s.env.age");
execFileSync("age", ["-r", recipient, "-o", enc, plain]);
expect(decryptSecrets(enc, keyFile)).toEqual({
MAILGUN_KEY: "mk-123",
OPENROUTER_KEY: "or-456",
});
});
});
describe("secretsFileFor", () => {
it("resolves the age store under the state dir it is given, not the cwd", () => {
expect(secretsFileFor("/srv/state", "widget", "prod")).toBe(
"/srv/state/secrets/widget.prod.env.age",
);
});
});
describe("keyFileFor", () => {
it("an env with no injected var and no standing key refuses, naming both ways in", () => {
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
expect(() => keyFileFor("prod")).toThrow(
/no age key for prod.*CAST_AGE_KEY_FILE_PROD.*age-prod\.key/s,
);
});
it("the injected var wins, and is resolved per environment name", () => {
process.env.CAST_AGE_KEY_FILE_PROD = "/tmp/prod.key";
expect(keyFileFor("prod")).toBe("/tmp/prod.key");
Reflect.deleteProperty(process.env, "CAST_AGE_KEY_FILE_PROD");
});
it("falls back to a standing key on disk when one exists", () => {
const home = process.env.HOME;
const dir = mkdtempSync(join(tmpdir(), "cast-home-"));
const cfg = join(dir, ".config", "cast");
mkdirSync(cfg, { recursive: true });
writeFileSync(join(cfg, "age-staging.key"), "AGE-SECRET-KEY-1\n");
process.env.HOME = dir; // os.homedir() reads $HOME on POSIX
try {
expect(keyFileFor("staging")).toBe(join(cfg, "age-staging.key"));
} finally {
process.env.HOME = home;
}
});
});

90
test/smoke.test.ts Normal file
View file

@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { CoolifyClient } from "../src/coolify.js";
import { smoke } from "../src/smoke.js";
type EnvVar = {
key: string;
value: string;
is_buildtime: boolean;
uuid: string;
};
// A stateful fetch mock standing in for Coolify's env-var store, so the
// bulk-write step's effect on previously-written keys is observable —
// `bulkMode: "upsert"` mirrors verified Coolify 4.1.2 behavior (see
// syncEnv's comment in cli.ts); `bulkMode: "replace"` simulates a
// regression to full-replace that smoke must catch.
function mockEnvStore(
appUuid: string,
bulkMode: "upsert" | "replace",
): typeof fetch {
let store: EnvVar[] = [];
let nextUuid = 1;
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const method = init?.method ?? "GET";
const path = new URL(String(url)).pathname;
const base = `/api/v1/applications/${appUuid}/envs`;
if (method === "GET" && path === base) {
return new Response(JSON.stringify(store), { status: 200 });
}
if (method === "POST" && path === base) {
const body = JSON.parse(String(init?.body)) as {
key: string;
value: string;
is_buildtime: boolean;
};
const created: EnvVar = { ...body, uuid: `env-${nextUuid++}` };
store.push(created);
return new Response(JSON.stringify(created), { status: 200 });
}
if (method === "PATCH" && path === `${base}/bulk`) {
const body = JSON.parse(String(init?.body)) as {
data: Array<{ key: string; value: string; is_buildtime: boolean }>;
};
if (bulkMode === "replace") {
store = body.data.map((v) => ({ ...v, uuid: `env-${nextUuid++}` }));
} else {
for (const v of body.data) {
const existing = store.find((e) => e.key === v.key);
if (existing) Object.assign(existing, v);
else store.push({ ...v, uuid: `env-${nextUuid++}` });
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (method === "DELETE" && path.startsWith(`${base}/`)) {
const uuid = path.slice(`${base}/`.length);
store = store.filter((e) => e.uuid !== uuid);
return new Response(null, { status: 204 });
}
if (method === "GET" && path === "/api/v1/version") {
return new Response("4.1.2", { status: 200 });
}
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
}
describe("smoke", () => {
it("passes and leaves no residue when bulk env write is a true upsert", async () => {
const fetchImpl = mockEnvStore("app-1", "upsert");
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const log = vi.spyOn(console, "log").mockImplementation(() => {});
await expect(smoke(client, "app-1")).resolves.toBeUndefined();
expect(log.mock.calls.at(-1)?.[0]).toMatch(/smoke OK/);
log.mockRestore();
// both probe vars cleaned up
const envsRes = await fetchImpl(
"https://coolify.test/api/v1/applications/app-1/envs",
{ method: "GET" },
);
expect(await envsRes.json()).toEqual([]);
});
it("fails loudly when the bulk env write is destructive (full-replace regression)", async () => {
const fetchImpl = mockEnvStore("app-1", "replace");
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
await expect(smoke(client, "app-1")).rejects.toThrow(
/bulk env write is destructive \(full-replace\) — never-delete broken/,
);
});
});

265
test/wire.test.ts Normal file
View file

@ -0,0 +1,265 @@
import { describe, expect, it, vi } from "vitest";
import {
applicationApiFields,
buildExecutor,
databaseApiFields,
databaseVersionFromImage,
defaultDatabaseImage,
projectLiveFields,
serviceApiFields,
} from "../src/cli.js";
import { CoolifyClient } from "../src/coolify.js";
import { computeDiff } from "../src/diff.js";
// Pure wire-translation helpers (Desired vocabulary <-> Coolify API
// vocabulary). Importing src/cli.ts here must not run the CLI — see the
// import.meta.url guard around main() at the bottom of that file.
describe("applicationApiFields", () => {
it("joins domains into a comma-separated string and renames port/healthcheck", () => {
const out = applicationApiFields({
port: 3000,
healthcheck: "/health",
domains: ["https://a.example.com", "https://b.example.com"],
});
expect(out).toEqual({
ports_exposes: "3000",
health_check_path: "/health",
domains: "https://a.example.com,https://b.example.com",
});
});
it("maps a docker_compose_domains map to the wire array-of-{name,domain} shape", () => {
const out = applicationApiFields({
docker_compose_domains: {
api: ["https://a", "https://b"],
admin: ["https://c"],
},
});
expect(out).toEqual({
docker_compose_domains: [
{ name: "api", domain: "https://a,https://b" },
{ name: "admin", domain: "https://c" },
],
});
});
});
describe("databaseApiFields", () => {
it("maps type+version to an image and drops the type/version keys", () => {
const out = databaseApiFields({ type: "postgresql", version: "17" });
expect(out).toEqual({ image: "postgres:17-alpine" });
expect(out).not.toHaveProperty("type");
expect(out).not.toHaveProperty("version");
});
});
describe("serviceApiFields", () => {
it("drops domains (services have no flat-domains create/update field)", () => {
const out = serviceApiFields({
type: "plausible",
domains: ["https://stats.example.com"],
});
expect(out).toEqual({ type: "plausible" });
expect(out).not.toHaveProperty("domains");
});
});
describe("projectLiveFields", () => {
it("projects a live application onto the Desired vocabulary", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
ports_exposes: "3000",
health_check_path: "/health",
fqdn: "https://a.example.com,https://b.example.com",
});
expect(out.domains).toEqual([
"https://a.example.com",
"https://b.example.com",
]);
expect(out.port).toBe(3000);
expect(out.healthcheck).toBe("/health");
});
it("normalizes a live database's database_type to the manifest vocabulary", () => {
const out = projectLiveFields("database", {
database_type: "standalone-postgresql",
image: "postgres:17-alpine",
});
expect(out).toEqual({ type: "postgresql", version: "17" });
});
it("projects both docker_compose_location and docker_compose_domains for a live compose app", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: JSON.stringify([
{ name: "api", domain: "https://api.widget.example.com" },
]),
});
expect(out.docker_compose_location).toBe("docker-compose.yaml");
expect(out.docker_compose_domains).toEqual({
api: ["https://api.widget.example.com"],
});
});
it("does not choke on an absent/null docker_compose_domains", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: null,
});
expect(out).not.toHaveProperty("docker_compose_domains");
});
});
describe("compose app idempotency (review finding #2)", () => {
it("produces zero field diffs when live docker_compose_location/domains match the manifest", () => {
const desired = [
{
kind: "application" as const,
name: "core",
fields: {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: {
api: ["https://api.widget.example.com"],
},
},
},
];
const liveRaw = {
git_repository: "acme/widget",
git_branch: "main",
build_pack: "dockercompose",
base_directory: "/",
docker_compose_location: "docker-compose.yaml",
docker_compose_domains: JSON.stringify([
{ name: "api", domain: "https://api.widget.example.com" },
]),
};
const live = [
{
kind: "application" as const,
name: "core",
uuid: "app-uuid",
fields: projectLiveFields("application", liveRaw),
},
];
const report = computeDiff(desired, live, "structural");
expect(report.clean).toBe(true);
});
});
describe("buildExecutor createResource (application, dockercompose)", () => {
function mockFetch(handler: (path: string, init?: RequestInit) => Response) {
return vi.fn(async (url: string | URL, init?: RequestInit) =>
handler(new URL(String(url)).pathname, init),
) as unknown as typeof fetch;
}
it("sets connect_to_docker_network: true on a compose app create payload", async () => {
let createBody: Record<string, unknown> | undefined;
const fetchImpl = mockFetch((path, init) => {
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/applications/private-github-app") {
createBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "app-1" }), {
status: 200,
});
}
return new Response("not found", { status: 404 });
});
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
backupSchedules: {},
});
const uuid = await exec.createResource({
kind: "application",
name: "core",
op: "create",
fieldDiffs: [
{ field: "build_pack", desired: "dockercompose", updatable: false },
{
field: "docker_compose_location",
desired: "docker-compose.yaml",
updatable: true,
},
{
field: "docker_compose_domains",
desired: { api: ["https://api.widget.example.com"] },
updatable: true,
},
],
envDiffs: [],
});
expect(uuid).toBe("app-1");
expect(createBody?.connect_to_docker_network).toBe(true);
expect(createBody?.docker_compose_domains).toEqual([
{ name: "api", domain: "https://api.widget.example.com" },
]);
});
it("does not set connect_to_docker_network for a non-compose app", async () => {
let createBody: Record<string, unknown> | undefined;
const fetchImpl = mockFetch((path, init) => {
if (path === "/api/v1/projects" && (!init || init.method === "GET"))
return new Response(
JSON.stringify([{ uuid: "proj-1", name: "widget" }]),
{ status: 200 },
);
if (path === "/api/v1/applications/private-github-app") {
createBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ uuid: "app-2" }), {
status: 200,
});
}
return new Response("not found", { status: 404 });
});
const client = new CoolifyClient("https://coolify.test", "tok", fetchImpl);
const exec = buildExecutor(client, {
projectName: "widget",
envName: "prod",
serverUuid: "srv-1",
githubAppUuid: "gh-1",
backupSchedules: {},
});
await exec.createResource({
kind: "application",
name: "core-api",
op: "create",
fieldDiffs: [
{ field: "build_pack", desired: "nixpacks", updatable: false },
{ field: "domains", desired: ["https://a"], updatable: true },
],
envDiffs: [],
});
expect(createBody).not.toHaveProperty("connect_to_docker_network");
});
});
describe("databaseVersionFromImage / defaultDatabaseImage", () => {
it("round-trips through defaultDatabaseImage for postgres", () => {
const image = defaultDatabaseImage("postgresql", "17");
expect(databaseVersionFromImage(image)).toBe("17");
});
});

13
tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"declaration": false
},
"include": ["src"]
}

2
vitest.config.ts Normal file
View file

@ -0,0 +1,2 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["test/**/*.test.ts"] } });