mirror of
https://github.com/techforces-ai/Cial.git
synced 2026-05-15 20:14:11 +00:00
phase(L0): tenant container runs full Core + Platform stack
Per-tenant container now boots all five processes behind a single exposed port (:8080), with the Core/Platform boundary enforced at the filesystem level (two Linux users, mode 0700 on cial-core). - @cial/edge: http-proxy edge (HTTP+WS) + node supervisor (PID 1 under tini, spawns each service via gosu as the right user) - Routes: /.cial/api/* -> back (prefix stripped), /.cial/* -> core front (basePath kept), /* -> platform front. Platform Back is internal-only for v1. - Dockerfile: multi-stage (builder + runtime). Builds protocol/sdk/ back/edge/front/platform-back/platform-front. Runtime installs tini+gosu, creates cial:1000 / agent:1001, locks down cial-core to 0700. - Placeholder pages now render TENANT_ID at request time so the smoke can verify per-tenant env propagation end-to-end. - scripts/smoke-tenant.mjs: docker-driven L0 acceptance — boots the image, polls healthz, probes the four route classes, and asserts the agent user cannot read /opt/cial-monorepo/cial-core. - PLAN-LOCAL.md: phased local-mode roadmap (L0..L6). Verify on a host with docker: docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev . pnpm smoke:tenant Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d5cc3f320e
commit
d4a39d425a
16 changed files with 857 additions and 58 deletions
259
PLAN-LOCAL.md
Normal file
259
PLAN-LOCAL.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Local mode — full multi-tenant Cial on your laptop
|
||||
|
||||
> Goal: `pnpm local:up` brings up the entire Cial platform locally with Docker
|
||||
> standing in for Fly Machines. You sign up as the owner in the App admin,
|
||||
> create a "client", a real Docker container is spawned for that tenant, you
|
||||
> click the tenant URL, and you land in their per-tenant Cial — SSO'd in,
|
||||
> already authenticated.
|
||||
|
||||
This document scopes "**plumbing v1**" — same code path as production, only
|
||||
the orchestrator's *driver* swaps (Docker ↔ Fly). It pulls **Phase 2** of
|
||||
PLAN.md (real container entrypoint) and parts of **Phases 11–13** (App auth,
|
||||
tenant CRUD, orchestrator, router) forward.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (locked in)
|
||||
|
||||
| | choice |
|
||||
|-|-|
|
||||
| Tenant URL | `{slug}.localhost:8080` *(modern browsers resolve `*.localhost` → 127.0.0.1; no /etc/hosts edits)* |
|
||||
| Signup | owner-only (you log into admin and create tenants) |
|
||||
| Scope | plumbing v1 — spawn tenant, route to it, SSO works, tenant container responds with an identifiable placeholder |
|
||||
| Orchestration | `docker compose` for App+Postgres+Router; the App spawns tenant containers via the host Docker socket (DooD pattern) |
|
||||
|
||||
## Tech picks
|
||||
|
||||
| concern | choice | why |
|
||||
|-|-|-|
|
||||
| App DB | `postgres:16` in compose | matches prod |
|
||||
| App ORM | `drizzle-orm` + `drizzle-kit` | already in deps, lightweight |
|
||||
| App Auth | `better-auth` | locked-in choice from infra deck |
|
||||
| Orchestrator driver | `dockerode` | typed, supports the full Docker API; no shelling out |
|
||||
| Router proxy | tiny Node service using `http-proxy` (lives in `cial-app/router`) | dedicated process, separates concerns, matches `@cial/app-router` package we already scaffolded |
|
||||
| SSO | HS256 JWT (shared secret in `.env.local`) for v1; bumps to RS256 in prod | smaller blast radius for a dev mode |
|
||||
| Tenant data | named docker volume per tenant: `cial-tenant-{id}-data` | survives `docker stop`, gone with the tenant on destroy |
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
host (your laptop)
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ browser → http://acme.localhost:8080 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────┐ │
|
||||
│ │ @cial/app- │ reads tenants.{slug, container_port} from PG │
|
||||
│ │ router │ forwards to 172.x.x.x:port │
|
||||
│ │ (:8080) │ │
|
||||
│ └────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
|
||||
│ │ @cial/app- │ │ postgres │ │ tenant containers │ │
|
||||
│ │ api │◄──►│ :5432 │ │ (one per client) │ │
|
||||
│ │ (:3100) │ └────────────┘ │ cial-tenant:dev │ │
|
||||
│ └────────────┘ └────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ spawns/stops via /var/run/docker.sock (DooD) │
|
||||
│ ▼ │
|
||||
│ host Docker daemon │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phasing
|
||||
|
||||
Each phase is independently runnable and ends with a verifiable check.
|
||||
|
||||
### L0 — Tenant image runs the full Core + Platform stack
|
||||
|
||||
**No scope reduction.** The tenant image runs all four processes with proper
|
||||
user separation and a single exposed port. This derisks the hardest piece of
|
||||
the infra (multi-process container, internal edge, two-user model) before any
|
||||
of the orchestration / routing is built. Chat / agent / deploy logic stays as
|
||||
501 stubs — those come back via PLAN.md phases — but the *plumbing* is real.
|
||||
|
||||
**Container topology (single exposed port: 8080):**
|
||||
|
||||
```
|
||||
external :8080
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ @cial/edge │ PID 1 supervises and routes
|
||||
│ (Node, user cial)│
|
||||
└──────────────────┘
|
||||
│ │ │ │
|
||||
/.cial/api/* │ │ │
|
||||
▼ │ │ │
|
||||
@cial/back │ │ │ user: cial :4000
|
||||
(Express) │ │ │ data: /var/lib/cial (cial:0700)
|
||||
/.cial/* │ │
|
||||
▼ │ │
|
||||
@cial/front │ │ user: cial :4001
|
||||
(Next, basePath=/.cial)
|
||||
│ │
|
||||
/api/p/* │ (internal — NOT exposed for v1)
|
||||
▼ │
|
||||
@cial/platform-back :3001 user: agent
|
||||
│
|
||||
│ /* (everything else)
|
||||
▼
|
||||
@cial/platform-front :3000 user: agent
|
||||
(Next, basePath=/)
|
||||
```
|
||||
|
||||
**Key decisions (calling these now to avoid round-trips):**
|
||||
|
||||
- **Supervisor**: small Node script as PID 1 wrapped by `tini`. It spawns
|
||||
the four children, pipes their stdout/stderr with prefixes, and crashes
|
||||
the container if any child dies (so Docker restarts it). No s6/supervisord
|
||||
— keeps the runtime stack pure Node.
|
||||
- **Internal edge**: tiny Node `http-proxy` server in a new package
|
||||
`cial-core/edge` (~100 LOC). Handles HTTP and WebSocket upgrade. The only
|
||||
process bound to `:8080`.
|
||||
- **Two-user model preserved**: `cial` user owns `/opt/cial-core` (mode 0700),
|
||||
runs edge + back + front; `agent` user owns `/opt/cial-platform`, runs
|
||||
platform-back + platform-front. Edge is `cial`-owned so the agent process
|
||||
can never bind the public port.
|
||||
- **Platform Back exposure**: **internal-only for v1** — reachable from
|
||||
Platform Front server-side via `http://localhost:3001`, not from the
|
||||
browser. Avoids fighting Next.js for the `/api/*` namespace. We pick a
|
||||
public mount path later when we know the convention.
|
||||
- **Next.js mode**: production builds with `output: 'standalone'` so each
|
||||
Next app ships only what it needs (smaller image, faster cold start).
|
||||
- **No watch / dev mode in the container** — that's purely for local dev
|
||||
outside the container (where `pnpm smoke` already works).
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add `cial-core/edge` package: Node + `http-proxy` + ws upgrade handling.
|
||||
2. Add `cial-core/supervisor` (or a small `bin/supervisor.mjs` inside `edge`):
|
||||
spawns the four children with the right user via `process.setuid` (or via
|
||||
`su-exec` in the entrypoint).
|
||||
3. Add `output: 'standalone'` + `outputFileTracingRoot` to both Next apps
|
||||
(`@cial/front`, `@cial/platform-front`) so monorepo symlinks resolve in
|
||||
the standalone bundle.
|
||||
4. Add a tiny placeholder route to each surface so we can prove routing:
|
||||
- `@cial/platform-front` `/` → `<h1>Platform · Tenant {TENANT_ID}</h1>`
|
||||
- `@cial/front` `/.cial` → `<h1>Cial Core · Tenant {TENANT_ID}</h1>`
|
||||
- `@cial/back` `/.cial/api/health` already exists (`/healthz` mounted there)
|
||||
- `@cial/platform-back` `/health` already exists (internal only)
|
||||
5. Rewrite `cial-core/docker/Dockerfile` as a real multi-stage build that
|
||||
produces the runtime image with all four services + the supervisor +
|
||||
correct ownership/permissions. Entry: `tini -- node /opt/cial-core/edge/bin/supervisor.mjs`.
|
||||
6. Add `cial-core/docker/.dockerignore` (already there).
|
||||
|
||||
**Verify (acceptance for L0):**
|
||||
|
||||
```bash
|
||||
docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
|
||||
docker run --rm -e TENANT_ID=demo -p 9000:8080 cial-tenant:dev
|
||||
# In another shell:
|
||||
curl http://localhost:9000/ # → "Platform · Tenant demo"
|
||||
curl http://localhost:9000/.cial # → "Cial Core · Tenant demo"
|
||||
curl http://localhost:9000/.cial/api/healthz # → {"status":"ok",…}
|
||||
docker exec <id> ls -la /opt/cial-core # → owner cial, mode 0700
|
||||
docker exec <id> sudo -u agent cat /opt/cial-core/back/dist/index.js
|
||||
# → permission denied (proves boundary)
|
||||
```
|
||||
|
||||
If all five checks pass, L0 is done. The container is then a real,
|
||||
production-shaped artifact — every later phase just plugs into it.
|
||||
|
||||
### L1 — docker compose + Postgres + App API + owner signup
|
||||
|
||||
- `docker-compose.yml` at repo root with services: `postgres`, `app-api`
|
||||
- `cial-app/api`:
|
||||
- Drizzle schema: `users`, `tenants` (slug, name, container_id, container_port, state, owner_id)
|
||||
- `drizzle-kit generate` + on-boot migrate
|
||||
- Better-Auth wired at `/api/auth/[...all]` — email+password, no verification in dev
|
||||
- `/admin/login` and `/admin/signup` pages (signup disabled after first user; first user is owner)
|
||||
- `app-api` Dockerfile updated to actually run `next start` on :3100
|
||||
- **Verify**: `docker compose up`, browser to `http://localhost:3100/admin/signup`,
|
||||
create owner account, redirected to `/admin` (empty tenant list).
|
||||
|
||||
### L2 — Admin tenant CRUD
|
||||
|
||||
- `/admin` page: list tenants from DB
|
||||
- "New tenant" form (slug, name) → POST `/api/admin/tenants`
|
||||
- Validates slug (`^[a-z0-9-]{2,40}$`), inserts row with `state='provisioning'`
|
||||
- For now, no orchestrator call — just DB row + UI confirmation
|
||||
- **Verify**: create a tenant in UI, see it in the list with state=provisioning.
|
||||
|
||||
### L3 — Orchestrator Docker driver
|
||||
|
||||
- `cial-app/orchestrator/src/drivers/docker.ts`: implements `Orchestrator` via
|
||||
`dockerode`
|
||||
- `create(tenant)`: `docker create` from `cial-tenant:dev` with env
|
||||
`TENANT_ID`, label `cial.tenant=<id>`, named volume, exposed port mapped
|
||||
to a free host port. Sets state=`starting`.
|
||||
- `start/stop/destroy/status`: trivial dockerode calls
|
||||
- `app-api` calls `orchestrator.create()` after the DB insert in L2;
|
||||
records `container_id`, `container_port`; flips state to `running` once
|
||||
the container's `/healthz` responds.
|
||||
- App container needs `/var/run/docker.sock` mounted in compose.
|
||||
- **Verify**: create a tenant in UI → admin list shows state=running with a
|
||||
port; `docker ps` shows the container; `curl localhost:<port>` returns the
|
||||
L0 placeholder page.
|
||||
|
||||
### L4 — Router reverse proxy on :8080
|
||||
|
||||
- `cial-app/router/src/index.ts`: small Node HTTP+WS server on :8080 using
|
||||
`http-proxy`
|
||||
- Reads tenant routes from Postgres on each request (cache with 5s TTL)
|
||||
- `Host` header `acme.localhost` → `tenants WHERE slug='acme' AND state='running'`
|
||||
→ forward to `localhost:<container_port>`
|
||||
- 404 if unknown slug; 503 if state ≠ running
|
||||
- Add `router` service to compose
|
||||
- **Verify**: `http://acme.localhost:8080` shows the tenant's page (Tenant acme).
|
||||
|
||||
### L5 — SSO handoff App → tenant Core
|
||||
|
||||
- App admin: tenant detail page has an **Open** button → `/api/admin/tenants/:slug/open`
|
||||
- That endpoint mints a short-lived (60s) HS256 JWT `{ sub: ownerId, tenant: slug, role: 'owner' }`
|
||||
signed with `CIAL_SSO_SECRET`, redirects browser to `http://{slug}.localhost:8080/.cial/sso?token=...`
|
||||
- Core Back: `/.cial/sso` validates token, sets a session cookie scoped to
|
||||
the tenant subdomain, redirects to `/`. (For v1 the cookie is the marker;
|
||||
Phase 3 of PLAN.md replaces this with real Better-Auth in Core.)
|
||||
- The tenant `/` page now shows `Tenant {id} · signed in as {sub}`.
|
||||
- **Verify**: from admin, click "Open acme" → land on `acme.localhost:8080`
|
||||
with the tenant page showing your owner email.
|
||||
|
||||
### L6 — `pnpm local:up` wraps everything
|
||||
|
||||
- One-shot script that:
|
||||
1. Builds `cial-tenant:dev` image
|
||||
2. Runs `docker compose up --build -d`
|
||||
3. Tails logs, prints `→ http://localhost:3100/admin` when ready
|
||||
- `pnpm local:down` tears compose + prunes any orphan tenant containers
|
||||
(label-selected: `label=cial.tenant`)
|
||||
- A new `scripts/smoke-local.mjs` end-to-end:
|
||||
1. local:up
|
||||
2. sign up owner via API
|
||||
3. create tenant via API
|
||||
4. probe `acme.localhost:8080` until 200
|
||||
5. local:down
|
||||
- **Verify**: green E2E smoke from a clean state.
|
||||
|
||||
---
|
||||
|
||||
## Working agreement
|
||||
|
||||
- Same as PLAN.md: implement → self-test → commit `phase(L<n>): …` → push.
|
||||
- Each phase ends with a green check. If a phase's verify step fails, we
|
||||
fix before moving on.
|
||||
- Anything bigger than L0–L6 (e.g., bring Core Front + Platform processes
|
||||
inside the tenant image) is a follow-up scoped after L6 is green.
|
||||
|
||||
## Open questions to revisit later (NOT for v1)
|
||||
|
||||
- Multi-process tenant (Core Front + Platform Back + Platform Front inside
|
||||
the container) — needed for the real Platform editing experience
|
||||
- Trigger fabric / scheduler — needed once tenants have cron triggers
|
||||
- Custom domains for tenants
|
||||
- Per-tenant resource limits (memory/CPU caps via Docker)
|
||||
- Tenant suspend (Docker stop) ↔ cold start time on first request
|
||||
2
cial-app/api/next-env.d.ts
vendored
2
cial-app/api/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,79 +1,97 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cial — per-tenant container image.
|
||||
# Cial — per-tenant container image (cial-tenant).
|
||||
#
|
||||
# One container per tenant. Five processes inside, single exposed port.
|
||||
#
|
||||
# PID 1 (root) tini → node supervisor
|
||||
# ├─ edge (cial) :8080 (only exposed)
|
||||
# ├─ core-back (cial) :4000
|
||||
# ├─ core-front (cial) :4001 basePath=/.cial
|
||||
# ├─ platform-back (agent) :3001 internal-only
|
||||
# └─ platform-front (agent) :3000
|
||||
#
|
||||
# Two Linux users:
|
||||
# - cial (uid 1000) — owns Core code (cial-core/*) at mode 0700.
|
||||
# Runs @cial/back and @cial/front.
|
||||
# - agent (uid 1001) — runs the Claude/Codex agent and the platform code
|
||||
# (cial-platform/*). Cannot read Core files. Talks to
|
||||
# Core only over /run/cial-core.sock.
|
||||
# - cial (uid 1000) — owns /opt/cial-monorepo/cial-core (mode 0700).
|
||||
# Runs edge + back + front. Agent cannot read it.
|
||||
# - agent (uid 1001) — owns /opt/cial-monorepo/cial-platform.
|
||||
# Runs platform-back + platform-front + (later) the AI agent.
|
||||
#
|
||||
# This Dockerfile is intentionally a skeleton — phase 2 of PLAN.md fills in
|
||||
# the build steps. Everything below is the minimum needed to boot.
|
||||
# Built from the repo root:
|
||||
# docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARG NODE_VERSION=22.12.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base — shared deps (pnpm, build toolchain for better-sqlite3).
|
||||
# Base — pnpm + native build toolchain (better-sqlite3 / sharp need it)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS base
|
||||
ENV PNPM_HOME=/pnpm \
|
||||
PATH=/pnpm:$PATH \
|
||||
NODE_ENV=production
|
||||
PATH=/pnpm:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates git \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 make g++ ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /workspace
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builder — install + build all workspaces.
|
||||
# Builder — install deps + build every workspace this image needs
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json turbo.json tsconfig.base.json ./
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json turbo.json tsconfig.base.json eslint.config.js ./
|
||||
COPY cial-core ./cial-core
|
||||
COPY cial-platform ./cial-platform
|
||||
COPY cial-app ./cial-app
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile=false
|
||||
|
||||
RUN pnpm turbo run build \
|
||||
--filter @cial/protocol \
|
||||
--filter @cial/sdk \
|
||||
--filter @cial/back \
|
||||
--filter @cial/front
|
||||
--filter @cial/edge \
|
||||
--filter @cial/front \
|
||||
--filter @cial/platform-back \
|
||||
--filter @cial/platform-front
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime — two users, locked-down filesystem.
|
||||
# Runtime — two users + tini + gosu, the supervisor as PID 1
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION}-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production \
|
||||
CIAL_BACK_PORT=4000 \
|
||||
CIAL_FRONT_PORT=4001 \
|
||||
CIAL_SOCK=/run/cial-core.sock
|
||||
|
||||
RUN groupadd --system --gid 1000 cial \
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini gosu ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1000 cial \
|
||||
&& useradd --system --uid 1000 --gid 1000 --home /home/cial --create-home --shell /bin/bash cial \
|
||||
&& groupadd --system --gid 1001 agent \
|
||||
&& useradd --system --uid 1001 --gid 1001 --home /home/agent --create-home --shell /bin/bash agent \
|
||||
&& mkdir -p /run /opt/cial-core /opt/cial-platform /var/lib/cial \
|
||||
&& chown -R cial:cial /opt/cial-core /var/lib/cial \
|
||||
&& chown -R agent:agent /opt/cial-platform \
|
||||
&& chmod 0700 /opt/cial-core
|
||||
&& mkdir -p /opt/cial-monorepo /var/lib/cial \
|
||||
&& chown cial:cial /var/lib/cial \
|
||||
&& chmod 0700 /var/lib/cial
|
||||
|
||||
# Copy built Core (owned by `cial`, mode 0700 — agent CANNOT read it).
|
||||
COPY --from=builder --chown=cial:cial /workspace/cial-core /opt/cial-core
|
||||
# Copy the entire built workspace (root-owned by default) so node_modules
|
||||
# symlinks line up.
|
||||
COPY --from=builder /workspace /opt/cial-monorepo
|
||||
|
||||
# Copy built Platform (owned by `agent` — editable).
|
||||
COPY --from=builder --chown=agent:agent /workspace/cial-platform /opt/cial-platform
|
||||
# Lock down /opt/cial-monorepo/cial-core to user `cial` only (mode 0700).
|
||||
# Agent literally cannot enter the directory — the filesystem enforces the
|
||||
# Core/Platform boundary.
|
||||
RUN chown -R cial:cial /opt/cial-monorepo/cial-core \
|
||||
&& chmod -R u=rwX,go= /opt/cial-monorepo/cial-core \
|
||||
&& chown -R agent:agent /opt/cial-monorepo/cial-platform \
|
||||
&& chmod -R u=rwX,go=rX /opt/cial-monorepo/cial-platform \
|
||||
# /opt/cial-app is built but not used at runtime in tenant containers; remove
|
||||
# to avoid confusion + shrink image.
|
||||
&& rm -rf /opt/cial-monorepo/cial-app
|
||||
|
||||
# Entry script (phase 2 will turn this into a tini-based supervisor that
|
||||
# launches @cial/back as `cial` and the platform process as `agent`).
|
||||
COPY cial-core/docker/entrypoint.sh /usr/local/bin/cial-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/cial-entrypoint
|
||||
|
||||
USER cial
|
||||
WORKDIR /opt/cial-core
|
||||
EXPOSE 4000 4001
|
||||
ENTRYPOINT ["/usr/local/bin/cial-entrypoint"]
|
||||
WORKDIR /opt/cial-monorepo
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "node", "/opt/cial-monorepo/cial-core/edge/dist/supervisor.js"]
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Per-tenant container entrypoint.
|
||||
#
|
||||
# Phase 2 of PLAN.md replaces this with a real supervisor (tini + two child
|
||||
# processes). For now this just prints the boot intent so the image can be
|
||||
# built and smoke-tested.
|
||||
set -euo pipefail
|
||||
|
||||
echo "[cial] entrypoint placeholder — phase 2 will start:"
|
||||
echo " - @cial/back on :${CIAL_BACK_PORT:-4000} (user: cial)"
|
||||
echo " - @cial/front on :${CIAL_FRONT_PORT:-4001} (user: cial)"
|
||||
echo " - platform process (user: agent)"
|
||||
echo "[cial] sleeping so container stays up for inspection..."
|
||||
exec sleep infinity
|
||||
41
cial-core/edge/README.md
Normal file
41
cial-core/edge/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# `@cial/edge` — Tenant container edge + supervisor
|
||||
|
||||
Two scripts that together make the tenant container behave like a single
|
||||
HTTP origin while preserving the four-process layout and the two-user
|
||||
filesystem boundary.
|
||||
|
||||
## Scripts
|
||||
|
||||
- **`dist/supervisor.js`** — PID 1 (under `tini`). Runs as `root`. Spawns
|
||||
the four service processes plus the edge using `gosu` to drop to the
|
||||
correct Linux user (`cial` for Core + edge, `agent` for Platform). If
|
||||
any child exits, tears down and exits non-zero.
|
||||
- **`dist/edge.js`** — runs as `cial`. Tiny `http-proxy`-based reverse
|
||||
proxy on `:8080`. Multiplexes:
|
||||
|
||||
| path | → target | notes |
|
||||
|------------------|--------------------------------|-------------------------|
|
||||
| `/.cial/api/*` | `http://127.0.0.1:4000` (back) | strips `/.cial/api` |
|
||||
| `/.cial/*` | `http://127.0.0.1:4001` (front)| basePath stays intact |
|
||||
| `/*` | `http://127.0.0.1:3000` (platform-front) | catch-all |
|
||||
|
||||
Platform Back (`http://127.0.0.1:3001`) is **not exposed** publicly in v1.
|
||||
It's reachable from Platform Front server-side only. We pick a public
|
||||
mount path later when there's a real use case.
|
||||
|
||||
## Env
|
||||
|
||||
| var | default |
|
||||
|------------------------------|--------------------------|
|
||||
| `EDGE_PORT` | `8080` |
|
||||
| `TENANT_ID` | `unknown` (logged only) |
|
||||
| `EDGE_TARGET_BACK` | `http://127.0.0.1:4000` |
|
||||
| `EDGE_TARGET_CORE_FRONT` | `http://127.0.0.1:4001` |
|
||||
| `EDGE_TARGET_PLATFORM_FRONT` | `http://127.0.0.1:3000` |
|
||||
|
||||
## Local
|
||||
|
||||
This package is not meant to be run on the host. The supervisor expects
|
||||
`gosu` and the `cial`/`agent` users to exist, and looks for built code at
|
||||
`/opt/cial-monorepo/...`. Use the tenant Dockerfile (see
|
||||
`cial-core/docker/`).
|
||||
20
cial-core/edge/package.json
Normal file
20
cial-core/edge/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@cial/edge",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/edge.js",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"http-proxy": "1.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/http-proxy": "1.17.15",
|
||||
"@types/node": "22.10.5",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
84
cial-core/edge/src/edge.ts
Normal file
84
cial-core/edge/src/edge.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Per-tenant edge proxy.
|
||||
*
|
||||
* Single process bound to the container's exposed port (8080 by default).
|
||||
* Multiplexes inbound HTTP and WebSocket traffic to four internal services
|
||||
* on loopback:
|
||||
*
|
||||
* /.cial/api/* → @cial/back (strip /.cial/api prefix)
|
||||
* /.cial/* → @cial/front (basePath=/.cial — keep prefix)
|
||||
* /* → @cial/platform-front
|
||||
*
|
||||
* @cial/platform-back is reachable only from Platform Front server-side
|
||||
* (http://127.0.0.1:3001) — no public route in v1.
|
||||
*
|
||||
* Runs as the `cial` user inside the tenant container. Started by the
|
||||
* supervisor.
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import httpProxy from 'http-proxy';
|
||||
|
||||
const PORT = Number(process.env.EDGE_PORT ?? 8080);
|
||||
const TENANT_ID = process.env.TENANT_ID ?? 'unknown';
|
||||
|
||||
const targets = {
|
||||
back: process.env.EDGE_TARGET_BACK ?? 'http://127.0.0.1:4000',
|
||||
coreFront: process.env.EDGE_TARGET_CORE_FRONT ?? 'http://127.0.0.1:4001',
|
||||
platformFront: process.env.EDGE_TARGET_PLATFORM_FRONT ?? 'http://127.0.0.1:3000',
|
||||
};
|
||||
|
||||
const proxy = httpProxy.createProxyServer({
|
||||
ws: true,
|
||||
changeOrigin: false,
|
||||
xfwd: true,
|
||||
});
|
||||
|
||||
proxy.on('error', (err: Error, _req, res) => {
|
||||
process.stderr.write(`[edge] proxy error: ${err.message}\n`);
|
||||
if (res && 'writeHead' in res && !res.headersSent) {
|
||||
res.writeHead(502, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { code: 'BAD_GATEWAY', message: err.message } }));
|
||||
}
|
||||
});
|
||||
|
||||
interface RouteDecision {
|
||||
target: string;
|
||||
rewrittenUrl: string;
|
||||
}
|
||||
|
||||
function route(url: string): RouteDecision {
|
||||
if (url.startsWith('/.cial/api/') || url === '/.cial/api') {
|
||||
const stripped = url.replace(/^\/\.cial\/api/, '') || '/';
|
||||
return { target: targets.back, rewrittenUrl: stripped };
|
||||
}
|
||||
if (url.startsWith('/.cial/') || url === '/.cial') {
|
||||
return { target: targets.coreFront, rewrittenUrl: url };
|
||||
}
|
||||
return { target: targets.platformFront, rewrittenUrl: url };
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const decision = route(req.url ?? '/');
|
||||
req.url = decision.rewrittenUrl;
|
||||
proxy.web(req, res, { target: decision.target });
|
||||
});
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const decision = route(req.url ?? '/');
|
||||
req.url = decision.rewrittenUrl;
|
||||
proxy.ws(req, socket, head, { target: decision.target });
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
process.stdout.write(`[edge] tenant=${TENANT_ID} listening on :${PORT}\n`);
|
||||
});
|
||||
|
||||
function shutdown(signal: string): void {
|
||||
process.stdout.write(`[edge] received ${signal}, closing\n`);
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
132
cial-core/edge/src/supervisor.ts
Normal file
132
cial-core/edge/src/supervisor.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Tenant container supervisor.
|
||||
*
|
||||
* PID 1 (under tini). Spawns the four service processes — Core Back, Core
|
||||
* Front, Platform Back, Platform Front — plus the edge proxy. Uses `gosu`
|
||||
* to run each child as the right Linux user (cial vs agent), enforcing the
|
||||
* filesystem-level boundary defined in cial-core/docker/Dockerfile.
|
||||
*
|
||||
* If any child exits unexpectedly, the supervisor tears the others down and
|
||||
* exits non-zero so Docker's restart policy kicks in.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
|
||||
interface ServiceSpec {
|
||||
name: string;
|
||||
user: 'cial' | 'agent';
|
||||
cmd: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
const MONOREPO = '/opt/cial-monorepo';
|
||||
|
||||
const SERVICES: ServiceSpec[] = [
|
||||
{
|
||||
name: 'core-back',
|
||||
user: 'cial',
|
||||
cmd: ['node', `${MONOREPO}/cial-core/back/dist/index.js`],
|
||||
env: { PORT: '4000', DB_PATH: '/var/lib/cial/cial.db', LOG_LEVEL: 'info' },
|
||||
},
|
||||
{
|
||||
name: 'core-front',
|
||||
user: 'cial',
|
||||
cmd: ['node', 'node_modules/next/dist/bin/next', 'start'],
|
||||
cwd: `${MONOREPO}/cial-core/front`,
|
||||
env: { PORT: '4001', HOSTNAME: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
name: 'platform-back',
|
||||
user: 'agent',
|
||||
cmd: ['node', `${MONOREPO}/cial-platform/back/dist/index.js`],
|
||||
env: { PLATFORM_BACK_PORT: '3001' },
|
||||
},
|
||||
{
|
||||
name: 'platform-front',
|
||||
user: 'agent',
|
||||
cmd: ['node', 'node_modules/next/dist/bin/next', 'start'],
|
||||
cwd: `${MONOREPO}/cial-platform/front`,
|
||||
env: { PORT: '3000', HOSTNAME: '127.0.0.1' },
|
||||
},
|
||||
{
|
||||
name: 'edge',
|
||||
user: 'cial',
|
||||
cmd: ['node', `${MONOREPO}/cial-core/edge/dist/edge.js`],
|
||||
env: { EDGE_PORT: '8080' },
|
||||
},
|
||||
];
|
||||
|
||||
const TENANT_ID = process.env.TENANT_ID ?? 'unknown';
|
||||
const baseEnv: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
TENANT_ID,
|
||||
NODE_ENV: 'production',
|
||||
};
|
||||
|
||||
const children = new Map<string, ChildProcess>();
|
||||
let shuttingDown = false;
|
||||
let exitCode = 0;
|
||||
|
||||
function logLine(stream: NodeJS.WriteStream, name: string, chunk: Buffer): void {
|
||||
const lines = chunk.toString('utf8').split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
for (const line of lines) {
|
||||
stream.write(`[${name}] ${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function start(svc: ServiceSpec): void {
|
||||
const env = { ...baseEnv, ...svc.env };
|
||||
const child = spawn('gosu', [svc.user, '--', ...svc.cmd], {
|
||||
cwd: svc.cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout?.on('data', (d: Buffer) => logLine(process.stdout, svc.name, d));
|
||||
child.stderr?.on('data', (d: Buffer) => logLine(process.stderr, svc.name, d));
|
||||
child.on('exit', (code, sig) => {
|
||||
process.stderr.write(
|
||||
`[supervisor] child "${svc.name}" exited code=${code} sig=${sig}\n`,
|
||||
);
|
||||
if (!shuttingDown) {
|
||||
exitCode = code === 0 ? 1 : (code ?? 1);
|
||||
shutdown('child-exit');
|
||||
}
|
||||
});
|
||||
children.set(svc.name, child);
|
||||
process.stdout.write(
|
||||
`[supervisor] started ${svc.name} as ${svc.user} (pid=${child.pid})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function shutdown(reason: string): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
process.stdout.write(`[supervisor] shutting down (${reason})\n`);
|
||||
for (const child of children.values()) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const child of children.values()) {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}, 5000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
process.stdout.write(
|
||||
`[supervisor] tenant=${TENANT_ID} starting ${SERVICES.length} services\n`,
|
||||
);
|
||||
for (const svc of SERVICES) start(svc);
|
||||
11
cial-core/edge/tsconfig.json
Normal file
11
cial-core/edge/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2
cial-core/front/next-env.d.ts
vendored
2
cial-core/front/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function CoreHome() {
|
||||
const tenantId = process.env.TENANT_ID ?? 'unknown';
|
||||
return (
|
||||
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
|
||||
<h1>Cial — Core</h1>
|
||||
<h1>Cial · Core</h1>
|
||||
<p>
|
||||
Rescue UI placeholder. Phase 4 will replace this with the chat shell, deploy
|
||||
controls, and trigger panel.
|
||||
Tenant: <code>{tenantId}</code>
|
||||
</p>
|
||||
<p>
|
||||
Rescue UI placeholder. Phase 4 of <code>PLAN.md</code> replaces this with the
|
||||
chat shell, deploy controls, and trigger panel.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
2
cial-platform/front/next-env.d.ts
vendored
2
cial-platform/front/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PlatformHome() {
|
||||
const tenantId = process.env.TENANT_ID ?? 'unknown';
|
||||
return (
|
||||
<main style={{ padding: 32, fontFamily: 'system-ui' }}>
|
||||
<h1>Platform</h1>
|
||||
<p>
|
||||
Tenant: <code>{tenantId}</code>
|
||||
</p>
|
||||
<p>This is the editable surface. The agent owns this code.</p>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"test": "turbo run test",
|
||||
"clean": "turbo run clean && rm -rf node_modules .turbo",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"smoke:tenant": "node scripts/smoke-tenant.mjs",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
|
||||
},
|
||||
|
|
|
|||
|
|
@ -157,6 +157,22 @@ importers:
|
|||
specifier: ^5.7.3
|
||||
version: 5.7.3
|
||||
|
||||
cial-core/edge:
|
||||
dependencies:
|
||||
http-proxy:
|
||||
specifier: 1.18.1
|
||||
version: 1.18.1
|
||||
devDependencies:
|
||||
'@types/http-proxy':
|
||||
specifier: 1.17.15
|
||||
version: 1.17.15
|
||||
'@types/node':
|
||||
specifier: 22.10.5
|
||||
version: 22.10.5
|
||||
typescript:
|
||||
specifier: 5.7.3
|
||||
version: 5.7.3
|
||||
|
||||
cial-core/front:
|
||||
dependencies:
|
||||
next:
|
||||
|
|
@ -1003,6 +1019,9 @@ packages:
|
|||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/http-proxy@1.17.15':
|
||||
resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
|
|
@ -1474,6 +1493,9 @@ packages:
|
|||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1526,6 +1548,15 @@ packages:
|
|||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -1591,6 +1622,10 @@ packages:
|
|||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http-proxy@1.18.1:
|
||||
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -1969,6 +2004,9 @@ packages:
|
|||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -2700,6 +2738,10 @@ snapshots:
|
|||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/http-proxy@1.17.15':
|
||||
dependencies:
|
||||
'@types/node': 22.10.5
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
|
@ -3197,6 +3239,8 @@ snapshots:
|
|||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
express@4.21.2:
|
||||
|
|
@ -3277,6 +3321,8 @@ snapshots:
|
|||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
|
@ -3338,6 +3384,14 @@ snapshots:
|
|||
statuses: 2.0.1
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy@1.18.1:
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
follow-redirects: 1.16.0
|
||||
requires-port: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -3682,6 +3736,8 @@ snapshots:
|
|||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
|
|||
179
scripts/smoke-tenant.mjs
Normal file
179
scripts/smoke-tenant.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tenant container acceptance test (L0 of PLAN-LOCAL.md).
|
||||
*
|
||||
* Pre-req:
|
||||
* docker build -f cial-core/docker/Dockerfile -t cial-tenant:dev .
|
||||
*
|
||||
* Usage:
|
||||
* pnpm smoke:tenant
|
||||
*
|
||||
* What it does:
|
||||
* 1. Runs `docker run -d` with TENANT_ID=demo on host port 19090
|
||||
* 2. Polls /.cial/api/healthz until 200 (60s budget)
|
||||
* 3. Probes the four route classes through the edge proxy
|
||||
* 4. Verifies the two-user filesystem boundary (agent cannot read Core)
|
||||
* 5. Tears the container down
|
||||
*
|
||||
* Exits 0 on success, 1 otherwise.
|
||||
*/
|
||||
|
||||
import { spawnSync, spawn } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const IMAGE = process.env.CIAL_TENANT_IMAGE ?? 'cial-tenant:dev';
|
||||
const HOST_PORT = Number(process.env.CIAL_TENANT_HOST_PORT ?? 19090);
|
||||
const TENANT_ID = process.env.CIAL_TENANT_ID ?? 'demo';
|
||||
const NAME = `cial-tenant-smoke-${process.pid}`;
|
||||
|
||||
let failed = false;
|
||||
|
||||
function dockerRun(args, opts = {}) {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8', ...opts });
|
||||
if (r.status !== 0 && !opts.allowFail) {
|
||||
process.stderr.write(`docker ${args.join(' ')}\nstdout: ${r.stdout}\nstderr: ${r.stderr}\n`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
process.stdout.write('\n▶ Tearing down…\n');
|
||||
dockerRun(['rm', '-f', NAME], { allowFail: true });
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
teardown();
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
async function probe(url) {
|
||||
try {
|
||||
return await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitReady(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await probe(url);
|
||||
if (res && res.status < 500) return true;
|
||||
await sleep(750);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function check(label, ok, detail = '') {
|
||||
const mark = ok ? '✓' : '✗';
|
||||
process.stdout.write(`${mark} ${label}${detail ? ` ${detail}` : ''}\n`);
|
||||
if (!ok) failed = true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.stdout.write(`▶ Starting ${IMAGE} as ${NAME} on host :${HOST_PORT}\n`);
|
||||
|
||||
// Image present?
|
||||
const img = dockerRun(['image', 'inspect', IMAGE], { allowFail: true });
|
||||
if (img.status !== 0) {
|
||||
process.stderr.write(
|
||||
`✗ image "${IMAGE}" not found. Build it first:\n` +
|
||||
` docker build -f cial-core/docker/Dockerfile -t ${IMAGE} .\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Clean any leftover container with the same name (defensive).
|
||||
dockerRun(['rm', '-f', NAME], { allowFail: true });
|
||||
|
||||
const run = dockerRun([
|
||||
'run',
|
||||
'-d',
|
||||
'--name', NAME,
|
||||
'-e', `TENANT_ID=${TENANT_ID}`,
|
||||
'-p', `${HOST_PORT}:8080`,
|
||||
IMAGE,
|
||||
]);
|
||||
if (run.status !== 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Tail container logs in the background so failures are debuggable.
|
||||
const logs = spawn('docker', ['logs', '-f', NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
logs.stdout.on('data', (d) => process.stdout.write(`[container] ${d}`));
|
||||
logs.stderr.on('data', (d) => process.stderr.write(`[container!] ${d}`));
|
||||
|
||||
try {
|
||||
process.stdout.write('\n▶ Waiting for /.cial/api/healthz…\n');
|
||||
const ready = await waitReady(`http://127.0.0.1:${HOST_PORT}/.cial/api/healthz`, 60_000);
|
||||
check('Core Back reachable through edge', ready);
|
||||
if (!ready) return;
|
||||
|
||||
process.stdout.write('\n▶ Probes…\n');
|
||||
|
||||
// Core Back via edge (prefix stripped: /.cial/api/healthz → /healthz)
|
||||
const healthz = await probe(`http://127.0.0.1:${HOST_PORT}/.cial/api/healthz`);
|
||||
const healthzBody = healthz ? await healthz.json().catch(() => null) : null;
|
||||
check(
|
||||
'GET /.cial/api/healthz → 200 ok',
|
||||
healthz?.status === 200 && healthzBody?.status === 'ok',
|
||||
`(${healthz?.status})`,
|
||||
);
|
||||
|
||||
// Core Back 501 stub envelope through edge
|
||||
const vault = await probe(`http://127.0.0.1:${HOST_PORT}/.cial/api/vault`);
|
||||
const vaultBody = vault ? await vault.json().catch(() => null) : null;
|
||||
check(
|
||||
'GET /.cial/api/vault → 501 NOT_IMPLEMENTED',
|
||||
vault?.status === 501 && vaultBody?.error?.code === 'NOT_IMPLEMENTED',
|
||||
`(${vault?.status})`,
|
||||
);
|
||||
|
||||
// Core Front (Next, basePath=/.cial)
|
||||
const coreFront = await probe(`http://127.0.0.1:${HOST_PORT}/.cial`);
|
||||
const coreFrontHtml = coreFront ? await coreFront.text() : '';
|
||||
check(
|
||||
'GET /.cial → Core Front shows tenant id',
|
||||
coreFront?.status === 200 && coreFrontHtml.includes(TENANT_ID),
|
||||
`(${coreFront?.status})`,
|
||||
);
|
||||
|
||||
// Platform Front (Next, /)
|
||||
const platformFront = await probe(`http://127.0.0.1:${HOST_PORT}/`);
|
||||
const platformFrontHtml = platformFront ? await platformFront.text() : '';
|
||||
check(
|
||||
'GET / → Platform Front shows tenant id',
|
||||
platformFront?.status === 200 && platformFrontHtml.includes(TENANT_ID),
|
||||
`(${platformFront?.status})`,
|
||||
);
|
||||
|
||||
// Two-user boundary: agent must NOT be able to read Core code
|
||||
const exec = dockerRun(
|
||||
[
|
||||
'exec',
|
||||
'--user', 'agent',
|
||||
NAME,
|
||||
'cat', '/opt/cial-monorepo/cial-core/back/dist/index.js',
|
||||
],
|
||||
{ allowFail: true },
|
||||
);
|
||||
check(
|
||||
'agent cannot read /opt/cial-monorepo/cial-core/* (mode 0700)',
|
||||
exec.status !== 0,
|
||||
`(exit ${exec.status})`,
|
||||
);
|
||||
|
||||
process.stdout.write(`\n${failed ? '✗ FAIL' : '✓ PASS'}\n`);
|
||||
} finally {
|
||||
logs.kill('SIGTERM');
|
||||
teardown();
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`smoke-tenant harness error: ${err.stack || err}\n`);
|
||||
teardown();
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue