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/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/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) 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/apps.ts b/packages/api/src/routes/apps.ts index dcef3e8..d6450b8 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", @@ -185,7 +186,7 @@ export function createAppsRouter() { * * @auth Requires valid session cookie * @param {string} req.params.id — app ID - * @returns {Array} 200 — array of deployment objects + * @returns {{ deployments: Deployment[], activeDeploymentId: string | null }} 200 — deployments with active ID * @throws 404 — not_found if app does not exist */ router.get("/:id/deployments", async (req, res) => { diff --git a/packages/api/src/routes/deployments.ts b/packages/api/src/routes/deployments.ts index 50c9c5f..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"; @@ -71,5 +72,58 @@ 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; + } + + 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, + }); + } 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..37968d2 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, }; @@ -48,17 +53,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, + }; } /** @@ -88,3 +104,55 @@ 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 depDir = path.join(getEnv().SITES_DIR, 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, + }); + } + + return { deployment, app } as { + deployment: typeof deployments.$inferSelect | null; + app: typeof apps.$inferSelect | null; + }; +} diff --git a/packages/api/test/unit/deployments.test.ts b/packages/api/test/unit/deployments.test.ts new file mode 100644 index 0000000..14af0a1 --- /dev/null +++ b/packages/api/test/unit/deployments.test.ts @@ -0,0 +1,246 @@ +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) => ({ + __type: "eq", + col, + val, + })), + asc: vi.fn(), + desc: vi.fn(), +})); + +vi.mock("@shipyard/shared/schema", () => ({ + deployments: { + id: {}, + appId: {}, + commitSha: {}, + commitMessage: {}, + branch: {}, + status: {}, + detectedFramework: {}, + outputDir: {}, + startedAt: {}, + finishedAt: {}, + prunedAt: {}, + createdAt: {}, + }, + apps: { + id: {}, + name: {}, + organizationId: {}, + activeDeploymentId: {}, + }, + buildJobs: { id: {} }, + deploymentLogs: { id: {} }, +})); + +vi.mock("../../src/plugins/db.js", () => ({ + db: { + select: vi.fn(), + update: vi.fn(), + }, +})); + +function makeSelect(result: unknown[]) { + const where = vi.fn().mockResolvedValue(result); + const from = vi.fn().mockReturnValue({ where }); + return { from, where }; +} + +describe("deployment service", () => { + let deploymentService: typeof import("../../src/services/deployments.js"); + + 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"); + const depSelect = makeSelect([]); + vi.mocked(db.select).mockReturnValue({ from: depSelect.from }); + + const result = await deploymentService.rollbackDeployment( + "unknown-id", + TEST_ORG_ID, + ); + + expect(result).toEqual({ deployment: null, app: null }); + }); + + it("throws 422 when deployment status is not success", async () => { + const { db } = await import("../../src/plugins/db.js"); + const depSelect = makeSelect([ + { + id: "dep-1", + appId: "app-1", + status: "failed", + prunedAt: null, + }, + ]); + vi.mocked(db.select).mockReturnValue({ from: depSelect.from }); + + await expect( + deploymentService.rollbackDeployment("dep-1", TEST_ORG_ID), + ).rejects.toMatchObject({ + message: expect.stringContaining("failed"), + statusCode: 422, + }); + }); + + it("throws 410 when deployment artifacts have been pruned", async () => { + const { db } = await import("../../src/plugins/db.js"); + const depSelect = makeSelect([ + { + id: "dep-1", + appId: "app-1", + status: "success", + prunedAt: new Date("2025-01-01"), + }, + ]); + vi.mocked(db.select).mockReturnValue({ from: depSelect.from }); + + await expect( + deploymentService.rollbackDeployment("dep-1", TEST_ORG_ID), + ).rejects.toMatchObject({ + message: "Cannot rollback: deployment artifacts have been pruned", + statusCode: 410, + }); + }); + + it("returns null when app belongs to different organization", async () => { + const { db } = await import("../../src/plugins/db.js"); + const depSelect = makeSelect([ + { + id: "dep-1", + appId: "app-1", + status: "success", + prunedAt: null, + }, + ]); + const appSelect = makeSelect([ + { + id: "app-1", + organizationId: "other-org-id", + activeDeploymentId: "dep-old", + }, + ]); + vi.mocked(db.select) + .mockReturnValueOnce({ from: depSelect.from }) + .mockReturnValueOnce({ from: appSelect.from }); + + const result = await deploymentService.rollbackDeployment( + "dep-1", + TEST_ORG_ID, + ); + + expect(result).toEqual({ deployment: null, app: null }); + }); + + it("throws 409 when deployment is already active", async () => { + const { db } = await import("../../src/plugins/db.js"); + const depSelect = makeSelect([ + { + id: "dep-active", + appId: "app-1", + status: "success", + prunedAt: null, + }, + ]); + const appSelect = makeSelect([ + { + id: "app-1", + organizationId: TEST_ORG_ID, + activeDeploymentId: "dep-active", + }, + ]); + vi.mocked(db.select) + .mockReturnValueOnce({ from: depSelect.from }) + .mockReturnValueOnce({ from: appSelect.from }); + + await expect( + deploymentService.rollbackDeployment("dep-active", TEST_ORG_ID), + ).rejects.toMatchObject({ + message: "Deployment is already active", + statusCode: 409, + }); + }); + + it("returns deployment and app on successful validation", async () => { + const { db } = await import("../../src/plugins/db.js"); + const depData = { + id: "dep-target", + appId: "app-1", + status: "success", + prunedAt: null, + }; + const appData = { + id: "app-1", + organizationId: TEST_ORG_ID, + activeDeploymentId: "dep-old", + }; + const depSelect = makeSelect([depData]); + const appSelect = makeSelect([appData]); + + vi.mocked(db.select) + .mockReturnValueOnce({ from: depSelect.from }) + .mockReturnValueOnce({ from: appSelect.from }); + + const result = await deploymentService.rollbackDeployment( + "dep-target", + TEST_ORG_ID, + ); + + expect(result.deployment).toBeDefined(); + expect(result.app).toBeDefined(); + expect((result.deployment as any).id).toBe("dep-target"); + expect((result.app as any).id).toBe("app-1"); + }); + }); +}); 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/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/web/src/hooks/useDeployments.ts b/packages/web/src/hooks/useDeployments.ts index 004d777..3cd4725 100644 --- a/packages/web/src/hooks/useDeployments.ts +++ b/packages/web/src/hooks/useDeployments.ts @@ -3,11 +3,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; export interface Deployment { id: string; status: "pending" | "building" | "success" | "failed"; + prunedAt: string | null; 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`, { @@ -17,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() { @@ -52,3 +62,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..4546b22 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 = { @@ -187,6 +188,7 @@ interface AppData { branch: string; buildPack: string; status?: string; + activeDeploymentId?: string | null; activeUrl?: string | null; createdAt: string; } @@ -196,11 +198,14 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { const [confirmDelete, setConfirmDelete] = useState(false); const [showDeployments, setShowDeployments] = useState(false); const [deployVersion, setDeployVersion] = useState(0); - const { data: deployments, latestDeployment } = useDeployments( - app.id, - showDeployments, - ); + const [rollbackTarget, setRollbackTarget] = useState(null); + const { + data: deployments, + latestDeployment, + activeDeploymentId, + } = useDeployments(app.id, showDeployments); const deployApp = useDeployApp(); + const rollbackDeployment = useRollbackDeployment(); const deployStatus = latestDeployment?.status ?? "idle"; const packColor = @@ -338,24 +343,77 @@ function AppCard({ app, onDelete }: { app: AppData; onDelete: () => void }) { ) : ( - deployments.map((d: Deployment) => ( -
- { + const isRollbackable = + d.status === "success" && + !d.prunedAt && + d.id !== activeDeploymentId; + const isTarget = rollbackTarget === d.id; + + return ( +
- - {d.status} - - - {new Date(d.createdAt).toLocaleString()} - -
- )) + > + + + {d.status} + + {d.id === activeDeploymentId && ( + + ACTIVE + + )} + {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/rollback.ts b/packages/worker/src/deployments/rollback.ts new file mode 100644 index 0000000..c0eeb8c --- /dev/null +++ b/packages/worker/src/deployments/rollback.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +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, + prunedAt: deployments.prunedAt, + }) + .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}"`); + } + 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)) { + 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/deployments/storage.ts b/packages/worker/src/deployments/storage.ts new file mode 100644 index 0000000..6b835f8 --- /dev/null +++ b/packages/worker/src/deployments/storage.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { deployments } from "@shipyard/shared"; +import { and, desc, eq, isNull } 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`); + } + + 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( + 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"), + isNull(deployments.prunedAt), + ), + ) + .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 868c7b5..e20f2e6 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>; @@ -90,12 +95,15 @@ 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); + fs.mkdirSync(sitesPath, { recursive: true }); try { execSync(`docker create --name ${containerName} ${imageTag}`, { @@ -261,7 +269,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); @@ -275,7 +288,10 @@ export async function deployNixpacks( 'Step "extract" completed', ); - // Activate + Caddy file route + // Activate via symlink swap + activateDeployment(env.SITES_DIR, app.id, deploymentId); + + // Caddy file route (root permanently points to sites/{appId}/current) await db .update(apps) .set({ activeDeploymentId: deploymentId }) @@ -370,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( 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", 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/deployments/rollback.test.ts b/packages/worker/test/unit/deployments/rollback.test.ts new file mode 100644 index 0000000..10ce8b7 --- /dev/null +++ b/packages/worker/test/unit/deployments/rollback.test.ts @@ -0,0 +1,101 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { processRollback } from "../../../src/deployments/rollback.js"; + +const TEST_SITES_DIR = "/tmp/shipyard-test/rollback-test"; +const APP_ID = "test-app-1"; + +function makeDb(results: Record[]) { + const where = vi.fn().mockResolvedValue(results); + const from = vi.fn().mockReturnValue({ where }); + return { + select: vi.fn().mockReturnValue({ from }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; +} + +describe("processRollback", () => { + beforeEach(() => { + fs.mkdirSync(TEST_SITES_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(TEST_SITES_DIR, { recursive: true, force: true }); + }); + + it("throws when deployment is not found", async () => { + const db = makeDb([]); + + await expect( + processRollback(db as any, TEST_SITES_DIR, "nonexistent"), + ).rejects.toThrow("Deployment nonexistent not found"); + }); + + it("throws when deployment status is not success", async () => { + const db = makeDb([ + { + deploymentId: "dep-1", + appId: APP_ID, + status: "failed", + }, + ]); + + await expect( + processRollback(db as any, TEST_SITES_DIR, "dep-1"), + ).rejects.toThrow('Cannot rollback: deployment has status "failed"'); + }); + + it("throws when deployment directory is missing", async () => { + const db = makeDb([ + { + deploymentId: "dep-1", + appId: APP_ID, + status: "success", + }, + ]); + + await expect( + processRollback(db as any, TEST_SITES_DIR, "dep-1"), + ).rejects.toThrow( + "Cannot rollback: deployment directory does not exist on disk", + ); + }); + + it("activates deployment and updates DB on success", async () => { + const depDir = path.join(TEST_SITES_DIR, APP_ID, "dep-target"); + fs.mkdirSync(depDir, { recursive: true }); + + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { + deploymentId: "dep-target", + appId: APP_ID, + status: "success", + }, + ]), + }), + }), + update: vi.fn().mockReturnValue({ set: updateSet }), + }; + + await processRollback(db as any, TEST_SITES_DIR, "dep-target"); + + const symlinkPath = path.join(TEST_SITES_DIR, APP_ID, "current"); + expect(fs.readlinkSync(symlinkPath)).toBe("dep-target"); + + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ activeDeploymentId: "dep-target" }), + ); + expect(updateWhere).toHaveBeenCalled(); + }); +}); diff --git a/packages/worker/test/unit/deployments/storage.test.ts b/packages/worker/test/unit/deployments/storage.test.ts new file mode 100644 index 0000000..464711b --- /dev/null +++ b/packages/worker/test/unit/deployments/storage.test.ts @@ -0,0 +1,178 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + activateDeployment, + getCurrentSymlinkPath, + getDeploymentDir, + pruneDeployments, +} from "../../../src/deployments/storage.js"; + +const TEST_SITES_DIR = "/tmp/shipyard-test/storage-test"; +const APP_ID = "test-app-1"; + +describe("deployment storage", () => { + beforeEach(() => { + fs.mkdirSync(TEST_SITES_DIR, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(TEST_SITES_DIR, { recursive: true, force: true }); + }); + + describe("getDeploymentDir", () => { + it("returns correct path", () => { + const result = getDeploymentDir(TEST_SITES_DIR, APP_ID, "deploy-1"); + expect(result).toBe(path.join(TEST_SITES_DIR, APP_ID, "deploy-1")); + }); + }); + + describe("getCurrentSymlinkPath", () => { + it("returns correct path", () => { + const result = getCurrentSymlinkPath(TEST_SITES_DIR, APP_ID); + expect(result).toBe(path.join(TEST_SITES_DIR, APP_ID, "current")); + }); + }); + + describe("activateDeployment", () => { + it("creates app directory and symlink on first deploy", () => { + const depId = "deploy-first"; + const depDir = getDeploymentDir(TEST_SITES_DIR, APP_ID, depId); + fs.mkdirSync(depDir, { recursive: true }); + fs.writeFileSync(path.join(depDir, "index.html"), "hello"); + + activateDeployment(TEST_SITES_DIR, APP_ID, depId); + + const symlinkPath = getCurrentSymlinkPath(TEST_SITES_DIR, APP_ID); + expect(fs.existsSync(symlinkPath)).toBe(true); + expect(fs.readlinkSync(symlinkPath)).toBe(depId); + }); + + it("replaces existing symlink on re-deploy", () => { + const dep1Id = "deploy-v1"; + const dep2Id = "deploy-v2"; + fs.mkdirSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, dep1Id), { + recursive: true, + }); + fs.mkdirSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, dep2Id), { + recursive: true, + }); + + activateDeployment(TEST_SITES_DIR, APP_ID, dep1Id); + activateDeployment(TEST_SITES_DIR, APP_ID, dep2Id); + + const symlinkPath = getCurrentSymlinkPath(TEST_SITES_DIR, APP_ID); + expect(fs.readlinkSync(symlinkPath)).toBe(dep2Id); + }); + + it("throws when deployment directory does not exist", () => { + expect(() => + activateDeployment(TEST_SITES_DIR, APP_ID, "nonexistent"), + ).toThrow("Deployment directory"); + }); + }); + + describe("pruneDeployments", () => { + it("no-ops when count is within keepCount", async () => { + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi + .fn() + .mockResolvedValue([{ id: "dep-1" }, { id: "dep-2" }]), + }), + }), + }), + update: vi.fn(), + }; + + await pruneDeployments(db as any, APP_ID, TEST_SITES_DIR, 5); + + expect(db.update).not.toHaveBeenCalled(); + }); + + it("prunes excess deployments and sets prunedAt", async () => { + // orderBy(desc(createdAt)) — newest first + const ordered = [ + "dep-new-1", + "dep-new-2", + "dep-old-1", + "dep-old-2", + "dep-old-3", + ]; + for (const id of ordered) { + fs.mkdirSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, id), { + recursive: true, + }); + } + + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(ordered.map((id) => ({ id }))), + }), + }), + }), + update: vi.fn().mockReturnValue({ set: updateSet }), + }; + + await pruneDeployments(db as any, APP_ID, TEST_SITES_DIR, 2); + + // keepCount=2 → newest 2 survive + expect( + fs.existsSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-new-1")), + ).toBe(true); + expect( + fs.existsSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-new-2")), + ).toBe(true); + // old ones pruned + expect( + fs.existsSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-old-1")), + ).toBe(false); + expect( + fs.existsSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-old-2")), + ).toBe(false); + expect( + fs.existsSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-old-3")), + ).toBe(false); + + expect(db.update).toHaveBeenCalledTimes(3); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ prunedAt: expect.any(Date) }), + ); + }); + + it("handles missing deployment directories gracefully", async () => { + const depIds = ["dep-a", "dep-b", "dep-c"]; + // Only create dir for dep-a + fs.mkdirSync(getDeploymentDir(TEST_SITES_DIR, APP_ID, "dep-a"), { + recursive: true, + }); + + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const db = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(depIds.map((id) => ({ id }))), + }), + }), + }), + update: vi.fn().mockReturnValue({ set: updateSet }), + }; + + await expect( + pruneDeployments(db as any, APP_ID, TEST_SITES_DIR, 1), + ).resolves.toBeUndefined(); + + // Should prune 2 (dep-b and dep-c), dep-a should survive (keepCount=1 means newest survives) + expect(db.update).toHaveBeenCalledTimes(2); + }); + }); +}); 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", }); });