From 438347234e4c930d843090518a95a6b60e013808 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sun, 17 May 2026 07:02:41 +0300 Subject: [PATCH 1/2] refactor(worker): restructure src into infrastructure/ + strategies/ --- packages/worker/src/deployments/env-vars.ts | 29 ++ .../src/deployments/{shared.ts => events.ts} | 28 +- .../{orchestrator.ts => pipeline.ts} | 8 +- .../{process-deployment.ts => processor.ts} | 9 +- .../src/deployments/steps/build-step.ts | 4 +- .../src/deployments/steps/clone-step.ts | 6 +- .../src/deployments/steps/install-step.ts | 6 +- .../src/deployments/steps/verify-step.ts | 2 +- .../dockerfile.ts} | 6 +- .../static.ts} | 22 +- packages/worker/src/index.ts | 6 +- .../caddy/client.ts | 0 .../caddy/config-builder.ts | 0 .../docker/docker-runner.ts | 0 .../logs => infrastructure}/log-buffer.ts | 4 +- .../utils => infrastructure}/retry.ts | 0 packages/worker/src/jobs/deploy.ts | 1 - .../worker/test/unit/caddy-config.test.ts | 55 --- .../test/unit/deploy-dockerfile.test.ts | 240 ----------- .../{ => deployments}/classify-error.test.ts | 2 +- .../test/unit/deployments/pipeline.test.ts | 406 ++++++++++++++++++ .../deployments/steps/verify-step.test.ts | 71 +++ .../unit/infrastructure/caddy-config.test.ts | 68 +++ .../{ => infrastructure}/log-buffer.test.ts | 3 +- .../unit/{ => infrastructure}/retry.test.ts | 4 +- .../worker/test/unit/orchestrator.test.ts | 270 ------------ packages/worker/test/unit/verify-step.test.ts | 54 --- 27 files changed, 617 insertions(+), 687 deletions(-) create mode 100644 packages/worker/src/deployments/env-vars.ts rename packages/worker/src/deployments/{shared.ts => events.ts} (69%) rename packages/worker/src/deployments/{orchestrator.ts => pipeline.ts} (88%) rename packages/worker/src/deployments/{process-deployment.ts => processor.ts} (65%) rename packages/worker/src/deployments/{deploy-dockerfile.ts => strategies/dockerfile.ts} (97%) rename packages/worker/src/deployments/{deploy-static.ts => strategies/static.ts} (92%) rename packages/worker/src/{deployments => infrastructure}/caddy/client.ts (100%) rename packages/worker/src/{deployments => infrastructure}/caddy/config-builder.ts (100%) rename packages/worker/src/{deployments => infrastructure}/docker/docker-runner.ts (100%) rename packages/worker/src/{deployments/logs => infrastructure}/log-buffer.ts (93%) rename packages/worker/src/{deployments/utils => infrastructure}/retry.ts (100%) delete mode 100644 packages/worker/src/jobs/deploy.ts delete mode 100644 packages/worker/test/unit/caddy-config.test.ts delete mode 100644 packages/worker/test/unit/deploy-dockerfile.test.ts rename packages/worker/test/unit/{ => deployments}/classify-error.test.ts (97%) create mode 100644 packages/worker/test/unit/deployments/pipeline.test.ts create mode 100644 packages/worker/test/unit/deployments/steps/verify-step.test.ts create mode 100644 packages/worker/test/unit/infrastructure/caddy-config.test.ts rename packages/worker/test/unit/{ => infrastructure}/log-buffer.test.ts (93%) rename packages/worker/test/unit/{ => infrastructure}/retry.test.ts (95%) delete mode 100644 packages/worker/test/unit/orchestrator.test.ts delete mode 100644 packages/worker/test/unit/verify-step.test.ts diff --git a/packages/worker/src/deployments/env-vars.ts b/packages/worker/src/deployments/env-vars.ts new file mode 100644 index 0000000..d2298c5 --- /dev/null +++ b/packages/worker/src/deployments/env-vars.ts @@ -0,0 +1,29 @@ +import { decrypt, envVars } from "@shipyard/shared"; +import { eq } from "drizzle-orm"; +import type { Env } from "../config/env.js"; + +export async function fetchDecryptedEnvVars( + db: unknown, + env: Env, + appId: string, +) { + // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract + const rows = await (db as any) + .select() + .from(envVars) + .where(eq(envVars.appId, appId)); + + const masterKey = env.ENCRYPTION_KEY; + const result: Record = {}; + + for (const row of rows as { + key: string; + value: string; + isSecret: boolean; + }[]) { + const val = row.isSecret ? decrypt(row.value, masterKey) : row.value; + result[row.key] = val; + } + + return result; +} diff --git a/packages/worker/src/deployments/shared.ts b/packages/worker/src/deployments/events.ts similarity index 69% rename from packages/worker/src/deployments/shared.ts rename to packages/worker/src/deployments/events.ts index b18e490..dcad295 100644 --- a/packages/worker/src/deployments/shared.ts +++ b/packages/worker/src/deployments/events.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { buildJobs, decrypt, deploymentLogs, envVars } from "@shipyard/shared"; +import { buildJobs, deploymentLogs } from "@shipyard/shared"; import { and, eq } from "drizzle-orm"; import type { Env } from "../config/env.js"; @@ -12,32 +12,6 @@ export function createWorkspace(env: Env, deploymentId: string): string { return ws; } -export async function fetchDecryptedEnvVars( - db: unknown, - env: Env, - appId: string, -) { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - const rows = await (db as any) - .select() - .from(envVars) - .where(eq(envVars.appId, appId)); - - const masterKey = env.ENCRYPTION_KEY; - const result: Record = {}; - - for (const row of rows as { - key: string; - value: string; - isSecret: boolean; - }[]) { - const val = row.isSecret ? decrypt(row.value, masterKey) : row.value; - result[row.key] = val; - } - - return result; -} - export async function createBuildJobRow( db: unknown, deploymentId: string, diff --git a/packages/worker/src/deployments/orchestrator.ts b/packages/worker/src/deployments/pipeline.ts similarity index 88% rename from packages/worker/src/deployments/orchestrator.ts rename to packages/worker/src/deployments/pipeline.ts index 8348971..8625e6a 100644 --- a/packages/worker/src/deployments/orchestrator.ts +++ b/packages/worker/src/deployments/pipeline.ts @@ -6,9 +6,9 @@ import { } from "@shipyard/shared"; import { asc, eq } from "drizzle-orm"; import type { Env } from "../config/env.js"; -import { deployDockerfile } from "./deploy-dockerfile.js"; -import { deployBuildPack } from "./deploy-static.js"; -import type { DockerRunner } from "./docker/docker-runner.js"; +import type { DockerRunner } from "../infrastructure/docker/docker-runner.js"; +import { deployDockerfile } from "./strategies/dockerfile.js"; +import { deployBuildPack } from "./strategies/static.js"; export interface OrchestratorDeps { db: unknown; @@ -34,8 +34,8 @@ export interface OrchestratorDeps { export class DeploymentOrchestrator { constructor(private deps: OrchestratorDeps) {} - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract private get db() { + // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract return this.deps.db as any; } diff --git a/packages/worker/src/deployments/process-deployment.ts b/packages/worker/src/deployments/processor.ts similarity index 65% rename from packages/worker/src/deployments/process-deployment.ts rename to packages/worker/src/deployments/processor.ts index e4fa51c..dffcdb1 100644 --- a/packages/worker/src/deployments/process-deployment.ts +++ b/packages/worker/src/deployments/processor.ts @@ -1,9 +1,12 @@ import { db } from "../config/db.js"; import { getEnv } from "../config/env.js"; import { logger } from "../config/logger.js"; -import { upsertFileRoute, upsertProxyRoute } from "./caddy/client.js"; -import { DockerRunner } from "./docker/docker-runner.js"; -import { DeploymentOrchestrator } from "./orchestrator.js"; +import { + upsertFileRoute, + upsertProxyRoute, +} from "../infrastructure/caddy/client.js"; +import { DockerRunner } from "../infrastructure/docker/docker-runner.js"; +import { DeploymentOrchestrator } from "./pipeline.js"; export async function processDeployment(deploymentId: string): Promise { const orchestrator = new DeploymentOrchestrator({ diff --git a/packages/worker/src/deployments/steps/build-step.ts b/packages/worker/src/deployments/steps/build-step.ts index dad1439..8c2a340 100644 --- a/packages/worker/src/deployments/steps/build-step.ts +++ b/packages/worker/src/deployments/steps/build-step.ts @@ -1,7 +1,7 @@ import type { App } from "@shipyard/shared/schema"; -import type { DockerRunner } from "../docker/docker-runner.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import type { LogBuffer } from "../../infrastructure/log-buffer.js"; import { classifyError } from "../errors/classify-error.js"; -import type { LogBuffer } from "../logs/log-buffer.js"; import type { StepResult } from "./clone-step.js"; /** diff --git a/packages/worker/src/deployments/steps/clone-step.ts b/packages/worker/src/deployments/steps/clone-step.ts index 4bfcae9..00c81a5 100644 --- a/packages/worker/src/deployments/steps/clone-step.ts +++ b/packages/worker/src/deployments/steps/clone-step.ts @@ -1,9 +1,9 @@ import { StepError } from "@shipyard/shared"; import type { App } from "@shipyard/shared/schema"; -import type { DockerRunner } from "../docker/docker-runner.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import type { LogBuffer } from "../../infrastructure/log-buffer.js"; +import { RetryExhaustedError, withRetry } from "../../infrastructure/retry.js"; import { classifyError } from "../errors/classify-error.js"; -import type { LogBuffer } from "../logs/log-buffer.js"; -import { RetryExhaustedError, withRetry } from "../utils/retry.js"; /** Result returned by a build step — ok or classified error. */ export type StepResult = { diff --git a/packages/worker/src/deployments/steps/install-step.ts b/packages/worker/src/deployments/steps/install-step.ts index f8f5a7b..0d18dc9 100644 --- a/packages/worker/src/deployments/steps/install-step.ts +++ b/packages/worker/src/deployments/steps/install-step.ts @@ -1,9 +1,9 @@ import { StepError } from "@shipyard/shared"; import type { App } from "@shipyard/shared/schema"; -import type { DockerRunner } from "../docker/docker-runner.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import type { LogBuffer } from "../../infrastructure/log-buffer.js"; +import { RetryExhaustedError, withRetry } from "../../infrastructure/retry.js"; import { classifyError } from "../errors/classify-error.js"; -import type { LogBuffer } from "../logs/log-buffer.js"; -import { RetryExhaustedError, withRetry } from "../utils/retry.js"; import type { StepResult } from "./clone-step.js"; /** diff --git a/packages/worker/src/deployments/steps/verify-step.ts b/packages/worker/src/deployments/steps/verify-step.ts index a789e34..cf79073 100644 --- a/packages/worker/src/deployments/steps/verify-step.ts +++ b/packages/worker/src/deployments/steps/verify-step.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { getEnv } from "../../config/env.js"; -import type { LogBuffer } from "../logs/log-buffer.js"; +import type { LogBuffer } from "../../infrastructure/log-buffer.js"; import type { StepResult } from "./clone-step.js"; /** Filenames to exclude from output file count. */ diff --git a/packages/worker/src/deployments/deploy-dockerfile.ts b/packages/worker/src/deployments/strategies/dockerfile.ts similarity index 97% rename from packages/worker/src/deployments/deploy-dockerfile.ts rename to packages/worker/src/deployments/strategies/dockerfile.ts index 0595e08..8606b68 100644 --- a/packages/worker/src/deployments/deploy-dockerfile.ts +++ b/packages/worker/src/deployments/strategies/dockerfile.ts @@ -2,14 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import { apps, deployments, domains } from "@shipyard/shared"; import { and, eq } from "drizzle-orm"; -import type { DockerRunner } from "./docker/docker-runner.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import { fetchDecryptedEnvVars } from "../env-vars.js"; import { createBuildJobRow, createWorkspace, - fetchDecryptedEnvVars, finalizeBuildJobRow, insertStructuredEvent, -} from "./shared.js"; +} from "../events.js"; const PORT_CONFLICT_RETRIES = 3; const PORT_CONFLICT_BACKOFF_MS = 2000; diff --git a/packages/worker/src/deployments/deploy-static.ts b/packages/worker/src/deployments/strategies/static.ts similarity index 92% rename from packages/worker/src/deployments/deploy-static.ts rename to packages/worker/src/deployments/strategies/static.ts index cdaf290..f74d8ea 100644 --- a/packages/worker/src/deployments/deploy-static.ts +++ b/packages/worker/src/deployments/strategies/static.ts @@ -2,28 +2,28 @@ import fs from "node:fs"; import path from "node:path"; import { apps, deployments, domains } from "@shipyard/shared"; import { and, eq } from "drizzle-orm"; -import type { DockerRunner } from "./docker/docker-runner.js"; -import { LogBuffer } from "./logs/log-buffer.js"; +import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; +import { LogBuffer } from "../../infrastructure/log-buffer.js"; +import { fetchDecryptedEnvVars } from "../env-vars.js"; import { createBuildJobRow, createWorkspace, - fetchDecryptedEnvVars, finalizeBuildJobRow, insertStructuredEvent, TWO_GB, -} from "./shared.js"; -import { runBuildStep } from "./steps/build-step.js"; -import type { StepResult } from "./steps/clone-step.js"; -import { runCloneStep } from "./steps/clone-step.js"; -import { runCopyStep } from "./steps/copy-step.js"; -import { runInstallStep } from "./steps/install-step.js"; -import { runVerifyStep } from "./steps/verify-step.js"; +} from "../events.js"; +import { runBuildStep } from "../steps/build-step.js"; +import type { StepResult } from "../steps/clone-step.js"; +import { runCloneStep } from "../steps/clone-step.js"; +import { runCopyStep } from "../steps/copy-step.js"; +import { runInstallStep } from "../steps/install-step.js"; +import { runVerifyStep } from "../steps/verify-step.js"; export async function deployBuildPack( deploymentId: string, // biome-ignore lint/suspicious/noExplicitAny: DB query result shape known at runtime app: Record, - userId: string, + _userId: string, githubAccessToken: string | null, // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract db: any, diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 27860fc..755de79 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -16,12 +16,12 @@ import Redis from "ioredis"; 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 { upsertFileRoute, upsertProxyRoute, -} from "./deployments/caddy/client.js"; -import { DockerRunner } from "./deployments/docker/docker-runner.js"; -import { processDeployment } from "./jobs/deploy.js"; +} from "./infrastructure/caddy/client.js"; +import { DockerRunner } from "./infrastructure/docker/docker-runner.js"; logger.info("Shipyard worker starting..."); diff --git a/packages/worker/src/deployments/caddy/client.ts b/packages/worker/src/infrastructure/caddy/client.ts similarity index 100% rename from packages/worker/src/deployments/caddy/client.ts rename to packages/worker/src/infrastructure/caddy/client.ts diff --git a/packages/worker/src/deployments/caddy/config-builder.ts b/packages/worker/src/infrastructure/caddy/config-builder.ts similarity index 100% rename from packages/worker/src/deployments/caddy/config-builder.ts rename to packages/worker/src/infrastructure/caddy/config-builder.ts diff --git a/packages/worker/src/deployments/docker/docker-runner.ts b/packages/worker/src/infrastructure/docker/docker-runner.ts similarity index 100% rename from packages/worker/src/deployments/docker/docker-runner.ts rename to packages/worker/src/infrastructure/docker/docker-runner.ts diff --git a/packages/worker/src/deployments/logs/log-buffer.ts b/packages/worker/src/infrastructure/log-buffer.ts similarity index 93% rename from packages/worker/src/deployments/logs/log-buffer.ts rename to packages/worker/src/infrastructure/log-buffer.ts index a587d05..a0ecb35 100644 --- a/packages/worker/src/deployments/logs/log-buffer.ts +++ b/packages/worker/src/infrastructure/log-buffer.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { getEnv } from "../../config/env.js"; -import { logger } from "../../config/logger.js"; +import { getEnv } from "../config/env.js"; +import { logger } from "../config/logger.js"; export class LogBuffer { private step: string; diff --git a/packages/worker/src/deployments/utils/retry.ts b/packages/worker/src/infrastructure/retry.ts similarity index 100% rename from packages/worker/src/deployments/utils/retry.ts rename to packages/worker/src/infrastructure/retry.ts diff --git a/packages/worker/src/jobs/deploy.ts b/packages/worker/src/jobs/deploy.ts deleted file mode 100644 index 1d58501..0000000 --- a/packages/worker/src/jobs/deploy.ts +++ /dev/null @@ -1 +0,0 @@ -export { processDeployment } from "../deployments/process-deployment.js"; diff --git a/packages/worker/test/unit/caddy-config.test.ts b/packages/worker/test/unit/caddy-config.test.ts deleted file mode 100644 index bceb555..0000000 --- a/packages/worker/test/unit/caddy-config.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildReverseProxyRouteConfig, - buildRouteConfig, -} from "../../src/deployments/caddy/config-builder.js"; - -describe("buildRouteConfig", () => { - const domain = "myapp.bigboss.dev"; - const appId = "app-1"; - - it("returns a route with correct @id and host match", () => { - const route = buildRouteConfig(domain, appId, false); - expect(route["@id"]).toBe("app-app-1"); - expect(route.match).toEqual([{ host: [domain] }]); - expect(route.terminal).toBe(true); - }); - - it("uses file_server with correct root when isSpa is false", () => { - const route = buildRouteConfig(domain, appId, false); - expect(route.handle).toHaveLength(1); - const handler = route.handle[0] as Record; - expect(handler.handler).toBe("file_server"); - expect(handler.root).toBe("/var/lib/shipyard/sites/app-1"); - }); - - it("uses subroute with error fallback when isSpa is true", () => { - const route = buildRouteConfig(domain, appId, true); - expect(route.handle).toHaveLength(1); - const handler = route.handle[0] as Record; - expect(handler.handler).toBe("subroute"); - expect(handler.routes).toBeDefined(); - expect(handler.errors).toBeDefined(); - }); -}); - -describe("buildReverseProxyRouteConfig", () => { - const domain = "myapp.bigboss.dev"; - const appId = "app-1"; - const port = 3000; - - it("returns a route with correct @id and host match", () => { - const route = buildReverseProxyRouteConfig(domain, appId, port); - expect(route["@id"]).toBe("app-app-1"); - expect(route.match).toEqual([{ host: [domain] }]); - expect(route.terminal).toBe(true); - }); - - it("uses reverse_proxy handler with correct upstream port", () => { - const route = buildReverseProxyRouteConfig(domain, appId, port); - expect(route.handle).toHaveLength(1); - const handler = route.handle[0] as Record; - expect(handler.handler).toBe("reverse_proxy"); - expect(handler.upstreams).toEqual([{ dial: "shipyard-app-app-1:3000" }]); - }); -}); diff --git a/packages/worker/test/unit/deploy-dockerfile.test.ts b/packages/worker/test/unit/deploy-dockerfile.test.ts deleted file mode 100644 index 71dc957..0000000 --- a/packages/worker/test/unit/deploy-dockerfile.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OrchestratorDeps } from "../../src/deployments/orchestrator.js"; -import { DeploymentOrchestrator } from "../../src/deployments/orchestrator.js"; - -const BUILD_DIR = "/tmp/shipyard-test/dockerfile-builds"; - -function makeSelectChain(results: unknown[][]) { - let callIndex = 0; - const chain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockImplementation(() => { - const idx = callIndex; - callIndex++; - const value = results[idx] ?? []; - return { - orderBy: vi.fn().mockResolvedValue(value), - then: vi - .fn() - .mockImplementation( - (resolve: (v: unknown) => void, _reject: (e: unknown) => void) => { - resolve(value); - }, - ), - }; - }), - }; - return chain; -} - -function makeDeps(selectResults?: unknown[][]) { - const selectChain = makeSelectChain(selectResults ?? []); - - const mockDb = { - select: vi.fn().mockReturnValue(selectChain), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - }), - }; - - const mockRunner = { - create: vi.fn().mockResolvedValue({ id: "container-1" }), - exec: vi.fn().mockResolvedValue({ - exitCode: 0, - oomKilled: false, - stdout: "", - stderr: "", - }), - remove: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - listManaged: vi.fn().mockResolvedValue([]), - inspect: vi.fn().mockResolvedValue({}), - buildImage: vi.fn().mockResolvedValue(undefined), - runLongLived: vi.fn().mockResolvedValue(43210), - stopByName: vi.fn().mockResolvedValue(undefined), - runOnce: vi.fn().mockResolvedValue(0), - pruneOldImageTags: vi.fn().mockResolvedValue(undefined), - }; - - const upsertFileRoute = vi.fn().mockResolvedValue(undefined); - const upsertProxyRoute = vi.fn().mockResolvedValue(undefined); - - return { - db: mockDb, - env: { - BUILD_WORKSPACE_DIR: "/tmp/shipyard-test/dockerfile-builds", - BASE_DOMAIN: "bigboss.dev", - ENCRYPTION_KEY: - "ded637fc26820406b811e228d84a0c26dc8b561d6d7fea7ecd0d980b2544cc61", - WORKER_ID: "worker-test", - DOCKER_NETWORK: "shipyard", - CADDY_ADMIN_URL: "http://caddy:2019", - AUTO_HTTPS: false, - LOG_TO_FILE: false, - DATABASE_URL: "postgres://localhost:5432/test", - REDIS_URL: "redis://localhost:6379", - NODE_ENV: "test", - SITES_DIR: "/tmp/shipyard-test/sites", - }, - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - runner: mockRunner as any, - upsertFileRoute, - upsertProxyRoute, - } as OrchestratorDeps; -} - -function makeDockerfileAppContext() { - return [ - { - deployment: { id: "deploy-df-1" }, - app: { - id: "app-df-1", - name: "myapp-dockerfile", - githubRepo: "user/repo", - buildTimeout: 900, - buildPack: "dockerfile", - port: 3000, - dockerfilePath: "./Dockerfile", - outputDir: null, - isSpa: false, - branch: "main", - }, - githubAccessToken: "gh_token_123", - userId: "user-1", - }, - ]; -} - -function setupRepo(deploymentId: string) { - const dir = path.join(BUILD_DIR, deploymentId, "repo"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "Dockerfile"), "FROM nginx:alpine\n"); -} - -function cleanupBuilds() { - fs.rmSync(BUILD_DIR, { recursive: true, force: true }); -} - -describe("Dockerfile build pack", () => { - afterEach(() => { - cleanupBuilds(); - }); - - it("routes to dockerfile path when buildPack is dockerfile", async () => { - setupRepo("deploy-df-1"); - const deps = makeDeps([makeDockerfileAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-df-1"); - - expect(deps.runner.runOnce).toHaveBeenCalledTimes(1); - expect(deps.runner.runOnce).toHaveBeenCalledWith( - expect.objectContaining({ image: "alpine/git" }), - ); - expect(deps.runner.buildImage).toHaveBeenCalledTimes(1); - expect(deps.runner.stopByName).toHaveBeenCalledWith( - "shipyard-app-app-df-1", - ); - expect(deps.runner.runLongLived).toHaveBeenCalledTimes(1); - expect(deps.upsertProxyRoute).toHaveBeenCalledWith( - "app-df-1", - "myapp-dockerfile.bigboss.dev", - 3000, - ); - expect(deps.upsertFileRoute).not.toHaveBeenCalled(); - }); - - it("stops old container before starting new one on re-deploy", async () => { - setupRepo("deploy-df-2"); - const deps = makeDeps([makeDockerfileAppContext(), [], []]); - deps.runner.stopByName = vi.fn().mockResolvedValue(undefined); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-df-2"); - - expect(deps.runner.stopByName).toHaveBeenCalledWith( - "shipyard-app-app-df-1", - ); - expect(deps.runner.runLongLived).toHaveBeenCalledTimes(1); - }); - - it("marks deployment as failed when clone step fails", async () => { - setupRepo("deploy-df-3"); - const deps = makeDeps([makeDockerfileAppContext(), [], []]); - deps.runner.runOnce = vi.fn().mockResolvedValue(128); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-df-3"); - - expect(deps.runner.buildImage).not.toHaveBeenCalled(); - expect(deps.runner.runLongLived).not.toHaveBeenCalled(); - expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "failed" }), - ); - }); - - it("marks deployment as failed when build step fails", async () => { - setupRepo("deploy-df-4"); - const deps = makeDeps([makeDockerfileAppContext(), [], []]); - deps.runner.buildImage = vi - .fn() - .mockRejectedValue(new Error("Build failed")); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-df-4"); - - expect(deps.runner.runLongLived).not.toHaveBeenCalled(); - expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "failed" }), - ); - }); - - it("fails on missing GitHub token — no clone container created", async () => { - const ctx = [ - { - deployment: { id: "deploy-df-5" }, - app: { - id: "app-df-5", - name: "myapp-dockerfile", - githubRepo: "user/repo", - buildPack: "dockerfile", - }, - githubAccessToken: null, - userId: "user-5", - }, - ]; - const deps = makeDeps([ctx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-df-5"); - - expect(deps.runner.runOnce).not.toHaveBeenCalled(); - expect(deps.runner.buildImage).not.toHaveBeenCalled(); - expect(deps.runner.runLongLived).not.toHaveBeenCalled(); - }); - - it("cleans up workspace after successful deployment", async () => { - const deploymentId = "deploy-df-6"; - setupRepo(deploymentId); - const deps = makeDeps([makeDockerfileAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process(deploymentId); - - expect(fs.existsSync(path.join(BUILD_DIR, deploymentId))).toBe(false); - }); -}); diff --git a/packages/worker/test/unit/classify-error.test.ts b/packages/worker/test/unit/deployments/classify-error.test.ts similarity index 97% rename from packages/worker/test/unit/classify-error.test.ts rename to packages/worker/test/unit/deployments/classify-error.test.ts index ef9d485..9060e7c 100644 --- a/packages/worker/test/unit/classify-error.test.ts +++ b/packages/worker/test/unit/deployments/classify-error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyError } from "../../src/deployments/errors/classify-error.js"; +import { classifyError } from "../../../src/deployments/errors/classify-error.js"; describe("classifyError", () => { describe("clone step", () => { diff --git a/packages/worker/test/unit/deployments/pipeline.test.ts b/packages/worker/test/unit/deployments/pipeline.test.ts new file mode 100644 index 0000000..aaf89c8 --- /dev/null +++ b/packages/worker/test/unit/deployments/pipeline.test.ts @@ -0,0 +1,406 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OrchestratorDeps } from "../../../src/deployments/pipeline.js"; +import { DeploymentOrchestrator } from "../../../src/deployments/pipeline.js"; + +function makeSelectChain(results: unknown[][]) { + let callIndex = 0; + const chain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockImplementation(() => { + const idx = callIndex; + callIndex++; + const value = results[idx] ?? []; + return { + orderBy: vi.fn().mockResolvedValue(value), + then: vi + .fn() + .mockImplementation( + (resolve: (v: unknown) => void, _reject: (e: unknown) => void) => { + resolve(value); + }, + ), + }; + }), + }; + return chain; +} + +function makeDeps(selectResults?: unknown[][], buildDir?: string) { + const selectChain = makeSelectChain(selectResults ?? []); + + const mockDb = { + select: vi.fn().mockReturnValue(selectChain), + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockResolvedValue(undefined), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }), + }; + + const mockRunner = { + create: vi.fn().mockResolvedValue({ id: "container-1" }), + exec: vi.fn().mockResolvedValue({ + exitCode: 0, + oomKilled: false, + stdout: "", + stderr: "", + }), + remove: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + listManaged: vi.fn().mockResolvedValue([]), + inspect: vi.fn().mockResolvedValue({}), + buildImage: vi.fn().mockResolvedValue(undefined), + runLongLived: vi.fn().mockResolvedValue(43210), + stopByName: vi.fn().mockResolvedValue(undefined), + runOnce: vi.fn().mockResolvedValue(0), + pruneOldImageTags: vi.fn().mockResolvedValue(undefined), + }; + + const upsertFileRoute = vi.fn().mockResolvedValue(undefined); + const upsertProxyRoute = vi.fn().mockResolvedValue(undefined); + + return { + db: mockDb, + env: { + BUILD_WORKSPACE_DIR: buildDir ?? "/tmp/shipyard-test/builds", + BASE_DOMAIN: "bigboss.dev", + ENCRYPTION_KEY: + "ded637fc26820406b811e228d84a0c26dc8b561d6d7fea7ecd0d980b2544cc61", + WORKER_ID: "worker-test", + DOCKER_NETWORK: "shipyard", + CADDY_ADMIN_URL: "http://caddy:2019", + AUTO_HTTPS: false, + LOG_TO_FILE: false, + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + runner: mockRunner as any, + upsertFileRoute, + upsertProxyRoute, + } as OrchestratorDeps; +} + +describe("DeploymentOrchestrator", () => { + describe("static build pack", () => { + const BUILD_DIR = "/tmp/shipyard-test/builds"; + const SITES_DIR = "/tmp/shipyard-test/sites"; + + function makeAppContext() { + return [ + { + deployment: { id: "deploy-1" }, + app: { + id: "app-1", + name: "myapp", + githubRepo: "user/repo", + buildTimeout: 900, + outputDir: "dist", + isSpa: false, + branch: "main", + }, + githubAccessToken: "gh_token_123", + userId: "user-1", + }, + ]; + } + + function setupOutput(deploymentId: string) { + const dir = path.join(BUILD_DIR, deploymentId, "repo", "dist"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "index.html"), "

test

"); + } + + afterEach(() => { + fs.rmSync(BUILD_DIR, { recursive: true, force: true }); + fs.rmSync(SITES_DIR, { recursive: true, force: true }); + }); + + it("runs all steps and activates deployment on success", async () => { + setupOutput("deploy-1"); + const deps = makeDeps([makeAppContext(), [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-1"); + + expect(deps.runner.create).toHaveBeenCalledTimes(1); + expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); + expect(deps.upsertFileRoute).toHaveBeenCalledWith( + "app-1", + "myapp.bigboss.dev", + false, + ); + }); + + it("marks deployment as 'building' on start", async () => { + setupOutput("deploy-1"); + const deps = makeDeps([makeAppContext(), [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-1"); + + expect((deps.db as any).update).toHaveBeenCalled(); + }); + + it("marks deployment as 'success' after completion", async () => { + setupOutput("deploy-1"); + const deps = makeDeps([makeAppContext(), [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-1"); + + const updates = (deps.db as any).update.mock.results; + const lastSet = updates[updates.length - 1].value.set; + expect(lastSet).toHaveBeenCalledWith( + expect.objectContaining({ status: "success" }), + ); + }); + + it("fails on missing GitHub token — no container created", async () => { + const ctx = [ + { + deployment: { id: "deploy-2" }, + app: { id: "app-2", name: "myapp", githubRepo: "user/repo" }, + githubAccessToken: null, + userId: "user-2", + }, + ]; + const deps = makeDeps([ctx, [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-2"); + + expect(deps.runner.create).not.toHaveBeenCalled(); + expect(deps.runner.remove).not.toHaveBeenCalled(); + }); + + it("marks deployment as 'failed' when GitHub token is missing", async () => { + const ctx = [ + { + deployment: { id: "deploy-3" }, + app: { id: "app-3", name: "myapp", githubRepo: "user/repo" }, + githubAccessToken: null, + userId: "user-3", + }, + ]; + const deps = makeDeps([ctx, [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-3"); + + const updates = (deps.db as any).update.mock.results; + const failedUpdate = updates + .map((r: any) => r.value.set.mock.calls[0]?.[0]) + .find((s: any) => s?.status === "failed"); + expect(failedUpdate).toBeDefined(); + }); + + it("times out when build takes too long", async () => { + setupOutput("deploy-4"); + const appCtx = makeAppContext(); + appCtx[0].app.buildTimeout = 0; + const deps = makeDeps([appCtx, [], []]); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-4"); + + expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); + expect(deps.logger.error).toHaveBeenCalled(); + const updates = (deps.db as any).update.mock.results; + const lastSet = updates[updates.length - 1].value.set; + expect(lastSet).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed" }), + ); + }); + + it("marks deployment as failed when clone step fails", async () => { + setupOutput("deploy-5"); + const deps = makeDeps([makeAppContext(), [], []]); + (deps.runner as any).exec = vi.fn().mockResolvedValue({ + exitCode: 128, + oomKilled: false, + stdout: "", + stderr: "Permission denied", + }); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-5"); + + expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); + expect(deps.upsertFileRoute).not.toHaveBeenCalled(); + expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); + const updates = (deps.db as any).update.mock.results; + const lastSet = updates[updates.length - 1].value.set; + expect(lastSet).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed" }), + ); + }); + + it("cleans up container and workspace on unexpected error", async () => { + setupOutput("deploy-6"); + const deps = makeDeps([makeAppContext(), [], []]); + (deps.runner as any).create = vi + .fn() + .mockRejectedValue(new Error("docker error")); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-6"); + + const updates = (deps.db as any).update.mock.results; + const failedSet = updates + .map((r: any) => r.value.set.mock.calls[0]?.[0]) + .find((s: any) => s?.status === "failed"); + expect(failedSet).toBeDefined(); + }); + }); + + describe("dockerfile build pack", () => { + const BUILD_DIR = "/tmp/shipyard-test/dockerfile-builds"; + + function makeDockerfileAppContext() { + return [ + { + deployment: { id: "deploy-df-1" }, + app: { + id: "app-df-1", + name: "myapp-dockerfile", + githubRepo: "user/repo", + buildTimeout: 900, + buildPack: "dockerfile", + port: 3000, + dockerfilePath: "./Dockerfile", + outputDir: null, + isSpa: false, + branch: "main", + }, + githubAccessToken: "gh_token_123", + userId: "user-1", + }, + ]; + } + + function setupRepo(deploymentId: string) { + const dir = path.join(BUILD_DIR, deploymentId, "repo"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "Dockerfile"), "FROM nginx:alpine\n"); + } + + afterEach(() => { + fs.rmSync(BUILD_DIR, { recursive: true, force: true }); + }); + + it("routes to dockerfile path when buildPack is dockerfile", async () => { + setupRepo("deploy-df-1"); + const deps = makeDeps([makeDockerfileAppContext(), [], []], BUILD_DIR); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-df-1"); + + expect(deps.runner.runOnce).toHaveBeenCalledTimes(1); + expect(deps.runner.runOnce).toHaveBeenCalledWith( + expect.objectContaining({ image: "alpine/git" }), + ); + expect(deps.runner.buildImage).toHaveBeenCalledTimes(1); + expect(deps.runner.stopByName).toHaveBeenCalledWith( + "shipyard-app-app-df-1", + ); + expect(deps.runner.runLongLived).toHaveBeenCalledTimes(1); + expect(deps.upsertProxyRoute).toHaveBeenCalledWith( + "app-df-1", + "myapp-dockerfile.bigboss.dev", + 3000, + ); + expect(deps.upsertFileRoute).not.toHaveBeenCalled(); + }); + + it("stops old container before starting new one on re-deploy", async () => { + setupRepo("deploy-df-2"); + const deps = makeDeps([makeDockerfileAppContext(), [], []], BUILD_DIR); + deps.runner.stopByName = vi.fn().mockResolvedValue(undefined); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-df-2"); + + expect(deps.runner.stopByName).toHaveBeenCalledWith( + "shipyard-app-app-df-1", + ); + expect(deps.runner.runLongLived).toHaveBeenCalledTimes(1); + }); + + it("marks deployment as failed when clone step fails", async () => { + setupRepo("deploy-df-3"); + const deps = makeDeps([makeDockerfileAppContext(), [], []], BUILD_DIR); + deps.runner.runOnce = vi.fn().mockResolvedValue(128); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-df-3"); + + expect(deps.runner.buildImage).not.toHaveBeenCalled(); + expect(deps.runner.runLongLived).not.toHaveBeenCalled(); + expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); + const updates = (deps.db as any).update.mock.results; + const lastSet = updates[updates.length - 1].value.set; + expect(lastSet).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed" }), + ); + }); + + it("marks deployment as failed when build step fails", async () => { + setupRepo("deploy-df-4"); + const deps = makeDeps([makeDockerfileAppContext(), [], []], BUILD_DIR); + deps.runner.buildImage = vi + .fn() + .mockRejectedValue(new Error("Build failed")); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-df-4"); + + expect(deps.runner.runLongLived).not.toHaveBeenCalled(); + expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); + const updates = (deps.db as any).update.mock.results; + const lastSet = updates[updates.length - 1].value.set; + expect(lastSet).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed" }), + ); + }); + + it("fails on missing GitHub token — no clone container created", async () => { + const ctx = [ + { + deployment: { id: "deploy-df-5" }, + app: { + id: "app-df-5", + name: "myapp-dockerfile", + githubRepo: "user/repo", + buildPack: "dockerfile", + }, + githubAccessToken: null, + userId: "user-5", + }, + ]; + const deps = makeDeps([ctx, [], []], BUILD_DIR); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process("deploy-df-5"); + + expect(deps.runner.runOnce).not.toHaveBeenCalled(); + expect(deps.runner.buildImage).not.toHaveBeenCalled(); + expect(deps.runner.runLongLived).not.toHaveBeenCalled(); + }); + + it("cleans up workspace after successful deployment", async () => { + const deploymentId = "deploy-df-6"; + setupRepo(deploymentId); + const deps = makeDeps([makeDockerfileAppContext(), [], []], BUILD_DIR); + const orchestrator = new DeploymentOrchestrator(deps); + + await orchestrator.process(deploymentId); + + expect(fs.existsSync(path.join(BUILD_DIR, deploymentId))).toBe(false); + }); + }); +}); diff --git a/packages/worker/test/unit/deployments/steps/verify-step.test.ts b/packages/worker/test/unit/deployments/steps/verify-step.test.ts new file mode 100644 index 0000000..cff7e1e --- /dev/null +++ b/packages/worker/test/unit/deployments/steps/verify-step.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runVerifyStep } from "../../../../src/deployments/steps/verify-step.js"; + +const TEST_DIR = "/tmp/shipyard-test/verify-step"; + +function makeDir(relative: string) { + const dir = path.join(TEST_DIR, relative); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function makeFile(relative: string) { + const dir = makeDir(path.dirname(relative)); + const full = path.join(dir, path.basename(relative)); + fs.writeFileSync(full, "content"); + return full; +} + +describe("runVerifyStep", () => { + beforeEach(() => { + process.env.BUILD_WORKSPACE_DIR = TEST_DIR; + }); + + afterEach(() => { + delete process.env.BUILD_WORKSPACE_DIR; + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it("returns ok when output directory exists with files", async () => { + makeFile("deploy-1/repo/dist/index.html"); + const result = await runVerifyStep("deploy-1", "dist", { + appendLine: () => {}, + } as any); + expect(result.ok).toBe(true); + }); + + it("returns ok when output directory has subdirectories with files", async () => { + makeFile("deploy-2/repo/dist/sub/index.html"); + const result = await runVerifyStep("deploy-2", "dist", { + appendLine: () => {}, + } as any); + expect(result.ok).toBe(true); + }); + + it("ignores .gitkeep and .DS_Store when counting files", async () => { + const dist = makeDir("deploy-3/repo/dist"); + fs.writeFileSync(path.join(dist, ".gitkeep"), ""); + fs.writeFileSync(path.join(dist, ".DS_Store"), ""); + const result = await runVerifyStep("deploy-3", "dist", { + appendLine: () => {}, + } as any); + expect(result.ok).toBe(false); + }); + + it("returns not ok when output directory does not exist", async () => { + const result = await runVerifyStep("deploy-4", "dist", { + appendLine: () => {}, + } as any); + expect(result.ok).toBe(false); + }); + + it("returns not ok when output directory is empty", async () => { + makeDir("deploy-5/repo/dist"); + const result = await runVerifyStep("deploy-5", "dist", { + appendLine: () => {}, + } as any); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/worker/test/unit/infrastructure/caddy-config.test.ts b/packages/worker/test/unit/infrastructure/caddy-config.test.ts new file mode 100644 index 0000000..4ef506c --- /dev/null +++ b/packages/worker/test/unit/infrastructure/caddy-config.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + buildReverseProxyRouteConfig, + buildRouteConfig, +} from "../../../src/infrastructure/caddy/config-builder.js"; + +describe("Caddy config builder", () => { + describe("buildRouteConfig", () => { + it("builds a file server route for non-SPA apps", () => { + const route = buildRouteConfig("example.com", "app-1", false); + expect(route["@id"]).toBe("app-app-1"); + expect(route.terminal).toBe(true); + expect(route.match).toEqual([{ host: ["example.com"] }]); + expect(route.handle).toHaveLength(1); + expect(route.handle[0]).toMatchObject({ + handler: "file_server", + root: "/var/lib/shipyard/sites/app-1", + }); + }); + + it("builds an SPA route with fallback to index.html", () => { + const route = buildRouteConfig("spa.example.com", "app-2", true); + expect(route["@id"]).toBe("app-app-2"); + expect(route.terminal).toBe(true); + expect(route.handle[0].handler).toBe("subroute"); + const subroutes = route.handle[0].routes; + expect(subroutes).toHaveLength(1); + expect(subroutes[0].handle[0]).toMatchObject({ + handler: "file_server", + root: "/var/lib/shipyard/sites/app-2", + }); + const errors = route.handle[0].errors; + expect(errors.routes[0].handle[0]).toMatchObject({ + handler: "rewrite", + uri: "/index.html", + }); + }); + + it("accepts a boolean for isSpa", () => { + const route = buildRouteConfig("x.com", "app-3", true); + expect(route.terminal).toBe(true); + }); + }); + + describe("buildReverseProxyRouteConfig", () => { + it("builds a reverse proxy route with port", () => { + const route = buildReverseProxyRouteConfig( + "api.example.com", + "app-42", + 3000, + ); + expect(route["@id"]).toBe("app-app-42"); + expect(route.match).toEqual([{ host: ["api.example.com"] }]); + expect(route.handle[0]).toMatchObject({ + handler: "reverse_proxy", + upstreams: [{ dial: "shipyard-app-app-42:3000" }], + }); + expect(route.terminal).toBe(true); + }); + + it("accepts a numeric port", () => { + const route = buildReverseProxyRouteConfig("x.com", "app-99", 8080); + expect(route.handle[0].upstreams[0].dial).toBe( + "shipyard-app-app-99:8080", + ); + }); + }); +}); diff --git a/packages/worker/test/unit/log-buffer.test.ts b/packages/worker/test/unit/infrastructure/log-buffer.test.ts similarity index 93% rename from packages/worker/test/unit/log-buffer.test.ts rename to packages/worker/test/unit/infrastructure/log-buffer.test.ts index 2020d10..4b96394 100644 --- a/packages/worker/test/unit/log-buffer.test.ts +++ b/packages/worker/test/unit/infrastructure/log-buffer.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { LogBuffer } from "../../src/deployments/logs/log-buffer.js"; +import { LogBuffer } from "../../../src/infrastructure/log-buffer.js"; const TEST_DIR = "/tmp/shipyard-test/builds"; @@ -55,7 +55,6 @@ describe("LogBuffer", () => { log.appendLine("first"); await log.flushOnStepEnd(); - // calling flush again on a closed stream should be a no-op await log.flushOnStepEnd(); }); }); diff --git a/packages/worker/test/unit/retry.test.ts b/packages/worker/test/unit/infrastructure/retry.test.ts similarity index 95% rename from packages/worker/test/unit/retry.test.ts rename to packages/worker/test/unit/infrastructure/retry.test.ts index a6837e1..06959c9 100644 --- a/packages/worker/test/unit/retry.test.ts +++ b/packages/worker/test/unit/infrastructure/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { RetryExhaustedError, withRetry, -} from "../../src/deployments/utils/retry.js"; +} from "../../../src/infrastructure/retry.js"; describe("withRetry", () => { it("returns the result on first success", async () => { @@ -33,7 +33,7 @@ describe("withRetry", () => { await expect( withRetry(fn, { maxRetries: 2, backoffs: [1, 1] }), ).rejects.toThrow(RetryExhaustedError); - expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries + expect(fn).toHaveBeenCalledTimes(3); }); it("preserves the last error as cause in RetryExhaustedError", async () => { diff --git a/packages/worker/test/unit/orchestrator.test.ts b/packages/worker/test/unit/orchestrator.test.ts deleted file mode 100644 index 9af1c69..0000000 --- a/packages/worker/test/unit/orchestrator.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { OrchestratorDeps } from "../../src/deployments/orchestrator.js"; -import { DeploymentOrchestrator } from "../../src/deployments/orchestrator.js"; - -function makeSelectChain(results: unknown[][]) { - let callIndex = 0; - const chain = { - from: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockImplementation(() => { - const idx = callIndex; - callIndex++; - const value = results[idx] ?? []; - return { - orderBy: vi.fn().mockResolvedValue(value), - then: vi - .fn() - .mockImplementation( - (resolve: (v: unknown) => void, _reject: (e: unknown) => void) => { - resolve(value); - }, - ), - }; - }), - }; - return chain; -} - -function makeDeps(selectResults?: unknown[][]) { - const selectChain = makeSelectChain(selectResults ?? []); - - const mockDb = { - select: vi.fn().mockReturnValue(selectChain), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockResolvedValue(undefined), - }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - }), - }; - - const mockRunner = { - create: vi.fn().mockResolvedValue({ id: "container-1" }), - exec: vi.fn().mockResolvedValue({ - exitCode: 0, - oomKilled: false, - stdout: "", - stderr: "", - }), - remove: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - listManaged: vi.fn().mockResolvedValue([]), - inspect: vi.fn().mockResolvedValue({}), - buildImage: vi.fn().mockResolvedValue(undefined), - runLongLived: vi.fn().mockResolvedValue(43210), - stopByName: vi.fn().mockResolvedValue(undefined), - pruneOldImageTags: vi.fn().mockResolvedValue(undefined), - }; - - const upsertFileRoute = vi.fn().mockResolvedValue(undefined); - const upsertProxyRoute = vi.fn().mockResolvedValue(undefined); - - return { - db: mockDb, - env: { - BUILD_WORKSPACE_DIR: "/tmp/shipyard-test/builds", - BASE_DOMAIN: "bigboss.dev", - ENCRYPTION_KEY: - "ded637fc26820406b811e228d84a0c26dc8b561d6d7fea7ecd0d980b2544cc61", - WORKER_ID: "worker-test", - DOCKER_NETWORK: "shipyard", - CADDY_ADMIN_URL: "http://caddy:2019", - AUTO_HTTPS: false, - LOG_TO_FILE: false, - }, - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - runner: mockRunner as any, - upsertFileRoute, - upsertProxyRoute, - } as OrchestratorDeps; -} - -function makeAppContext() { - return [ - { - deployment: { id: "deploy-1" }, - app: { - id: "app-1", - name: "myapp", - githubRepo: "user/repo", - buildTimeout: 900, - outputDir: "dist", - isSpa: false, - branch: "main", - }, - githubAccessToken: "gh_token_123", - userId: "user-1", - }, - ]; -} - -const BUILD_DIR = "/tmp/shipyard-test/builds"; -const SITES_DIR = "/tmp/shipyard-test/sites"; - -function setupOutput(deploymentId: string) { - const dir = path.join(BUILD_DIR, deploymentId, "repo", "dist"); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "index.html"), "

