From 188d5f87b46737fd14b7dacf60b9234a805ee639 Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Tue, 21 Jul 2026 15:10:57 +0700 Subject: [PATCH] feat: integration the github action for deployment --- .../scripts/dokploy-deployment-tracker.mjs | 311 ++++++++++++++++++ .github/workflows/dokploy-deploy.yml | 167 ++++++++++ CHANGELOG.md | 5 + docs/DOKPLOY.md | 68 +++- tests/unit/dokploy-deployment-tracker.test.js | 221 +++++++++++++ 5 files changed, 767 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/dokploy-deployment-tracker.mjs create mode 100644 .github/workflows/dokploy-deploy.yml create mode 100644 tests/unit/dokploy-deployment-tracker.test.js diff --git a/.github/scripts/dokploy-deployment-tracker.mjs b/.github/scripts/dokploy-deployment-tracker.mjs new file mode 100644 index 00000000..474ac2bf --- /dev/null +++ b/.github/scripts/dokploy-deployment-tracker.mjs @@ -0,0 +1,311 @@ +import { appendFile, readFile, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); + +export class DokployApiError extends Error { + constructor(message, { status, retryable = false } = {}) { + super(message); + this.name = "DokployApiError"; + this.status = status; + this.retryable = retryable; + } +} + +export function normalizeDokployUrl(value) { + if (!value?.trim()) { + throw new Error("DOKPLOY_URL is required"); + } + + const url = new URL(value.trim()); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("DOKPLOY_URL must use http or https"); + } + + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +export function buildDeploymentsUrl(dokployUrl, composeId) { + if (!composeId?.trim()) { + throw new Error("DOKPLOY_COMPOSE_ID is required"); + } + + const url = new URL(`${normalizeDokployUrl(dokployUrl)}/api/deployment.allByCompose`); + url.searchParams.set("composeId", composeId.trim()); + return url.toString(); +} + +function asPositiveInteger(value, fallback, name) { + if (value === undefined || value === "") return fallback; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + +function defaultSleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function fetchComposeDeployments({ + dokployUrl, + composeId, + apiToken, + fetchImpl = globalThis.fetch, + sleep = defaultSleep, + requestTimeoutMs = 15_000, + retryDelayMs = 2_000, + maxRetries = 3, +}) { + if (!apiToken) { + throw new Error("DOKPLOY_API_TOKEN is required"); + } + if (typeof fetchImpl !== "function") { + throw new Error("A fetch implementation is required"); + } + + const url = buildDeploymentsUrl(dokployUrl, composeId); + let lastError; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); + + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { + accept: "application/json", + "x-api-key": apiToken, + }, + signal: controller.signal, + }); + + if (!response.ok) { + const retryable = RETRYABLE_STATUS_CODES.has(response.status); + throw new DokployApiError( + `Dokploy deployment API returned HTTP ${response.status}`, + { status: response.status, retryable }, + ); + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + throw new DokployApiError("Dokploy deployment API returned an unexpected response"); + } + + return payload; + } catch (error) { + const retryable = error instanceof DokployApiError + ? error.retryable + : error?.name === "AbortError" || error instanceof TypeError; + lastError = error instanceof DokployApiError + ? error + : new DokployApiError( + error?.name === "AbortError" + ? "Dokploy deployment API request timed out" + : "Unable to reach the Dokploy deployment API", + { retryable }, + ); + + if (!retryable || attempt === maxRetries) { + throw lastError; + } + } finally { + clearTimeout(timeout); + } + + await sleep(retryDelayMs); + } + + throw lastError; +} + +export function createDeploymentSnapshot(deployments, capturedAt = new Date().toISOString()) { + return { + capturedAt, + deploymentIds: deployments + .map((deployment) => deployment?.deploymentId) + .filter((deploymentId) => typeof deploymentId === "string" && deploymentId.length > 0), + }; +} + +export function findNewDeployments(deployments, snapshot) { + const knownIds = new Set(snapshot?.deploymentIds ?? []); + return deployments.filter((deployment) => ( + typeof deployment?.deploymentId === "string" + && deployment.deploymentId.length > 0 + && !knownIds.has(deployment.deploymentId) + )); +} + +export async function waitForDeployment({ + snapshot, + fetchDeployments, + sleep = defaultSleep, + now = Date.now, + pollIntervalMs = 10_000, + discoveryTimeoutMs = 120_000, + deploymentTimeoutMs = 1_800_000, + logger = console, +}) { + if (typeof fetchDeployments !== "function") { + throw new Error("fetchDeployments is required"); + } + + const startedAt = now(); + const discoveryDeadline = startedAt + discoveryTimeoutMs; + const deploymentDeadline = startedAt + deploymentTimeoutMs; + let trackedDeploymentId; + let lastStatus; + + while (now() <= deploymentDeadline) { + const deployments = await fetchDeployments(); + + if (!trackedDeploymentId) { + const newDeployments = findNewDeployments(deployments, snapshot); + if (newDeployments.length > 1) { + const ids = newDeployments.map(({ deploymentId }) => deploymentId).join(", "); + throw new Error(`Multiple new Dokploy deployments were found; correlation is ambiguous: ${ids}`); + } + + if (newDeployments.length === 1) { + trackedDeploymentId = newDeployments[0].deploymentId; + logger.info(`Tracking Dokploy deployment ${trackedDeploymentId}`); + } else if (now() >= discoveryDeadline) { + throw new Error("Timed out waiting for Dokploy to create a deployment record"); + } + } + + if (trackedDeploymentId) { + const deployment = deployments.find(({ deploymentId }) => deploymentId === trackedDeploymentId); + if (deployment) { + const status = deployment.status ?? "unknown"; + if (status !== lastStatus) { + logger.info(`Dokploy deployment ${trackedDeploymentId} status: ${status}`); + lastStatus = status; + } + + if (TERMINAL_STATUSES.has(status)) { + return deployment; + } + } + } + + const remainingMs = deploymentDeadline - now(); + if (remainingMs <= 0) break; + await sleep(Math.min(pollIntervalMs, remainingMs)); + } + + throw new Error( + trackedDeploymentId + ? `Timed out waiting for Dokploy deployment ${trackedDeploymentId} to finish` + : "Timed out waiting for a Dokploy deployment", + ); +} + +function outputValue(value) { + if (value === null || value === undefined) return ""; + return typeof value === "string" ? value : JSON.stringify(value); +} + +export function escapeGitHubCommandValue(value) { + return outputValue(value) + .replaceAll("%", "%25") + .replaceAll("\r", "%0D") + .replaceAll("\n", "%0A"); +} + +export async function writeGitHubOutputs(filePath, outputs) { + if (!filePath) return; + + let content = ""; + for (const [name, rawValue] of Object.entries(outputs)) { + const value = outputValue(rawValue); + const delimiter = `DOKPLOY_${randomUUID()}`; + content += `${name}<<${delimiter}\n${value}\n${delimiter}\n`; + } + await appendFile(filePath, content, "utf8"); +} + +function deploymentOutputs(deployment) { + return { + deployment_id: deployment?.deploymentId, + status: deployment?.status, + title: deployment?.title, + created_at: deployment?.createdAt, + started_at: deployment?.startedAt, + finished_at: deployment?.finishedAt, + error_message: deployment?.errorMessage, + }; +} + +async function runCli() { + const command = process.argv[2]; + const dokployUrl = process.env.DOKPLOY_URL; + const composeId = process.env.DOKPLOY_COMPOSE_ID; + const apiToken = process.env.DOKPLOY_API_TOKEN; + const snapshotFile = process.env.DOKPLOY_SNAPSHOT_FILE; + + if (!snapshotFile) { + throw new Error("DOKPLOY_SNAPSHOT_FILE is required"); + } + + const requestOptions = { + dokployUrl, + composeId, + apiToken, + requestTimeoutMs: asPositiveInteger(process.env.DOKPLOY_REQUEST_TIMEOUT_MS, 15_000, "DOKPLOY_REQUEST_TIMEOUT_MS"), + retryDelayMs: asPositiveInteger(process.env.DOKPLOY_RETRY_DELAY_MS, 2_000, "DOKPLOY_RETRY_DELAY_MS"), + maxRetries: asPositiveInteger(process.env.DOKPLOY_MAX_RETRIES, 3, "DOKPLOY_MAX_RETRIES"), + }; + + if (command === "snapshot") { + const deployments = await fetchComposeDeployments(requestOptions); + const snapshot = createDeploymentSnapshot(deployments); + await writeFile(snapshotFile, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + console.info(`Captured ${snapshot.deploymentIds.length} existing Dokploy deployment IDs`); + return; + } + + if (command === "wait") { + const snapshot = JSON.parse(await readFile(snapshotFile, "utf8")); + const deployment = await waitForDeployment({ + snapshot, + fetchDeployments: () => fetchComposeDeployments(requestOptions), + pollIntervalMs: asPositiveInteger(process.env.DOKPLOY_POLL_INTERVAL_MS, 10_000, "DOKPLOY_POLL_INTERVAL_MS"), + discoveryTimeoutMs: asPositiveInteger(process.env.DOKPLOY_DISCOVERY_TIMEOUT_MS, 120_000, "DOKPLOY_DISCOVERY_TIMEOUT_MS"), + deploymentTimeoutMs: asPositiveInteger(process.env.DOKPLOY_DEPLOYMENT_TIMEOUT_MS, 1_800_000, "DOKPLOY_DEPLOYMENT_TIMEOUT_MS"), + }); + + await writeGitHubOutputs(process.env.GITHUB_OUTPUT, deploymentOutputs(deployment)); + + if (deployment.status !== "done") { + const detail = deployment.errorMessage ? `: ${deployment.errorMessage}` : ""; + throw new Error(`Dokploy deployment ${deployment.deploymentId} ended with status ${deployment.status}${detail}`); + } + + console.info(`Dokploy deployment ${deployment.deploymentId} completed successfully`); + return; + } + + throw new Error("Usage: dokploy-deployment-tracker.mjs "); +} + +const isCliEntry = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isCliEntry) { + runCli().catch((error) => { + console.error( + `::error title=Dokploy deployment tracking failed::${escapeGitHubCommandValue(error.message)}`, + ); + process.exitCode = 1; + }); +} diff --git a/.github/workflows/dokploy-deploy.yml b/.github/workflows/dokploy-deploy.yml new file mode 100644 index 00000000..952e88cc --- /dev/null +++ b/.github/workflows/dokploy-deploy.yml @@ -0,0 +1,167 @@ +name: Deploy to Dokploy + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: dokploy-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Deploy production + runs-on: ubuntu-latest + timeout-minutes: 40 + environment: + name: production + url: ${{ vars.NINEROUTER_PUBLIC_URL }} + env: + DOKPLOY_URL: ${{ vars.DOKPLOY_URL }} + DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }} + DOKPLOY_API_TOKEN: ${{ secrets.DOKPLOY_API_TOKEN }} + DOKPLOY_SNAPSHOT_FILE: ${{ runner.temp }}/dokploy-deployments-before.json + + steps: + - name: Checkout deployment tooling + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Validate deployment configuration + env: + PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }} + run: | + node <<'NODE' + const required = [ + "DOKPLOY_URL", + "DOKPLOY_COMPOSE_ID", + "DOKPLOY_API_TOKEN", + "PUBLIC_URL", + ]; + + for (const name of required) { + if (!process.env[name]?.trim()) { + throw new Error(`${name} is required`); + } + } + + for (const name of ["DOKPLOY_URL", "PUBLIC_URL"]) { + const url = new URL(process.env[name]); + if (url.protocol !== "https:") { + throw new Error(`${name} must use HTTPS`); + } + } + NODE + + - name: Capture deployment baseline + run: node .github/scripts/dokploy-deployment-tracker.mjs snapshot + + - name: Trigger Dokploy Compose deployment + id: trigger + uses: benbristow/dokploy-deploy-action@0.2.2 + with: + dokploy_url: ${{ vars.DOKPLOY_URL }} + api_token: ${{ secrets.DOKPLOY_API_TOKEN }} + application_id: ${{ vars.DOKPLOY_COMPOSE_ID }} + service_type: compose + + - name: Wait for Dokploy deployment + id: deployment + run: node .github/scripts/dokploy-deployment-tracker.mjs wait + + - name: Verify production health + id: health + env: + PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }} + run: | + node <<'NODE' + const publicUrl = process.env.PUBLIC_URL?.trim(); + if (!publicUrl) { + throw new Error("NINEROUTER_PUBLIC_URL is required"); + } + + const healthUrl = new URL("/api/health", publicUrl).toString(); + const attempts = 18; + const intervalMs = 10_000; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(healthUrl, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + const body = response.ok ? await response.json() : null; + if (response.ok && body?.ok === true) { + console.info(`Production health check passed: ${healthUrl}`); + process.exit(0); + } + console.warn(`Health check ${attempt}/${attempts} returned HTTP ${response.status}`); + } catch (error) { + console.warn(`Health check ${attempt}/${attempts} failed: ${error.message}`); + } finally { + clearTimeout(timeout); + } + + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + throw new Error(`Production health check failed after ${attempts} attempts: ${healthUrl}`); + NODE + + - name: Publish deployment summary + if: always() + env: + JOB_STATUS: ${{ job.status }} + DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }} + DEPLOYMENT_STATUS: ${{ steps.deployment.outputs.status }} + DEPLOYMENT_TITLE: ${{ steps.deployment.outputs.title }} + DEPLOYMENT_CREATED_AT: ${{ steps.deployment.outputs.created_at }} + DEPLOYMENT_STARTED_AT: ${{ steps.deployment.outputs.started_at }} + DEPLOYMENT_FINISHED_AT: ${{ steps.deployment.outputs.finished_at }} + DEPLOYMENT_ERROR: ${{ steps.deployment.outputs.error_message }} + PUBLIC_URL: ${{ vars.NINEROUTER_PUBLIC_URL }} + run: | + sanitize_summary_value() { + printf '%s' "$1" | tr '\r\n|' ' ' + } + + safe_title=$(sanitize_summary_value "$DEPLOYMENT_TITLE") + safe_error=$(sanitize_summary_value "$DEPLOYMENT_ERROR") + + { + echo "## Dokploy production deployment" + echo + echo "| Field | Value |" + echo "| --- | --- |" + printf '| Workflow result | `%s` |\n' "${JOB_STATUS:-unknown}" + printf '| Git ref | `%s` |\n' "$GITHUB_REF_NAME" + printf '| Commit | `%s` |\n' "$GITHUB_SHA" + printf '| Compose ID | `%s` |\n' "$DOKPLOY_COMPOSE_ID" + printf '| Deployment ID | `%s` |\n' "${DEPLOYMENT_ID:-not discovered}" + printf '| Deployment status | `%s` |\n' "${DEPLOYMENT_STATUS:-unknown}" + printf '| Created | `%s` |\n' "${DEPLOYMENT_CREATED_AT:-unknown}" + printf '| Started | `%s` |\n' "${DEPLOYMENT_STARTED_AT:-unknown}" + printf '| Finished | `%s` |\n' "${DEPLOYMENT_FINISHED_AT:-unknown}" + printf '| Health endpoint | %s/api/health |\n' "${PUBLIC_URL%/}" + + if [ -n "$safe_title" ]; then + printf '\n**Deployment:** %s\n' "$safe_title" + fi + if [ -n "$safe_error" ]; then + printf '\n**Dokploy error:** %s\n' "$safe_error" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index e080cb8e..c5cab0f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +## DevOps +- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries + # v0.5.35 (2026-07-16) ## Fixes diff --git a/docs/DOKPLOY.md b/docs/DOKPLOY.md index fbb1d024..707cf8ea 100644 --- a/docs/DOKPLOY.md +++ b/docs/DOKPLOY.md @@ -25,14 +25,20 @@ first successful deployment**, or Dokploy will mount a new, empty volume. ## 1. Create the Dokploy application 1. Create a **Compose** application in Dokploy and connect this repository. -2. Select the branch that should deploy (for example, `main`). +2. Select the `master` branch. 3. Use the repository root and `docker-compose.yml` as the Compose file. -4. Enable automatic deployment on pushes for the selected branch. +4. Leave Dokploy **Auto Deploy** disabled. GitHub Actions is the deployment + authority for this setup. + +Do not configure a Dokploy deployment webhook for the same branch. Enabling +both Dokploy Auto Deploy and the GitHub workflow can create two deployments for +one push and prevents the workflow from reliably identifying the rollout it +triggered. The Compose file contains a `build` section, so every deploy builds the image from the exact checked-out commit rather than pulling a published image. -The `9router` service is also limited to **0.5 CPU** and **2 GB RAM** through +The `9router` service is also limited to **0.5 CPU** and **512 MB RAM** through the Compose `deploy.resources.limits` configuration. The limit is per `9router` container instance; the optional `headroom` sidecar has independent resource usage. @@ -65,12 +71,64 @@ Add the application's public domain in Dokploy and target the `9router` service on internal port `20128`. Let Dokploy/Traefik terminate TLS. With TLS enabled, retain `AUTH_COOKIE_SECURE=true`. -## 4. Deploy and verify persistence +## 4. Configure GitHub Actions deployment + +Create a protected GitHub Environment named `production`. Restrict it to the +`master` branch and optionally require a reviewer before production +deployments. + +Add these environment values: + +| Name | Kind | Value | +| --- | --- | --- | +| `DOKPLOY_API_TOKEN` | Secret | API key generated from the Dokploy profile settings | +| `DOKPLOY_URL` | Variable | Public Dokploy base URL without a trailing slash, for example `https://dokploy.example.com` | +| `DOKPLOY_COMPOSE_ID` | Variable | ID of this Compose service in Dokploy | +| `NINEROUTER_PUBLIC_URL` | Variable | Public 9Router URL used for the post-deployment health check | + +Both URL variables must use HTTPS. + +The API key must be allowed to deploy the Compose service and read its +deployment records. GitHub-hosted runners must be able to reach the Dokploy API +over HTTPS. If the trigger or tracking request consistently returns HTTP 403, +check whether Cloudflare bot protection is challenging Dokploy API requests. + +The Compose ID can be found in the Dokploy service URL. The workflow passes it +to the action's `application_id` input because +`benbristow/dokploy-deploy-action@0.2.2` uses that input for both Applications +and Compose services. `service_type: compose` selects the Compose API. + +The workflow in `.github/workflows/dokploy-deploy.yml` runs for pushes to +`master` and can also be started with **Run workflow**. It performs these steps: + +1. Records the deployment IDs that already exist for the Compose service. +2. Triggers the Compose deployment with + `benbristow/dokploy-deploy-action@0.2.2`. +3. Finds the new deployment and waits for `done`, `error`, or `cancelled`. +4. After `done`, retries `GET /api/health` until it returns `{ "ok": true }`. +5. Writes deployment metadata and the final result to the GitHub job summary. + +The action itself only confirms that Dokploy accepted the deploy request with +HTTP 200. The repository's tracking script performs the actual progress and +final-status checks. It does not report a percentage or copy full Dokploy build +logs into GitHub; use the Dokploy deployment page for detailed build logs. + +Production deployments are serialized. Do not manually start another deploy +for this Compose service while the GitHub workflow is running. If multiple new +deployment records appear, the workflow fails safely instead of tracking an +ambiguous deployment. + +Runtime secrets such as `JWT_SECRET`, `INITIAL_PASSWORD`, `API_KEY_SECRET`, and +`MACHINE_ID_SALT` remain in Dokploy. They do not need to be copied into the +GitHub Environment. + +## 5. Deploy and verify persistence 1. Run the first deployment, open `/dashboard`, and sign in. 2. Add a provider connection or a model combo, then change a setting. 3. Push a harmless commit to the configured branch. -4. Wait for Dokploy's automatic deployment to finish and sign in again. +4. Wait for the GitHub Actions deployment and health check to finish, then sign + in again. 5. Verify the provider, combo, and setting are still present. If the dashboard is empty after a redeploy, check that the running service has diff --git a/tests/unit/dokploy-deployment-tracker.test.js b/tests/unit/dokploy-deployment-tracker.test.js new file mode 100644 index 00000000..7d7f959a --- /dev/null +++ b/tests/unit/dokploy-deployment-tracker.test.js @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DokployApiError, + buildDeploymentsUrl, + createDeploymentSnapshot, + escapeGitHubCommandValue, + fetchComposeDeployments, + findNewDeployments, + normalizeDokployUrl, + waitForDeployment, +} from "../../.github/scripts/dokploy-deployment-tracker.mjs"; + +const DOKPLOY_URL = "https://dokploy.example.com"; +const COMPOSE_ID = "compose/id with spaces"; +const API_TOKEN = "super-secret-token"; + +function response(payload, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: vi.fn().mockResolvedValue(payload), + }; +} + +function deployment(deploymentId, status = "running", overrides = {}) { + return { + deploymentId, + status, + title: `Deployment ${deploymentId}`, + createdAt: "2026-07-21T10:00:00.000Z", + ...overrides, + }; +} + +function createClock() { + let time = 0; + return { + now: () => time, + sleep: vi.fn(async (ms) => { + time += ms; + }), + }; +} + +describe("Dokploy deployment tracker", () => { + it("normalizes the base URL and safely encodes the compose ID", () => { + expect(normalizeDokployUrl(" https://dokploy.example.com/// ")).toBe(DOKPLOY_URL); + + const url = new URL(buildDeploymentsUrl(`${DOKPLOY_URL}/`, COMPOSE_ID)); + expect(url.pathname).toBe("/api/deployment.allByCompose"); + expect(url.searchParams.get("composeId")).toBe(COMPOSE_ID); + }); + + it("escapes untrusted text before writing a GitHub workflow command", () => { + expect(escapeGitHubCommandValue("build 50%\r\n::warning::unsafe")) + .toBe("build 50%25%0D%0A::warning::unsafe"); + }); + + it("creates a snapshot and finds only unseen deployments", () => { + const existing = deployment("existing", "done"); + const snapshot = createDeploymentSnapshot([existing, {}, null], "captured-at"); + + expect(snapshot).toEqual({ + capturedAt: "captured-at", + deploymentIds: ["existing"], + }); + expect(findNewDeployments([existing, deployment("new")], snapshot)) + .toEqual([deployment("new")]); + }); + + it("sends the API key in a header without exposing it in errors", async () => { + const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 403 })); + + await expect(fetchComposeDeployments({ + dokployUrl: DOKPLOY_URL, + composeId: COMPOSE_ID, + apiToken: API_TOKEN, + fetchImpl, + maxRetries: 0, + })).rejects.toMatchObject({ + message: "Dokploy deployment API returned HTTP 403", + status: 403, + }); + + const [, request] = fetchImpl.mock.calls[0]; + expect(request.headers["x-api-key"]).toBe(API_TOKEN); + expect(fetchImpl.mock.calls[0][0]).not.toContain(API_TOKEN); + }); + + it("retries transient HTTP errors and network failures", async () => { + const sleep = vi.fn().mockResolvedValue(undefined); + const fetchImpl = vi.fn() + .mockResolvedValueOnce(response([], { ok: false, status: 503 })) + .mockRejectedValueOnce(new TypeError("network failed")) + .mockResolvedValueOnce(response([deployment("new")])); + + await expect(fetchComposeDeployments({ + dokployUrl: DOKPLOY_URL, + composeId: COMPOSE_ID, + apiToken: API_TOKEN, + fetchImpl, + sleep, + retryDelayMs: 1, + maxRetries: 2, + })).resolves.toEqual([deployment("new")]); + + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("does not retry authentication and configuration failures", async () => { + const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 401 })); + const sleep = vi.fn(); + + await expect(fetchComposeDeployments({ + dokployUrl: DOKPLOY_URL, + composeId: COMPOSE_ID, + apiToken: API_TOKEN, + fetchImpl, + sleep, + })).rejects.toBeInstanceOf(DokployApiError); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("tracks a new deployment from running to done", async () => { + const existing = deployment("existing", "done"); + const running = deployment("new", "running"); + const done = deployment("new", "done", { finishedAt: "finished-at" }); + const fetchDeployments = vi.fn() + .mockResolvedValueOnce([existing]) + .mockResolvedValueOnce([running, existing]) + .mockResolvedValueOnce([done, existing]); + const clock = createClock(); + const logger = { info: vi.fn() }; + + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([existing]), + fetchDeployments, + sleep: clock.sleep, + now: clock.now, + pollIntervalMs: 10, + discoveryTimeoutMs: 100, + deploymentTimeoutMs: 200, + logger, + })).resolves.toEqual(done); + + expect(logger.info).toHaveBeenCalledWith("Tracking Dokploy deployment new"); + expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: running"); + expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: done"); + }); + + it("accepts a new deployment that is already done", async () => { + const done = deployment("new", "done"); + + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([]), + fetchDeployments: vi.fn().mockResolvedValue([done]), + pollIntervalMs: 1, + discoveryTimeoutMs: 10, + deploymentTimeoutMs: 20, + logger: { info: vi.fn() }, + })).resolves.toEqual(done); + }); + + it.each(["error", "cancelled"])("returns terminal %s deployment details", async (status) => { + const failed = deployment("new", status, { errorMessage: "Build failed" }); + + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([]), + fetchDeployments: vi.fn().mockResolvedValue([failed]), + pollIntervalMs: 1, + discoveryTimeoutMs: 10, + deploymentTimeoutMs: 20, + logger: { info: vi.fn() }, + })).resolves.toEqual(failed); + }); + + it("fails rather than guessing when multiple new deployments appear", async () => { + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([]), + fetchDeployments: vi.fn().mockResolvedValue([ + deployment("new-a"), + deployment("new-b"), + ]), + logger: { info: vi.fn() }, + })).rejects.toThrow("correlation is ambiguous: new-a, new-b"); + }); + + it("times out while waiting for a new deployment record", async () => { + const clock = createClock(); + + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([]), + fetchDeployments: vi.fn().mockResolvedValue([]), + sleep: clock.sleep, + now: clock.now, + pollIntervalMs: 10, + discoveryTimeoutMs: 20, + deploymentTimeoutMs: 100, + logger: { info: vi.fn() }, + })).rejects.toThrow("Timed out waiting for Dokploy to create a deployment record"); + }); + + it("times out when a deployment stays running", async () => { + const clock = createClock(); + + await expect(waitForDeployment({ + snapshot: createDeploymentSnapshot([]), + fetchDeployments: vi.fn().mockResolvedValue([deployment("new")]), + sleep: clock.sleep, + now: clock.now, + pollIntervalMs: 10, + discoveryTimeoutMs: 20, + deploymentTimeoutMs: 30, + logger: { info: vi.fn() }, + })).rejects.toThrow("Timed out waiting for Dokploy deployment new to finish"); + }); +});