From 1f229692d0201c40480d7bd2c09ddfa0f3f7b4f6 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 07:49:44 +0300 Subject: [PATCH 1/8] docs: add ADR-0014 symlink activation, update storage and caddy docs --- CONTEXT.md | 23 +++---- docs/adr/0006-static-caddy-config.md | 14 ++--- docs/adr/0013-volume-based-storage.md | 28 ++++++--- docs/adr/0014-symlink-activation-model.md | 73 +++++++++++++++++++++++ 4 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 docs/adr/0014-symlink-activation-model.md diff --git a/CONTEXT.md b/CONTEXT.md index 1a7e199..8884095 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -93,28 +93,21 @@ All five build packs produce long-lived containers with health checks and automa ## Storage & Routing -### Object Storage +### Volume-Based Storage -S3-compatible blob storage for deployed artifacts. Abstracted behind an `uploadToObjectStorage()` interface using the AWS S3 SDK with `forcePathStyle: true`. Garage for local dev; Garage (self-hosted) or Cloudflare R2 for production. No hardcoded S3-specific logic. +A shared Docker named volume (`shipyard_sites`) mounted in the worker and Caddy containers. Each deployment's build output lives at `sites/{appId}/{deploymentId}/`. A symlink at `sites/{appId}/current` points to the active deployment. Caddy's `file_server` root is permanently `sites/{appId}/current`. -### S3 Path Structure +### Symlink Activation -`/users/{userId}/apps/{appId}/deployments/{deploymentId}/{filepath}` +A symlink at `sites/{appId}/current` points to the currently-active deployment's artifact directory. Swap the symlink atomically on deploy or rollback — no Caddy API call needed. Caddy root is set once at first deploy and never changes. ### Deployment Retention -The newest 5 deployments per App are kept in object storage. Older deployments are deleted automatically after each successful deploy. Enables rollback without unlimited storage growth. - -### Deployment Retention Rules - -- Keep metadata in database forever (deployments table, build_jobs table, deployment_logs) -- Delete files from object storage only (after successful deploy) -- Check before deleting: if deployment.id == app.active_deployment_id, skip delete -- Deletion is async background job (queued after deploy succeeds) +After each successful deploy, the newest `KEEP_COUNT` (default 5) deployment subdirectories per App are kept. Older directories are deleted. Building deployments are excluded from pruning. Pruned deployments have `prunedAt` set in the database for queryable rollback eligibility. ### Active Deployment -The currently-live Deployment for an App, referenced by `active_deployment_id` in the App record. Rollback changes this pointer without modifying object storage. +The currently-live Deployment for an App, referenced by `active_deployment_id` in the App record. Rollback changes this pointer AND swaps the `current` symlink to the target deployment's artifact directory. See ADR-0014. ### SPA Fallback @@ -124,6 +117,8 @@ Every `404` response from Caddy returns `index.html`, enabling client-side routi Dynamic Caddy configuration via JSON API at deploy time (not at request time). Config is sent to Caddy's `/config/` API endpoint. After update, Caddy automatically reloads. Built-in auto-HTTPS with Let's Encrypt for wildcard certs. +For static sites, Caddy config is set once at first deploy (root: `sites/{appId}/current`). Subsequent deploys and rollbacks update the symlink — no Caddy API call needed. See ADR-0014. + ## Infrastructure Philosophy ### Deployment Control Plane @@ -142,7 +137,7 @@ Redis-backed job queue via BullMQ. A Job contains only the `deployment_id`; all ### Worker -A Node.1.js process that connects to BullMQ, picks up Jobs, executes the build pipeline, updates Deployment status in the database, and regenerates the Caddy config. Stateless per-job execution. +A Node.js process that connects to BullMQ, picks up Jobs, executes the build pipeline, updates Deployment status in the database, and generates Caddy config on first deploy (static sites) or on every deploy (server containers). Stateless per-job execution. ### Heartbeat diff --git a/docs/adr/0006-static-caddy-config.md b/docs/adr/0006-static-caddy-config.md index 47e09a2..b540319 100644 --- a/docs/adr/0006-static-caddy-config.md +++ b/docs/adr/0006-static-caddy-config.md @@ -2,7 +2,7 @@ ## Status -Accepted (updated) +Accepted (updated by ADR-0014) ## Context @@ -30,9 +30,9 @@ POST /config/apps/http/servers/srv0/routes → create new route Static config generation at deploy time: -1. Deployment succeeds (or rollback triggered) -2. Read `active_deployment_id` from DB -3. Generate Caddy JSON config: +1. First deploy: generate and send Caddy JSON config (replaces any previous route for `app-{appId}`) +2. Subsequent deploys: Caddy root is already `sites/{appId}/current` — no Caddy API call needed for static sites +3. Rollback: symlink swap (per ADR-0014) — no Caddy API call at all **Static sites (isStatic=true):** ```json @@ -40,9 +40,9 @@ Static config generation at deploy time: "@id": "app-{appId}", "match": [{"host": ["myapp.bigboss.dev"]}], "handle": [ - {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}", "pass_thru": true}, + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}/current", "pass_thru": true}, {"handler": "rewrite", "uri": "/index.html"}, - {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}"} + {"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}/current"} ], "terminal": true } @@ -68,7 +68,7 @@ The `pass_thru` on the first `file_server` lets unmatched requests fall through ## Consequences - Zero latency overhead per request (no DB query, no subrequest). -- Rollback is instant from user perspective (config reload is ~milliseconds). +- Rollback is instant from user perspective (symlink swap, no Caddy interaction per ADR-0014). - Adding/removing apps requires config regeneration + reload (acceptable for low-write system). - Adding custom domains later requires regenerating all configs (known trade-off, acceptable). - No need for OpenResty or Lua — plain Caddy works. diff --git a/docs/adr/0013-volume-based-storage.md b/docs/adr/0013-volume-based-storage.md index ff74a38..6f1c30f 100644 --- a/docs/adr/0013-volume-based-storage.md +++ b/docs/adr/0013-volume-based-storage.md @@ -1,6 +1,6 @@ # ADR-0013: Volume-Based Storage for Static File Serving ## Status -Accepted +Accepted (updated by ADR-0014) ## Context The original design (ADR-0003) used Garage (S3-compatible storage) as the primary storage layer, with Caddy reverse-proxying to Garage's S3 API. This introduced complexity: @@ -14,13 +14,15 @@ For a self-hosted deployment platform serving static sites, this adds unnecessar ## Decision - **Storage**: Use a shared Docker named volume (`shipyard_sites`) mounted in both the worker and Caddy containers. -- **Upload**: After build, worker copies output to `/var/lib/shipyard/sites/{appId}/`. -- **Serving**: Caddy uses `file_server` handler pointing to the app's directory - no proxy, no auth. -- **Retention**: Not implemented for volume storage. Each deploy overwrites the previous (latest deployment always live). +- **Upload**: After build, worker copies output to `/var/lib/shipyard/sites/{appId}/{deploymentId}/`. +- **Activation**: A symlink at `sites/{appId}/current` points to the active deployment's directory. Swap the symlink on rollback or re-deploy. +- **Serving**: Caddy's `file_server` root points to `sites/{appId}/current` — set once at first deploy, unchanged on rollback. +- **Retention**: Per-deployment subdirectories are pruned after each successful deploy (keep newest `KEEP_COUNT`, default 5). Building deployments excluded. ## Architecture ``` -Worker builds → copies to shared volume → Caddy serves via file_server +Worker builds → extracts to sites/{appId}/{deploymentId}/ + → ln -sfn {deploymentId} current → Caddy serves via sites/{appId}/current ``` ### Docker Compose @@ -38,6 +40,15 @@ services: - shipyard_sites:/var/lib/shipyard/sites:ro ``` +### Symlink layout +``` +sites/{appId}/ +├── current/ → symlink to active deployment +├── {deploymentIdX}/ → files from deployment X +├── {deploymentIdY}/ → files from deployment Y (pruned after KEEP_COUNT) +└── ... +``` + ### Caddy Route (per-app) ```json { @@ -45,7 +56,7 @@ services: "match": [{ "host": ["{domain}"] }], "handle": [{ "handler": "file_server", - "root": "/var/lib/shipyard/sites/{appId}" + "root": "/var/lib/shipyard/sites/{appId}/current" }], "terminal": true } @@ -58,12 +69,13 @@ For SPAs: use subroute with error fallback to rewrite to `/index.html`. - Zero auth complexity - Caddy reads directly from filesystem - Simple deployment - no Garage cluster to manage - Fast serving - filesystem I/O vs network round-trip to S3 - - No retention needed - latest deployment always serves + - Symlink activation — atomic, no Caddy API call on rollback per ADR-0014 + - Per-deployment artifact isolation — immutable historical builds - **Cons:** - No built-in redundancy - volume is server-local - No content hashing/deduplication (schema exists but unused) - - Rollback loses previous deployment's files (only DB pointer changes) + - Per-deployment retention requires pruning (background job per deploy) ## Alternatives Considered - **Garage with Web API (port 3902)**: Requires `root_domain` config, bucket website flag, host header manipulation in Caddy - fragile. diff --git a/docs/adr/0014-symlink-activation-model.md b/docs/adr/0014-symlink-activation-model.md new file mode 100644 index 0000000..22dc9e9 --- /dev/null +++ b/docs/adr/0014-symlink-activation-model.md @@ -0,0 +1,73 @@ +# ADR-0014: Symlink-Based Activation for Static Sites + +Rollback needs per-deployment artifacts and atomic activation. Use per-deployment subdirectories (`sites/{appId}/{deploymentId}/`) and a symlink (`sites/{appId}/current`) pointing to the active deployment. Caddy root is permanently `sites/{appId}/current` — never changes on rollback or re-deploy. + +## Status + +Accepted + +## Context + +ADR-0013 used a flat `sites/{appId}/` directory that every deploy overwrote. Caddy's `root` pointed directly at it. Rollback required changing `apps.activeDeploymentId` in the DB plus a Caddy API call to update the root path — adding latency, a config-generation dependency, and a race window between two deploys finishing concurrently. + +Issue #24 identified that files on disk were never reverted on rollback — only the DB pointer changed. + +## Decision + +### Storage layout + +``` +sites/{appId}/ +├── current/ → symlink to the active deployment +├── {deploymentId}/ → files from deployment X +├── {deploymentId}/ → files from deployment Y (pruned after KEEP_COUNT) +└── ... +``` + +### Activation + +Deploy flow: +1. Build completes → artifacts extracted to `sites/{appId}/{deploymentId}/` +2. `ln -sfn {deploymentId} current` in `sites/{appId}/` +3. `UPDATE apps SET activeDeploymentId = $id` + +Rollback flow: +1. Validate `isRollbackable`: `status === 'success' AND prunedAt IS NULL` +2. `ln -sfn {targetDeploymentId} current` +3. `UPDATE apps SET activeDeploymentId = $targetId` + +### Caddy + +Root permanently points to `sites/{appId}/current` — configured once at first deploy, never touched again for static sites. Caddy is uninvolved in rollback. + +### First-deploy null state + +Before the first successful deploy, `sites/{appId}/current` does not exist. Caddy returns 404 for the app's domain. No placeholder files or fallback routes. + +### Retention + +After each successful deploy, prune the app's deployment subdirectories keeping the newest `KEEP_COUNT` (configurable, default 5). Building deployments are excluded from pruning. Pruned directories are deleted, `deployments.prunedAt` is set, and per-deployment Caddy routes (see #43) are removed. + +## Consequences + +- **Atomic activation** — `ln -sfn` is atomic on Linux. No partial state exposed. +- **Rollback is a filesystem operation** — no Caddy API call, no config race, no latency from infra orchestration. +- **Deploy and activation are now distinct** — a deployment can succeed (artifacts exist) without being active. Activation failure is a system-level error (disk full, permissions) that throws. +- **No Caddy interaction on rollback** — eliminates the race window between concurrent deploys updating Caddy config. +- **Symlink is the single source of runtime truth** — mirrors `apps.activeDeploymentId` at the filesystem level. +- **Prune becomes the authority for artifact invalidation** — `prunedAt` column makes rollback eligibility queryable without filesystem probing. + +## Considered Options + +1. **Per-deployment subdirectories + Caddy root pointing to active deployment** — rollback requires Caddy API call. Slower, adds race window, couples activation to proxy orchestration. Rejected for #24. + +2. **Symlink approach (selected)** — described above. Atomic, Caddy-uninvolved rollback, simple. + +3. **Re-build on rollback** — re-clone the commit and rebuild. Guaranteed artifacts but slow. Useful for container rollback (Dockerfile/nixpacks server) where old images may be pruned, but unnecessary for static file rollback. + +## Related + +- Supersedes the rollback assumptions in ADR-0013 (consequence: "Rollback loses previous deployment's files") +- Updates ADR-0006 (rollback no longer triggers Caddy config) +- Enables #43 (multi-version routing via per-deployment routes) +- Enables #42 (preview deployments via per-branch subdirectories) From e132578b2158facf99387e12410de6cb8ecc115a Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 08:47:33 +0300 Subject: [PATCH 2/8] feat: add deployment rollback with symlink activation Backend: prunedAt column, per-deployment subdirs, symlink activation, pruning (KEEP_COUNT=5), POST /api/deployments/:id/rollback, Caddy root -> {appId}/current Worker: extractStaticOutput per-deployment, fix nixpacks --env-file -> --env (v1.41.0 compat) Frontend: rollback button with confirm dialog, GitHub URL auto-parse (owner/repo/branch/subdir), subdir dropdown for coolify-examples Infra: API mounts shipyard_sites volume Closes #24 --- docker-compose.yaml | 3 + drizzle/0008_nosy_wrecking_crew.sql | 1 + drizzle/meta/0008_snapshot.json | 1116 +++++++++++++++++ drizzle/meta/_journal.json | 7 + packages/api/src/config/env.ts | 1 + packages/api/src/routes/deployments.ts | 45 + packages/api/src/services/deployments.ts | 73 ++ packages/shared/src/schema.ts | 1 + packages/web/src/hooks/useDeployments.ts | 27 + packages/web/src/pages/Dashboard.tsx | 82 +- packages/worker/src/config/env.ts | 5 + packages/worker/src/deployments/storage.ts | 80 ++ .../src/deployments/strategies/nixpacks.ts | 23 +- .../infrastructure/caddy/config-builder.ts | 2 +- .../unit/infrastructure/caddy-config.test.ts | 6 +- 15 files changed, 1447 insertions(+), 25 deletions(-) create mode 100644 drizzle/0008_nosy_wrecking_crew.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 packages/worker/src/deployments/storage.ts diff --git a/docker-compose.yaml b/docker-compose.yaml index 64f6741..ad0c9b6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -74,6 +74,9 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:-32_char_encryption_key_here_xxxx} NODE_ENV: ${NODE_ENV:-production} PORT: ${PORT:-3000} + SITES_DIR: /var/lib/shipyard/sites + volumes: + - shipyard_sites:/var/lib/shipyard/sites:ro depends_on: postgres: condition: service_healthy diff --git a/drizzle/0008_nosy_wrecking_crew.sql b/drizzle/0008_nosy_wrecking_crew.sql new file mode 100644 index 0000000..7ea1dc9 --- /dev/null +++ b/drizzle/0008_nosy_wrecking_crew.sql @@ -0,0 +1 @@ +ALTER TABLE "deployments" ADD COLUMN "pruned_at" timestamp; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..9ff8daa --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1116 @@ +{ + "id": "2e526fc2-7a81-4b5b-9e3e-4b2d136a3321", + "prevId": "4a94366c-d07d-4188-9d37-66d879a8b8fc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.apps": { + "name": "apps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_repo": { + "name": "github_repo", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "build_command": { + "name": "build_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "output_dir": { + "name": "output_dir", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "subdirectory": { + "name": "subdirectory", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_static": { + "name": "is_static", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "branch": { + "name": "branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "build_timeout": { + "name": "build_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 900 + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "build_pack": { + "name": "build_pack", + "type": "build_pack", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nixpacks'" + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 80 + }, + "run_command": { + "name": "run_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "install_command": { + "name": "install_command", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "dockerfile_path": { + "name": "dockerfile_path", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "'./Dockerfile'" + }, + "is_spa": { + "name": "is_spa", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "custom_nginx_config": { + "name": "custom_nginx_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_apps_org_id": { + "name": "idx_apps_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_apps_org_name_unique": { + "name": "idx_apps_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apps_organization_id_organizations_id_fk": { + "name": "apps_organization_id_organizations_id_fk", + "tableFrom": "apps", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.build_jobs": { + "name": "build_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_build_jobs_deployment_id": { + "name": "idx_build_jobs_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "build_jobs_deployment_id_deployments_id_fk": { + "name": "build_jobs_deployment_id_deployments_id_fk", + "tableFrom": "build_jobs", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_files": { + "name": "deployment_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_files_deployment_id": { + "name": "idx_deployment_files_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_files_deployment_id_deployments_id_fk": { + "name": "deployment_files_deployment_id_deployments_id_fk", + "tableFrom": "deployment_files", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_logs": { + "name": "deployment_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_logs_deployment_id": { + "name": "idx_deployment_logs_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_logs_deployment_id_deployments_id_fk": { + "name": "deployment_logs_deployment_id_deployments_id_fk", + "tableFrom": "deployment_logs", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "commit_message": { + "name": "commit_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "detected_framework": { + "name": "detected_framework", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "output_dir": { + "name": "output_dir", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pruned_at": { + "name": "pruned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployments_app_id": { + "name": "idx_deployments_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_status": { + "name": "idx_deployments_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_app_id_apps_id_fk": { + "name": "deployments_app_id_apps_id_fk", + "tableFrom": "deployments", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_app_id": { + "name": "idx_domains_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_domains_domain": { + "name": "idx_domains_domain", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_app_id_apps_id_fk": { + "name": "domains_app_id_apps_id_fk", + "tableFrom": "domains", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_domain_unique": { + "name": "domains_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.env_vars": { + "name": "env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "app_id": { + "name": "app_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_env_vars_app_id": { + "name": "idx_env_vars_app_id", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "env_vars_app_id_apps_id_fk": { + "name": "env_vars_app_id_apps_id_fk", + "tableFrom": "env_vars", + "tableTo": "apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_members": { + "name": "organization_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_members_user_id": { + "name": "idx_org_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_members_org_id": { + "name": "idx_org_members_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_organizations_slug": { + "name": "idx_organizations_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_org_id_organizations_id_fk": { + "name": "sessions_org_id_organizations_id_fk", + "tableFrom": "sessions", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "github_username": { + "name": "github_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "github_access_token": { + "name": "github_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_github_id": { + "name": "idx_users_github_id", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.build_pack": { + "name": "build_pack", + "schema": "public", + "values": ["nixpacks", "dockerfile", "dockercompose", "dockerimage"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bd15107..c1c1830 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1779075369464, "tag": "0007_medical_terror", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1780030263594, + "tag": "0008_nosy_wrecking_crew", + "breakpoints": true } ] } diff --git a/packages/api/src/config/env.ts b/packages/api/src/config/env.ts index 82f19a8..10f5905 100644 --- a/packages/api/src/config/env.ts +++ b/packages/api/src/config/env.ts @@ -25,6 +25,7 @@ export const envSchema = z.object({ SESSION_TTL: z.string().default("604800"), NODE_ENV: z.string().default("development"), PORT: z.string().default("3000"), + SITES_DIR: z.string().default("/var/lib/shipyard/sites"), }); export type Env = z.infer; diff --git a/packages/api/src/routes/deployments.ts b/packages/api/src/routes/deployments.ts index 50c9c5f..0b507ea 100644 --- a/packages/api/src/routes/deployments.ts +++ b/packages/api/src/routes/deployments.ts @@ -71,5 +71,50 @@ export function createDeploymentsRouter() { } }); + /** + * Rollback to a previous deployment. Swaps the symlink atomically + * and updates the app's active deployment pointer. + * + * Validates the deployment is a valid rollback target: + * - status must be "success" + * - artifacts must not have been pruned + * - deployment directory must exist on disk + * - deployment must not already be active + * + * @auth Requires valid session cookie + * @param {string} req.params.id — deployment ID to rollback TO + * @returns {{ deployment, app }} 200 — updated deployment + app + * @throws 404 — not_found if deployment or its app does not exist + * @throws 409 — conflict if deployment is already active + * @throws 410 — gone if artifacts have been pruned + * @throws 422 — unprocessable if deployment status is not success + */ + router.post("/:id/rollback", async (req, res) => { + try { + const result = await deploymentService.rollbackDeployment( + req.params.id, + req.orgId!, + ); + + if (!result.deployment || !result.app) { + res.status(404).json({ error: "not_found" }); + return; + } + + res.json({ + deployment: result.deployment, + app: result.app, + }); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode ?? 500; + if (status === 500) { + logger.error({ err }, "Failed to rollback deployment"); + } + res.status(status).json({ + error: status === 500 ? "internal_error" : (err as Error).message, + }); + } + }); + return router; } diff --git a/packages/api/src/services/deployments.ts b/packages/api/src/services/deployments.ts index 5ae190b..09029f0 100644 --- a/packages/api/src/services/deployments.ts +++ b/packages/api/src/services/deployments.ts @@ -1,9 +1,13 @@ +import fs from "node:fs"; +import path from "node:path"; import { + apps, buildJobs, deploymentLogs, deployments, } from "@shipyard/shared/schema"; import { asc, desc, eq } from "drizzle-orm"; +import { getEnv } from "../config/env.js"; import { db } from "../plugins/db.js"; const safeColumns = { @@ -17,6 +21,7 @@ const safeColumns = { outputDir: deployments.outputDir, startedAt: deployments.startedAt, finishedAt: deployments.finishedAt, + prunedAt: deployments.prunedAt, createdAt: deployments.createdAt, }; @@ -88,3 +93,71 @@ export async function getDeploymentLogs(deploymentId: string) { .where(eq(deploymentLogs.deploymentId, deploymentId)) .orderBy(asc(deploymentLogs.createdAt)); } + +export async function rollbackDeployment( + deploymentId: string, + orgId: string, +): Promise<{ + deployment: typeof deployments.$inferSelect | null; + app: typeof apps.$inferSelect | null; +}> { + const deployment = await getDeployment(deploymentId); + if (!deployment) return { deployment: null, app: null }; + if (deployment.status !== "success") { + throw Object.assign( + new Error( + `Cannot rollback: deployment has status "${deployment.status}"`, + ), + { statusCode: 422 }, + ); + } + if (deployment.prunedAt) { + throw Object.assign( + new Error("Cannot rollback: deployment artifacts have been pruned"), + { statusCode: 410 }, + ); + } + + const siteDir = getEnv().SITES_DIR; + const depDir = path.join(siteDir, deployment.appId, deployment.id); + if (!fs.existsSync(depDir)) { + throw Object.assign( + new Error("Cannot rollback: deployment directory does not exist on disk"), + { statusCode: 410 }, + ); + } + + const [app] = await db + .select() + .from(apps) + .where(eq(apps.id, deployment.appId)); + if (!app || app.organizationId !== orgId) { + return { deployment: null, app: null }; + } + + if (app.activeDeploymentId === deployment.id) { + throw Object.assign(new Error("Deployment is already active"), { + statusCode: 409, + }); + } + + // Swap symlink atomically + const currentPath = path.join(siteDir, deployment.appId, "current"); + try { + fs.unlinkSync(currentPath); + } catch { + // Symlink doesn't exist yet (shouldn't happen if there's an active deployment) + } + fs.symlinkSync(deployment.id, currentPath, "dir"); + + // Update DB pointer + await db + .update(apps) + .set({ activeDeploymentId: deployment.id, updatedAt: new Date() }) + .where(eq(apps.id, app.id)); + + return { deployment, app } as { + deployment: typeof deployments.$inferSelect | null; + app: typeof apps.$inferSelect | null; + }; +} diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 190824a..1036942 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -120,6 +120,7 @@ export const deployments = pgTable( outputDir: varchar("output_dir", { length: 255 }), startedAt: timestamp("started_at"), finishedAt: timestamp("finished_at"), + prunedAt: timestamp("pruned_at"), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ diff --git a/packages/web/src/hooks/useDeployments.ts b/packages/web/src/hooks/useDeployments.ts index 004d777..c266e85 100644 --- a/packages/web/src/hooks/useDeployments.ts +++ b/packages/web/src/hooks/useDeployments.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; export interface Deployment { id: string; status: "pending" | "building" | "success" | "failed"; + prunedAt: string | null; createdAt: string; } @@ -52,3 +53,29 @@ export function useDeployApp() { }, }); } + +export function useRollbackDeployment() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + deploymentId, + }: { + deploymentId: string; + appId: string; + }) => { + const res = await fetch(`/api/deployments/${deploymentId}/rollback`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? "Failed to rollback"); + } + return res.json(); + }, + onSuccess: (_data, variables) => { + qc.invalidateQueries({ queryKey: ["deployments", variables.appId] }); + }, + }); +} diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index f797e7b..12f912d 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -9,6 +9,7 @@ import { type Deployment, useDeployApp, useDeployments, + useRollbackDeployment, } from "../hooks/useDeployments"; const BUILD_PACK_COLORS: Record = { @@ -196,11 +197,13 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { const [confirmDelete, setConfirmDelete] = useState(false); const [showDeployments, setShowDeployments] = useState(false); const [deployVersion, setDeployVersion] = useState(0); + const [rollbackTarget, setRollbackTarget] = useState(null); const { data: deployments, latestDeployment } = useDeployments( app.id, showDeployments, ); const deployApp = useDeployApp(); + const rollbackDeployment = useRollbackDeployment(); const deployStatus = latestDeployment?.status ?? "idle"; const packColor = @@ -338,24 +341,67 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { ) : ( - deployments.map((d: Deployment) => ( -
- - - {d.status} - - - {new Date(d.createdAt).toLocaleString()} - -
- )) + deployments.map((d: Deployment) => { + const isRollbackable = d.status === "success" && !d.prunedAt; + const isTarget = rollbackTarget === d.id; + + return ( +
+ + + {d.status} + + {isRollbackable && !isTarget && ( + + )} + {isTarget && ( +
+ + CONFIRM? + + + +
+ )} + + {new Date(d.createdAt).toLocaleString()} + +
+ ); + }) )} )} diff --git a/packages/worker/src/config/env.ts b/packages/worker/src/config/env.ts index c3cd38e..9689aa8 100644 --- a/packages/worker/src/config/env.ts +++ b/packages/worker/src/config/env.ts @@ -30,6 +30,11 @@ const envSchema = z.object({ .default("false") .transform((v) => v === "true") .pipe(z.boolean()), + DEPLOYMENT_KEEP_COUNT: z + .string() + .default("5") + .transform((v) => parseInt(v, 10)) + .pipe(z.number().int().positive()), }); export type Env = z.infer; diff --git a/packages/worker/src/deployments/storage.ts b/packages/worker/src/deployments/storage.ts new file mode 100644 index 0000000..145393e --- /dev/null +++ b/packages/worker/src/deployments/storage.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import { deployments } from "@shipyard/shared"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +type DB = PostgresJsDatabase>; + +export function getDeploymentDir( + sitesDir: string, + appId: string, + deploymentId: string, +): string { + return path.join(sitesDir, appId, deploymentId); +} + +export function getCurrentSymlinkPath(sitesDir: string, appId: string): string { + return path.join(sitesDir, appId, "current"); +} + +export function activateDeployment( + sitesDir: string, + appId: string, + deploymentId: string, +): void { + const appDir = path.join(sitesDir, appId); + fs.mkdirSync(appDir, { recursive: true }); + + const symlinkPath = getCurrentSymlinkPath(sitesDir, appId); + const target = path.join(appDir, deploymentId); + + if (!fs.existsSync(target)) { + throw new Error(`Deployment directory ${target} does not exist`); + } + + try { + fs.unlinkSync(symlinkPath); + } catch { + // Symlink doesn't exist yet (first deploy) + } + + fs.symlinkSync(deploymentId, symlinkPath, "dir"); +} + +export async function pruneDeployments( + db: DB, + appId: string, + sitesDir: string, + keepCount: number, +): Promise { + const all = await db + .select({ id: deployments.id }) + .from(deployments) + .where( + and( + eq(deployments.appId, appId), + eq(deployments.status, "success"), + inArray(deployments.prunedAt, [null as unknown as Date]), + ), + ) + .orderBy(desc(deployments.createdAt)); + + if (all.length <= keepCount) return; + + const toPrune = all.slice(keepCount); + + for (const dep of toPrune) { + const depDir = getDeploymentDir(sitesDir, appId, dep.id); + try { + fs.rmSync(depDir, { recursive: true, force: true }); + } catch { + // Dir may not exist — that's fine + } + + await db + .update(deployments) + .set({ prunedAt: new Date() }) + .where(eq(deployments.id, dep.id)); + } +} diff --git a/packages/worker/src/deployments/strategies/nixpacks.ts b/packages/worker/src/deployments/strategies/nixpacks.ts index 344bc37..c716b3e 100644 --- a/packages/worker/src/deployments/strategies/nixpacks.ts +++ b/packages/worker/src/deployments/strategies/nixpacks.ts @@ -15,6 +15,11 @@ import { finalizeBuildJobRow, insertStructuredEvent, } from "../events.js"; +import { + activateDeployment, + getDeploymentDir, + pruneDeployments, +} from "../storage.js"; type DB = PostgresJsDatabase>; @@ -92,12 +97,14 @@ function runNixpacksBuild( async function extractStaticOutput( appId: string, + deploymentId: string, outputDir: string, workspacePath: string, ): Promise { const imageTag = `shipyard-${appId}:${workspacePath.split("/").pop()}`; const containerName = `shipyard-extract-${appId}-${Date.now()}`; - const sitesPath = path.join(getEnv().SITES_DIR, appId); + const sitesDir = getEnv().SITES_DIR; + const sitesPath = getDeploymentDir(sitesDir, appId, deploymentId); try { execSync(`docker create --name ${containerName} ${imageTag}`, { @@ -263,7 +270,12 @@ export async function deployNixpacks( logger.info({ deploymentId, outputDir }, "Extracting static output"); try { - await extractStaticOutput(app.id, outputDir, workspacePath); + await extractStaticOutput( + app.id, + deploymentId, + outputDir, + workspacePath, + ); await finalizeBuildJobRow(db, deploymentId, "extract", true, 1); } catch (err) { await finalizeBuildJobRow(db, deploymentId, "extract", false, 0); @@ -277,7 +289,12 @@ export async function deployNixpacks( 'Step "extract" completed', ); - // Activate + Caddy file route + // Activate via symlink swap, then prune old deployments + const sitesDir = env.SITES_DIR; + activateDeployment(sitesDir, app.id, deploymentId); + await pruneDeployments(db, app.id, sitesDir, env.DEPLOYMENT_KEEP_COUNT); + + // Caddy file route (root permanently points to sites/{appId}/current) await db .update(apps) .set({ activeDeploymentId: deploymentId }) diff --git a/packages/worker/src/infrastructure/caddy/config-builder.ts b/packages/worker/src/infrastructure/caddy/config-builder.ts index 2219c0c..2cc06a2 100644 --- a/packages/worker/src/infrastructure/caddy/config-builder.ts +++ b/packages/worker/src/infrastructure/caddy/config-builder.ts @@ -11,7 +11,7 @@ export function buildRouteConfig( appId: string, isSpa: boolean, ): Record { - const root = `${SITES_ROOT}/${appId}`; + const root = `${SITES_ROOT}/${appId}/current`; if (!isSpa) { return { diff --git a/packages/worker/test/unit/infrastructure/caddy-config.test.ts b/packages/worker/test/unit/infrastructure/caddy-config.test.ts index aebbbe6..89ed219 100644 --- a/packages/worker/test/unit/infrastructure/caddy-config.test.ts +++ b/packages/worker/test/unit/infrastructure/caddy-config.test.ts @@ -14,7 +14,7 @@ describe("Caddy config builder", () => { expect(route.handle).toHaveLength(1); expect(route.handle[0]).toMatchObject({ handler: "file_server", - root: "/var/lib/shipyard/sites/app-1", + root: "/var/lib/shipyard/sites/app-1/current", }); }); @@ -25,7 +25,7 @@ describe("Caddy config builder", () => { expect(route.handle).toHaveLength(3); expect(route.handle[0]).toMatchObject({ handler: "file_server", - root: "/var/lib/shipyard/sites/app-2", + root: "/var/lib/shipyard/sites/app-2/current", pass_thru: true, }); expect(route.handle[1]).toMatchObject({ @@ -34,7 +34,7 @@ describe("Caddy config builder", () => { }); expect(route.handle[2]).toMatchObject({ handler: "file_server", - root: "/var/lib/shipyard/sites/app-2", + root: "/var/lib/shipyard/sites/app-2/current", }); }); From b85edcb193042b8632da63ea7ce1b69a7a77d768 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 09:36:53 +0300 Subject: [PATCH 3/8] fix(worker): create deploy dir before docker cp - mkdir -p target before docker cp (was failing with ENOENT) - use rmSync instead of silent try/catch unlinkSync (avoids EEXIST when path is a directory, not a symlink) --- packages/worker/src/deployments/storage.ts | 6 +----- packages/worker/src/deployments/strategies/nixpacks.ts | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/worker/src/deployments/storage.ts b/packages/worker/src/deployments/storage.ts index 145393e..bedefff 100644 --- a/packages/worker/src/deployments/storage.ts +++ b/packages/worker/src/deployments/storage.ts @@ -33,11 +33,7 @@ export function activateDeployment( throw new Error(`Deployment directory ${target} does not exist`); } - try { - fs.unlinkSync(symlinkPath); - } catch { - // Symlink doesn't exist yet (first deploy) - } + fs.rmSync(symlinkPath, { force: true, recursive: true }); fs.symlinkSync(deploymentId, symlinkPath, "dir"); } diff --git a/packages/worker/src/deployments/strategies/nixpacks.ts b/packages/worker/src/deployments/strategies/nixpacks.ts index 2211af7..ba32ddc 100644 --- a/packages/worker/src/deployments/strategies/nixpacks.ts +++ b/packages/worker/src/deployments/strategies/nixpacks.ts @@ -103,6 +103,7 @@ async function extractStaticOutput( const containerName = `shipyard-extract-${appId}-${Date.now()}`; const sitesDir = getEnv().SITES_DIR; const sitesPath = getDeploymentDir(sitesDir, appId, deploymentId); + fs.mkdirSync(sitesPath, { recursive: true }); try { execSync(`docker create --name ${containerName} ${imageTag}`, { From b74c3a7d0af275c9a34f17a51bbd8690a6e2f356 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 09:37:17 +0300 Subject: [PATCH 4/8] feat(api,worker): delegate rollback execution to worker via BullMQ API mounts sites volume :ro, can't write symlinks. Move filesystem ops to worker (has :rw). API validates only, then enqueues a type:"rollback" job. Worker runs processRollback: validates dir exists, calls activateDeployment, updates DB. Closes #24 --- packages/api/src/routes/apps.ts | 1 + packages/api/src/routes/deployments.ts | 9 +++++ packages/api/src/services/deployments.ts | 44 +++++++-------------- packages/shared/src/types/queue.ts | 1 + packages/worker/src/deployments/rollback.ts | 42 ++++++++++++++++++++ packages/worker/src/index.ts | 10 +++++ 6 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 packages/worker/src/deployments/rollback.ts diff --git a/packages/api/src/routes/apps.ts b/packages/api/src/routes/apps.ts index dcef3e8..773bc2b 100644 --- a/packages/api/src/routes/apps.ts +++ b/packages/api/src/routes/apps.ts @@ -162,6 +162,7 @@ export function createAppsRouter() { ); await myQueue.add("deploy", { + type: "deploy", deploymentId: deployment.id, applicationId: req.params.id, titleLog: "Manual deploy", diff --git a/packages/api/src/routes/deployments.ts b/packages/api/src/routes/deployments.ts index 0b507ea..1ed435b 100644 --- a/packages/api/src/routes/deployments.ts +++ b/packages/api/src/routes/deployments.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { logger } from "../config/logger.js"; import { requireAuth } from "../middleware/auth.js"; +import { myQueue } from "../plugins/queue.js"; import * as appService from "../services/apps.js"; import * as deploymentService from "../services/deployments.js"; @@ -101,6 +102,14 @@ export function createDeploymentsRouter() { return; } + await myQueue.add("rollback", { + type: "rollback", + deploymentId: req.params.id, + applicationId: result.app.id, + titleLog: "Rollback", + descriptionLog: `Rollback to deployment ${req.params.id}`, + }); + res.json({ deployment: result.deployment, app: result.app, diff --git a/packages/api/src/services/deployments.ts b/packages/api/src/services/deployments.ts index 09029f0..c75e1c4 100644 --- a/packages/api/src/services/deployments.ts +++ b/packages/api/src/services/deployments.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { apps, buildJobs, @@ -7,7 +5,6 @@ import { deployments, } from "@shipyard/shared/schema"; import { asc, desc, eq } from "drizzle-orm"; -import { getEnv } from "../config/env.js"; import { db } from "../plugins/db.js"; const safeColumns = { @@ -53,17 +50,28 @@ export async function createDeploymentWithBuildJob(appId: string) { } /** - * Lists deployments for an app, newest first. + * Lists deployments for an app, newest first, along with the app's active + * deployment ID so the frontend can highlight which deployment is live. * * @param appId - The app to list deployments for - * @returns Array of deployment objects + * @returns Object containing deployments array and activeDeploymentId */ export async function listDeployments(appId: string) { - return db + const [app] = await db + .select({ activeDeploymentId: apps.activeDeploymentId }) + .from(apps) + .where(eq(apps.id, appId)); + + const list = await db .select(safeColumns) .from(deployments) .where(eq(deployments.appId, appId)) .orderBy(desc(deployments.createdAt)); + + return { + deployments: list, + activeDeploymentId: app?.activeDeploymentId ?? null, + }; } /** @@ -118,15 +126,6 @@ export async function rollbackDeployment( ); } - const siteDir = getEnv().SITES_DIR; - const depDir = path.join(siteDir, deployment.appId, deployment.id); - if (!fs.existsSync(depDir)) { - throw Object.assign( - new Error("Cannot rollback: deployment directory does not exist on disk"), - { statusCode: 410 }, - ); - } - const [app] = await db .select() .from(apps) @@ -141,21 +140,6 @@ export async function rollbackDeployment( }); } - // Swap symlink atomically - const currentPath = path.join(siteDir, deployment.appId, "current"); - try { - fs.unlinkSync(currentPath); - } catch { - // Symlink doesn't exist yet (shouldn't happen if there's an active deployment) - } - fs.symlinkSync(deployment.id, currentPath, "dir"); - - // Update DB pointer - await db - .update(apps) - .set({ activeDeploymentId: deployment.id, updatedAt: new Date() }) - .where(eq(apps.id, app.id)); - return { deployment, app } as { deployment: typeof deployments.$inferSelect | null; app: typeof apps.$inferSelect | null; diff --git a/packages/shared/src/types/queue.ts b/packages/shared/src/types/queue.ts index 08d4e0a..24151a2 100644 --- a/packages/shared/src/types/queue.ts +++ b/packages/shared/src/types/queue.ts @@ -1,4 +1,5 @@ export type DeploymentJob = { + type: "deploy" | "rollback"; deploymentId: string; applicationId: string; titleLog: string; diff --git a/packages/worker/src/deployments/rollback.ts b/packages/worker/src/deployments/rollback.ts new file mode 100644 index 0000000..69b83b7 --- /dev/null +++ b/packages/worker/src/deployments/rollback.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import path from "node:path"; +import { apps, deployments } from "@shipyard/shared"; +import { eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { activateDeployment, getDeploymentDir } from "./storage.js"; + +type DB = PostgresJsDatabase>; + +export async function processRollback( + db: DB, + sitesDir: string, + deploymentId: string, +): Promise { + const [row] = await db + .select({ + deploymentId: deployments.id, + appId: deployments.appId, + status: deployments.status, + }) + .from(deployments) + .where(eq(deployments.id, deploymentId)); + + if (!row) throw new Error(`Deployment ${deploymentId} not found`); + if (row.status !== "success") { + throw new Error(`Cannot rollback: deployment has status "${row.status}"`); + } + + const depDir = getDeploymentDir(sitesDir, row.appId, row.deploymentId); + if (!fs.existsSync(depDir)) { + throw new Error( + "Cannot rollback: deployment directory does not exist on disk", + ); + } + + activateDeployment(sitesDir, row.appId, row.deploymentId); + + await db + .update(apps) + .set({ activeDeploymentId: row.deploymentId, updatedAt: new Date() }) + .where(eq(apps.id, row.appId)); +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 765dede..553d5a8 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -17,6 +17,7 @@ import { db } from "./config/db.js"; import { getEnv } from "./config/env.js"; import { logger } from "./config/logger.js"; import { processDeployment } from "./deployments/processor.js"; +import { processRollback } from "./deployments/rollback.js"; import { upsertFileRoute, upsertProxyRoute, @@ -171,6 +172,15 @@ const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null }); const worker = new Worker( QUEUE_NAME, async (job) => { + if (job.data.type === "rollback") { + logger.info( + { deploymentId: job.data.deploymentId }, + "Processing rollback", + ); + await processRollback(db, env.SITES_DIR, job.data.deploymentId); + logger.info({ deploymentId: job.data.deploymentId }, "Rollback complete"); + return; + } logger.info( { deploymentId: job.data.deploymentId }, "Processing deployment", From 75d7a35c179dfd37f311fa0bea43579fccfa492c Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 09:37:45 +0300 Subject: [PATCH 5/8] fix: remove unused path import feat(web): highlight active deployment in list API now returns activeDeploymentId alongside deployments. Frontend highlights the active row with a background tint and an ACTIVE badge. Also fixes unused path import in rollback.ts. --- packages/web/src/hooks/useDeployments.ts | 31 +++++++++++++-------- packages/web/src/pages/Dashboard.tsx | 19 +++++++++---- packages/worker/src/deployments/rollback.ts | 1 - 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/web/src/hooks/useDeployments.ts b/packages/web/src/hooks/useDeployments.ts index c266e85..3cd4725 100644 --- a/packages/web/src/hooks/useDeployments.ts +++ b/packages/web/src/hooks/useDeployments.ts @@ -7,8 +7,13 @@ export interface Deployment { createdAt: string; } +interface DeploymentsResponse { + deployments: Deployment[]; + activeDeploymentId: string | null; +} + export function useDeployments(appId: string, isOpen: boolean) { - const query = useQuery({ + const query = useQuery({ queryKey: ["deployments", appId], queryFn: async () => { const res = await fetch(`/api/apps/${appId}/deployments`, { @@ -18,20 +23,24 @@ export function useDeployments(appId: string, isOpen: boolean) { return res.json(); }, enabled: isOpen, - refetchInterval: (q) => { - if (!isOpen) return false; - const data = q.state.data; - if (!data) return false; - const hasActive = data.some( - (d) => d.status === "pending" || d.status === "building", - ); - return hasActive ? 3000 : false; + refetchInterval: isOpen ? 5000 : false, + select: (data) => { + if (Array.isArray(data)) { + const list = data as unknown as Deployment[]; + return { + deployments: list, + activeDeploymentId: null, + } as DeploymentsResponse; + } + return data as DeploymentsResponse; }, }); - const latestDeployment = query.data?.[0] ?? null; + const deployments = query.data?.deployments ?? null; + const activeDeploymentId = query.data?.activeDeploymentId ?? null; + const latestDeployment = deployments?.[0] ?? null; - return { ...query, latestDeployment }; + return { ...query, data: deployments, latestDeployment, activeDeploymentId }; } export function useDeployApp() { diff --git a/packages/web/src/pages/Dashboard.tsx b/packages/web/src/pages/Dashboard.tsx index 12f912d..2fe1108 100644 --- a/packages/web/src/pages/Dashboard.tsx +++ b/packages/web/src/pages/Dashboard.tsx @@ -188,6 +188,7 @@ interface AppData { branch: string; buildPack: string; status?: string; + activeDeploymentId?: string | null; activeUrl?: string | null; createdAt: string; } @@ -198,10 +199,11 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { const [showDeployments, setShowDeployments] = useState(false); const [deployVersion, setDeployVersion] = useState(0); const [rollbackTarget, setRollbackTarget] = useState(null); - const { data: deployments, latestDeployment } = useDeployments( - app.id, - showDeployments, - ); + const { + data: deployments, + latestDeployment, + activeDeploymentId, + } = useDeployments(app.id, showDeployments); const deployApp = useDeployApp(); const rollbackDeployment = useRollbackDeployment(); @@ -348,7 +350,9 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { return (
void }) { {d.status} + {d.id === activeDeploymentId && ( + + ACTIVE + + )} {isRollbackable && !isTarget && (
) : ( deployments.map((d: Deployment) => { - const isRollbackable = d.status === "success" && !d.prunedAt; + const isRollbackable = + d.status === "success" && + !d.prunedAt && + d.id !== activeDeploymentId; const isTarget = rollbackTarget === d.id; return ( diff --git a/packages/worker/src/deployments/rollback.ts b/packages/worker/src/deployments/rollback.ts index 26a77e2..c0eeb8c 100644 --- a/packages/worker/src/deployments/rollback.ts +++ b/packages/worker/src/deployments/rollback.ts @@ -16,6 +16,7 @@ export async function processRollback( deploymentId: deployments.id, appId: deployments.appId, status: deployments.status, + prunedAt: deployments.prunedAt, }) .from(deployments) .where(eq(deployments.id, deploymentId)); @@ -24,6 +25,9 @@ export async function processRollback( if (row.status !== "success") { throw new Error(`Cannot rollback: deployment has status "${row.status}"`); } + if (row.prunedAt) { + throw new Error("Cannot rollback: deployment artifacts have been pruned"); + } const depDir = getDeploymentDir(sitesDir, row.appId, row.deploymentId); if (!fs.existsSync(depDir)) { diff --git a/packages/worker/src/deployments/storage.ts b/packages/worker/src/deployments/storage.ts index bedefff..6b835f8 100644 --- a/packages/worker/src/deployments/storage.ts +++ b/packages/worker/src/deployments/storage.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { deployments } from "@shipyard/shared"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; type DB = PostgresJsDatabase>; @@ -33,9 +33,10 @@ export function activateDeployment( throw new Error(`Deployment directory ${target} does not exist`); } - fs.rmSync(symlinkPath, { force: true, recursive: true }); - - fs.symlinkSync(deploymentId, symlinkPath, "dir"); + const tmp = `${symlinkPath}.tmp-${process.pid}-${Date.now()}`; + fs.rmSync(tmp, { force: true, recursive: true }); + fs.symlinkSync(deploymentId, tmp, "dir"); + fs.renameSync(tmp, symlinkPath); } export async function pruneDeployments( @@ -51,7 +52,7 @@ export async function pruneDeployments( and( eq(deployments.appId, appId), eq(deployments.status, "success"), - inArray(deployments.prunedAt, [null as unknown as Date]), + isNull(deployments.prunedAt), ), ) .orderBy(desc(deployments.createdAt)); diff --git a/packages/worker/src/deployments/strategies/nixpacks.ts b/packages/worker/src/deployments/strategies/nixpacks.ts index ba32ddc..e20f2e6 100644 --- a/packages/worker/src/deployments/strategies/nixpacks.ts +++ b/packages/worker/src/deployments/strategies/nixpacks.ts @@ -288,10 +288,8 @@ export async function deployNixpacks( 'Step "extract" completed', ); - // Activate via symlink swap, then prune old deployments - const sitesDir = env.SITES_DIR; - activateDeployment(sitesDir, app.id, deploymentId); - await pruneDeployments(db, app.id, sitesDir, env.DEPLOYMENT_KEEP_COUNT); + // Activate via symlink swap + activateDeployment(env.SITES_DIR, app.id, deploymentId); // Caddy file route (root permanently points to sites/{appId}/current) await db @@ -388,6 +386,17 @@ export async function deployNixpacks( .set({ status: "success", finishedAt: new Date() }) .where(eq(deployments.id, deploymentId)); logger.info({ deploymentId }, "Deployment succeeded"); + + // Prune old deployments now that status is "success", + // so the retention count includes this deployment. + if (isStatic) { + await pruneDeployments( + db, + app.id, + env.SITES_DIR, + env.DEPLOYMENT_KEEP_COUNT, + ); + } } catch (err) { logger.error({ err, deploymentId }, "Nixpacks deployment failed"); await insertStructuredEvent( From d1046f8ca0f36ab7d37c8496a32199311b21b103 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 29 May 2026 12:28:48 +0300 Subject: [PATCH 8/8] fix: fix tests from code review --- packages/api/test/unit/deployments.test.ts | 44 +++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/api/test/unit/deployments.test.ts b/packages/api/test/unit/deployments.test.ts index 01bbb5a..14af0a1 100644 --- a/packages/api/test/unit/deployments.test.ts +++ b/packages/api/test/unit/deployments.test.ts @@ -1,6 +1,35 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const TEST_ORG_ID = "test-org-id"; +const TEST_SITES_DIR = path.join( + os.tmpdir(), + "shipyard-test", + "deployments-api", +); + +vi.mock("../../src/config/env.js", () => ({ + getEnv: () => ({ + DATABASE_URL: "postgres://test:test@localhost:5432/test", + REDIS_URL: "redis://localhost:6379", + CADDY_ADMIN_URL: "http://localhost:2019", + GITHUB_CLIENT_ID: "test", + GITHUB_CLIENT_SECRET: "test", + GITHUB_CALLBACK_URL: "http://localhost:3000/auth/github/callback", + API_SECRET: "test-secret", + SESSION_SECRET: "test-session-secret", + ENCRYPTION_KEY: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + BASE_DOMAIN: "test.dev", + AUTO_HTTPS: false, + SESSION_TTL: "604800", + NODE_ENV: "test", + PORT: "3000", + SITES_DIR: TEST_SITES_DIR, + }), +})); vi.mock("drizzle-orm", () => ({ eq: vi.fn().mockImplementation((col: unknown, val: unknown) => ({ @@ -55,9 +84,22 @@ describe("deployment service", () => { beforeEach(async () => { vi.clearAllMocks(); + fs.mkdirSync(path.join(TEST_SITES_DIR, "app-1", "dep-1"), { + recursive: true, + }); + fs.mkdirSync(path.join(TEST_SITES_DIR, "app-1", "dep-active"), { + recursive: true, + }); + fs.mkdirSync(path.join(TEST_SITES_DIR, "app-1", "dep-target"), { + recursive: true, + }); deploymentService = await import("../../src/services/deployments.js"); }); + afterEach(() => { + fs.rmSync(TEST_SITES_DIR, { recursive: true, force: true }); + }); + describe("rollbackDeployment", () => { it("returns null for unknown deployment", async () => { const { db } = await import("../../src/plugins/db.js");