test

"); -} - -function cleanupOutput() { - fs.rmSync(BUILD_DIR, { recursive: true, force: true }); - fs.rmSync(SITES_DIR, { recursive: true, force: true }); -} - -describe("DeploymentOrchestrator", () => { - afterEach(() => { - cleanupOutput(); - }); - it("runs all steps and activates deployment on success", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-1"); - - expect(deps.runner.create).toHaveBeenCalledTimes(1); - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - expect(deps.upsertFileRoute).toHaveBeenCalledWith( - "app-1", - "myapp.bigboss.dev", - false, - ); - }); - - it("marks deployment as 'building' on start", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-1"); - - expect((deps.db as any).update).toHaveBeenCalled(); - }); - - it("marks deployment as 'success' after completion", async () => { - setupOutput("deploy-1"); - const deps = makeDeps([makeAppContext(), [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-1"); - - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "success" }), - ); - }); - - it("fails on missing GitHub token — no container created", async () => { - const ctx = [ - { - deployment: { id: "deploy-2" }, - app: { id: "app-2", name: "myapp", githubRepo: "user/repo" }, - githubAccessToken: null, - userId: "user-2", - }, - ]; - const deps = makeDeps([ctx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-2"); - - expect(deps.runner.create).not.toHaveBeenCalled(); - expect(deps.runner.remove).not.toHaveBeenCalled(); - }); - - it("marks deployment as 'failed' when GitHub token is missing", async () => { - const ctx = [ - { - deployment: { id: "deploy-3" }, - app: { id: "app-3", name: "myapp", githubRepo: "user/repo" }, - githubAccessToken: null, - userId: "user-3", - }, - ]; - const deps = makeDeps([ctx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-3"); - - const updates = (deps.db as any).update.mock.results; - const failedUpdate = updates - .map((r: any) => r.value.set.mock.calls[0]?.[0]) - .find((s: any) => s?.status === "failed"); - expect(failedUpdate).toBeDefined(); - }); - - it("times out when build takes too long", async () => { - setupOutput("deploy-4"); - const appCtx = makeAppContext(); - appCtx[0].app.buildTimeout = 0; // timeout fires immediately - const deps = makeDeps([appCtx, [], []]); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-4"); - - // Container should still be cleaned up - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - // Error should have been logged - expect(deps.logger.error).toHaveBeenCalled(); - // Final status should be failed - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "failed" }), - ); - }); - - it("marks deployment as failed when clone step fails", async () => { - setupOutput("deploy-5"); - const deps = makeDeps([makeAppContext(), [], []]); - // Exit code 128 means auth failure — classifyError marks as user_error, no retry - (deps.runner as any).exec = vi.fn().mockResolvedValue({ - exitCode: 128, - oomKilled: false, - stdout: "", - stderr: "Permission denied", - }); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-5"); - - // Container should still be cleaned up - expect(deps.runner.remove).toHaveBeenCalledWith("container-1"); - // Should NOT have called upsertFileRoute (deployment never activated) - expect(deps.upsertFileRoute).not.toHaveBeenCalled(); - expect(deps.upsertProxyRoute).not.toHaveBeenCalled(); - // Final status should be failed - const updates = (deps.db as any).update.mock.results; - const lastSet = updates[updates.length - 1].value.set; - expect(lastSet).toHaveBeenCalledWith( - expect.objectContaining({ status: "failed" }), - ); - }); - - it("cleans up container and workspace on unexpected error", async () => { - setupOutput("deploy-6"); - const deps = makeDeps([makeAppContext(), [], []]); - // Make runner.create throw - (deps.runner as any).create = vi - .fn() - .mockRejectedValue(new Error("docker error")); - const orchestrator = new DeploymentOrchestrator(deps); - - await orchestrator.process("deploy-6"); - - // Container was never created, so remove should not be called - // But update should have been called to mark failed - const updates = (deps.db as any).update.mock.results; - const failedSet = updates - .map((r: any) => r.value.set.mock.calls[0]?.[0]) - .find((s: any) => s?.status === "failed"); - expect(failedSet).toBeDefined(); - }); -}); diff --git a/packages/worker/test/unit/verify-step.test.ts b/packages/worker/test/unit/verify-step.test.ts deleted file mode 100644 index efed6c9..0000000 --- a/packages/worker/test/unit/verify-step.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { countFiles } from "../../src/deployments/steps/verify-step.js"; - -const TEST_DIR = "/tmp/shipyard-test/verify"; - -beforeEach(() => { - fs.mkdirSync(TEST_DIR, { recursive: true }); -}); - -afterEach(() => { - fs.rmSync(TEST_DIR, { recursive: true, force: true }); -}); - -describe("countFiles", () => { - it("returns 0 for a missing directory", () => { - expect(countFiles("/tmp/shipyard-test/nonexistent")).toBe(0); - }); - - it("returns 0 for an empty directory", () => { - expect(countFiles(TEST_DIR)).toBe(0); - }); - - it("counts files in the root", () => { - fs.writeFileSync(path.join(TEST_DIR, "index.html"), ""); - fs.writeFileSync(path.join(TEST_DIR, "style.css"), "body {}"); - expect(countFiles(TEST_DIR)).toBe(2); - }); - - it("excludes dotfiles like .gitkeep", () => { - fs.writeFileSync(path.join(TEST_DIR, "index.html"), ""); - fs.writeFileSync(path.join(TEST_DIR, ".gitkeep"), ""); - expect(countFiles(TEST_DIR)).toBe(1); - }); - - it("counts files in subdirectories recursively", () => { - fs.mkdirSync(path.join(TEST_DIR, "assets"), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, "index.html"), ""); - fs.writeFileSync( - path.join(TEST_DIR, "assets", "app.js"), - "console.log('hi')", - ); - fs.writeFileSync(path.join(TEST_DIR, "assets", "style.css"), "body {}"); - expect(countFiles(TEST_DIR)).toBe(3); - }); - - it("excludes .git directories recursively", () => { - fs.mkdirSync(path.join(TEST_DIR, ".git"), { recursive: true }); - fs.writeFileSync(path.join(TEST_DIR, ".git", "HEAD"), "ref: main"); - fs.writeFileSync(path.join(TEST_DIR, "index.html"), ""); - expect(countFiles(TEST_DIR)).toBe(1); - }); -}); From 2fc49ab072c17225e208f73ac45efbcbbfc2d943 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Sun, 17 May 2026 07:13:16 +0300 Subject: [PATCH 2/2] refactor(worker): replace any with proper types in source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db: unknown/any → PostgresJsDatabase> - app: Record → App from @shipyard/shared/schema - env: any → Env from config/env - Remove all (db as any) casts and biome-ignore noExplicitAny - Suppress noExplicitAny in test files via biome overrides Closes #30 --- biome.json | 12 +++++++++ packages/worker/src/deployments/env-vars.ts | 15 ++++------- packages/worker/src/deployments/events.ts | 20 +++++++-------- packages/worker/src/deployments/pipeline.ts | 21 ++++++++-------- .../src/deployments/strategies/dockerfile.ts | 15 ++++++----- .../src/deployments/strategies/static.ts | 25 +++++++++++-------- 6 files changed, 59 insertions(+), 49 deletions(-) diff --git a/biome.json b/biome.json index 274c03f..c7b5782 100644 --- a/biome.json +++ b/biome.json @@ -34,6 +34,18 @@ } } }, + "overrides": [ + { + "includes": ["packages/worker/test/**"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ], "javascript": { "formatter": { "quoteStyle": "double" diff --git a/packages/worker/src/deployments/env-vars.ts b/packages/worker/src/deployments/env-vars.ts index d2298c5..0d61717 100644 --- a/packages/worker/src/deployments/env-vars.ts +++ b/packages/worker/src/deployments/env-vars.ts @@ -1,17 +1,12 @@ import { decrypt, envVars } from "@shipyard/shared"; import { eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { Env } from "../config/env.js"; -export async function fetchDecryptedEnvVars( - db: unknown, - env: Env, - appId: string, -) { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - const rows = await (db as any) - .select() - .from(envVars) - .where(eq(envVars.appId, appId)); +type DB = PostgresJsDatabase>; + +export async function fetchDecryptedEnvVars(db: DB, env: Env, appId: string) { + const rows = await db.select().from(envVars).where(eq(envVars.appId, appId)); const masterKey = env.ENCRYPTION_KEY; const result: Record = {}; diff --git a/packages/worker/src/deployments/events.ts b/packages/worker/src/deployments/events.ts index dcad295..86dbce7 100644 --- a/packages/worker/src/deployments/events.ts +++ b/packages/worker/src/deployments/events.ts @@ -2,8 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import { buildJobs, deploymentLogs } from "@shipyard/shared"; import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { Env } from "../config/env.js"; +type DB = PostgresJsDatabase>; + export const TWO_GB = 2 * 1024 * 1024 * 1024; export function createWorkspace(env: Env, deploymentId: string): string { @@ -13,12 +16,11 @@ export function createWorkspace(env: Env, deploymentId: string): string { } export async function createBuildJobRow( - db: unknown, + db: DB, deploymentId: string, stepName: string, ) { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - await (db as any).insert(buildJobs).values({ + await db.insert(buildJobs).values({ deploymentId, step: stepName, status: "running", @@ -27,14 +29,13 @@ export async function createBuildJobRow( } export async function finalizeBuildJobRow( - db: unknown, + db: DB, deploymentId: string, stepName: string, ok: boolean, attempts: number, ) { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - await (db as any) + await db .update(buildJobs) .set({ status: ok ? "success" : "failed", @@ -51,13 +52,10 @@ export async function finalizeBuildJobRow( } export async function insertStructuredEvent( - db: unknown, + db: DB, deploymentId: string, step: string, content: string, ) { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - await (db as any) - .insert(deploymentLogs) - .values({ deploymentId, step, content }); + await db.insert(deploymentLogs).values({ deploymentId, step, content }); } diff --git a/packages/worker/src/deployments/pipeline.ts b/packages/worker/src/deployments/pipeline.ts index 8625e6a..391fe8a 100644 --- a/packages/worker/src/deployments/pipeline.ts +++ b/packages/worker/src/deployments/pipeline.ts @@ -4,14 +4,19 @@ import { organizationMembers, users, } from "@shipyard/shared"; +import type { App } from "@shipyard/shared/schema"; import { asc, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +type DB = PostgresJsDatabase>; + import type { Env } from "../config/env.js"; import type { DockerRunner } from "../infrastructure/docker/docker-runner.js"; import { deployDockerfile } from "./strategies/dockerfile.js"; import { deployBuildPack } from "./strategies/static.js"; export interface OrchestratorDeps { - db: unknown; + db: DB; env: Env; logger: { info: (obj: Record, msg?: string) => void; @@ -34,13 +39,8 @@ export interface OrchestratorDeps { export class DeploymentOrchestrator { constructor(private deps: OrchestratorDeps) {} - private get db() { - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - return this.deps.db as any; - } - private async fetchDeploymentContext(deploymentId: string) { - const rows = await this.db + const rows = await this.deps.db .select({ deployment: deployments, app: apps, @@ -65,8 +65,7 @@ export class DeploymentOrchestrator { async process(deploymentId: string): Promise { const ctx = await this.fetchDeploymentContext(deploymentId); - // biome-ignore lint/suspicious/noExplicitAny: DB query result shape known at runtime - const app: Record = ctx.app; + const app: App = ctx.app; const userId: string = ctx.userId; const githubAccessToken: string | null = ctx.githubAccessToken; @@ -80,7 +79,7 @@ export class DeploymentOrchestrator { deploymentId, app, githubAccessToken, - this.db, + this.deps.db, this.deps.env, this.deps.logger, this.deps.runner, @@ -94,7 +93,7 @@ export class DeploymentOrchestrator { app, userId, githubAccessToken, - this.db, + this.deps.db, this.deps.env, this.deps.logger, this.deps.runner, diff --git a/packages/worker/src/deployments/strategies/dockerfile.ts b/packages/worker/src/deployments/strategies/dockerfile.ts index 8606b68..7196531 100644 --- a/packages/worker/src/deployments/strategies/dockerfile.ts +++ b/packages/worker/src/deployments/strategies/dockerfile.ts @@ -1,9 +1,15 @@ import fs from "node:fs"; import path from "node:path"; import { apps, deployments, domains } from "@shipyard/shared"; +import type { App } from "@shipyard/shared/schema"; import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { Env } from "../../config/env.js"; import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; import { fetchDecryptedEnvVars } from "../env-vars.js"; + +type DB = PostgresJsDatabase>; + import { createBuildJobRow, createWorkspace, @@ -45,13 +51,10 @@ async function runLongLivedWithRetry( export async function deployDockerfile( deploymentId: string, - // biome-ignore lint/suspicious/noExplicitAny: DB query result shape known at runtime - app: Record, + app: App, githubAccessToken: string | null, - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - db: any, - // biome-ignore lint/suspicious/noExplicitAny: runtime shape matches Env - env: any, + db: DB, + env: Env, logger: { info: (obj: Record, msg?: string) => void; warn: (obj: Record, msg?: string) => void; diff --git a/packages/worker/src/deployments/strategies/static.ts b/packages/worker/src/deployments/strategies/static.ts index f74d8ea..74e5e22 100644 --- a/packages/worker/src/deployments/strategies/static.ts +++ b/packages/worker/src/deployments/strategies/static.ts @@ -1,10 +1,16 @@ import fs from "node:fs"; import path from "node:path"; import { apps, deployments, domains } from "@shipyard/shared"; +import type { App } from "@shipyard/shared/schema"; import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { Env } from "../../config/env.js"; import type { DockerRunner } from "../../infrastructure/docker/docker-runner.js"; import { LogBuffer } from "../../infrastructure/log-buffer.js"; import { fetchDecryptedEnvVars } from "../env-vars.js"; + +type DB = PostgresJsDatabase>; + import { createBuildJobRow, createWorkspace, @@ -21,14 +27,11 @@ import { runVerifyStep } from "../steps/verify-step.js"; export async function deployBuildPack( deploymentId: string, - // biome-ignore lint/suspicious/noExplicitAny: DB query result shape known at runtime - app: Record, + app: App, _userId: string, githubAccessToken: string | null, - // biome-ignore lint/suspicious/noExplicitAny: Drizzle query builder type too complex to abstract - db: any, - // biome-ignore lint/suspicious/noExplicitAny: runtime shape matches Env - env: any, + db: DB, + env: Env, logger: { info: (obj: Record, msg?: string) => void; warn: (obj: Record, msg?: string) => void; @@ -98,7 +101,7 @@ export async function deployBuildPack( return runCloneStep( runner, containerId!, - app as any, + app, githubAccessToken, log, ).finally(() => log.flushOnStepEnd()); @@ -108,8 +111,8 @@ export async function deployBuildPack( name: "install", run: () => { const log = new LogBuffer(deploymentId, "install"); - return runInstallStep(runner, containerId!, app as any, log).finally( - () => log.flushOnStepEnd(), + return runInstallStep(runner, containerId!, app, log).finally(() => + log.flushOnStepEnd(), ); }, }, @@ -117,8 +120,8 @@ export async function deployBuildPack( name: "build", run: () => { const log = new LogBuffer(deploymentId, "build"); - return runBuildStep(runner, containerId!, app as any, log).finally( - () => log.flushOnStepEnd(), + return runBuildStep(runner, containerId!, app, log).finally(() => + log.flushOnStepEnd(), ); }, },