From a10349d83555bb7dc67eefdefcca8834239340a8 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Sat, 11 Jul 2026 12:25:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20cast=20=E2=80=94=20the=20Coolify=20exec?= =?UTF-8?q?utor,=20extracted=20from=20the=20infra=20state=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments..forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_ then ~/.config/cast/age-.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 24 + .gitignore | 3 + README.md | 118 + bin/cast | 20 + biome.jsonc | 29 + install.sh | 86 + package-lock.json | 1653 +++ package.json | 24 + reference/README.md | 30 + reference/coolify-openapi-4.1.2.json | 13677 +++++++++++++++++++++++++ scripts/dump-coolify-db.sh | 12 + scripts/register-github-app.sh | 36 + scripts/restore-db.sh | 12 + src/apply.ts | 110 + src/bindings.ts | 32 + src/cli.ts | 600 ++ src/config.ts | 25 + src/coolify.ts | 92 + src/diff.ts | 165 + src/envtemplate.ts | 64 + src/manifest.ts | 113 + src/resolve.ts | 127 + src/secrets.ts | 51 + src/server.ts | 29 + src/smoke.ts | 66 + test/apply.test.ts | 191 + test/cli.test.ts | 35 + test/coolify.test.ts | 46 + test/diff.test.ts | 113 + test/envtemplate.test.ts | 55 + test/fixtures/environments.yaml | 8 + test/fixtures/manifest.yaml | 31 + test/manifest.test.ts | 138 + test/resolve.test.ts | 246 + test/secrets.test.ts | 60 + test/smoke.test.ts | 90 + test/wire.test.ts | 265 + tsconfig.json | 13 + vitest.config.ts | 2 + 39 files changed, 18491 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 bin/cast create mode 100644 biome.jsonc create mode 100644 install.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 reference/README.md create mode 100644 reference/coolify-openapi-4.1.2.json create mode 100755 scripts/dump-coolify-db.sh create mode 100755 scripts/register-github-app.sh create mode 100755 scripts/restore-db.sh create mode 100644 src/apply.ts create mode 100644 src/bindings.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/coolify.ts create mode 100644 src/diff.ts create mode 100644 src/envtemplate.ts create mode 100644 src/manifest.ts create mode 100644 src/resolve.ts create mode 100644 src/secrets.ts create mode 100644 src/server.ts create mode 100644 src/smoke.ts create mode 100644 test/apply.test.ts create mode 100644 test/cli.test.ts create mode 100644 test/coolify.test.ts create mode 100644 test/diff.test.ts create mode 100644 test/envtemplate.test.ts create mode 100644 test/fixtures/environments.yaml create mode 100644 test/fixtures/manifest.yaml create mode 100644 test/manifest.test.ts create mode 100644 test/resolve.test.ts create mode 100644 test/secrets.test.ts create mode 100644 test/smoke.test.ts create mode 100644 test/wire.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1939747 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8749b54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.coolify.env diff --git a/README.md b/README.md index 8b13789..a602198 100644 --- a/README.md +++ b/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/..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/..env.age # age-encrypted values for the ${…} placeholders +.coolify.env # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN (never commit) +``` + +Pass it with `--state `, or set `CAST_STATE`. Defaults to the cwd. + +## Commands + +```sh +cast apply / --env [--path ] [--hostname-overlay ] +cast diff / --env [--full] +cast server add --ip --key [--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_` — injected for this invocation +2. `~/.config/cast/age-.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 +``` diff --git a/bin/cast b/bin/cast new file mode 100755 index 0000000..feefc2a --- /dev/null +++ b/bin/cast @@ -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" "$@" diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..a3f701d --- /dev/null +++ b/biome.jsonc @@ -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 + } + } +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..2c5a0c0 --- /dev/null +++ b/install.sh @@ -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-/ +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" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..03b57fe --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1653 @@ +{ + "name": "cast", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cast", + "version": "0.1.0", + "dependencies": { + "yaml": "^2.5.0", + "zod": "^3.25.76" + }, + "bin": { + "cast": "dist/cli.js" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0820109 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/reference/README.md b/reference/README.md new file mode 100644 index 0000000..b05d3df --- /dev/null +++ b/reference/README.md @@ -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. diff --git a/reference/coolify-openapi-4.1.2.json b/reference/coolify-openapi-4.1.2.json new file mode 100644 index 0000000..ca445ad --- /dev/null +++ b/reference/coolify-openapi-4.1.2.json @@ -0,0 +1,13677 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Coolify", + "version": "0.1" + }, + "servers": [ + { + "url": "https:\/\/app.coolify.io\/api\/v1", + "description": "Coolify Cloud API. Change the host to your own instance if you are self-hosting." + } + ], + "paths": { + "\/applications": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List", + "description": "List all applications.", + "operationId": "list-applications", + "parameters": [ + { + "name": "tag", + "in": "query", + "description": "Filter applications by tag name.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all applications.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Application" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/public": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Public)", + "description": "Create new application based on a public git repository.", + "operationId": "create-public-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "environment_name", + "environment_uuid", + "git_repository", + "git_branch", + "build_pack" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "git_repository": { + "type": "string", + "description": "The git repository URL." + }, + "git_branch": { + "type": "string", + "description": "The git branch." + }, + "build_pack": { + "type": "string", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ], + "description": "The build pack type." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "git_commit_sha": { + "type": "string", + "description": "The git commit SHA." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "is_static": { + "type": "boolean", + "description": "The flag to indicate if the application is static." + }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "static_image": { + "type": "string", + "enum": [ + "nginx:alpine" + ], + "description": "The static image." + }, + "install_command": { + "type": "string", + "description": "The install command." + }, + "build_command": { + "type": "string", + "description": "The build command." + }, + "start_command": { + "type": "string", + "description": "The start command." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "The publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, + "docker_compose_location": { + "type": "string", + "description": "The Docker Compose location." + }, + "docker_compose_custom_start_command": { + "type": "string", + "description": "The Docker Compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "description": "The Docker Compose custom build command." + }, + "docker_compose_domains": { + "type": "array", + "description": "Array of URLs to be applied to containers of a dockercompose application.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "domain": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + } + }, + "type": "object" + } + }, + "watch_paths": { + "type": "string", + "description": "The watch paths." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/private-github-app": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Private - GH App)", + "description": "Create new application based on a private repository through a Github App.", + "operationId": "create-private-github-app-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "environment_name", + "environment_uuid", + "github_app_uuid", + "git_repository", + "git_branch", + "build_pack" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "github_app_uuid": { + "type": "string", + "description": "The Github App UUID." + }, + "git_repository": { + "type": "string", + "description": "The git repository URL." + }, + "git_branch": { + "type": "string", + "description": "The git branch." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "build_pack": { + "type": "string", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ], + "description": "The build pack type." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "git_commit_sha": { + "type": "string", + "description": "The git commit SHA." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "is_static": { + "type": "boolean", + "description": "The flag to indicate if the application is static." + }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "static_image": { + "type": "string", + "enum": [ + "nginx:alpine" + ], + "description": "The static image." + }, + "install_command": { + "type": "string", + "description": "The install command." + }, + "build_command": { + "type": "string", + "description": "The build command." + }, + "start_command": { + "type": "string", + "description": "The start command." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "The publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository" + }, + "docker_compose_location": { + "type": "string", + "description": "The Docker Compose location." + }, + "docker_compose_custom_start_command": { + "type": "string", + "description": "The Docker Compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "description": "The Docker Compose custom build command." + }, + "docker_compose_domains": { + "type": "array", + "description": "Array of URLs to be applied to containers of a dockercompose application.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "domain": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + } + }, + "type": "object" + } + }, + "watch_paths": { + "type": "string", + "description": "The watch paths." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/private-deploy-key": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Private - Deploy Key)", + "description": "Create new application based on a private repository through a Deploy Key.", + "operationId": "create-private-deploy-key-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "environment_name", + "environment_uuid", + "private_key_uuid", + "git_repository", + "git_branch", + "build_pack" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "private_key_uuid": { + "type": "string", + "description": "The private key UUID." + }, + "git_repository": { + "type": "string", + "description": "The git repository URL." + }, + "git_branch": { + "type": "string", + "description": "The git branch." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "build_pack": { + "type": "string", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ], + "description": "The build pack type." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "git_commit_sha": { + "type": "string", + "description": "The git commit SHA." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "is_static": { + "type": "boolean", + "description": "The flag to indicate if the application is static." + }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "static_image": { + "type": "string", + "enum": [ + "nginx:alpine" + ], + "description": "The static image." + }, + "install_command": { + "type": "string", + "description": "The install command." + }, + "build_command": { + "type": "string", + "description": "The build command." + }, + "start_command": { + "type": "string", + "description": "The start command." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "The publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, + "docker_compose_location": { + "type": "string", + "description": "The Docker Compose location." + }, + "docker_compose_custom_start_command": { + "type": "string", + "description": "The Docker Compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "description": "The Docker Compose custom build command." + }, + "docker_compose_domains": { + "type": "array", + "description": "Array of URLs to be applied to containers of a dockercompose application.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "domain": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + } + }, + "type": "object" + } + }, + "watch_paths": { + "type": "string", + "description": "The watch paths." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/dockerfile": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Dockerfile without git)", + "description": "Create new application based on a simple Dockerfile (without git).", + "operationId": "create-dockerfile-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "environment_name", + "environment_uuid", + "dockerfile" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "build_pack": { + "type": "string", + "enum": [ + "dockerfile" + ], + "description": "The build pack type." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/dockerimage": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Docker Image without git)", + "description": "Create new application based on a prebuilt docker image (without git).", + "operationId": "create-dockerimage-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "environment_name", + "environment_uuid", + "docker_registry_image_name" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Get", + "description": "Get application by UUID.", + "operationId": "get-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get application by UUID.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Application" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete", + "description": "Delete application by UUID.", + "operationId": "delete-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "delete_configurations", + "in": "query", + "description": "Delete configurations.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_volumes", + "in": "query", + "description": "Delete volumes.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Run docker cleanup.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_connected_networks", + "in": "query", + "description": "Delete connected networks.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Application deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Application deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update", + "description": "Update application by UUID.", + "operationId": "update-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Application updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name." + }, + "github_app_uuid": { + "type": "string", + "description": "The Github App UUID." + }, + "git_repository": { + "type": "string", + "description": "The git repository URL." + }, + "git_branch": { + "type": "string", + "description": "The git branch." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "build_pack": { + "type": "string", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ], + "description": "The build pack type." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "git_commit_sha": { + "type": "string", + "description": "The git commit SHA." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "is_static": { + "type": "boolean", + "description": "The flag to indicate if the application is static." + }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "install_command": { + "type": "string", + "description": "The install command." + }, + "build_command": { + "type": "string", + "description": "The build command." + }, + "start_command": { + "type": "string", + "description": "The start command." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "The publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository." + }, + "docker_compose_location": { + "type": "string", + "description": "The Docker Compose location." + }, + "docker_compose_custom_start_command": { + "type": "string", + "description": "The Docker Compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "description": "The Docker Compose custom build command." + }, + "docker_compose_domains": { + "type": "array", + "description": "Array of URLs to be applied to containers of a dockercompose application.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "domain": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + } + }, + "type": "object" + } + }, + "watch_paths": { + "type": "string", + "description": "The watch paths." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Application updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/logs": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Get application logs.", + "description": "Get application logs by UUID.", + "operationId": "get-application-logs-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "lines", + "in": "query", + "description": "Number of lines to show from the end of the logs.", + "required": false, + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Get application logs by UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "logs": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/envs": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Envs", + "description": "List all envs by application UUID.", + "operationId": "list-envs-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All environment variables by application UUID.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Env", + "description": "Create env by application UUID.", + "operationId": "create-env-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Env", + "description": "Update env by application UUID.", + "operationId": "update-env-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by application UUID.", + "operationId": "update-envs-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/start": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Start", + "description": "Start application. `Post` request is also accepted.", + "operationId": "start-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "Force rebuild.", + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "instant_deploy", + "in": "query", + "description": "Instant deploy (skip queuing).", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Start application.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deployment request queued.", + "description": "Message." + }, + "deployment_uuid": { + "type": "string", + "example": "doogksw", + "description": "UUID of the deployment." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/stop": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Stop", + "description": "Stop application. `Post` request is also accepted.", + "operationId": "stop-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stop application.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Application stopping request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/restart": { + "get": { + "tags": [ + "Applications" + ], + "summary": "Restart", + "description": "Restart application. `Post` request is also accepted.", + "operationId": "restart-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart application.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Restart request queued." + }, + "deployment_uuid": { + "type": "string", + "example": "doogksw", + "description": "UUID of the deployment." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/storages": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by application UUID.", + "operationId": "list-storages-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by application UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for an application.", + "operationId": "create-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Applications" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by application UUID.", + "operationId": "update-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by application UUID.", + "operationId": "delete-storage-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/previews\/{pull_request_id}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Preview Deployment", + "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.", + "operationId": "delete-preview-deployment-by-pull-request-id", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pull_request_id", + "in": "path", + "description": "Pull request ID of the preview to delete.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Preview deletion queued.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "List Cloud Provider Tokens", + "description": "List all cloud provider tokens for the authenticated team.", + "operationId": "list-cloud-tokens", + "responses": { + "200": { + "description": "Get all cloud provider tokens.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "hetzner", + "digitalocean" + ] + }, + "team_id": { + "type": "integer" + }, + "servers_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Create Cloud Provider Token", + "description": "Create a new cloud provider token. The token will be validated before being stored.", + "operationId": "create-cloud-token", + "requestBody": { + "description": "Cloud provider token details", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "provider", + "token", + "name" + ], + "properties": { + "provider": { + "type": "string", + "enum": [ + "hetzner", + "digitalocean" + ], + "example": "hetzner", + "description": "The cloud provider." + }, + "token": { + "type": "string", + "example": "your-api-token-here", + "description": "The API token for the cloud provider." + }, + "name": { + "type": "string", + "example": "My Hetzner Token", + "description": "A friendly name for the token." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Cloud provider token created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the token." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}": { + "get": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Get Cloud Provider Token", + "description": "Get cloud provider token by UUID.", + "operationId": "get-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get cloud provider token by UUID", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "team_id": { + "type": "integer" + }, + "servers_count": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Delete Cloud Provider Token", + "description": "Delete cloud provider token by UUID. Cannot delete if token is used by any servers.", + "operationId": "delete-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the cloud provider token.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cloud provider token deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Cloud provider token deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Update Cloud Provider Token", + "description": "Update cloud provider token name.", + "operationId": "update-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Cloud provider token updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The friendly name for the token." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Cloud provider token updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/cloud-tokens\/{uuid}\/validate": { + "post": { + "tags": [ + "Cloud Tokens" + ], + "summary": "Validate Cloud Provider Token", + "description": "Validate a cloud provider token against the provider API.", + "operationId": "validate-cloud-token-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Token UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Token validation result.", + "content": { + "application\/json": { + "schema": { + "properties": { + "valid": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Token is valid." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List", + "description": "List all databases.", + "operationId": "list-databases", + "responses": { + "200": { + "description": "Get all databases", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get", + "description": "Get backups details by database UUID.", + "operationId": "get-database-backups-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all backups for a database", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Backup", + "description": "Create a new scheduled backup configuration for a database", + "operationId": "create-database-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Backup configuration data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "frequency" + ], + "properties": { + "frequency": { + "type": "string", + "description": "Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)" + }, + "enabled": { + "type": "boolean", + "description": "Whether the backup is enabled", + "default": true + }, + "save_s3": { + "type": "boolean", + "description": "Whether to save backups to S3", + "default": false + }, + "s3_storage_uuid": { + "type": "string", + "description": "S3 storage UUID (required if save_s3 is true)" + }, + "databases_to_backup": { + "type": "string", + "description": "Comma separated list of databases to backup" + }, + "dump_all": { + "type": "boolean", + "description": "Whether to dump all databases", + "default": false + }, + "backup_now": { + "type": "boolean", + "description": "Whether to trigger backup immediately after creation" + }, + "database_backup_retention_amount_locally": { + "type": "integer", + "description": "Number of backups to retain locally" + }, + "database_backup_retention_days_locally": { + "type": "integer", + "description": "Number of days to retain backups locally" + }, + "database_backup_retention_max_storage_locally": { + "type": "number", + "description": "Max storage (GB) for local backups" + }, + "database_backup_retention_amount_s3": { + "type": "integer", + "description": "Number of backups to retain in S3" + }, + "database_backup_retention_days_s3": { + "type": "integer", + "description": "Number of days to retain backups in S3" + }, + "database_backup_retention_max_storage_s3": { + "type": "number", + "description": "Max storage (GB) for S3 backups" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Backup configuration created successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "message": { + "type": "string", + "example": "Backup configuration created successfully." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Get", + "description": "Get database by UUID.", + "operationId": "get-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all databases", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete", + "description": "Delete database by UUID.", + "operationId": "delete-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "delete_configurations", + "in": "query", + "description": "Delete configurations.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_volumes", + "in": "query", + "description": "Delete volumes.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Run docker cleanup.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_connected_networks", + "in": "query", + "description": "Delete connected networks.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Database deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update", + "description": "Update database by UUID.", + "operationId": "update-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "postgres_user": { + "type": "string", + "description": "PostgreSQL user" + }, + "postgres_password": { + "type": "string", + "description": "PostgreSQL password" + }, + "postgres_db": { + "type": "string", + "description": "PostgreSQL database" + }, + "postgres_initdb_args": { + "type": "string", + "description": "PostgreSQL initdb args" + }, + "postgres_host_auth_method": { + "type": "string", + "description": "PostgreSQL host auth method" + }, + "postgres_conf": { + "type": "string", + "description": "PostgreSQL conf" + }, + "clickhouse_admin_user": { + "type": "string", + "description": "Clickhouse admin user" + }, + "clickhouse_admin_password": { + "type": "string", + "description": "Clickhouse admin password" + }, + "dragonfly_password": { + "type": "string", + "description": "DragonFly password" + }, + "redis_password": { + "type": "string", + "description": "Redis password" + }, + "redis_conf": { + "type": "string", + "description": "Redis conf" + }, + "keydb_password": { + "type": "string", + "description": "KeyDB password" + }, + "keydb_conf": { + "type": "string", + "description": "KeyDB conf" + }, + "mariadb_conf": { + "type": "string", + "description": "MariaDB conf" + }, + "mariadb_root_password": { + "type": "string", + "description": "MariaDB root password" + }, + "mariadb_user": { + "type": "string", + "description": "MariaDB user" + }, + "mariadb_password": { + "type": "string", + "description": "MariaDB password" + }, + "mariadb_database": { + "type": "string", + "description": "MariaDB database" + }, + "mongo_conf": { + "type": "string", + "description": "Mongo conf" + }, + "mongo_initdb_root_username": { + "type": "string", + "description": "Mongo initdb root username" + }, + "mongo_initdb_root_password": { + "type": "string", + "description": "Mongo initdb root password" + }, + "mongo_initdb_database": { + "type": "string", + "description": "Mongo initdb init database" + }, + "mysql_root_password": { + "type": "string", + "description": "MySQL root password" + }, + "mysql_password": { + "type": "string", + "description": "MySQL password" + }, + "mysql_user": { + "type": "string", + "description": "MySQL user" + }, + "mysql_database": { + "type": "string", + "description": "MySQL database" + }, + "mysql_conf": { + "type": "string", + "description": "MySQL conf" + }, + "health_check_enabled": { + "type": "boolean", + "description": "Enable the database healthcheck probe.", + "default": true + }, + "health_check_interval": { + "type": "integer", + "description": "Healthcheck interval in seconds.", + "minimum": 1, + "default": 15 + }, + "health_check_timeout": { + "type": "integer", + "description": "Healthcheck timeout in seconds.", + "minimum": 1, + "default": 5 + }, + "health_check_retries": { + "type": "integer", + "description": "Healthcheck retries count.", + "minimum": 1, + "default": 5 + }, + "health_check_start_period": { + "type": "integer", + "description": "Healthcheck start period in seconds.", + "minimum": 0, + "default": 5 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup configuration", + "description": "Deletes a backup configuration and all its executions.", + "operationId": "delete-backup-configuration-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration to delete", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete all backup files from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup configuration deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup configuration and all executions deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup configuration not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update", + "description": "Update a specific backup configuration for a given database, identified by its UUID and the backup ID", + "operationId": "update-database-backup", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Database backup configuration data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "save_s3": { + "type": "boolean", + "description": "Whether data is saved in s3 or not" + }, + "s3_storage_uuid": { + "type": "string", + "description": "S3 storage UUID" + }, + "backup_now": { + "type": "boolean", + "description": "Whether to take a backup now or not" + }, + "enabled": { + "type": "boolean", + "description": "Whether the backup is enabled or not" + }, + "databases_to_backup": { + "type": "string", + "description": "Comma separated list of databases to backup" + }, + "dump_all": { + "type": "boolean", + "description": "Whether all databases are dumped or not" + }, + "frequency": { + "type": "string", + "description": "Frequency of the backup" + }, + "database_backup_retention_amount_locally": { + "type": "integer", + "description": "Retention amount of the backup locally" + }, + "database_backup_retention_days_locally": { + "type": "integer", + "description": "Retention days of the backup locally" + }, + "database_backup_retention_max_storage_locally": { + "type": "number", + "description": "Max storage of the backup locally" + }, + "database_backup_retention_amount_s3": { + "type": "integer", + "description": "Retention amount of the backup in s3" + }, + "database_backup_retention_days_s3": { + "type": "integer", + "description": "Retention days of the backup in s3" + }, + "database_backup_retention_max_storage_s3": { + "type": "number", + "description": "Max storage of the backup in S3" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database backup configuration updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/postgresql": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (PostgreSQL)", + "description": "Create a new PostgreSQL database.", + "operationId": "create-database-postgresql", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "postgres_user": { + "type": "string", + "description": "PostgreSQL user" + }, + "postgres_password": { + "type": "string", + "description": "PostgreSQL password" + }, + "postgres_db": { + "type": "string", + "description": "PostgreSQL database" + }, + "postgres_initdb_args": { + "type": "string", + "description": "PostgreSQL initdb args" + }, + "postgres_host_auth_method": { + "type": "string", + "description": "PostgreSQL host auth method" + }, + "postgres_conf": { + "type": "string", + "description": "PostgreSQL conf" + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/clickhouse": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (Clickhouse)", + "description": "Create a new Clickhouse database.", + "operationId": "create-database-clickhouse", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "clickhouse_admin_user": { + "type": "string", + "description": "Clickhouse admin user" + }, + "clickhouse_admin_password": { + "type": "string", + "description": "Clickhouse admin password" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/dragonfly": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (DragonFly)", + "description": "Create a new DragonFly database.", + "operationId": "create-database-dragonfly", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "dragonfly_password": { + "type": "string", + "description": "DragonFly password" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/redis": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (Redis)", + "description": "Create a new Redis database.", + "operationId": "create-database-redis", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "redis_password": { + "type": "string", + "description": "Redis password" + }, + "redis_conf": { + "type": "string", + "description": "Redis conf" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/keydb": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (KeyDB)", + "description": "Create a new KeyDB database.", + "operationId": "create-database-keydb", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "keydb_password": { + "type": "string", + "description": "KeyDB password" + }, + "keydb_conf": { + "type": "string", + "description": "KeyDB conf" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/mariadb": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (MariaDB)", + "description": "Create a new MariaDB database.", + "operationId": "create-database-mariadb", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "mariadb_conf": { + "type": "string", + "description": "MariaDB conf" + }, + "mariadb_root_password": { + "type": "string", + "description": "MariaDB root password" + }, + "mariadb_user": { + "type": "string", + "description": "MariaDB user" + }, + "mariadb_password": { + "type": "string", + "description": "MariaDB password" + }, + "mariadb_database": { + "type": "string", + "description": "MariaDB database" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/mysql": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (MySQL)", + "description": "Create a new MySQL database.", + "operationId": "create-database-mysql", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "mysql_root_password": { + "type": "string", + "description": "MySQL root password" + }, + "mysql_password": { + "type": "string", + "description": "MySQL password" + }, + "mysql_user": { + "type": "string", + "description": "MySQL user" + }, + "mysql_database": { + "type": "string", + "description": "MySQL database" + }, + "mysql_conf": { + "type": "string", + "description": "MySQL conf" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/mongodb": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Create (MongoDB)", + "description": "Create a new MongoDB database.", + "operationId": "create-database-mongodb", + "requestBody": { + "description": "Database data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "server_uuid": { + "type": "string", + "description": "UUID of the server" + }, + "project_uuid": { + "type": "string", + "description": "UUID of the project" + }, + "environment_name": { + "type": "string", + "description": "Name of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "UUID of the environment. You need to provide at least one of environment_name or environment_uuid." + }, + "destination_uuid": { + "type": "string", + "description": "UUID of the destination if the server has multiple destinations" + }, + "mongo_conf": { + "type": "string", + "description": "MongoDB conf" + }, + "mongo_initdb_root_username": { + "type": "string", + "description": "MongoDB initdb root username" + }, + "name": { + "type": "string", + "description": "Name of the database" + }, + "description": { + "type": "string", + "description": "Description of the database" + }, + "image": { + "type": "string", + "description": "Docker Image of the database" + }, + "is_public": { + "type": "boolean", + "description": "Is the database public?" + }, + "public_port": { + "type": "integer", + "description": "Public port of the database" + }, + "public_port_timeout": { + "type": "integer", + "description": "Public port timeout in seconds (default: 3600)" + }, + "limits_memory": { + "type": "string", + "description": "Memory limit of the database" + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit of the database" + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness of the database" + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation of the database" + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit of the database" + }, + "limits_cpuset": { + "type": "string", + "description": "CPU set of the database" + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares of the database" + }, + "instant_deploy": { + "type": "boolean", + "description": "Instant deploy the database" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database updated" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete backup execution", + "description": "Deletes a specific backup execution.", + "operationId": "delete-backup-execution-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "execution_uuid", + "in": "path", + "description": "UUID of the backup execution to delete", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "delete_s3", + "in": "query", + "description": "Whether to delete the backup from S3", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Backup execution deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup execution deleted." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup execution not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Backup execution not found." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List backup executions", + "description": "Get all executions for a specific backup configuration.", + "operationId": "list-backup-executions", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scheduled_backup_uuid", + "in": "path", + "description": "UUID of the backup configuration", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of backup executions", + "content": { + "application\/json": { + "schema": { + "properties": { + "executions": { + "type": "array", + "items": { + "properties": { + "uuid": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Backup configuration not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/start": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Start", + "description": "Start database. `Post` request is also accepted.", + "operationId": "start-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Start database.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database starting request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/stop": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Stop", + "description": "Stop database. `Post` request is also accepted.", + "operationId": "stop-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stop database.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database stopping request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/restart": { + "get": { + "tags": [ + "Databases" + ], + "summary": "Restart", + "description": "Restart database. `Post` request is also accepted.", + "operationId": "restart-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Restart database.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database restaring request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Envs", + "description": "List all envs by database UUID.", + "operationId": "list-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variables.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Env", + "description": "Create env by database UUID.", + "operationId": "create-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Env", + "description": "Update env by database UUID.", + "operationId": "update-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by database UUID.", + "operationId": "update-envs-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by database UUID.", + "operationId": "list-storages-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by database UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a database.", + "operationId": "create-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Databases" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by database UUID.", + "operationId": "update-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by database UUID.", + "operationId": "delete-storage-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/deployments": { + "get": { + "tags": [ + "Deployments" + ], + "summary": "List", + "description": "List currently running deployments", + "operationId": "list-deployments", + "responses": { + "200": { + "description": "Get all currently running deployments.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ApplicationDeploymentQueue" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/deployments\/{uuid}": { + "get": { + "tags": [ + "Deployments" + ], + "summary": "Get", + "description": "Get deployment by UUID.", + "operationId": "get-deployment-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Deployment UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get deployment by UUID.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ApplicationDeploymentQueue" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/deployments\/{uuid}\/cancel": { + "post": { + "tags": [ + "Deployments" + ], + "summary": "Cancel", + "description": "Cancel a deployment by UUID.", + "operationId": "cancel-deployment-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Deployment UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deployment cancelled successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deployment cancelled successfully." + }, + "deployment_uuid": { + "type": "string", + "example": "cm37r6cqj000008jm0veg5tkm" + }, + "status": { + "type": "string", + "example": "cancelled-by-user" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Deployment cannot be cancelled (already finished\/failed\/cancelled).", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deployment cannot be cancelled. Current status: finished" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "User doesn't have permission to cancel this deployment.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You do not have permission to cancel this deployment." + } + }, + "type": "object" + } + } + } + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/deploy": { + "get": { + "tags": [ + "Deployments" + ], + "summary": "Deploy", + "description": "Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.", + "operationId": "deploy-by-tag-or-uuid", + "parameters": [ + { + "name": "tag", + "in": "query", + "description": "Tag name(s). Comma separated list is also accepted.", + "schema": { + "type": "string" + } + }, + { + "name": "uuid", + "in": "query", + "description": "Resource UUID(s). Comma separated list is also accepted.", + "schema": { + "type": "string" + } + }, + { + "name": "force", + "in": "query", + "description": "Force rebuild (without cache)", + "schema": { + "type": "boolean" + } + }, + { + "name": "pr", + "in": "query", + "description": "Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.", + "schema": { + "type": "integer" + } + }, + { + "name": "pull_request_id", + "in": "query", + "description": "Preview deployment identifier. Alias of pr.", + "schema": { + "type": "integer" + } + }, + { + "name": "docker_tag", + "in": "query", + "description": "Docker image tag for Docker Image preview deployments. Requires pull_request_id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get deployment(s) UUID's", + "content": { + "application\/json": { + "schema": { + "properties": { + "deployments": { + "type": "array", + "items": { + "properties": { + "message": { + "type": "string" + }, + "resource_uuid": { + "type": "string" + }, + "deployment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/deployments\/applications\/{uuid}": { + "get": { + "tags": [ + "Deployments" + ], + "summary": "List application deployments", + "description": "List application deployments by using the app uuid", + "operationId": "list-deployments-by-app-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "description": "Number of records to skip.", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "minimum": 0 + } + }, + { + "name": "take", + "in": "query", + "description": "Number of records to take.", + "required": false, + "schema": { + "type": "integer", + "default": 10, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "List application deployments by using the app uuid.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Application" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "List", + "description": "List all GitHub apps.", + "operationId": "list-github-apps", + "responses": { + "200": { + "description": "List of GitHub apps.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "type": "string", + "nullable": true + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "app_id": { + "type": "integer" + }, + "installation_id": { + "type": "integer" + }, + "client_id": { + "type": "string" + }, + "private_key_id": { + "type": "integer" + }, + "is_system_wide": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + }, + "type": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "GitHub Apps" + ], + "summary": "Create GitHub App", + "description": "Create a new GitHub app.", + "operationId": "create-github-app", + "requestBody": { + "description": "GitHub app creation payload.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "api_url", + "html_url", + "app_id", + "installation_id", + "client_id", + "client_secret", + "private_key_uuid" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the GitHub app." + }, + "organization": { + "type": "string", + "nullable": true, + "description": "Organization to associate the app with." + }, + "api_url": { + "type": "string", + "description": "API URL for the GitHub app (e.g., https:\/\/api.github.com)." + }, + "html_url": { + "type": "string", + "description": "HTML URL for the GitHub app (e.g., https:\/\/github.com)." + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH access (default: git)." + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH access (default: 22)." + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID from GitHub." + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID." + }, + "client_id": { + "type": "string", + "description": "GitHub OAuth App Client ID." + }, + "client_secret": { + "type": "string", + "description": "GitHub OAuth App Client Secret." + }, + "webhook_secret": { + "type": "string", + "description": "Webhook secret for GitHub webhooks." + }, + "private_key_uuid": { + "type": "string", + "description": "UUID of an existing private key for GitHub App authentication." + }, + "is_system_wide": { + "type": "boolean", + "description": "Is this app system-wide (cloud only)." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "GitHub app created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "type": "string", + "nullable": true + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "app_id": { + "type": "integer" + }, + "installation_id": { + "type": "integer" + }, + "client_id": { + "type": "string" + }, + "private_key_id": { + "type": "integer" + }, + "is_system_wide": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Repositories for a GitHub App", + "description": "Fetch repositories from GitHub for a given GitHub app.", + "operationId": "load-repositories", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Repositories loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "repositories": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}\/repositories\/{owner}\/{repo}\/branches": { + "get": { + "tags": [ + "GitHub Apps" + ], + "summary": "Load Branches for a GitHub Repository", + "description": "Fetch branches from GitHub for a given repository.", + "operationId": "load-branches", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "owner", + "in": "path", + "description": "Repository owner", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "description": "Repository name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Branches loaded successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "branches": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/github-apps\/{github_app_id}": { + "delete": { + "tags": [ + "GitHub Apps" + ], + "summary": "Delete GitHub App", + "description": "Delete a GitHub app if it's not being used by any applications.", + "operationId": "deleteGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "GitHub app deleted successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app deleted successfully" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "409": { + "description": "Conflict - GitHub app is in use", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "This GitHub app is being used by 5 application(s). Please delete all applications first." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "GitHub Apps" + ], + "summary": "Update GitHub App", + "description": "Update an existing GitHub app.", + "operationId": "updateGithubApp", + "parameters": [ + { + "name": "github_app_id", + "in": "path", + "description": "GitHub App ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "GitHub App name" + }, + "organization": { + "type": "string", + "nullable": true, + "description": "GitHub organization" + }, + "api_url": { + "type": "string", + "description": "GitHub API URL" + }, + "html_url": { + "type": "string", + "description": "GitHub HTML URL" + }, + "custom_user": { + "type": "string", + "description": "Custom user for SSH" + }, + "custom_port": { + "type": "integer", + "description": "Custom port for SSH" + }, + "app_id": { + "type": "integer", + "description": "GitHub App ID" + }, + "installation_id": { + "type": "integer", + "description": "GitHub Installation ID" + }, + "client_id": { + "type": "string", + "description": "GitHub Client ID" + }, + "client_secret": { + "type": "string", + "description": "GitHub Client Secret" + }, + "webhook_secret": { + "type": "string", + "description": "GitHub Webhook Secret" + }, + "private_key_uuid": { + "type": "string", + "description": "Private key UUID" + }, + "is_system_wide": { + "type": "boolean", + "description": "Is system wide (non-cloud instances only)" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "GitHub app updated successfully", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "GitHub app updated successfully" + }, + "data": { + "type": "object", + "description": "Updated GitHub app data" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "GitHub app not found" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/locations": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Locations", + "description": "Get all available Hetzner datacenter locations.", + "operationId": "get-hetzner-locations", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner locations.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "country": { + "type": "string" + }, + "city": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/server-types": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Server Types", + "description": "Get all available Hetzner server types (instance sizes).", + "operationId": "get-hetzner-server-types", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner server types.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "cores": { + "type": "integer" + }, + "memory": { + "type": "number" + }, + "disk": { + "type": "integer" + }, + "prices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "Datacenter location name" + }, + "price_hourly": { + "type": "object", + "properties": { + "net": { + "type": "string" + }, + "gross": { + "type": "string" + } + } + }, + "price_monthly": { + "type": "object", + "properties": { + "net": { + "type": "string" + }, + "gross": { + "type": "string" + } + } + } + } + } + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/images": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner Images", + "description": "Get all available Hetzner system images (operating systems).", + "operationId": "get-hetzner-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner images.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string" + }, + "os_flavor": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "architecture": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/hetzner\/ssh-keys": { + "get": { + "tags": [ + "Hetzner" + ], + "summary": "Get Hetzner SSH Keys", + "description": "Get all SSH keys stored in the Hetzner account.", + "operationId": "get-hetzner-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of Hetzner SSH keys.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "public_key": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/hetzner": { + "post": { + "tags": [ + "Hetzner" + ], + "summary": "Create Hetzner Server", + "description": "Create a new server on Hetzner and register it in Coolify.", + "operationId": "create-hetzner-server", + "requestBody": { + "description": "Hetzner server creation parameters", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "location", + "server_type", + "image", + "private_key_uuid" + ], + "properties": { + "cloud_provider_token_uuid": { + "type": "string", + "example": "abc123", + "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided." + }, + "cloud_provider_token_id": { + "type": "string", + "example": "abc123", + "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.", + "deprecated": true + }, + "location": { + "type": "string", + "example": "nbg1", + "description": "Hetzner location name" + }, + "server_type": { + "type": "string", + "example": "cx11", + "description": "Hetzner server type name" + }, + "image": { + "type": "integer", + "example": 15512617, + "description": "Hetzner image ID" + }, + "name": { + "type": "string", + "example": "my-server", + "description": "Server name (auto-generated if not provided)" + }, + "private_key_uuid": { + "type": "string", + "example": "xyz789", + "description": "Private key UUID" + }, + "enable_ipv4": { + "type": "boolean", + "example": true, + "description": "Enable IPv4 (default: true)" + }, + "enable_ipv6": { + "type": "boolean", + "example": true, + "description": "Enable IPv6 (default: true)" + }, + "hetzner_ssh_key_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Additional Hetzner SSH key IDs" + }, + "cloud_init_script": { + "type": "string", + "description": "Cloud-init YAML script (optional)" + }, + "instant_validate": { + "type": "boolean", + "example": false, + "description": "Validate server immediately after creation" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Hetzner server created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the server." + }, + "hetzner_server_id": { + "type": "integer", + "description": "The Hetzner server ID." + }, + "ip": { + "type": "string", + "description": "The server IP address." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + }, + "429": { + "$ref": "#\/components\/responses\/429" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/version": { + "get": { + "summary": "Version", + "description": "Get Coolify version.", + "operationId": "version", + "responses": { + "200": { + "description": "Returns the version of the application", + "content": { + "text\/html": { + "schema": { + "type": "string" + }, + "example": "v4.0.0" + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/enable": { + "get": { + "summary": "Enable API", + "description": "Enable API (only with root permissions).", + "operationId": "enable-api", + "responses": { + "200": { + "description": "Enable API.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "API enabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to enable the API.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to enable the API." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/disable": { + "get": { + "summary": "Disable API", + "description": "Disable API (only with root permissions).", + "operationId": "disable-api", + "responses": { + "200": { + "description": "Disable API.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "API disabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to disable the API.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to disable the API." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/mcp\/enable": { + "post": { + "summary": "Enable MCP Server", + "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "enable-mcp", + "responses": { + "200": { + "description": "MCP server enabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server enabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to enable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to enable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/mcp\/disable": { + "post": { + "summary": "Disable MCP Server", + "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).", + "operationId": "disable-mcp", + "responses": { + "200": { + "description": "MCP server disabled.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "MCP server disabled." + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "You are not allowed to disable the MCP server.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "You are not allowed to disable the MCP server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/health": { + "get": { + "summary": "Healthcheck", + "description": "Healthcheck endpoint.", + "operationId": "healthcheck", + "responses": { + "200": { + "description": "Healthcheck endpoint.", + "content": { + "text\/html": { + "schema": { + "type": "string" + }, + "example": "OK" + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + } + } + }, + "\/projects": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List", + "description": "List projects.", + "operationId": "list-projects", + "responses": { + "200": { + "description": "Get all projects.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Project" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Projects" + ], + "summary": "Create", + "description": "Create Project.", + "operationId": "create-project", + "requestBody": { + "description": "Project created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the project." + }, + "description": { + "type": "string", + "description": "The description of the project." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Project created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the project." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}": { + "get": { + "tags": [ + "Projects" + ], + "summary": "Get", + "description": "Get project by UUID.", + "operationId": "get-project-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project details", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Project" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Projects" + ], + "summary": "Delete", + "description": "Delete project by UUID.", + "operationId": "delete-project-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Project deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Projects" + ], + "summary": "Update", + "description": "Update Project.", + "operationId": "update-project-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the project.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Project updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the project." + }, + "description": { + "type": "string", + "description": "The description of the project." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Project updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os" + }, + "name": { + "type": "string", + "example": "Project Name" + }, + "description": { + "type": "string", + "example": "Project Description" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/{environment_name_or_uuid}": { + "get": { + "tags": [ + "Projects" + ], + "summary": "Environment", + "description": "Get environment by name or UUID.", + "operationId": "get-environment-by-name-or-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment details", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Environment" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List Environments", + "description": "List all environments in a project.", + "operationId": "get-environments", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of environments", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Environment" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Projects" + ], + "summary": "Create Environment", + "description": "Create environment in project.", + "operationId": "create-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Environment created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "env123", + "description": "The UUID of the environment." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + }, + "409": { + "description": "Environment with this name already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}": { + "delete": { + "tags": [ + "Projects" + ], + "summary": "Delete Environment", + "description": "Delete environment by name or UUID. Environment must be empty.", + "operationId": "delete-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "description": "Environment has resources, so it cannot be deleted." + }, + "404": { + "description": "Project or environment not found." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/resources": { + "get": { + "tags": [ + "Resources" + ], + "summary": "List", + "description": "Get all resources.", + "operationId": "list-resources", + "responses": { + "200": { + "description": "Get all resources", + "content": { + "application\/json": { + "schema": { + "type": "string" + }, + "example": "Content is very complex. Will be implemented later." + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for an application.", + "operationId": "list-scheduled-tasks-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for an application.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for an application.", + "operationId": "create-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for an application.", + "operationId": "delete-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for an application.", + "operationId": "update-scheduled-task-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on an application.", + "operationId": "list-scheduled-task-executions-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Tasks", + "description": "List all scheduled tasks for a service.", + "operationId": "list-scheduled-tasks-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all scheduled tasks for a service.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Create Task", + "description": "Create a new scheduled task for a service.", + "operationId": "create-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "name", + "command", + "frequency" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Scheduled task created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": { + "delete": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Delete Task", + "description": "Delete a scheduled task for a service.", + "operationId": "delete-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Scheduled task deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Scheduled task deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "Update Task", + "description": "Update a scheduled task for a service.", + "operationId": "update-scheduled-task-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Scheduled task data", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds.", + "default": 300 + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled.", + "default": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Scheduled task updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/ScheduledTask" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": { + "get": { + "tags": [ + "Scheduled Tasks" + ], + "summary": "List Executions", + "description": "List all executions for a scheduled task on a service.", + "operationId": "list-scheduled-task-executions-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "task_uuid", + "in": "path", + "description": "UUID of the scheduled task.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all executions for a scheduled task.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/ScheduledTaskExecution" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/security\/keys": { + "get": { + "tags": [ + "Private Keys" + ], + "summary": "List", + "description": "List all private keys.", + "operationId": "list-private-keys", + "responses": { + "200": { + "description": "Get all private keys.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/PrivateKey" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Private Keys" + ], + "summary": "Create", + "description": "Create a new private key.", + "operationId": "create-private-key", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "private_key" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "private_key": { + "type": "string" + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "The created private key's UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Private Keys" + ], + "summary": "Update", + "description": "Update a private key.", + "operationId": "update-private-key", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "private_key" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "private_key": { + "type": "string" + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "The updated private key's UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/security\/keys\/{uuid}": { + "get": { + "tags": [ + "Private Keys" + ], + "summary": "Get", + "description": "Get key by UUID.", + "operationId": "get-private-key-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Private Key UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get all private keys.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/PrivateKey" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Private Key not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Private Keys" + ], + "summary": "Delete", + "description": "Delete a private key.", + "operationId": "delete-private-key-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Private Key UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Private Key deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Private Key deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Private Key not found." + }, + "422": { + "description": "Private Key is in use and cannot be deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Private Key is in use and cannot be deleted." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers": { + "get": { + "tags": [ + "Servers" + ], + "summary": "List", + "description": "List all servers.", + "operationId": "list-servers", + "responses": { + "200": { + "description": "Get all servers.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Server" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Servers" + ], + "summary": "Create", + "description": "Create Server.", + "operationId": "create-server", + "requestBody": { + "description": "Server created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "example": "My Server", + "description": "The name of the server." + }, + "description": { + "type": "string", + "example": "My Server Description", + "description": "The description of the server." + }, + "ip": { + "type": "string", + "example": "127.0.0.1", + "description": "The IP of the server." + }, + "port": { + "type": "integer", + "example": 22, + "description": "The port of the server." + }, + "user": { + "type": "string", + "example": "root", + "description": "The user of the server." + }, + "private_key_uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the private key." + }, + "is_build_server": { + "type": "boolean", + "example": false, + "description": "Is build server." + }, + "instant_validate": { + "type": "boolean", + "example": false, + "description": "Instant validate." + }, + "proxy_type": { + "type": "string", + "enum": [ + "traefik", + "caddy", + "none" + ], + "example": "traefik", + "description": "The proxy type." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Server created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "og888os", + "description": "The UUID of the server." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Get", + "description": "Get server by UUID.", + "operationId": "get-server-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server's UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get server by UUID", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Server" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Servers" + ], + "summary": "Delete", + "description": "Delete server by UUID.", + "operationId": "delete-server-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the server.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Server deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Server deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Servers" + ], + "summary": "Update", + "description": "Update Server.", + "operationId": "update-server-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Server updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the server." + }, + "description": { + "type": "string", + "description": "The description of the server." + }, + "ip": { + "type": "string", + "description": "The IP of the server." + }, + "port": { + "type": "integer", + "description": "The port of the server." + }, + "user": { + "type": "string", + "description": "The user of the server." + }, + "private_key_uuid": { + "type": "string", + "description": "The UUID of the private key." + }, + "is_build_server": { + "type": "boolean", + "description": "Is build server." + }, + "instant_validate": { + "type": "boolean", + "description": "Instant validate." + }, + "proxy_type": { + "type": "string", + "enum": [ + "traefik", + "caddy", + "none" + ], + "description": "The proxy type." + }, + "concurrent_builds": { + "type": "integer", + "description": "Number of concurrent builds." + }, + "dynamic_timeout": { + "type": "integer", + "description": "Deployment timeout in seconds." + }, + "deployment_queue_limit": { + "type": "integer", + "description": "Maximum number of queued deployments." + }, + "server_disk_usage_notification_threshold": { + "type": "integer", + "description": "Server disk usage notification threshold (%)." + }, + "server_disk_usage_check_frequency": { + "type": "string", + "description": "Cron expression for disk usage check frequency." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds (1-300). Default: 10." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Server updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Server" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/resources": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Resources", + "description": "Get resources by server.", + "operationId": "get-resources-by-server-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server's UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get resources by server", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/domains": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Domains", + "description": "Get domains by server.", + "operationId": "get-domains-by-server-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server's UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get domains by server", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "ip": { + "type": "string" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{uuid}\/validate": { + "get": { + "tags": [ + "Servers" + ], + "summary": "Validate", + "description": "Validate server by UUID.", + "operationId": "validate-server-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Server validation started.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Validation started." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services": { + "get": { + "tags": [ + "Services" + ], + "summary": "List", + "description": "List all services.", + "operationId": "list-services", + "responses": { + "200": { + "description": "Get all services", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Service" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create service", + "description": "Create a one-click \/ custom service", + "operationId": "create-service", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "server_uuid", + "project_uuid", + "environment_name", + "environment_uuid" + ], + "properties": { + "type": { + "description": "The one-click service type (e.g. \"actualbudget\", \"calibre-web\", \"gitea-with-mysql\" ...)", + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 255, + "description": "Name of the service." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Description of the service." + }, + "project_uuid": { + "type": "string", + "description": "Project UUID." + }, + "environment_name": { + "type": "string", + "description": "Environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "Environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "server_uuid": { + "type": "string", + "description": "Server UUID." + }, + "destination_uuid": { + "type": "string", + "description": "Destination UUID. Required if server has multiple destinations." + }, + "instant_deploy": { + "type": "boolean", + "default": false, + "description": "Start the service immediately after creation." + }, + "docker_compose_raw": { + "type": "string", + "description": "The base64 encoded Docker Compose content." + }, + "urls": { + "type": "array", + "description": "Array of URLs to be applied to containers of a service.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "url": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + } + }, + "type": "object" + } + }, + "force_domain_override": { + "type": "boolean", + "default": false, + "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Service created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "description": "Service UUID." + }, + "domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Service domains." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}": { + "get": { + "tags": [ + "Services" + ], + "summary": "Get", + "description": "Get service by UUID.", + "operationId": "get-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get a service by UUID.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Service" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete", + "description": "Delete service by UUID.", + "operationId": "delete-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Service UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "delete_configurations", + "in": "query", + "description": "Delete configurations.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_volumes", + "in": "query", + "description": "Delete volumes.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Run docker cleanup.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "delete_connected_networks", + "in": "query", + "description": "Delete connected networks.", + "required": false, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Delete a service by UUID", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service deletion request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update", + "description": "Update service by UUID.", + "operationId": "update-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Service updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The service name." + }, + "description": { + "type": "string", + "description": "The service description." + }, + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the service should be deployed instantly." + }, + "connect_to_docker_network": { + "type": "boolean", + "default": false, + "description": "Connect the service to the predefined docker network." + }, + "docker_compose_raw": { + "type": "string", + "description": "The base64 encoded Docker Compose content." + }, + "urls": { + "type": "array", + "description": "Array of URLs to be applied to containers of a service.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "url": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + } + }, + "type": "object" + } + }, + "force_domain_override": { + "type": "boolean", + "default": false, + "description": "Force domain override even if conflicts are detected." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Service updated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "description": "Service UUID." + }, + "domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Service domains." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/envs": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Envs", + "description": "List all envs by service UUID.", + "operationId": "list-envs-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All environment variables by service UUID.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Env", + "description": "Create env by service UUID.", + "operationId": "create-env-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "nc0k04gk8g0cgsk440g0koko" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Env", + "description": "Update env by service UUID.", + "operationId": "update-env-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Env updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variable updated.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/envs\/bulk": { + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Envs (Bulk)", + "description": "Update multiple envs by service UUID.", + "operationId": "update-envs-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Bulk envs updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "description": "The key of the environment variable." + }, + "value": { + "type": "string", + "description": "The value of the environment variable." + }, + "is_preview": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is used in preview deployments." + }, + "is_literal": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is a literal, nothing espaced." + }, + "is_multiline": { + "type": "boolean", + "description": "The flag to indicate if the environment variable is multiline." + }, + "is_shown_once": { + "type": "boolean", + "description": "The flag to indicate if the environment variable's value is shown on the UI." + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment variables updated.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/EnvironmentVariable" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/envs\/{env_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Env", + "description": "Delete env by UUID.", + "operationId": "delete-env-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "env_uuid", + "in": "path", + "description": "UUID of the environment variable.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment variable deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment variable deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/start": { + "get": { + "tags": [ + "Services" + ], + "summary": "Start", + "description": "Start service. `Post` request is also accepted.", + "operationId": "start-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Start service.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service starting request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/stop": { + "get": { + "tags": [ + "Services" + ], + "summary": "Stop", + "description": "Stop service. `Post` request is also accepted.", + "operationId": "stop-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "docker_cleanup", + "in": "query", + "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stop service.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service stopping request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/restart": { + "get": { + "tags": [ + "Services" + ], + "summary": "Restart", + "description": "Restart service. `Post` request is also accepted.", + "operationId": "restart-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "latest", + "in": "query", + "description": "Pull latest images.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Restart service.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service restaring request queued." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Storages", + "description": "List all persistent storages and file storages by service UUID.", + "operationId": "list-storages-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "All storages by service UUID.", + "content": { + "application\/json": { + "schema": { + "properties": { + "persistent_storages": { + "type": "array", + "items": { + "type": "object" + } + }, + "file_storages": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Storage", + "description": "Create a persistent storage or file storage for a service sub-resource.", + "operationId": "create-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type", + "mount_path", + "resource_uuid" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage." + }, + "resource_uuid": { + "type": "string", + "description": "UUID of the service application or database sub-resource." + }, + "name": { + "type": "string", + "description": "Volume name (persistent only, required for persistent)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, optional)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "File content (file only, optional)." + }, + "is_directory": { + "type": "boolean", + "description": "Whether this is a directory mount (file only, default false)." + }, + "fs_path": { + "type": "string", + "description": "Host directory path (required when is_directory is true)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "201": { + "description": "Storage created.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "tags": [ + "Services" + ], + "summary": "Update Storage", + "description": "Update a persistent storage or file storage by service UUID.", + "operationId": "update-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "type" + ], + "properties": { + "uuid": { + "type": "string", + "description": "The UUID of the storage (preferred)." + }, + "id": { + "type": "integer", + "description": "The ID of the storage (deprecated, use uuid instead)." + }, + "type": { + "type": "string", + "enum": [ + "persistent", + "file" + ], + "description": "The type of storage: persistent or file." + }, + "is_preview_suffix_enabled": { + "type": "boolean", + "description": "Whether to add -pr-N suffix for preview deployments." + }, + "name": { + "type": "string", + "description": "The volume name (persistent only, not allowed for read-only storages)." + }, + "mount_path": { + "type": "string", + "description": "The container mount path (not allowed for read-only storages)." + }, + "host_path": { + "type": "string", + "nullable": true, + "description": "The host path (persistent only, not allowed for read-only storages)." + }, + "content": { + "type": "string", + "nullable": true, + "description": "The file content (file only, not allowed for read-only storages)." + } + }, + "type": "object", + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Storage updated.", + "content": { + "application\/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/storages\/{storage_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Storage", + "description": "Delete a persistent storage or file storage by service UUID.", + "operationId": "delete-storage-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "storage_uuid", + "in": "path", + "description": "UUID of the storage.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Storage deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/teams": { + "get": { + "tags": [ + "Teams" + ], + "summary": "List", + "description": "Get all teams.", + "operationId": "list-teams", + "responses": { + "200": { + "description": "List of teams.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Team" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/teams\/{id}": { + "get": { + "tags": [ + "Teams" + ], + "summary": "Get", + "description": "Get team by TeamId.", + "operationId": "get-team-by-id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "List of teams.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Team" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/teams\/{id}\/members": { + "get": { + "tags": [ + "Teams" + ], + "summary": "Members", + "description": "Get members by TeamId.", + "operationId": "get-members-by-team-id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "List of members.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/User" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/teams\/current": { + "get": { + "tags": [ + "Teams" + ], + "summary": "Authenticated Team", + "description": "Get currently authenticated team.", + "operationId": "get-current-team", + "responses": { + "200": { + "description": "Current Team.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Team" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/teams\/current\/members": { + "get": { + "tags": [ + "Teams" + ], + "summary": "Authenticated Team Members", + "description": "Get currently authenticated team members.", + "operationId": "get-current-team-members", + "responses": { + "200": { + "description": "Currently authenticated team members.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/User" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "Application": { + "description": "Application model", + "properties": { + "id": { + "type": "integer", + "description": "The application identifier in the database." + }, + "description": { + "type": "string", + "nullable": true, + "description": "The application description." + }, + "repository_project_id": { + "type": "integer", + "nullable": true, + "description": "The repository project identifier." + }, + "uuid": { + "type": "string", + "description": "The application UUID." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "fqdn": { + "type": "string", + "nullable": true, + "description": "The application domains." + }, + "config_hash": { + "type": "string", + "description": "Configuration hash." + }, + "git_repository": { + "type": "string", + "description": "Git repository URL." + }, + "git_branch": { + "type": "string", + "description": "Git branch." + }, + "git_commit_sha": { + "type": "string", + "description": "Git commit SHA." + }, + "git_full_url": { + "type": "string", + "nullable": true, + "description": "Git full URL." + }, + "docker_registry_image_name": { + "type": "string", + "nullable": true, + "description": "Docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "nullable": true, + "description": "Docker registry image tag." + }, + "build_pack": { + "type": "string", + "description": "Build pack.", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ] + }, + "static_image": { + "type": "string", + "description": "Static image used when static site is deployed." + }, + "install_command": { + "type": "string", + "description": "Install command." + }, + "build_command": { + "type": "string", + "description": "Build command." + }, + "start_command": { + "type": "string", + "description": "Start command." + }, + "ports_exposes": { + "type": "string", + "description": "Ports exposes." + }, + "ports_mappings": { + "type": "string", + "nullable": true, + "description": "Ports mappings." + }, + "custom_network_aliases": { + "type": "string", + "nullable": true, + "description": "Network aliases for Docker container." + }, + "base_directory": { + "type": "string", + "description": "Base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "Publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "health_check_type": { + "type": "string", + "description": "Health check type: http or cmd.", + "enum": [ + "http", + "cmd" + ] + }, + "health_check_command": { + "type": "string", + "nullable": true, + "description": "Health check command for CMD type." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "status": { + "type": "string", + "description": "Application status." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "destination_type": { + "type": "string", + "description": "Destination type." + }, + "destination_id": { + "type": "integer", + "description": "Destination identifier." + }, + "source_id": { + "type": "integer", + "nullable": true, + "description": "Source identifier." + }, + "private_key_id": { + "type": "integer", + "nullable": true, + "description": "Private key identifier." + }, + "environment_id": { + "type": "integer", + "description": "Environment identifier." + }, + "dockerfile": { + "type": "string", + "nullable": true, + "description": "Dockerfile content. Used for dockerfile build pack." + }, + "dockerfile_location": { + "type": "string", + "description": "Dockerfile location." + }, + "custom_labels": { + "type": "string", + "nullable": true, + "description": "Custom labels." + }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "Dockerfile target build." + }, + "manual_webhook_secret_github": { + "type": "string", + "nullable": true, + "description": "Manual webhook secret for GitHub." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "nullable": true, + "description": "Manual webhook secret for GitLab." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "nullable": true, + "description": "Manual webhook secret for Bitbucket." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "nullable": true, + "description": "Manual webhook secret for Gitea." + }, + "docker_compose_location": { + "type": "string", + "description": "Docker compose location." + }, + "docker_compose": { + "type": "string", + "nullable": true, + "description": "Docker compose content. Used for docker compose build pack." + }, + "docker_compose_raw": { + "type": "string", + "nullable": true, + "description": "Docker compose raw content." + }, + "docker_compose_domains": { + "type": "string", + "nullable": true, + "description": "Docker compose domains." + }, + "docker_compose_custom_start_command": { + "type": "string", + "nullable": true, + "description": "Docker compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "nullable": true, + "description": "Docker compose custom build command." + }, + "swarm_replicas": { + "type": "integer", + "nullable": true, + "description": "Swarm replicas. Only used for swarm deployments." + }, + "swarm_placement_constraints": { + "type": "string", + "nullable": true, + "description": "Swarm placement constraints. Only used for swarm deployments." + }, + "custom_docker_run_options": { + "type": "string", + "nullable": true, + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "nullable": true, + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "nullable": true, + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "nullable": true, + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "nullable": true, + "description": "Pre deployment command container." + }, + "watch_paths": { + "type": "string", + "nullable": true, + "description": "Watch paths." + }, + "custom_healthcheck_found": { + "type": "boolean", + "description": "Custom healthcheck found." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the application was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the application was last updated." + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "The date and time when the application was deleted." + }, + "compose_parsing_version": { + "type": "string", + "description": "How Coolify parse the compose file." + }, + "custom_nginx_configuration": { + "type": "string", + "nullable": true, + "description": "Custom Nginx configuration base64 encoded." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + } + }, + "type": "object" + }, + "ApplicationDeploymentQueue": { + "description": "Project model", + "properties": { + "id": { + "type": "integer" + }, + "application_id": { + "type": "string" + }, + "deployment_uuid": { + "type": "string" + }, + "pull_request_id": { + "type": "integer" + }, + "docker_registry_image_tag": { + "type": "string", + "nullable": true + }, + "configuration_hash": { + "type": "string", + "nullable": true + }, + "configuration_snapshot": { + "type": "object", + "nullable": true + }, + "configuration_diff": { + "type": "object", + "nullable": true + }, + "force_rebuild": { + "type": "boolean" + }, + "commit": { + "type": "string" + }, + "status": { + "type": "string" + }, + "is_webhook": { + "type": "boolean" + }, + "is_api": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "logs": { + "type": "string" + }, + "current_process_id": { + "type": "string" + }, + "restart_only": { + "type": "boolean" + }, + "git_type": { + "type": "string" + }, + "server_id": { + "type": "integer" + }, + "application_name": { + "type": "string" + }, + "server_name": { + "type": "string" + }, + "deployment_url": { + "type": "string" + }, + "destination_id": { + "type": "string" + }, + "only_this_server": { + "type": "boolean" + }, + "rollback": { + "type": "boolean" + }, + "commit_message": { + "type": "string" + } + }, + "type": "object" + }, + "Environment": { + "description": "Environment model", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "project_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "type": "object" + }, + "EnvironmentVariable": { + "description": "Environment Variable model", + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "resourceable_type": { + "type": "string" + }, + "resourceable_id": { + "type": "integer" + }, + "is_literal": { + "type": "boolean" + }, + "is_multiline": { + "type": "boolean" + }, + "is_preview": { + "type": "boolean" + }, + "is_runtime": { + "type": "boolean" + }, + "is_buildtime": { + "type": "boolean" + }, + "is_shared": { + "type": "boolean" + }, + "is_shown_once": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "real_value": { + "type": "string" + }, + "comment": { + "type": "string", + "nullable": true + }, + "version": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, + "PrivateKey": { + "description": "Private Key model", + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "private_key": { + "type": "string", + "format": "private-key" + }, + "public_key": { + "type": "string", + "description": "The public key of the private key." + }, + "fingerprint": { + "type": "string", + "description": "The fingerprint of the private key." + }, + "is_git_related": { + "type": "boolean" + }, + "team_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, + "Project": { + "description": "Project model", + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "type": "object" + }, + "ScheduledTask": { + "description": "Scheduled Task model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the scheduled task in the database." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the scheduled task." + }, + "enabled": { + "type": "boolean", + "description": "The flag to indicate if the scheduled task is enabled." + }, + "name": { + "type": "string", + "description": "The name of the scheduled task." + }, + "command": { + "type": "string", + "description": "The command to execute." + }, + "frequency": { + "type": "string", + "description": "The frequency of the scheduled task." + }, + "container": { + "type": "string", + "nullable": true, + "description": "The container where the command should be executed." + }, + "timeout": { + "type": "integer", + "description": "The timeout of the scheduled task in seconds." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "The date and time when the scheduled task was last updated." + } + }, + "type": "object" + }, + "ScheduledTaskExecution": { + "description": "Scheduled Task Execution model", + "properties": { + "uuid": { + "type": "string", + "description": "The unique identifier of the execution." + }, + "status": { + "type": "string", + "enum": [ + "success", + "failed", + "running" + ], + "description": "The status of the execution." + }, + "message": { + "type": "string", + "nullable": true, + "description": "The output message of the execution." + }, + "retry_count": { + "type": "integer", + "description": "The number of retries." + }, + "duration": { + "type": "number", + "nullable": true, + "description": "Duration in seconds." + }, + "started_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "When the execution finished." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last updated." + } + }, + "type": "object" + }, + "Server": { + "description": "Server model", + "properties": { + "id": { + "type": "integer", + "description": "The server ID." + }, + "uuid": { + "type": "string", + "description": "The server UUID." + }, + "name": { + "type": "string", + "description": "The server name." + }, + "description": { + "type": "string", + "description": "The server description." + }, + "ip": { + "type": "string", + "description": "The IP address." + }, + "user": { + "type": "string", + "description": "The user." + }, + "port": { + "type": "integer", + "description": "The port number." + }, + "proxy": { + "type": "object", + "description": "The proxy configuration." + }, + "proxy_type": { + "type": "string", + "enum": [ + "traefik", + "caddy", + "none" + ], + "description": "The proxy type." + }, + "high_disk_usage_notification_sent": { + "type": "boolean", + "description": "The flag to indicate if the high disk usage notification has been sent." + }, + "unreachable_notification_sent": { + "type": "boolean", + "description": "The flag to indicate if the unreachable notification has been sent." + }, + "unreachable_count": { + "type": "integer", + "description": "The unreachable count for your server." + }, + "validation_logs": { + "type": "string", + "description": "The validation logs." + }, + "log_drain_notification_sent": { + "type": "boolean", + "description": "The flag to indicate if the log drain notification has been sent." + }, + "swarm_cluster": { + "type": "string", + "description": "The swarm cluster configuration." + }, + "settings": { + "$ref": "#\/components\/schemas\/ServerSetting" + } + }, + "type": "object" + }, + "ServerSetting": { + "description": "Server Settings model", + "properties": { + "id": { + "type": "integer" + }, + "concurrent_builds": { + "type": "integer" + }, + "deployment_queue_limit": { + "type": "integer" + }, + "dynamic_timeout": { + "type": "integer" + }, + "force_disabled": { + "type": "boolean" + }, + "force_server_cleanup": { + "type": "boolean" + }, + "is_build_server": { + "type": "boolean" + }, + "is_cloudflare_tunnel": { + "type": "boolean" + }, + "is_jump_server": { + "type": "boolean" + }, + "is_logdrain_axiom_enabled": { + "type": "boolean" + }, + "is_logdrain_custom_enabled": { + "type": "boolean" + }, + "is_logdrain_highlight_enabled": { + "type": "boolean" + }, + "is_logdrain_newrelic_enabled": { + "type": "boolean" + }, + "is_metrics_enabled": { + "type": "boolean" + }, + "is_reachable": { + "type": "boolean" + }, + "is_sentinel_enabled": { + "type": "boolean" + }, + "is_swarm_manager": { + "type": "boolean" + }, + "is_swarm_worker": { + "type": "boolean" + }, + "is_terminal_enabled": { + "type": "boolean" + }, + "is_usable": { + "type": "boolean" + }, + "logdrain_axiom_api_key": { + "type": "string" + }, + "logdrain_axiom_dataset_name": { + "type": "string" + }, + "logdrain_custom_config": { + "type": "string" + }, + "logdrain_custom_config_parser": { + "type": "string" + }, + "logdrain_highlight_project_id": { + "type": "string" + }, + "logdrain_newrelic_base_uri": { + "type": "string" + }, + "logdrain_newrelic_license_key": { + "type": "string" + }, + "sentinel_metrics_history_days": { + "type": "integer" + }, + "sentinel_metrics_refresh_rate_seconds": { + "type": "integer" + }, + "sentinel_token": { + "type": "string" + }, + "docker_cleanup_frequency": { + "type": "string" + }, + "docker_cleanup_threshold": { + "type": "integer" + }, + "server_id": { + "type": "integer" + }, + "wildcard_domain": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "delete_unused_volumes": { + "type": "boolean", + "description": "The flag to indicate if the unused volumes should be deleted." + }, + "delete_unused_networks": { + "type": "boolean", + "description": "The flag to indicate if the unused networks should be deleted." + }, + "connection_timeout": { + "type": "integer", + "description": "SSH connection timeout in seconds." + } + }, + "type": "object" + }, + "Service": { + "description": "Service model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the service. Only used for database identification." + }, + "uuid": { + "type": "string", + "description": "The unique identifier of the service." + }, + "name": { + "type": "string", + "description": "The name of the service." + }, + "environment_id": { + "type": "integer", + "description": "The unique identifier of the environment where the service is attached to." + }, + "server_id": { + "type": "integer", + "description": "The unique identifier of the server where the service is running." + }, + "description": { + "type": "string", + "description": "The description of the service." + }, + "docker_compose_raw": { + "type": "string", + "description": "The raw docker-compose.yml file of the service." + }, + "docker_compose": { + "type": "string", + "description": "The docker-compose.yml file that is parsed and modified by Coolify." + }, + "destination_type": { + "type": "string", + "description": "Destination type." + }, + "destination_id": { + "type": "integer", + "description": "The unique identifier of the destination where the service is running." + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "description": "The flag to enable the container label escape." + }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "description": "The flag to enable the container label readonly." + }, + "config_hash": { + "type": "string", + "description": "The hash of the service configuration." + }, + "service_type": { + "type": "string", + "description": "The type of the service." + }, + "created_at": { + "type": "string", + "description": "The date and time when the service was created." + }, + "updated_at": { + "type": "string", + "description": "The date and time when the service was last updated." + }, + "deleted_at": { + "type": "string", + "description": "The date and time when the service was deleted." + } + }, + "type": "object" + }, + "Team": { + "description": "Team model", + "properties": { + "id": { + "type": "integer", + "description": "The unique identifier of the team." + }, + "name": { + "type": "string", + "description": "The name of the team." + }, + "description": { + "type": "string", + "description": "The description of the team." + }, + "personal_team": { + "type": "boolean", + "description": "Whether the team is personal or not." + }, + "created_at": { + "type": "string", + "description": "The date and time the team was created." + }, + "updated_at": { + "type": "string", + "description": "The date and time the team was last updated." + }, + "show_boarding": { + "type": "boolean", + "description": "Whether to show the boarding screen or not." + }, + "custom_server_limit": { + "type": "string", + "description": "The custom server limit." + }, + "members": { + "description": "The members of the team.", + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/User" + } + } + }, + "type": "object" + }, + "User": { + "description": "User model", + "properties": { + "id": { + "type": "integer", + "description": "The user identifier in the database." + }, + "name": { + "type": "string", + "description": "The user name." + }, + "email": { + "type": "string", + "description": "The user email." + }, + "email_verified_at": { + "type": "string", + "description": "The date when the user email was verified." + }, + "created_at": { + "type": "string", + "description": "The date when the user was created." + }, + "updated_at": { + "type": "string", + "description": "The date when the user was updated." + }, + "two_factor_confirmed_at": { + "type": "string", + "description": "The date when the user two factor was confirmed." + }, + "force_password_reset": { + "type": "boolean", + "description": "The flag to force the user to reset the password." + }, + "marketing_emails": { + "type": "boolean", + "description": "The flag to receive marketing emails." + } + }, + "type": "object" + } + }, + "responses": { + "400": { + "description": "Invalid token.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Invalid token." + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthenticated.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Resource not found.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Resource not found." + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "Validation error.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Validation error." + }, + "errors": { + "type": "object", + "example": { + "name": [ + "The name field is required." + ], + "api_url": [ + "The api url field is required.", + "The api url format is invalid." + ] + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Rate limit exceeded.", + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying.", + "schema": { + "type": "integer", + "example": 60 + } + } + }, + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Rate limit exceeded. Please try again later." + } + }, + "type": "object" + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "description": "Go to `Keys & Tokens` \/ `API tokens` and create a new token. Use the token as the bearer token.", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "Applications", + "description": "Applications" + }, + { + "name": "Cloud Tokens", + "description": "Cloud Tokens" + }, + { + "name": "Databases", + "description": "Databases" + }, + { + "name": "Deployments", + "description": "Deployments" + }, + { + "name": "GitHub Apps", + "description": "GitHub Apps" + }, + { + "name": "Hetzner", + "description": "Hetzner" + }, + { + "name": "Projects", + "description": "Projects" + }, + { + "name": "Resources", + "description": "Resources" + }, + { + "name": "Scheduled Tasks", + "description": "Scheduled Tasks" + }, + { + "name": "Private Keys", + "description": "Private Keys" + }, + { + "name": "Servers", + "description": "Servers" + }, + { + "name": "Services", + "description": "Services" + }, + { + "name": "Teams", + "description": "Teams" + } + ] +} diff --git a/scripts/dump-coolify-db.sh b/scripts/dump-coolify-db.sh new file mode 100755 index 0000000..95512ca --- /dev/null +++ b/scripts/dump-coolify-db.sh @@ -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" diff --git a/scripts/register-github-app.sh b/scripts/register-github-app.sh new file mode 100755 index 0000000..59c0892 --- /dev/null +++ b/scripts/register-github-app.sh @@ -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.` 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. 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}" diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100755 index 0000000..d21f209 --- /dev/null +++ b/scripts/restore-db.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# usage: ./scripts/restore-db.sh +# 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" diff --git a/src/apply.ts b/src/apply.ts new file mode 100644 index 0000000..48a223c --- /dev/null +++ b/src/apply.ts @@ -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; + updateFields( + uuid: string, + kind: ResourceKind, + fields: Record, + ): Promise; + syncEnv(uuid: string, kind: ResourceKind, env: ResolvedEnv): Promise; + redeploy(uuid: string, kind: ResourceKind): Promise; +}; + +export function applyHostnameOverlay( + desired: Desired[], + overlay: Record>, +): 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 + | 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 }; +} diff --git a/src/bindings.ts b/src/bindings.ts new file mode 100644 index 0000000..bdce516 --- /dev/null +++ b/src/bindings.ts @@ -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; + +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; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..6dab2b5 --- /dev/null +++ b/src/cli.ts @@ -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 / --env [--path ] [--hostname-overlay ] + cast diff / --env [--full] + cast server add --ip --key [--user root] [--port 22] + cast smoke + + --state 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 = { + "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 | 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 = {}; + 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, +): Record { + 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 { + // List resources inside /; 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>; + postgresqls?: Array>; + redis?: Array>; + services?: Array>; + } | null; + if (!env) return []; + const map = ( + kind: ResourceKind, + items: Array> = [], + ): 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 { + 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 { + 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, +): Record { + 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, + ).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, +): Record { + // /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, +): Record { + // /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; + }, +): 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); + }, + ); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d4f13cd --- /dev/null +++ b/src/config.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; + +// Coolify's base URL + API token, read from /.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 = {}; + 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 }; +} diff --git a/src/coolify.ts b/src/coolify.ts new file mode 100644 index 0000000..ee1f744 --- /dev/null +++ b/src/coolify.ts @@ -0,0 +1,92 @@ +type Json = Record | 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 { + 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 { + 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 { + return this.reqText("GET", "/version"); + } + + private async resolve( + kind: string, + listPath: string, + name: string, + ): Promise { + 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 { + await this.post(`/deploy?uuid=${encodeURIComponent(uuid)}`); + } + async restart(uuid: string): Promise { + await this.post(`/services/${encodeURIComponent(uuid)}/restart`); + } +} diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..115dc4c --- /dev/null +++ b/src/diff.ts @@ -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; + env?: ResolvedEnv; +}; +export type Live = { + kind: ResourceKind; + name: string; + uuid: string; + fields: Record; + env?: Record; +}; +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 = { + 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, +): 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"); +} diff --git a/src/envtemplate.ts b/src/envtemplate.ts new file mode 100644 index 0000000..53cd5f6 --- /dev/null +++ b/src/envtemplate.ts @@ -0,0 +1,64 @@ +export type ResolvedEnv = { + vars: Record; +}; + +export function resolveTemplate( + text: string, + secrets: Record, +): 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..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, + 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)`, + ); + } + } + } +} diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..e22768f --- /dev/null +++ b/src/manifest.ts @@ -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; +export type DatabaseSpec = z.infer; +export type ServiceSpec = z.infer; +export type EnvironmentSpec = z.infer; +export type Manifest = z.infer; + +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; +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..fae4f2d --- /dev/null +++ b/src/resolve.ts @@ -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, +): { + desired: Desired[]; + resolvedEnvs: Record; + backupSchedules: Record; +} { + 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 = {}; + 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 }; +} diff --git a/src/secrets.ts b/src/secrets.ts new file mode 100644 index 0000000..63ca0f5 --- /dev/null +++ b/src/secrets.ts @@ -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 { + const out = execFileSync("age", ["-d", "-i", keyFile, file], { + encoding: "utf8", + }); + const secrets: Record = {}; + 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_ — injected for this invocation +// 2. ~/.config/cast/age-.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`); +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..cf8c02e --- /dev/null +++ b/src/server.ts @@ -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 { + 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`, + ); +} diff --git a/src/smoke.ts b/src/smoke.ts new file mode 100644 index 0000000..68c069c --- /dev/null +++ b/src/smoke.ts @@ -0,0 +1,66 @@ +import type { CoolifyClient } from "./coolify.js"; + +export async function smoke( + client: CoolifyClient, + targetAppUuid: string, +): Promise { + 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 => + (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()}`); +} diff --git a/test/apply.test.ts b/test/apply.test.ts new file mode 100644 index 0000000..7c1c059 --- /dev/null +++ b/test/apply.test.ts @@ -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..sslip.io"], + landing: ["http://landing..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..sslip.io"], + }); + // input untouched + expect(composeDesired[0].fields.docker_compose_domains).toEqual({ + api: ["http://api..sslip.io"], + landing: ["http://landing..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"]); + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..2af8dc6 --- /dev/null +++ b/test/cli.test.ts @@ -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); + }); +}); diff --git a/test/coolify.test.ts b/test/coolify.test.ts new file mode 100644 index 0000000..3a73001 --- /dev/null +++ b/test/coolify.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; +import { CoolifyClient } from "../src/coolify.js"; + +function mockFetch(routes: Record) { + 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).mock + .calls[0]; + expect((call[1].headers as Record).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"); + }); +}); diff --git a/test/diff.test.ts b/test/diff.test.ts new file mode 100644 index 0000000..a89a4fe --- /dev/null +++ b/test/diff.test.ts @@ -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/); + }); +}); diff --git a/test/envtemplate.test.ts b/test/envtemplate.test.ts new file mode 100644 index 0000000..108c10b --- /dev/null +++ b/test/envtemplate.test.ts @@ -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(); + }); +}); diff --git a/test/fixtures/environments.yaml b/test/fixtures/environments.yaml new file mode 100644 index 0000000..b0e03f8 --- /dev/null +++ b/test/fixtures/environments.yaml @@ -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 diff --git a/test/fixtures/manifest.yaml b/test/fixtures/manifest.yaml new file mode 100644 index 0000000..5c2a3f7 --- /dev/null +++ b/test/fixtures/manifest.yaml @@ -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 diff --git a/test/manifest.test.ts b/test/manifest.test.ts new file mode 100644 index 0000000..bcc021a --- /dev/null +++ b/test/manifest.test.ts @@ -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(); + }); +}); diff --git a/test/resolve.test.ts b/test/resolve.test.ts new file mode 100644 index 0000000..2adb7fa --- /dev/null +++ b/test/resolve.test.ts @@ -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/, + ); + }); +}); diff --git a/test/secrets.test.ts b/test/secrets.test.ts new file mode 100644 index 0000000..8cb5468 --- /dev/null +++ b/test/secrets.test.ts @@ -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; + } + }); +}); diff --git a/test/smoke.test.ts b/test/smoke.test.ts new file mode 100644 index 0000000..60228d6 --- /dev/null +++ b/test/smoke.test.ts @@ -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/, + ); + }); +}); diff --git a/test/wire.test.ts b/test/wire.test.ts new file mode 100644 index 0000000..e540a9c --- /dev/null +++ b/test/wire.test.ts @@ -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 | 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 | 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"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6cfb25f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "declaration": false + }, + "include": ["src"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..808ec69 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { include: ["test/**/*.test.ts"] } }); -- 2.45.2