cial/app/orchestrator/src/drivers/docker.ts
Eliot M c50cc2b5fb refactor(layout): consolidate workspace under /cial — core/, platform/, app/
Reorganize the dev/prod tenant container so the agent runs in the monorepo
root with a clear, semantic directory tree:

  /cial/core/      — runtime (back, front, edge, ui, sdk, protocol, scripts,
                     docker). Locked down to the cial linux user (mode 0700
                     in prod; :ro bind mount in restricted dev).
  /cial/platform/  — agent-editable surface (back, front).
  /cial/app/       — App control plane sources, present in workspace but
                     never built or run inside the tenant container.
  /cial/docs/      — architecture + ops reference.
  /cial/.claude/   — project skills/agents/commands (symlinked into the
                     harness HOME by the dev entrypoint).
  /cial/data/      — persistent state (sqlite, deploy-logs, agent home).

Concrete changes:
- git mv cial-core → core, cial-platform → platform, cial-app → app,
  scripts → core/scripts.
- pnpm-workspace.yaml: packages now core/*, platform/*, app/*.
- Bulk path rewrites across 250+ source / docker / docs files.
- core/scripts/dev-tenant.mjs: ROOT path fix, rw mount of repo + ro
  overlay of /cial/core when --unrestricted is not set (FS-level
  trust boundary, defense in depth).
- core/edge/src/supervisor.{ts,dev.ts}: cwd + CLAUDE_HOME relocated to
  /cial/data/home; agent runs from /cial root so skill discovery picks
  up /cial/.claude/skills automatically.
- core/back providers/claude.ts: HOME defaults to /cial/data/home, cwd
  defaults to /cial.
- core/docker/{Dockerfile,Dockerfile.dev,dev-entrypoint.sh}: COPY +
  WORKDIR + ENTRYPOINT updated; .claude → harness symlink.
- app/docker/{Dockerfile,Dockerfile.router}: COPY core, COPY app
  (instead of cial-core / cial-app).
- New docs/file-structure.md — single canonical map of the runtime
  layout. cial:self-edit SKILL.md mandates reading it first.
- cial:build SKILL.md: scope notes updated to platform/* and core/*.
- root package.json: smoke / dev:tenant scripts now under core/scripts/.
- core/scripts/smoke.mjs: cial-core.db → cial.db.

Externals preserved as-is by intent:
- JWT issuer string 'cial-app' in core/back/src/modules/sso/index.ts +
  app/api/src/lib/sso.ts is an external contract — NOT renamed.
- @cial/back / @cial/edge / @cial/protocol / @cial/sdk / @cial/front
  package names kept stable to minimize blast radius.

Verified:
- pnpm install --prod=false → ok
- turbo run build for protocol, sdk, back, edge, front, platform-back,
  platform-front → all 7 successful (Next builds + tsc clean).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 13:04:45 +00:00

165 lines
5.7 KiB
TypeScript

/**
* Docker driver — DooD (Docker-out-of-Docker) tenant lifecycle.
*
* Uses `dockerode` against the host Docker socket bind-mounted into the
* app-api container at `/var/run/docker.sock`.
*
* Conventions:
* - Container name: cial-tenant-<slug>
* - Image: cial-tenant:dev (overridable via TENANT_IMAGE)
* - Labels: cial.tenant=<id>, cial.slug=<slug>
* - Volume: cial-tenant-<id>-data → /cial/data
* - Env: TENANT_ID=<id>
* - Ports: container :8080 → ephemeral host port (Docker-assigned)
*/
import Docker from 'dockerode';
import type {
ContainerCreateOptions,
ContainerInspectInfo,
} from 'dockerode';
import type {
Orchestrator,
TenantMachine,
TenantSpec,
TenantState,
} from '../index.js';
export interface DockerOrchestratorOptions {
/** Image tag to spawn for tenants. Defaults to `cial-tenant:dev`. */
readonly image?: string;
/** Path to the docker socket. Defaults to `/var/run/docker.sock`. */
readonly socketPath?: string;
/** Optional pre-built dockerode instance (for tests). */
readonly docker?: Docker;
}
const TENANT_INTERNAL_PORT = '8080/tcp';
export function createDockerOrchestrator(
opts: DockerOrchestratorOptions = {},
): Orchestrator {
const image = opts.image ?? process.env.TENANT_IMAGE ?? 'cial-tenant:dev';
const docker =
opts.docker ??
new Docker({ socketPath: opts.socketPath ?? '/var/run/docker.sock' });
async function create(spec: TenantSpec): Promise<TenantMachine> {
const containerName = `cial-tenant-${spec.slug}`;
const volumeName = `cial-tenant-${spec.id}-data`;
const env = [`TENANT_ID=${spec.id}`];
// Forward the shared SSO secret so Core Back can verify owner-minted
// tokens (PLAN-LOCAL.md L5). Only set if the host process has it.
if (process.env.CIAL_SSO_SECRET) {
env.push(`CIAL_SSO_SECRET=${process.env.CIAL_SSO_SECRET}`);
}
// Better-Auth signing secret (Phase 3a). Same value across tenants is fine
// for v1 — sessions don't cross host boundaries since cookies are host-only.
if (process.env.BETTER_AUTH_SECRET) {
env.push(`BETTER_AUTH_SECRET=${process.env.BETTER_AUTH_SECRET}`);
}
// Public origin for this tenant — used by Better-Auth as baseURL so any
// emitted URLs (callbacks, magic links — phase 3c) resolve correctly.
const publicBaseTemplate = process.env.TENANT_PUBLIC_BASE;
if (publicBaseTemplate) {
env.push(`TENANT_PUBLIC_BASE=${publicBaseTemplate.replace('{slug}', spec.slug)}`);
}
// Mailer config (Phase 3c). Defaults inside Core Back are safe (log
// transport, no real email), so these are all optional pass-throughs.
if (process.env.MAIL_TRANSPORT) env.push(`MAIL_TRANSPORT=${process.env.MAIL_TRANSPORT}`);
if (process.env.MAIL_FROM) env.push(`MAIL_FROM=${process.env.MAIL_FROM}`);
if (process.env.RESEND_API_KEY) env.push(`RESEND_API_KEY=${process.env.RESEND_API_KEY}`);
const createOpts: ContainerCreateOptions = {
name: containerName,
Image: image,
Env: env,
Labels: {
'cial.tenant': spec.id,
'cial.slug': spec.slug,
},
ExposedPorts: { [TENANT_INTERNAL_PORT]: {} },
HostConfig: {
// Empty HostPort → docker daemon picks a free ephemeral port.
PortBindings: { [TENANT_INTERNAL_PORT]: [{ HostPort: '' }] },
Binds: [`${volumeName}:/cial/data`],
// Don't auto-restart in local mode; lifecycle is owner-driven.
RestartPolicy: { Name: 'no' },
},
};
const container = await docker.createContainer(createOpts);
await container.start();
const info = await container.inspect();
return toMachine(info);
}
async function start(containerId: string): Promise<TenantMachine> {
const container = docker.getContainer(containerId);
const info = await container.inspect();
if (!info.State.Running) await container.start();
return toMachine(await container.inspect());
}
async function stop(containerId: string): Promise<TenantMachine> {
const container = docker.getContainer(containerId);
try {
await container.stop({ t: 10 });
} catch (err) {
// 304 = already stopped — fine.
if ((err as { statusCode?: number }).statusCode !== 304) throw err;
}
return toMachine(await container.inspect());
}
async function destroy(containerId: string): Promise<void> {
const container = docker.getContainer(containerId);
try {
await container.remove({ force: true, v: true });
} catch (err) {
// 404 = already gone — fine.
if ((err as { statusCode?: number }).statusCode !== 404) throw err;
}
}
async function status(containerId: string): Promise<TenantMachine> {
const container = docker.getContainer(containerId);
return toMachine(await container.inspect());
}
return { create, start, stop, destroy, status };
}
function toMachine(info: ContainerInspectInfo): TenantMachine {
return {
containerId: info.Id,
state: mapState(info.State.Status, info.State.Running),
hostPort: extractHostPort(info),
};
}
function mapState(status: string, running: boolean): TenantState {
if (running) return 'running';
switch (status) {
case 'created':
case 'restarting':
return 'starting';
case 'paused':
case 'exited':
return 'stopped';
case 'dead':
case 'removing':
return 'errored';
default:
return 'starting';
}
}
function extractHostPort(info: ContainerInspectInfo): number | undefined {
const bindings = info.NetworkSettings?.Ports?.[TENANT_INTERNAL_PORT];
if (!bindings || bindings.length === 0) return undefined;
const port = Number(bindings[0]?.HostPort);
return Number.isFinite(port) && port > 0 ? port : undefined;
}