mirror of
https://github.com/techforces-ai/Cial.git
synced 2026-05-15 19:34:11 +00:00
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>
165 lines
5.7 KiB
TypeScript
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;
|
|
}
|