Skip to content

Commit 88494a5

Browse files
author
Ariel Shadkhan
committed
cloud: scaffold multi-tenant build (Postgres adapter + register-cloud DI + k8s job executor stub + auth middleware + CI). See CLOUD.md punchlist.
1 parent abbd025 commit 88494a5

12 files changed

Lines changed: 630 additions & 0 deletions

File tree

.github/workflows/cloud-build.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: cloud-build
2+
on:
3+
push:
4+
branches: [cloud]
5+
workflow_dispatch:
6+
7+
permissions:
8+
contents: read
9+
packages: write
10+
11+
jobs:
12+
image:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: docker/setup-buildx-action@v3
18+
19+
- uses: docker/login-action@v3
20+
with:
21+
registry: ghcr.io
22+
username: ${{ github.actor }}
23+
password: ${{ secrets.GITHUB_TOKEN }}
24+
25+
- name: meta
26+
id: meta
27+
uses: docker/metadata-action@v5
28+
with:
29+
images: ghcr.io/shep-ai/shep
30+
tags: |
31+
type=sha,prefix=cloud-,format=short
32+
type=raw,value=cloud-latest
33+
34+
- uses: docker/build-push-action@v6
35+
with:
36+
context: .
37+
file: Dockerfile.cloud
38+
push: true
39+
tags: ${{ steps.meta.outputs.tags }}
40+
labels: ${{ steps.meta.outputs.labels }}
41+
cache-from: type=gha,scope=cloud
42+
cache-to: type=gha,mode=max,scope=cloud

