diff --git a/.changeset/fresh-walls-docker.md b/.changeset/fresh-walls-docker.md new file mode 100644 index 000000000..fe0f124fc --- /dev/null +++ b/.changeset/fresh-walls-docker.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': minor +--- + +Allow environments to request a Docker runtime without a placeholder project, and configure the Roomote dogfood environment for authenticated nested task previews. diff --git a/.roomote/environments/roomote.yaml b/.roomote/environments/roomote.yaml index 09170738a..84aa77373 100644 --- a/.roomote/environments/roomote.yaml +++ b/.roomote/environments/roomote.yaml @@ -20,6 +20,7 @@ description: (Redis, API, BullMQ, controller) plus a local Mintlify preview of the public docs site (apps/docs) on the `docs` port. initialUrl: http://127.0.0.1:3000/auth/dev-login +nested_docker: true ports: - name: web port: 3000 @@ -27,6 +28,9 @@ ports: primary: true - name: docs port: 3333 + - name: preview + port: 18081 + wildcard_prefix: true services: - postgres16 repositories: @@ -41,7 +45,7 @@ repositories: timeout: 600 continue_on_error: false - name: Write local env overrides - run: printf 'DATABASE_URL=postgresql://postgres@localhost:5432/roomote_development\nREDIS_URL=redis://localhost:16379\nR_APP_ENV=development\nWEB_DEV_LOGIN_ENABLED=true\n' > .env.local + run: printf 'DATABASE_URL=postgresql://postgres@localhost:5432/roomote_development\nREDIS_URL=redis://localhost:16379\nR_APP_ENV=development\nWEB_DEV_LOGIN_ENABLED=true\nDEFAULT_COMPUTE_PROVIDER=docker\n' > .env.local timeout: 600 continue_on_error: false - name: Create development database @@ -77,7 +81,7 @@ repositories: - name: Build local worker release run: pnpm --filter @roomote/dev build-worker-release timeout: 600 - continue_on_error: true + continue_on_error: false - name: Start API server run: PORT=13001 R_APP_ENV=development pnpm --filter @roomote/api dev timeout: 600 @@ -85,7 +89,7 @@ repositories: detached: true logfile: /tmp/roomote-api.log - name: Start Next.js web dev server - run: PORT=3000 R_APP_ENV=development WEB_DEV_LOGIN_ENABLED=true pnpm --filter @roomote/web dev + run: bash .roomote/scripts/with-nested-preview-env.sh env PORT=3000 R_APP_ENV=development WEB_DEV_LOGIN_ENABLED=true pnpm --filter @roomote/web dev timeout: 600 continue_on_error: false detached: true @@ -97,11 +101,22 @@ repositories: detached: true logfile: /tmp/roomote-bullmq.log - name: Start controller - run: R_APP_ENV=development TRPC_URL="${ROOMOTE_API_HOST:-http://localhost:13001}" R_APP_URL="${ROOMOTE_WEB_HOST:-http://localhost:3000}" R_PUBLIC_URL="${ROOMOTE_WEB_HOST:-http://localhost:3000}" pnpm --filter @roomote/controller dev + run: bash .roomote/scripts/with-nested-preview-env.sh env R_APP_ENV=development DEFAULT_COMPUTE_PROVIDER=docker TRPC_URL="http://localhost:13001" R_APP_URL="${ROOMOTE_WEB_HOST:-http://localhost:3000}" R_PUBLIC_URL="${ROOMOTE_WEB_HOST:-http://localhost:3000}" PREVIEW_AUTH_COOKIE_NAME=preview_auth_inner pnpm --filter @roomote/controller dev timeout: 600 continue_on_error: true detached: true logfile: /tmp/roomote-controller.log + - name: Start nested preview proxy + run: bash .roomote/scripts/with-nested-preview-env.sh env PORT=18081 R_APP_ENV=development R_APP_URL="${ROOMOTE_WEB_HOST:-http://localhost:3000}" PREVIEW_AUTH_COOKIE_NAME=preview_auth_inner pnpm --filter @roomote/preview-proxy dev + timeout: 600 + continue_on_error: true + detached: true + logfile: /tmp/roomote-preview-proxy.log + - name: Verify task runtime services + run: >- + bash -lc 'set -eu; docker version --format "{{.Server.Version}}" >/dev/null; for endpoint in http://localhost:13001/health/liveness http://localhost:13001/health/controller http://localhost:13002/admin/health http://localhost:18081/health http://localhost:3000/auth/dev-login; do attempts=0; until curl -fsS "$endpoint" >/dev/null; do attempts=$((attempts + 1)); if [ "$attempts" -ge 90 ]; then echo "Timed out waiting for $endpoint" >&2; exit 1; fi; sleep 1; done; done' + timeout: 600 + continue_on_error: false - name: Start Mintlify docs preview # Mint is needed only for this optional final service. run: mise install node@22.20.0 && mise exec node@22.20.0 -- npm install -g mint && cd apps/docs && mise exec node@22.20.0 -- mint dev --port 3333 --no-open diff --git a/.roomote/scripts/with-nested-preview-env.sh b/.roomote/scripts/with-nested-preview-env.sh new file mode 100755 index 000000000..fc6503f9f --- /dev/null +++ b/.roomote/scripts/with-nested-preview-env.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deployments without outer preview routing omit named-port host variables. +# Keep the Roomote development stack usable there; only nested preview URLs +# are unavailable. +if [[ -z "${ROOMOTE_PREVIEW_HOST:-}" ]]; then + exec "$@" +fi + +preview_url="$ROOMOTE_PREVIEW_HOST" +preview_scheme="${preview_url%%://*}" +preview_host="${preview_url#*://}" +preview_host="${preview_host%%/*}" +preview_suffix="${preview_host%%.*}" +preview_base_host="${preview_host#*.}" + +if [[ "$preview_scheme" != "http" && "$preview_scheme" != "https" ]]; then + echo "Unsupported preview URL scheme: $preview_scheme" >&2 + exit 1 +fi + +if [[ "$preview_base_host" == "$preview_host" ]]; then + echo "ROOMOTE_PREVIEW_HOST must contain a task-specific subdomain" >&2 + exit 1 +fi + +export PREVIEW_PROXY_BASE_URL="${preview_scheme}://${preview_base_host}" +export PREVIEW_PROXY_SUBDOMAIN_SUFFIX="$preview_suffix" +export PREVIEW_DOMAINS="${preview_base_host%%:*}" +export NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL="$PREVIEW_PROXY_BASE_URL" +export NEXT_PUBLIC_PREVIEW_PROXY_SUBDOMAIN_SUFFIX="$preview_suffix" + +exec "$@" diff --git a/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts index ca764a916..97cca930d 100644 --- a/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts +++ b/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts @@ -195,6 +195,43 @@ describe('spawnModalWorker', () => { expect(mockFindTask).not.toHaveBeenCalled(); }); + it('uses a right-sized Modal VM sandbox when the environment requests a Docker runtime', async () => { + mockGetNamedPortsForTaskRun.mockResolvedValue({ + namedPorts: [{ name: 'SANDBOX_SERVER', port: 7777 }], + environmentSnapshotId: undefined, + environmentConfig: { nested_docker: true }, + }); + + await spawnModalWorker( + mockTaskRun({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { repo: 'test/repo', environmentId: 'env_123' }, + }), + 'auth_token', + { + deploymentSlug: 'roomote', + modalTokenId: 'token-id', + modalTokenSecret: 'token-secret', + modalBaseImageRef: 'image-ref', + modalVmMemoryMiB: 12_288, + modalTimeoutMs: 60_000, + }, + ); + + expect(mockCreateComputeProviderClient).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'modal', + config: expect.objectContaining({ + vmRuntime: true, + cpu: 2, + memoryMiB: 12_288, + }), + }), + ); + expect(mockCreateModalMachine).toHaveBeenCalled(); + expect(mockFindTask).not.toHaveBeenCalled(); + }); + it('uses a Modal VM sandbox for setup onboarding before an environment config exists', async () => { mockFindTask.mockResolvedValue({ workflow: 'setup_onboarding' }); diff --git a/apps/controller/src/compute-providers/task-sandbox-resources.ts b/apps/controller/src/compute-providers/task-sandbox-resources.ts index 8779a55e9..d7d543cda 100644 --- a/apps/controller/src/compute-providers/task-sandbox-resources.ts +++ b/apps/controller/src/compute-providers/task-sandbox-resources.ts @@ -15,7 +15,10 @@ export async function taskNeedsNestedDocker( environmentConfig: EnvironmentConfig | undefined, ): Promise { if (environmentConfig) { - return Boolean(environmentConfig.docker_projects?.length); + return ( + environmentConfig.nested_docker === true || + Boolean(environmentConfig.docker_projects?.length) + ); } const task = await db.query.tasks.findFirst({ diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index 935aaa5f1..d0cc0e8f0 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -299,6 +299,20 @@ All paths must be relative and stay inside the selected repository. Docker projects are supported by all sandbox providers. Modal runs them in its beta VM sandbox runtime. +### Docker runtime without a project + +Set `nested_docker: true` when the application starts containers dynamically +and therefore needs a Docker daemon without a `docker_projects` entry: + +```yaml +nested_docker: true +``` + +This selects a Docker-capable sandbox and verifies that its daemon is ready +before repository setup commands run. It does not build or start a placeholder +container. Environments that neither set this flag nor declare a Docker project +keep the existing runtime and resource requirements. + ## Ports `ports` declares the human-facing application URLs Roomote should expose as diff --git a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts index e4b16d33d..64b94361e 100644 --- a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts +++ b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts @@ -130,6 +130,20 @@ describe('configToYaml', () => { expect(yaml).toContain('docker_projects:'); }); + it('preserves an explicitly requested Docker runtime', () => { + const config: EnvironmentConfig = { + name: 'Docker Runtime Env', + repositories: [{ repository: 'Roomote/example-app' }], + nested_docker: true, + }; + + const yaml = configToYaml(config); + const parsed = YAML.parse(yaml); + + expect(parsed.nested_docker).toBe(true); + expect(yaml).toContain('nested_docker: true'); + }); + it('preserves manualSkills when serializing environment config', () => { const config: EnvironmentConfig = { name: 'Manual Skills Env', diff --git a/apps/web/src/components/settings/environments/yaml-utils.ts b/apps/web/src/components/settings/environments/yaml-utils.ts index f13169b85..372bfb786 100644 --- a/apps/web/src/components/settings/environments/yaml-utils.ts +++ b/apps/web/src/components/settings/environments/yaml-utils.ts @@ -91,6 +91,10 @@ export function configToYaml(config: EnvironmentConfig): string { cleanConfig.docker_projects = config.docker_projects; } + if (config.nested_docker === true) { + cleanConfig.nested_docker = true; + } + if (config.services && config.services.length > 0) { cleanConfig.services = config.services; } diff --git a/apps/worker/src/commands/__tests__/nested-preview-env-script.test.ts b/apps/worker/src/commands/__tests__/nested-preview-env-script.test.ts new file mode 100644 index 000000000..8008af40b --- /dev/null +++ b/apps/worker/src/commands/__tests__/nested-preview-env-script.test.ts @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const repositoryRoot = path.resolve(__dirname, '../../../../..'); +const scriptPath = './.roomote/scripts/with-nested-preview-env.sh'; + +function readEnvironment(stdout: string): Record { + return Object.fromEntries( + stdout + .trim() + .split('\n') + .map((line) => { + const separator = line.indexOf('='); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); +} + +describe('with-nested-preview-env.sh', () => { + it('runs the command unchanged when the outer preview host is unavailable', () => { + const result = spawnSync(scriptPath, ['/usr/bin/env'], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { PATH: '/usr/bin:/bin' }, + }); + + expect(result.status, result.stderr).toBe(0); + const environment = readEnvironment(result.stdout); + expect(environment.PREVIEW_PROXY_BASE_URL).toBeUndefined(); + expect(environment.PREVIEW_PROXY_SUBDOMAIN_SUFFIX).toBeUndefined(); + expect(environment.PREVIEW_DOMAINS).toBeUndefined(); + expect(environment.NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL).toBeUndefined(); + expect( + environment.NEXT_PUBLIC_PREVIEW_PROXY_SUBDOMAIN_SUFFIX, + ).toBeUndefined(); + }); + + it('derives nested preview settings from the wildcard preview named port', () => { + const result = spawnSync(scriptPath, ['/usr/bin/env'], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { + PATH: '/usr/bin:/bin', + ROOMOTE_PREVIEW_HOST: + 'https://outer-task-preview.preview.roomote.example.com', + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readEnvironment(result.stdout)).toMatchObject({ + PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example.com', + PREVIEW_PROXY_SUBDOMAIN_SUFFIX: 'outer-task-preview', + PREVIEW_DOMAINS: 'preview.roomote.example.com', + NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example.com', + NEXT_PUBLIC_PREVIEW_PROXY_SUBDOMAIN_SUFFIX: 'outer-task-preview', + }); + }); +}); diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index 329d1595f..7647ac413 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -173,6 +173,30 @@ describe('injectEnvVars', () => { ); }); + it('injects ROOMOTE_PREVIEW_HOST for a proxied PREVIEW named port', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { + PREVIEW: 'https://sandbox-preview.modal.host', + }, + proxyPorts: { + PREVIEW: 18_081, + }, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect(envVars.ROOMOTE_PREVIEW_HOST).toBe( + 'https://task-123-preview.preview.octomote.run', + ); + expect(envVars.ROOMOTE_PREVIEW_PREVIEW_URL).toBe( + 'https://task-123-preview.preview.octomote.run', + ); + }); + it('keeps direct hosts while exposing preview-proxy URLs for unproxied ports', async () => { const envVars: Record = {}; const taskRun = { diff --git a/apps/worker/src/commands/setup/__tests__/docker-projects.test.ts b/apps/worker/src/commands/setup/__tests__/docker-projects.test.ts index 6575be81d..5a71ae791 100644 --- a/apps/worker/src/commands/setup/__tests__/docker-projects.test.ts +++ b/apps/worker/src/commands/setup/__tests__/docker-projects.test.ts @@ -39,6 +39,46 @@ describe('initializeDockerProjects', () => { await fs.rm(workspacePath, { recursive: true, force: true }); }); + it('initializes Docker without starting a placeholder project when requested', async () => { + const runCommand = vi.fn().mockResolvedValue({ stdout: '' }); + + await initializeDockerProjects( + logger, + { + workspace: { + type: 'environment', + environmentId: 'env-1', + environmentConfig: { + name: 'Test', + repositories: [{ repository: 'acme/app' }], + nested_docker: true, + }, + }, + envVars: { PATH: '/usr/bin' }, + taskRunType: TaskPayloadKind.StandardTask, + }, + { + workspacePath, + repoPaths: { 'acme/app': repositoryPath }, + }, + runCommand, + ); + + expect(runCommand).toHaveBeenCalledTimes(2); + expect(runCommand).toHaveBeenCalledWith( + 'docker', + ['info'], + expect.objectContaining({ cwd: workspacePath }), + ); + expect(runCommand).toHaveBeenCalledWith( + 'docker', + ['compose', 'version'], + expect.objectContaining({ cwd: workspacePath }), + ); + expect(logger.userLog.log).toHaveBeenCalledWith('Docker runtime is ready'); + expect(startDockerProjectLogFollower).not.toHaveBeenCalled(); + }); + it('validates and starts an existing Compose project after repository cloning', async () => { await fs.writeFile( path.join(repositoryPath, 'compose.yaml'), diff --git a/apps/worker/src/commands/setup/workspace/docker-projects.ts b/apps/worker/src/commands/setup/workspace/docker-projects.ts index 361cd71b2..37e3afae5 100644 --- a/apps/worker/src/commands/setup/workspace/docker-projects.ts +++ b/apps/worker/src/commands/setup/workspace/docker-projects.ts @@ -499,7 +499,7 @@ export async function initializeDockerProjects( const config = options.workspace.environmentConfig; const projects = config.docker_projects ?? []; - if (projects.length === 0) return; + if (projects.length === 0 && config.nested_docker !== true) return; const baseEnv = getDefinedEnv(options.envVars); @@ -524,6 +524,11 @@ export async function initializeDockerProjects( throw new Error(message, { cause: error }); } + if (projects.length === 0) { + logger.userLog.log('Docker runtime is ready'); + return; + } + for (const project of projects) { let resolved: ResolvedDockerProject | undefined; diff --git a/packages/db/src/fixtures/__tests__/seed-demo-data.test.ts b/packages/db/src/fixtures/__tests__/seed-demo-data.test.ts index 98c68b13d..ea4e2efcb 100644 --- a/packages/db/src/fixtures/__tests__/seed-demo-data.test.ts +++ b/packages/db/src/fixtures/__tests__/seed-demo-data.test.ts @@ -158,6 +158,43 @@ describe('seedDemoData', () => { } }); + it('repairs incomplete setup state left by an earlier sandbox boot', async () => { + const settingsBefore = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + }); + + if (settingsBefore) { + await db + .update(deploymentSettings) + .set({ setupCompletedAt: null }) + .where(eq(deploymentSettings.id, 'default')); + } else { + await db + .insert(deploymentSettings) + .values({ id: 'default', setupCompletedAt: null }); + } + + try { + await seedDemoData(); + + const settingsAfter = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + }); + expect(settingsAfter?.setupCompletedAt).not.toBeNull(); + } finally { + if (settingsBefore) { + await db + .update(deploymentSettings) + .set({ setupCompletedAt: settingsBefore.setupCompletedAt }) + .where(eq(deploymentSettings.id, 'default')); + } else { + await db + .delete(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')); + } + } + }); + it('is idempotent and leaves existing rows untouched on re-run', async () => { await seedDemoData(); diff --git a/packages/db/src/fixtures/seed-demo-data.ts b/packages/db/src/fixtures/seed-demo-data.ts index 3708cfc55..2b3933a08 100644 --- a/packages/db/src/fixtures/seed-demo-data.ts +++ b/packages/db/src/fixtures/seed-demo-data.ts @@ -122,20 +122,27 @@ export async function seedDemoData(): Promise { const now = new Date(); // Deployment settings. The web app gates everything behind /setup until the - // singleton settings row has `setupCompletedAt`, so a freshly seeded - // database also gets setup marked complete. An existing row is never - // touched so real setup state is preserved. + // singleton settings row has `setupCompletedAt`, so a seeded sandbox gets + // setup marked complete. Repair an incomplete singleton left by an earlier + // sandbox boot, while preserving every other setting. const existingSettings = await db.query.deploymentSettings.findFirst({ where: eq(deploymentSettings.id, 'default'), }); + const setupIncomplete = existingSettings?.setupCompletedAt == null; + if (!existingSettings) { await db .insert(deploymentSettings) .values({ id: 'default', setupCompletedAt: now }); + } else if (setupIncomplete) { + await db + .update(deploymentSettings) + .set({ setupCompletedAt: now }) + .where(eq(deploymentSettings.id, 'default')); } - record('deployment settings default', !existingSettings); + record('deployment settings default', !existingSettings || setupIncomplete); // Demo user. const demoUser: CreateUser = { diff --git a/packages/db/src/lib/__tests__/declarative-environments.test.ts b/packages/db/src/lib/__tests__/declarative-environments.test.ts index 7c5da4fa3..9adb76447 100644 --- a/packages/db/src/lib/__tests__/declarative-environments.test.ts +++ b/packages/db/src/lib/__tests__/declarative-environments.test.ts @@ -542,5 +542,29 @@ describe('declarative environments', () => { expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); expect(result.data?.name).toBe('Roomote'); + expect(result.data?.nested_docker).toBe(true); + expect(result.data?.ports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'preview', + port: 18081, + wildcard_prefix: true, + }), + ]), + ); + expect(result.data?.ports).not.toEqual( + expect.arrayContaining([expect.objectContaining({ port: 13001 })]), + ); + + for (const port of result.data?.ports ?? []) { + expect(port.unauthenticated, port.name).not.toBe(true); + } + + const controller = result.data?.repositories[0]?.commands?.find( + (command) => command.name === 'Start controller', + ); + expect(controller?.run).toContain( + 'PREVIEW_AUTH_COOKIE_NAME=preview_auth_inner', + ); }); }); diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 633db8411..fe2647458 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -711,6 +711,12 @@ export const environmentConfigSchema = z * their repositories have been prepared. */ docker_projects: z.array(dockerProjectSchema).optional(), + /** + * Provision a Docker-capable sandbox without requiring a placeholder + * Docker project. Useful for applications that launch containers at + * runtime rather than during environment setup. + */ + nested_docker: z.boolean().optional(), /** * Optional sandbox OIDC targets for this environment. * Tokens are minted by Roomote, written into the sandbox filesystem, and