diff --git a/apps/api/src/orchestrator/__tests__/compose.test.ts b/apps/api/src/orchestrator/__tests__/compose.test.ts index b86cc46..2597901 100644 --- a/apps/api/src/orchestrator/__tests__/compose.test.ts +++ b/apps/api/src/orchestrator/__tests__/compose.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test, afterEach } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -121,3 +121,92 @@ services: } }); }); + +describe("spawnComposeCommand env isolation", () => { + afterEach(() => { + mock.restore(); + }); + + test("does not pass process.env to docker compose subprocess", async () => { + process.env.DATABASE_URL = "postgresql://dequel:secret@localhost:5432/dequel"; + process.env.PORT = "3001"; + process.env.WORKSPACE_ROOT = "./workspace"; + + let capturedEnv: Record | undefined; + const originalSpawn = (await import("node:child_process")).spawn; + mock.module("node:child_process", () => ({ + spawn: (...args: any[]) => { + capturedEnv = args[2]?.env; + const child = originalSpawn(...args); + setTimeout(() => child.kill("SIGTERM"), 10); + return child; + }, + })); + + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-env-test-")); + try { + writeFileSync( + join(dir, "docker-compose.yml"), + `version: '3.8'\nservices:\n app:\n image: alpine\n command: echo "hello"`, + ); + + const { buildWithCompose } = await import("../compose"); + const projectEnv = { MY_APP_VAR: "hello", ANOTHER_VAR: "world" }; + + try { + await buildWithCompose(dir, "test-project", async () => {}, null, projectEnv); + } catch { + // Expected to fail since we're killing the process + } + + expect(capturedEnv).toBeDefined(); + expect(capturedEnv).not.toHaveProperty("DATABASE_URL"); + expect(capturedEnv).not.toHaveProperty("PORT"); + expect(capturedEnv).not.toHaveProperty("WORKSPACE_ROOT"); + expect(capturedEnv).toHaveProperty("MY_APP_VAR", "hello"); + expect(capturedEnv).toHaveProperty("ANOTHER_VAR", "world"); + } finally { + rmSync(dir, { recursive: true, force: true }); + delete process.env.DATABASE_URL; + delete process.env.PORT; + delete process.env.WORKSPACE_ROOT; + } + }); + + test("passes empty env when no project env vars provided", async () => { + process.env.DATABASE_URL = "postgresql://dequel:secret@localhost:5432/dequel"; + + let capturedEnv: Record | undefined; + const originalSpawn = (await import("node:child_process")).spawn; + mock.module("node:child_process", () => ({ + spawn: (...args: any[]) => { + capturedEnv = args[2]?.env; + const child = originalSpawn(...args); + setTimeout(() => child.kill("SIGTERM"), 10); + return child; + }, + })); + + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-env-test-")); + try { + writeFileSync( + join(dir, "docker-compose.yml"), + `version: '3.8'\nservices:\n app:\n image: alpine\n command: echo "hello"`, + ); + + const { buildWithCompose } = await import("../compose"); + + try { + await buildWithCompose(dir, "test-project", async () => {}, null, undefined); + } catch { + // Expected to fail since we're killing the process + } + + expect(capturedEnv).toBeDefined(); + expect(capturedEnv).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + delete process.env.DATABASE_URL; + } + }); +}); diff --git a/apps/api/src/orchestrator/compose.ts b/apps/api/src/orchestrator/compose.ts index 5b3998a..d25df56 100644 --- a/apps/api/src/orchestrator/compose.ts +++ b/apps/api/src/orchestrator/compose.ts @@ -201,7 +201,7 @@ const spawnComposeCommand = ( return new Promise((resolve, reject) => { const child = spawn(dockerBin, ["compose", ...args], { cwd, - env: { ...process.env, ...(envVars || {}) }, + env: envVars || {}, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/apps/web/src/components/databases/CreateDatabaseDialog.tsx b/apps/web/src/components/databases/CreateDatabaseDialog.tsx index 9d14f03..04a05cf 100644 --- a/apps/web/src/components/databases/CreateDatabaseDialog.tsx +++ b/apps/web/src/components/databases/CreateDatabaseDialog.tsx @@ -1,3 +1,4 @@ +import { Database, HardDrive, ShieldAlert, Cpu } from "lucide-react"; import { useEffect, useState } from "react"; import * as api from "../../api/client"; import type { DatabaseType, Project } from "../../types"; @@ -68,36 +69,46 @@ export function CreateDatabaseDialog({ return ( - - - Create managed database - - Public access is enabled by default and protected by database credentials plus the network allowlist. - + + +
+
+ +
+
+ Provision Managed Database + + Public access endpoint enabled with optional CIDR network IP allowlisting. + +
+
+
-
-