CLOUD.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Shep Cloud — `cloud` branch
2+
3+
Multi-tenant SaaS build of shep that ships at **app.shep.bot**. Same web UI you'd see at `localhost:4050`, but every user is logged in via Keycloak, every row is scoped to their org, and every "run agent" spawns an isolated k8s Job in the cluster instead of a local subprocess.
4+
5+
This branch is **structural scaffolding, not yet a working build**. The skeleton lays down the swap points; the punchlist below is what's left.
6+
7+
## Mental model
8+
9+
The CLI build (`main`) is unchanged: SQLite, local subprocesses, single user. The cloud build is selected by `SHEP_MODE=cloud` at boot — that swaps:
10+
11+
| Concern | `main` (CLI) | `cloud` |
12+
|----------------|----------------------------|--------------------------------------------------------|
13+
| Persistence | SQLite (`~/.shep/shep.db`) | Postgres on the cluster, RLS + `org_id` on every table |
14+
| Auth | none (local user) | Keycloak OIDC, NextAuth session, request-scoped tenant |
15+
| Agent execution| `child_process.spawn` | k8s `batch/v1.Job` per run, ephemeral PVC, NetworkPol |
16+
| Worktree FS | `~/.shep/worktrees/...` | Per-Job ephemeral `emptyDir` or PVC; git remote is SoT |
17+
| Settings store | `~/.shep/settings.json` | Postgres `org_settings` table |
18+
19+
**No fork from main:** all cloud-mode code is additive (new files under `*/postgres/`, `register-cloud.ts`, etc.). The only edits to existing files are conditional (`if (SHEP_MODE === 'cloud')`). Rebases from `main` should be clean.
20+
21+
## What's scaffolded already
22+
23+
- `packages/core/src/infrastructure/persistence/postgres/` — connection, tenant context, RLS-based migration baseline
24+
- `packages/core/src/infrastructure/repositories/postgres-feature.repository.ts` — proof-of-pattern; matches `IFeatureRepository`
25+
- `packages/core/src/infrastructure/di/modules/register-cloud.ts` — DI module that swaps adapters when `SHEP_MODE=cloud`
26+
- `packages/core/src/infrastructure/di/container.ts` — branches on `SHEP_MODE` to call `registerCloud()` instead of (some of) the SQLite registrations
27+
- `packages/core/src/infrastructure/services/agent-executor/k8s-job.executor.ts` — stub implementing `IInteractiveAgentExecutor`
28+
- `src/presentation/web/middleware.ts` — Keycloak gate, request-scoped tenant context
29+
- `.github/workflows/cloud-build.yml` — CI builds `ghcr.io/shep-ai/shep:cloud-<sha>` on push to this branch
30+
31+
## Punchlist (in order of dependency)
32+
33+
### Persistence layer
34+
- [ ] **Port the remaining ~30 repositories** to Postgres. The pattern is `postgres-feature.repository.ts` — copy, swap query syntax, rely on RLS for tenant filter.
35+
- [ ] **Settings repository** is the next gating one (the UI hits it on every page load). Then `repository-repository`, `application-repository`, the PM family.
36+
- [ ] **Migrations** — flesh out `0001_init.sql` to cover every table currently in SQLite (`migrations/sqlite/*.sql` is the source). Use a per-table `org_id uuid not null` column + RLS policy `org_id = current_setting('app.org_id')::uuid`.
37+
- [ ] **`org_id` propagation in domain code** — the domain entities (`Feature`, `Application`, etc.) don't carry `org_id`. We have two options: (a) thread it through the domain (invasive), (b) keep RLS-only and let Postgres enforce. Going with (b). Repos take tenant context from a request-scoped tsyringe child container.
38+
39+
### Auth & tenancy
40+
- [ ] **Org/Member model** — currently no `orgs` or `org_members` tables. Add them in migrations. UI for create org / invite member.
41+
- [ ] **Keycloak token → org_id** — middleware reads JWT, looks up the user's default org, sets `app.org_id` on the connection. Multi-org users get an org switcher.
42+
- [ ] **Per-route guards** — every server action and API route currently assumes a single user. Add a higher-order wrapper that asserts `getServerSession()` and resolves tenant before the handler runs.
43+
44+
### Agent execution
45+
- [ ] **k8s-job.executor.ts** is a stub. Real impl needs to:
46+
- render a `batch/v1.Job` with the agent CLI (Claude Code / Cursor / Gemini) in the image
47+
- mount a fresh `emptyDir` at `/workspace`, clone the repo (using a per-org GitHub deploy token from `org_secrets`)
48+
- inject the org's AI provider API key via `secretRef`
49+
- stream pod logs back via SSE — reuse the existing `agent-events` API route, swap its source
50+
- report exit/result, post-process to update `agent_runs` row + open PR
51+
- [ ] **NetworkPolicy** scoped to the agent's namespace allowing egress only to `api.github.com` + the chosen AI provider host.
52+
- [ ] **Sandboxing tier** — for v1 use default runtime + restrictive securityContext. Migrate to gVisor (`runtimeClassName: gvisor`) once we have one untrusted real user.
53+
54+
### Filesystem
55+
- [ ] **No local worktrees.** Every Job clones afresh. State lives in git. Future: persistent worktrees via per-org PVCs if performance requires.
56+
- [ ] **Settings/uploads/attachments** — currently disk-based. Move to MinIO or skip until needed.
57+
58+
### Deployment
59+
- [ ] **shep-infra:** swap the placeholder Deployment at `app.shep.bot` to pull `ghcr.io/shep-ai/shep:cloud-<sha>`. Wire `SHEP_MODE=cloud`, `DATABASE_URL`, Keycloak env, k8s SA token.
60+
- [ ] **Run drizzle/postgres migrations** as initContainer (or built-in app boot, idempotent).
61+
- [ ] **Stop CLI bits** — at boot in cloud mode, don't initialize local SQLite, daemon, notification watcher, IDE integration, etc. `register-cloud.ts` should branch.
62+
63+
### Hardening / nice-to-have
64+
- [ ] Org-secrets vault (encrypt-at-rest, ideally via Infisical when we wire ESO back on).
65+
- [ ] Rate-limit per org on agent runs (so one tenant can't burn the cluster).
66+
- [ ] Audit log on tenant-impacting actions.
67+
- [ ] Idle-org reaper that pauses long-unused orgs' resources.
68+
69+
## How to work this branch
70+
71+
1. Pick a repository from the punchlist.
72+
2. Copy `postgres-feature.repository.ts` to `postgres-<name>.repository.ts`. Implement the interface methods.
73+
3. Add the registration to `register-cloud.ts`.
74+
4. Add migrations for any new tables in `migrations/postgres/`.
75+
5. Run locally with `SHEP_MODE=cloud DATABASE_URL=postgres://... pnpm dev` against a local Postgres.
76+
6. Push. CI builds `ghcr.io/shep-ai/shep:cloud-<sha>`.
77+
78+
Stay strict about additive-only changes to files that exist on `main`. Anything more is a recipe for merge hell.

Dockerfile.cloud

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Shep Cloud image — built from the `cloud` branch.
2+
#
3+
# Differences from the existing CLI Dockerfile:
4+
# - boots the Next.js web server in standalone mode (not the CLI)
5+
# - sets SHEP_MODE=cloud so DI swaps to Postgres + k8s Job executor
6+
# - migrations run at app boot
7+
FROM node:22-alpine AS deps
8+
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
9+
WORKDIR /app
10+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
11+
COPY src/presentation/web/package.json ./src/presentation/web/package.json
12+
COPY packages/core/package.json ./packages/core/package.json
13+
RUN pnpm install --frozen-lockfile
14+
15+
FROM node:22-alpine AS builder
16+
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
17+
WORKDIR /app
18+
COPY --from=deps /app/node_modules ./node_modules
19+
COPY . .
20+
ENV NEXT_TELEMETRY_DISABLED=1
21+
RUN pnpm run build:release
22+
23+
FROM node:22-alpine AS runtime
24+
RUN apk upgrade --no-cache && apk add --no-cache curl
25+
WORKDIR /app
26+
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 SHEP_MODE=cloud
27+
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 shep
28+
COPY --from=builder --chown=shep:nodejs /app/web ./web
29+
COPY --from=builder --chown=shep:nodejs /app/dist ./dist
30+
COPY --from=builder --chown=shep:nodejs /app/package.json ./
31+
USER shep
32+
EXPOSE 4050
33+
ENV HOSTNAME=0.0.0.0 PORT=4050
34+
# The shep CLI's `_serve` command boots the Next.js standalone server using
35+
# the same DI container that powers the CLI, just with SHEP_MODE=cloud the
36+
# repositories and executor are swapped out.
37+
CMD ["node", "dist/presentation/cli/index.js", "_serve", "--port", "4050"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Cloud-mode DI registrations.
3+
*
4+
* Called from `container.ts` instead of (or in addition to) the SQLite/CLI
5+
* registrations when `SHEP_MODE=cloud`.
6+
*
7+
* Strategy:
8+
* - Postgres-backed repositories replace SQLite ones (see CLOUD.md punchlist
9+
* for the full list to port).
10+
* - Agent execution swaps the local subprocess executor for a k8s Job one.
11+
* - The local daemon, notification watcher, IDE integration, etc. don't
12+
* register at all in cloud mode.
13+
*/
14+
15+
import type { DependencyContainer } from 'tsyringe';
16+
import type { IFeatureRepository } from '../../../application/ports/output/repositories/feature-repository.interface.js';
17+
import { PostgresFeatureRepository } from '../../repositories/postgres-feature.repository.js';
18+
import type { IInteractiveAgentExecutor } from '../../../application/ports/output/agents/interactive-agent-executor.interface.js';
19+
import { K8sJobAgentExecutor } from '../../services/agent-executor/k8s-job.executor.js';
20+
21+
export function registerCloud(container: DependencyContainer): void {
22+
// Repositories — start with feature; remaining repos are punchlisted.
23+
container.register<IFeatureRepository>('IFeatureRepository', {
24+
useClass: PostgresFeatureRepository,
25+
});
26+
27+
// Agent execution — k8s Job per run instead of local subprocess.
28+
container.register<IInteractiveAgentExecutor>('IInteractiveAgentExecutor', {
29+
useClass: K8sJobAgentExecutor,
30+
});
31+
32+
// TODO(CLOUD.md): once 30 repos are ported, remove the corresponding
33+
// sqlite registrations from registerRepositories rather than letting
34+
// them be silently overridden.
35+
}
36+
37+
export function isCloudMode(): boolean {
38+
return process.env.SHEP_MODE === 'cloud';
39+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Postgres Connection
3+
*
4+
* Single connection pool for cloud-mode shep. Equivalent of `sqlite/connection.ts`
5+
* but multi-tenant: each request must `SET app.org_id` (see tenant-context.ts)
6+
* before running queries — RLS policies in the schema do the actual filtering.
7+
*
8+
* Lazy-initialized; reads `DATABASE_URL` from env on first use.
9+
*/
10+
11+
import postgres, { type Sql } from 'postgres';
12+
13+
let _sql: Sql | null = null;
14+
15+
export function getPostgresConnection(): Sql {
16+
if (_sql) return _sql;
17+
const url = process.env.DATABASE_URL;
18+
if (!url) throw new Error('cloud mode: DATABASE_URL is required');
19+
_sql = postgres(url, {
20+
max: Number(process.env.PG_POOL_MAX ?? 10),
21+
idle_timeout: 20,
22+
prepare: false, // postgres-js + Node 22 + cluster pool: prepared statements break under churn
23+
});
24+
return _sql;
25+
}
26+
27+
export async function closePostgresConnection(): Promise<void> {
28+
if (!_sql) return;
29+
await _sql.end();
30+
_sql = null;
31+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Postgres migrations runner.
3+
*
4+
* Reads SQL files from ./migrations/, applies any not yet recorded in the
5+
* `_migrations` table. Idempotent — safe to run on every boot.
6+
*/
7+
8+
import { readFile, readdir } from 'node:fs/promises';
9+
import path from 'node:path';
10+
import { fileURLToPath } from 'node:url';
11+
import { withoutTenant } from './tenant-context.js';
12+
13+
const MIGRATIONS_DIR = path.resolve(
14+
fileURLToPath(import.meta.url),
15+
'..',
16+
'migrations'
17+
);
18+
19+
export async function runPostgresMigrations(): Promise<void> {
20+
await withoutTenant(async (sql) => {
21+
await sql`
22+
create table if not exists _migrations (
23+
name text primary key,
24+
applied_at timestamptz not null default now()
25+
)
26+
`;
27+
28+
const files = (await readdir(MIGRATIONS_DIR))
29+
.filter((f) => f.endsWith('.sql'))
30+
.sort();
31+
32+
for (const f of files) {
33+
const applied = await sql`select 1 from _migrations where name = ${f}`;
34+
if (applied.length > 0) continue;
35+
36+
const body = await readFile(path.join(MIGRATIONS_DIR, f), 'utf8');
37+
// postgres-js's .unsafe() runs raw SQL (multi-statement OK) — required
38+
// for migration files containing multiple `create table` etc.
39+
await sql.unsafe(body);
40+
await sql`insert into _migrations (name) values (${f})`;
41+
// eslint-disable-next-line no-console
42+
console.log(`[pg] applied migration ${f}`);
43+
}
44+
});
45+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
-- Cloud-mode initial schema baseline.
2+
--
3+
-- Goal: tenant-isolated copies of the SQLite schema in shep main, with RLS
4+
-- enforcing org_id filtering at the database layer.
5+
--
6+
-- This is a baseline. Subsequent migrations add the rest of shep's tables
7+
-- (every SQLite table needs an org_id column + RLS policy here).
8+
9+
create extension if not exists "pgcrypto";
10+
11+
create table if not exists orgs (
12+
id uuid primary key default gen_random_uuid(),
13+
slug text not null unique,
14+
name text not null,
15+
created_at timestamptz not null default now()
16+
);
17+
18+
create table if not exists users (
19+
id text primary key, -- Keycloak `sub`
20+
email text not null unique,
21+
name text,
22+
created_at timestamptz not null default now()
23+
);
24+
25+
create table if not exists org_members (
26+
org_id uuid not null references orgs(id) on delete cascade,
27+
user_id text not null references users(id) on delete cascade,
28+
role text not null check (role in ('owner','admin','member')),
29+
joined_at timestamptz not null default now(),
30+
primary key (org_id, user_id)
31+
);
32+
33+
-- ---------------------------------------------------------------------------
34+
-- features (proof-of-pattern; mirrors `features` from sqlite migrations)
35+
-- ---------------------------------------------------------------------------
36+
create table if not exists features (
37+
id uuid primary key default gen_random_uuid(),
38+
org_id uuid not null references orgs(id) on delete cascade,
39+
repository_path text not null,
40+
slug text not null,
41+
title text not null,
42+
description text,
43+
branch text,
44+
lifecycle text not null,
45+
status text,
46+
pr_url text,
47+
metadata jsonb,
48+
created_by text references users(id),
49+
created_at timestamptz not null default now(),
50+
updated_at timestamptz not null default now(),
51+
archived_at timestamptz,
52+
deleted_at timestamptz,
53+
unique (org_id, repository_path, slug)
54+
);
55+
56+
create index if not exists features_org_lifecycle_idx
57+
on features (org_id, lifecycle) where deleted_at is null;
58+
59+
-- ---------------------------------------------------------------------------
60+
-- RLS — every tenant table follows this exact pattern.
61+
-- ---------------------------------------------------------------------------
62+
alter table features enable row level security;
63+
64+
create policy features_tenant_isolation on features
65+
using (org_id = current_setting('app.org_id', true)::uuid)
66+
with check (org_id = current_setting('app.org_id', true)::uuid);
67+
68+
-- Helper for migrations: bypass RLS as table owner. App connections never
69+
-- get the bypass role.
70+
-- (App connects as a less-privileged role created in a later migration.)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Tenant Context (cloud mode)
3+
*
4+
* Every Postgres query in cloud mode must carry an org_id. We propagate it via
5+
* Postgres session settings + RLS, so repository code stays single-tenant
6+
* shaped and the DB enforces isolation.
7+
*
8+
* Pattern:
9+
* await withTenant(orgId, async (sql) => {
10+
* return sql<Feature[]>`SELECT * FROM features WHERE id = ${id}`;
11+
* })
12+
*
13+
* Inside the callback every query runs in a transaction with
14+
* `SET LOCAL app.org_id = ...`, which the RLS policy reads via
15+
* `current_setting('app.org_id')::uuid`.
16+
*
17+
* Server actions and API routes resolve the org from the Keycloak session
18+
* (see src/presentation/web/middleware.ts) and call `withTenant`.
19+
*/
20+
21+
import type { Sql, TransactionSql } from 'postgres';
22+
import { getPostgresConnection } from './connection.js';
23+
24+
export async function withTenant<T>(
25+
orgId: string,
26+
fn: (sql: TransactionSql) => Promise<T>
27+
): Promise<T> {
28+
const sql = getPostgresConnection();
29+
return sql.begin(async (tx) => {
30+
await tx`SELECT set_config('app.org_id', ${orgId}, true)`;
31+
return fn(tx);
32+
});
33+
}
34+
35+
/**
36+
* For admin-only operations (migrations, org provisioning) that need to bypass
37+
* RLS. Use sparingly — anything user-facing must go through `withTenant`.
38+
*/
39+
export async function withoutTenant<T>(
40+
fn: (sql: Sql) => Promise<T>
41+
): Promise<T> {
42+
return fn(getPostgresConnection());
43+
}

0 commit comments

Comments
 (0)