feat: cast — the Coolify executor, extracted from the infra state repo #1
39 changed files with 18491 additions and 0 deletions
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
.coolify.env
|
||||
118
README.md
118
README.md
|
|
@ -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
20
bin/cast
Executable 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
29
biome.jsonc
Normal 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
86
install.sh
Normal 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
1653
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
package.json
Normal file
24
package.json
Normal 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
30
reference/README.md
Normal 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.
|
||||
13677
reference/coolify-openapi-4.1.2.json
Normal file
13677
reference/coolify-openapi-4.1.2.json
Normal file
File diff suppressed because it is too large
Load diff
12
scripts/dump-coolify-db.sh
Executable file
12
scripts/dump-coolify-db.sh
Executable 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
36
scripts/register-github-app.sh
Executable 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
12
scripts/restore-db.sh
Executable 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
110
src/apply.ts
Normal 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
32
src/bindings.ts
Normal 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
600
src/cli.ts
Normal 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
25
src/config.ts
Normal 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
92
src/coolify.ts
Normal 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
165
src/diff.ts
Normal 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
64
src/envtemplate.ts
Normal 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
113
src/manifest.ts
Normal 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
127
src/resolve.ts
Normal 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
51
src/secrets.ts
Normal 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
29
src/server.ts
Normal 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
66
src/smoke.ts
Normal 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
191
test/apply.test.ts
Normal 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
35
test/cli.test.ts
Normal 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
46
test/coolify.test.ts
Normal 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
113
test/diff.test.ts
Normal 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
55
test/envtemplate.test.ts
Normal 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
8
test/fixtures/environments.yaml
vendored
Normal 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
31
test/fixtures/manifest.yaml
vendored
Normal 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
138
test/manifest.test.ts
Normal 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
246
test/resolve.test.ts
Normal 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
60
test/secrets.test.ts
Normal 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
90
test/smoke.test.ts
Normal 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
265
test/wire.test.ts
Normal 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
13
tsconfig.json
Normal 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
2
vitest.config.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({ test: { include: ["test/**/*.test.ts"] } });
|
||||
Loading…
Reference in a new issue