feat: add K8s API server, orchestrator abstraction, and CI pipeline

- Add apps/api/ — Hono REST API server for managing pentest scans via K8s Jobs
  - POST/GET /api/scans, GET /api/scans/:id, cancel, report endpoints
  - Bearer token auth, Temporal client integration, K8s Job builder
  - Dockerfile, Kustomize manifests (Deployment, Service, RBAC)
- Add CLI orchestrator abstraction (docker.ts → Orchestrator interface)
  - DockerOrchestrator and K8sOrchestrator implementations
  - Backend detection via SHANNON_BACKEND env var or --backend flag
- Add CI workflow: type-check + lint on PR, build+push both images on main
- Switch all workflows to self-hosted runners (runners-farhoodliquor)
- Add shannon-api image build to release and release-beta workflows
- Add root infra/kustomization.yaml as Flux entry point
- Export PipelineProgress from @shannon/worker/pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 13:08:51 -04:00
parent 54c92e8142
commit 1bbdd7acba
36 changed files with 2635 additions and 414 deletions
+48
View File
@@ -0,0 +1,48 @@
#
# Shannon API Server — minimal Node.js image (no security tools)
#
FROM node:22-alpine AS builder
RUN npm install -g pnpm@10.33.0
WORKDIR /app
# Copy workspace manifests for install layer caching
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
COPY apps/api/package.json ./apps/api/
COPY apps/worker/package.json ./apps/worker/
COPY apps/cli/package.json ./apps/cli/
RUN pnpm install --frozen-lockfile
COPY tsconfig.base.json ./
COPY apps/worker/ ./apps/worker/
COPY apps/api/ ./apps/api/
# Build worker first (API depends on it for types), then API
RUN pnpm --filter @shannon/worker run build && pnpm --filter @shannon/api run build
# Production-only deps
RUN rm -rf node_modules apps/*/node_modules && pnpm install --frozen-lockfile --prod
# Runtime stage
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml /app/.npmrc /app/
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/apps/api/dist /app/apps/api/dist
COPY --from=builder /app/apps/api/package.json /app/apps/api/package.json
COPY --from=builder /app/apps/api/node_modules /app/apps/api/node_modules
COPY --from=builder /app/apps/worker/dist /app/apps/worker/dist
COPY --from=builder /app/apps/worker/package.json /app/apps/worker/package.json
COPY --from=builder /app/apps/worker/node_modules /app/apps/worker/node_modules
RUN mkdir -p /app/workspaces
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "apps/api/dist/index.js"]
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@shannon/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"clean": "rm -rf dist",
"start": "node dist/index.js"
},
"dependencies": {
"@hono/node-server": "^1.14.0",
"@kubernetes/client-node": "^1.4.0",
"@shannon/worker": "workspace:*",
"@temporalio/client": "^1.11.0",
"hono": "^4.7.0",
"zod": "^4.3.6"
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Hono app factory.
* Creates the app with middleware and routes. Deps injected for testability.
*/
import type * as k8s from '@kubernetes/client-node';
import type { Client } from '@temporalio/client';
import { Hono } from 'hono';
import type { Config } from './config.js';
import { authMiddleware } from './middleware/auth.js';
import { errorHandler } from './middleware/error-handler.js';
import { healthRoutes } from './routes/health.js';
import { scanRoutes } from './routes/scans.js';
export interface AppDeps {
readonly temporalClient: Client;
readonly batchApi: k8s.BatchV1Api;
readonly coreApi: k8s.CoreV1Api;
}
export function createApp(config: Config, deps: AppDeps): Hono {
const app = new Hono();
// Global error handler
app.onError(errorHandler);
// Auth middleware (skips /healthz and /readyz)
app.use('*', authMiddleware(config.apiKey));
// Routes
app.route('/', healthRoutes(deps));
app.route('/api/scans', scanRoutes(config, deps));
return app;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Environment-driven configuration for the API server.
* Parsed once at startup — missing required values cause a hard exit.
*/
export interface Config {
readonly port: number;
readonly temporalAddress: string;
readonly apiKey: string;
readonly k8sNamespace: string;
readonly workerImage: string;
readonly workspacesDir: string;
readonly credentialsSecretName: string;
}
export function loadConfig(): Config {
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.error('ERROR: API_KEY environment variable is required');
process.exit(1);
}
const workerImage = process.env.WORKER_IMAGE;
if (!workerImage) {
console.error('ERROR: WORKER_IMAGE environment variable is required');
process.exit(1);
}
return {
port: Number(process.env.PORT) || 3000,
temporalAddress: process.env.TEMPORAL_ADDRESS || 'shannon-temporal:7233',
apiKey,
k8sNamespace: process.env.K8S_NAMESPACE || 'shannon',
workerImage,
workspacesDir: process.env.WORKSPACES_DIR || '/app/workspaces',
credentialsSecretName: process.env.CREDENTIALS_SECRET_NAME || 'shannon-credentials',
};
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Shannon API Server — entry point.
* Connects to Temporal, initializes K8s client, starts the Hono server.
*/
import { serve } from '@hono/node-server';
import * as k8s from '@kubernetes/client-node';
import { createApp } from './app.js';
import { loadConfig } from './config.js';
import { connectTemporal, disconnectTemporal } from './services/temporal-client.js';
async function main(): Promise<void> {
// 1. Load configuration
const config = loadConfig();
// 2. Connect to Temporal
const temporal = await connectTemporal(config.temporalAddress);
// 3. Initialize K8s client (in-cluster or from kubeconfig)
const kc = new k8s.KubeConfig();
try {
kc.loadFromCluster();
} catch {
// Fallback to default kubeconfig (for local development)
kc.loadFromDefault();
}
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
// 4. Create app
const app = createApp(config, {
temporalClient: temporal.client,
batchApi,
coreApi,
});
// 5. Start server
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`Shannon API server listening on port ${info.port}`);
});
// 6. Graceful shutdown
const shutdown = async (): Promise<void> => {
console.log('Shutting down...');
server.close();
await disconnectTemporal(temporal);
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
main().catch((err) => {
console.error('Failed to start API server:', err);
process.exit(1);
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Bearer token authentication middleware.
* Validates the Authorization header against the configured API key.
* Skips health check endpoints.
*/
import crypto from 'node:crypto';
import type { Context, Next } from 'hono';
const PUBLIC_PATHS = new Set(['/healthz', '/readyz']);
export function authMiddleware(apiKey: string) {
const expectedBuffer = Buffer.from(apiKey);
return async (c: Context, next: Next) => {
if (PUBLIC_PATHS.has(c.req.path)) {
return next();
}
const header = c.req.header('Authorization');
if (!header?.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401);
}
const token = header.slice(7);
const tokenBuffer = Buffer.from(token);
if (tokenBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) {
return c.json({ error: 'Invalid API key' }, 401);
}
return next();
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Global error handler middleware.
* Catches unhandled errors and returns structured JSON responses.
*/
import type { Context } from 'hono';
export function errorHandler(err: Error, c: Context): Response {
console.error('Unhandled error:', err);
const status = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : 500;
return c.json(
{
error: status === 500 ? 'Internal server error' : err.message,
code: err.name || 'UNKNOWN_ERROR',
},
status as 500,
);
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Health and readiness endpoints.
* /healthz — always 200 (server is running)
* /readyz — checks Temporal connectivity
*/
import { Hono } from 'hono';
import type { AppDeps } from '../app.js';
export function healthRoutes(deps: AppDeps): Hono {
const app = new Hono();
app.get('/healthz', (c) => {
return c.json({ status: 'ok' });
});
app.get('/readyz', async (c) => {
try {
// Lightweight Temporal connectivity check — list with a filter that matches nothing
const iter = deps.temporalClient.workflow.list({ query: 'ExecutionStatus = "Running"' });
// Consume iterator to trigger the gRPC call, then break immediately
for await (const _ of iter) {
break;
}
return c.json({ status: 'ok' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return c.json({ status: 'error', error: `Temporal unreachable: ${message}` }, 503);
}
});
return app;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Scan CRUD routes — POST/GET /api/scans, GET/POST /api/scans/:id/*
*/
import { Hono } from 'hono';
import type { AppDeps } from '../app.js';
import type { Config } from '../config.js';
import { cancelScan, getReport, getScan, listScans, startScan } from '../services/scan-manager.js';
import { CreateScanSchema } from '../types/api.js';
export function scanRoutes(config: Config, deps: AppDeps): Hono {
const app = new Hono();
// POST /api/scans — start a new scan
app.post('/', async (c) => {
const body = await c.req.json();
const parsed = CreateScanSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Validation failed', details: parsed.error.issues }, 400);
}
const result = await startScan(config, deps.batchApi, parsed.data);
return c.json(result, 201);
});
// GET /api/scans — list all scans
app.get('/', async (c) => {
const scans = await listScans(config, deps.temporalClient, deps.batchApi);
return c.json({ scans });
});
// GET /api/scans/:id — get scan status/progress
app.get('/:id', async (c) => {
const scanId = c.req.param('id');
const result = await getScan(config, deps.temporalClient, scanId);
if (!result) {
return c.json({ error: 'Scan not found' }, 404);
}
return c.json(result);
});
// POST /api/scans/:id/cancel — cancel a running scan
app.post('/:id/cancel', async (c) => {
const scanId = c.req.param('id');
await cancelScan(config, deps.temporalClient, deps.batchApi, scanId);
return c.json({ status: 'cancelled' });
});
// GET /api/scans/:id/report — get the scan report
app.get('/:id/report', async (c) => {
const scanId = c.req.param('id');
const report = await getReport(config, scanId);
if (!report) {
return c.json({ error: 'Report not found' }, 404);
}
return c.text(report);
});
return app;
}
+141
View File
@@ -0,0 +1,141 @@
/**
* K8s Job spec builder for worker scan Jobs.
* Constructs a Job that runs the Shannon worker image with the correct
* volumes, env, and security context. Optionally includes a git clone init container.
*/
import type * as k8s from '@kubernetes/client-node';
export interface JobParams {
readonly jobName: string;
readonly namespace: string;
readonly workerImage: string;
readonly targetUrl: string;
readonly taskQueue: string;
readonly workspace: string;
readonly credentialsSecretName: string;
readonly gitUrl?: string;
readonly gitRef?: string;
readonly repoPath?: string;
readonly configYaml?: string;
readonly pipelineTesting?: boolean;
}
const WORKER_LABEL = 'shannon-worker';
const REPO_MOUNT_PATH = '/repo';
export function buildJobSpec(params: JobParams): k8s.V1Job {
const repoPath = params.repoPath ?? REPO_MOUNT_PATH;
// 1. Build worker command
const command = ['node', 'apps/worker/dist/temporal/worker.js', params.targetUrl, repoPath];
const args: string[] = ['--task-queue', params.taskQueue, '--workspace', params.workspace];
if (params.pipelineTesting) {
args.push('--pipeline-testing');
}
// 2. Build volumes and mounts
const volumes: k8s.V1Volume[] = [
{ name: 'workspaces', persistentVolumeClaim: { claimName: 'shannon-workspaces' } },
{ name: 'shm', emptyDir: { medium: 'Memory', sizeLimit: '2Gi' } },
];
const volumeMounts: k8s.V1VolumeMount[] = [
{ name: 'workspaces', mountPath: '/app/workspaces' },
{ name: 'shm', mountPath: '/dev/shm' },
];
// Overlay dirs (writable areas over the read-only repo)
for (const overlay of ['deliverables', 'scratchpad', 'playwright-cli']) {
const volName = `overlay-${overlay}`;
volumes.push({ name: volName, emptyDir: {} });
volumeMounts.push({
name: volName,
mountPath: `${repoPath}/.shannon/${overlay === 'playwright-cli' ? '.playwright-cli' : overlay}`,
});
}
// 3. Repo volume — emptyDir for git clone, or PVC sub-path for pre-staged repos
const initContainers: k8s.V1Container[] = [];
if (params.gitUrl) {
// Git clone into an emptyDir
volumes.push({ name: 'repo', emptyDir: {} });
volumeMounts.push({ name: 'repo', mountPath: REPO_MOUNT_PATH, readOnly: true });
const cloneArgs = ['clone', '--depth', '1'];
if (params.gitRef) {
cloneArgs.push('--branch', params.gitRef);
}
cloneArgs.push(params.gitUrl, REPO_MOUNT_PATH);
initContainers.push({
name: 'git-clone',
image: 'bitnami/git:2',
command: ['git'],
args: cloneArgs,
volumeMounts: [{ name: 'repo', mountPath: REPO_MOUNT_PATH }],
});
} else if (params.repoPath) {
// Repo already on a PVC — mount the workspaces PVC (assumes repo is staged there)
volumeMounts.push({
name: 'workspaces',
mountPath: repoPath,
readOnly: true,
subPath: `repos/${params.workspace}`,
});
}
// 4. Env vars
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'shannon-temporal:7233' }];
// 5. Construct the Job
return {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: params.jobName,
namespace: params.namespace,
labels: {
app: WORKER_LABEL,
'shannon.io/workspace': params.workspace,
'shannon.io/scan-id': params.jobName,
},
},
spec: {
backoffLimit: 0,
ttlSecondsAfterFinished: 3600,
template: {
metadata: {
labels: {
app: WORKER_LABEL,
'shannon.io/workspace': params.workspace,
},
},
spec: {
restartPolicy: 'Never',
serviceAccountName: 'default',
securityContext: {
seccompProfile: { type: 'Unconfined' },
},
...(initContainers.length > 0 && { initContainers }),
containers: [
{
name: 'worker',
image: params.workerImage,
command,
args,
env,
envFrom: [{ secretRef: { name: params.credentialsSecretName } }],
volumeMounts,
resources: {
requests: { memory: '2Gi' },
},
},
],
volumes,
},
},
},
};
}
+35
View File
@@ -0,0 +1,35 @@
/**
* K8s Job lifecycle management — create, delete, list worker Jobs.
*/
import type * as k8s from '@kubernetes/client-node';
const WORKER_LABEL = 'shannon-worker';
export async function createJob(batchApi: k8s.BatchV1Api, namespace: string, job: k8s.V1Job): Promise<void> {
await batchApi.createNamespacedJob({ namespace, body: job });
}
export async function deleteJob(batchApi: k8s.BatchV1Api, namespace: string, name: string): Promise<void> {
await batchApi.deleteNamespacedJob({
name,
namespace,
propagationPolicy: 'Background',
});
}
export async function getJob(batchApi: k8s.BatchV1Api, namespace: string, name: string): Promise<k8s.V1Job | null> {
try {
return await batchApi.readNamespacedJob({ name, namespace });
} catch {
return null;
}
}
export async function listWorkerJobs(batchApi: k8s.BatchV1Api, namespace: string): Promise<k8s.V1Job[]> {
const response = await batchApi.listNamespacedJob({
namespace,
labelSelector: `app=${WORKER_LABEL}`,
});
return response.items;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Scan lifecycle orchestration — combines Temporal queries with K8s Job management.
* This is the main service that route handlers delegate to.
*/
import crypto from 'node:crypto';
import type * as k8s from '@kubernetes/client-node';
import type { Client } from '@temporalio/client';
import type { Config } from '../config.js';
import type { CreateScanInput, ScanResponse } from '../types/api.js';
import { buildJobSpec } from './job-builder.js';
import { createJob, deleteJob, listWorkerJobs } from './job-manager.js';
import { cancelWorkflow, queryProgress } from './temporal-client.js';
import { listWorkspaces, readReport, readSessionJson } from './workspace-reader.js';
function randomSuffix(): string {
return crypto.randomBytes(4).toString('hex');
}
// === Start Scan ===
export async function startScan(
config: Config,
batchApi: k8s.BatchV1Api,
input: CreateScanInput,
): Promise<ScanResponse> {
const suffix = randomSuffix();
const taskQueue = `api-${suffix}`;
const jobName = `shannon-worker-${suffix}`;
const workspace =
input.workspace ?? `${new URL(input.targetUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
const job = buildJobSpec({
jobName,
namespace: config.k8sNamespace,
workerImage: config.workerImage,
targetUrl: input.targetUrl,
taskQueue,
workspace,
credentialsSecretName: config.credentialsSecretName,
...(input.gitUrl && { gitUrl: input.gitUrl }),
...(input.gitRef && { gitRef: input.gitRef }),
...(input.repoPath && { repoPath: input.repoPath }),
...(input.configYaml && { configYaml: input.configYaml }),
...(input.pipelineTesting && { pipelineTesting: true }),
});
await createJob(batchApi, config.k8sNamespace, job);
return {
id: jobName,
workspace,
targetUrl: input.targetUrl,
status: 'running',
createdAt: new Date().toISOString(),
};
}
// === Get Scan ===
export async function getScan(config: Config, temporalClient: Client, scanId: string): Promise<ScanResponse | null> {
// 1. Try Temporal query for live progress
try {
const progress = await queryProgress(temporalClient, scanId);
return {
id: scanId,
workspace: scanId,
targetUrl: '',
status: progress.status,
createdAt: new Date(progress.startTime).toISOString(),
completedAgents: progress.completedAgents,
agentMetrics: progress.agentMetrics,
...(progress.currentPhase && { currentPhase: progress.currentPhase }),
...(progress.currentAgent && { currentAgent: progress.currentAgent }),
...(progress.summary && { summary: progress.summary }),
...(progress.error && { error: progress.error }),
};
} catch {
// Workflow not found in Temporal — try workspace session.json
}
// 2. Fall back to workspace session.json (completed/historical scans)
const session = readSessionJson(config.workspacesDir, scanId);
if (!session) return null;
return {
id: session.originalWorkflowId ?? scanId,
workspace: session.workspace,
targetUrl: session.webUrl ?? '',
status: 'completed',
createdAt: session.startTime ? new Date(session.startTime).toISOString() : '',
};
}
// === List Scans ===
export async function listScans(
config: Config,
_temporalClient: Client,
batchApi: k8s.BatchV1Api,
): Promise<ScanResponse[]> {
const results: ScanResponse[] = [];
// 1. Running scans from K8s Jobs
const jobs = await listWorkerJobs(batchApi, config.k8sNamespace);
for (const job of jobs) {
const jobName = job.metadata?.name ?? '';
const workspace = job.metadata?.labels?.['shannon.io/workspace'] ?? jobName;
const startTime = job.status?.startTime;
results.push({
id: jobName,
workspace,
targetUrl: '',
status: job.status?.succeeded ? 'completed' : job.status?.failed ? 'failed' : 'running',
createdAt: startTime ? new Date(startTime).toISOString() : '',
});
}
// 2. Historical scans from workspace session.json files
const workspaces = listWorkspaces(config.workspacesDir);
const jobNames = new Set(results.map((r) => r.workspace));
for (const ws of workspaces) {
if (jobNames.has(ws.workspace)) continue;
results.push({
id: ws.originalWorkflowId ?? ws.workspace,
workspace: ws.workspace,
targetUrl: ws.webUrl ?? '',
status: 'completed',
createdAt: ws.startTime ? new Date(ws.startTime).toISOString() : '',
});
}
return results;
}
// === Cancel Scan ===
export async function cancelScan(
config: Config,
temporalClient: Client,
batchApi: k8s.BatchV1Api,
scanId: string,
): Promise<void> {
// Cancel Temporal workflow (best-effort)
try {
await cancelWorkflow(temporalClient, scanId);
} catch {
// Workflow may have already completed
}
// Delete K8s Job
try {
await deleteJob(batchApi, config.k8sNamespace, scanId);
} catch {
// Job may have already been cleaned up
}
}
// === Get Report ===
export async function getReport(config: Config, scanId: string): Promise<string | null> {
return readReport(config.workspacesDir, scanId);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Temporal client management — connection lifecycle and workflow operations.
* Uses @temporalio/client (not worker) since the API server only submits and queries workflows.
*/
import type { PipelineProgress } from '@shannon/worker/pipeline';
import { Client, Connection } from '@temporalio/client';
export interface TemporalClients {
readonly client: Client;
readonly connection: Connection;
}
export async function connectTemporal(address: string): Promise<TemporalClients> {
console.log(`Connecting to Temporal at ${address}...`);
const connection = await Connection.connect({ address });
const client = new Client({ connection });
console.log('Temporal connected.');
return { client, connection };
}
export async function disconnectTemporal(clients: TemporalClients): Promise<void> {
await clients.connection.close();
}
/** Query a workflow's progress via the getProgress query. */
export async function queryProgress(client: Client, workflowId: string): Promise<PipelineProgress> {
const handle = client.workflow.getHandle(workflowId);
return handle.query<PipelineProgress>('getProgress');
}
/** Cancel a running workflow. */
export async function cancelWorkflow(client: Client, workflowId: string): Promise<void> {
const handle = client.workflow.getHandle(workflowId);
await handle.cancel();
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Workspace reader — reads session.json and deliverables from the shared workspaces PVC.
*/
import fs from 'node:fs';
import path from 'node:path';
export interface SessionInfo {
readonly workspace: string;
readonly originalWorkflowId?: string;
readonly webUrl?: string;
readonly startTime?: number;
readonly cost?: number;
readonly resumeAttempts?: readonly { workflowId: string; timestamp: number }[];
}
export function readSessionJson(workspacesDir: string, workspace: string): SessionInfo | null {
const sessionPath = path.join(workspacesDir, workspace, 'session.json');
try {
const raw = fs.readFileSync(sessionPath, 'utf-8');
const data = JSON.parse(raw) as Record<string, unknown>;
const session = data.session as Record<string, unknown> | undefined;
const originalWorkflowId = session?.originalWorkflowId as string | undefined;
const webUrl = session?.webUrl as string | undefined;
const startTime = session?.startTime as number | undefined;
const cost = session?.totalCostUsd as number | undefined;
const resumeAttempts = session?.resumeAttempts as SessionInfo['resumeAttempts'];
return {
workspace,
...(originalWorkflowId && { originalWorkflowId }),
...(webUrl && { webUrl }),
...(startTime && { startTime }),
...(cost && { cost }),
...(resumeAttempts && { resumeAttempts }),
};
} catch {
return null;
}
}
export function readReport(workspacesDir: string, workspace: string): string | null {
const delivDir = path.join(workspacesDir, workspace, 'deliverables');
try {
const files = fs.readdirSync(delivDir);
const reportFile = files.find((f) => f.includes('report') && f.endsWith('.md'));
if (!reportFile) return null;
return fs.readFileSync(path.join(delivDir, reportFile), 'utf-8');
} catch {
return null;
}
}
export function listWorkspaces(workspacesDir: string): SessionInfo[] {
try {
const entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
const results: SessionInfo[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const session = readSessionJson(workspacesDir, entry.name);
if (session) {
results.push(session);
}
}
return results.sort((a, b) => (b.startTime ?? 0) - (a.startTime ?? 0));
} catch {
return [];
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Request/response types and Zod validation schemas for the scan API.
*/
import type { AgentMetrics, PipelineSummary } from '@shannon/worker/pipeline';
import { z } from 'zod';
// === Request Schemas ===
export const CreateScanSchema = z
.object({
targetUrl: z.string().url(),
gitUrl: z.string().url().optional(),
repoPath: z.string().optional(),
gitRef: z.string().optional(),
configYaml: z.string().optional(),
workspace: z
.string()
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/)
.optional(),
pipelineTesting: z.boolean().optional(),
})
.refine((data) => data.gitUrl || data.repoPath, {
message: 'Either gitUrl or repoPath is required',
});
export type CreateScanInput = z.infer<typeof CreateScanSchema>;
// === Response Types ===
export interface ScanResponse {
id: string;
workspace: string;
targetUrl: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
createdAt: string;
currentPhase?: string;
currentAgent?: string;
completedAgents?: string[];
agentMetrics?: Record<string, AgentMetrics>;
summary?: PipelineSummary;
error?: string;
}
export interface ScanListResponse {
scans: ScanResponse[];
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"]
}
+1
View File
@@ -18,6 +18,7 @@
},
"dependencies": {
"@clack/prompts": "^1.1.0",
"@kubernetes/client-node": "^1.4.0",
"chokidar": "^5.0.0",
"dotenv": "^17.3.1",
"smol-toml": "^1.6.1"
+54
View File
@@ -0,0 +1,54 @@
/**
* Backend detection — Docker (default) vs Kubernetes.
*
* Orthogonal to the local/npx mode axis. Mode controls where state lives
* and where the image comes from. Backend controls how containers are orchestrated.
*/
import type { Orchestrator } from './orchestrator.js';
export type Backend = 'docker' | 'k8s';
let cachedBackend: Backend | undefined;
let cachedOrchestrator: Orchestrator | undefined;
/**
* Detect the orchestration backend.
* SHANNON_BACKEND env var takes precedence, otherwise defaults to docker.
*/
export function getBackend(): Backend {
if (cachedBackend !== undefined) return cachedBackend;
const env = process.env.SHANNON_BACKEND;
if (env === 'k8s' || env === 'kubernetes') {
cachedBackend = 'k8s';
} else {
cachedBackend = 'docker';
}
return cachedBackend;
}
export function setBackend(backend: Backend): void {
cachedBackend = backend;
cachedOrchestrator = undefined;
}
/**
* Get the orchestrator for the current backend.
* Lazy-loads the implementation to avoid importing unused dependencies.
*/
export async function getOrchestrator(): Promise<Orchestrator> {
if (cachedOrchestrator) return cachedOrchestrator;
let orchestrator: Orchestrator;
if (getBackend() === 'k8s') {
const { K8sOrchestrator } = await import('./k8s.js');
orchestrator = new K8sOrchestrator();
} else {
const { DockerOrchestrator } = await import('./docker.js');
orchestrator = new DockerOrchestrator();
}
cachedOrchestrator = orchestrator;
return orchestrator;
}
+15 -18
View File
@@ -5,11 +5,11 @@
* and npx mode (Docker Hub pull, ~/.shannon/).
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
import { getOrchestrator } from '../backend.js';
import { randomSuffix } from '../docker.js';
import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
import { isLocal } from '../mode.js';
@@ -55,9 +55,10 @@ export async function start(args: StartArgs): Promise<void> {
process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key';
}
// 6. Ensure image (auto-build in dev, pull in npx) and start infra
ensureImage(args.version);
await ensureInfra(useRouter);
// 6. Ensure image and start infra via orchestrator
const orchestrator = await getOrchestrator();
orchestrator.ensureImage(args.version);
await orchestrator.ensureInfra(useRouter);
// 7. Generate unique task queue and container name
const suffix = randomSuffix();
@@ -94,20 +95,20 @@ export async function start(args: StartArgs): Promise<void> {
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
}
// 10. Resolve output directory
// 11. Resolve output directory
const outputDir = args.output ? path.resolve(args.output) : undefined;
if (outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 11. Resolve prompts directory (local mode only)
// 12. Resolve prompts directory (local mode only)
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
// 12. Display splash screen
// 13. Display splash screen
displaySplash(isLocal() ? undefined : args.version);
// 13. Spawn worker container
const proc = spawnWorker({
// 14. Spawn worker via orchestrator
const handle = orchestrator.spawnWorker({
version: args.version,
url: args.url,
repo,
@@ -123,8 +124,8 @@ export async function start(args: StartArgs): Promise<void> {
...(args.pipelineTesting && { pipelineTesting: true }),
});
// 14. Wait for workflow to register, then display info
proc.on('error', (err) => {
// 15. Wait for workflow to register, then display info
handle.onError((err) => {
console.error(`Failed to start worker: ${err.message}`);
process.exit(1);
});
@@ -181,18 +182,14 @@ export async function start(args: StartArgs): Promise<void> {
process.stdout.write('.');
}, 2000);
// Stop the worker container only if it hasn't started yet
// Stop the worker only if it hasn't started yet
let cleaned = false;
const cleanup = (): void => {
if (cleaned || started) return;
cleaned = true;
clearInterval(pollInterval);
console.log(`\nStopping worker ${containerName}...`);
try {
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
} catch {
// Container may have already exited
}
handle.kill();
};
process.on('SIGINT', () => {
+6 -4
View File
@@ -2,11 +2,13 @@
* `shannon status` command — show running workers and Temporal health.
*/
import { isTemporalReady, listRunningWorkers } from '../docker.js';
import { getOrchestrator } from '../backend.js';
export async function status(): Promise<void> {
const orchestrator = await getOrchestrator();
export function status(): void {
// 1. Temporal health
const temporalUp = isTemporalReady();
const temporalUp = orchestrator.isTemporalReady();
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
if (temporalUp) {
console.log(' Web UI: http://localhost:8233');
@@ -14,7 +16,7 @@ export function status(): void {
console.log('');
// 2. Running workers
const workers = listRunningWorkers();
const workers = orchestrator.listRunningWorkers();
if (workers) {
console.log('Workers:');
console.log(workers);
+4 -3
View File
@@ -3,7 +3,7 @@
*/
import * as p from '@clack/prompts';
import { stopInfra, stopWorkers } from '../docker.js';
import { getOrchestrator } from '../backend.js';
export async function stop(clean: boolean): Promise<void> {
if (clean) {
@@ -16,6 +16,7 @@ export async function stop(clean: boolean): Promise<void> {
}
}
stopWorkers();
stopInfra(clean);
const orchestrator = await getOrchestrator();
orchestrator.stopWorkers();
orchestrator.stopInfra(clean);
}
+4 -3
View File
@@ -6,7 +6,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as p from '@clack/prompts';
import { stopInfra, stopWorkers } from '../docker.js';
import { getOrchestrator } from '../backend.js';
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
@@ -28,8 +28,9 @@ export async function uninstall(): Promise<void> {
}
// Stop any running containers first
stopWorkers();
stopInfra(false);
const orchestrator = await getOrchestrator();
orchestrator.stopWorkers();
orchestrator.stopInfra(false);
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
p.log.success('All Shannon data has been removed.');
+8 -19
View File
@@ -2,30 +2,19 @@
* `shannon workspaces` command — list all workspaces.
*/
import { execFileSync } from 'node:child_process';
import os from 'node:os';
import { getWorkerImage } from '../docker.js';
import { getOrchestrator } from '../backend.js';
import { getWorkspacesDir } from '../home.js';
export function workspaces(version: string): void {
export async function workspaces(version: string): Promise<void> {
const orchestrator = await getOrchestrator();
const workspacesDir = getWorkspacesDir();
const image = getWorkerImage(version);
const image = orchestrator.getWorkerImage(version);
try {
execFileSync(
'docker',
[
'run',
'--rm',
'-v',
`${workspacesDir}:/app/workspaces`,
'-e',
'WORKSPACES_DIR=/app/workspaces',
image,
'node',
'apps/worker/dist/temporal/workspaces.js',
],
{ stdio: 'inherit', ...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }) },
orchestrator.runEphemeral(
image,
['node', 'apps/worker/dist/temporal/workspaces.js'],
[`${workspacesDir}:/app/workspaces`],
);
} catch {
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
+255 -245
View File
@@ -12,27 +12,22 @@ import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { getMode } from './mode.js';
import type { Orchestrator, WorkerHandle, WorkerOptions } from './orchestrator.js';
export type { WorkerOptions };
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NPX_IMAGE_REPO = 'keygraph/shannon';
const DEV_IMAGE = 'shannon-worker';
export function getWorkerImage(version: string): string {
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
}
function getComposeFile(): string {
return getMode() === 'local'
? path.resolve('docker-compose.yml')
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
}
/** Generate an 8-char random hex suffix for container/queue names. */
export function randomSuffix(): string {
return crypto.randomBytes(4).toString('hex');
}
// === Internal Helpers ===
/** Run a command silently, return true if it succeeds. */
function runQuiet(cmd: string, args: string[]): boolean {
try {
@@ -52,21 +47,10 @@ function runOutput(cmd: string, args: string[]): string {
}
}
/**
* Check if Temporal is running and healthy.
*/
export function isTemporalReady(): boolean {
const output = runOutput('docker', [
'exec',
'shannon-temporal',
'temporal',
'operator',
'cluster',
'health',
'--address',
'localhost:7233',
]);
return output.includes('SERVING');
function getComposeFile(): string {
return getMode() === 'local'
? path.resolve('docker-compose.yml')
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
}
/** Check if the router container is running and healthy. */
@@ -75,99 +59,6 @@ function isRouterReady(): boolean {
return status === 'healthy';
}
/**
* Ensure Temporal (and optionally router) are running via compose.
* If Temporal is already up but router is needed and missing, starts router only.
*/
export async function ensureInfra(useRouter: boolean): Promise<void> {
const temporalReady = isTemporalReady();
const routerNeeded = useRouter && !isRouterReady();
if (temporalReady && !routerNeeded) {
return;
}
const composeFile = getComposeFile();
const composeArgs = ['compose', '-f', composeFile];
if (useRouter) composeArgs.push('--profile', 'router');
composeArgs.push('up', '-d');
if (temporalReady && routerNeeded) {
console.log('Starting router...');
} else {
console.log('Starting Shannon infrastructure...');
}
execFileSync('docker', composeArgs, { stdio: 'inherit' });
// Wait for Temporal if it wasn't already running
if (!temporalReady) {
console.log('Waiting for Temporal to be ready...');
for (let i = 0; i < 30; i++) {
if (isTemporalReady()) {
console.log('Temporal is ready!');
break;
}
if (i === 29) {
console.error('Timeout waiting for Temporal');
process.exit(1);
}
await sleep(2000);
}
}
// Wait for router if needed
if (routerNeeded) {
console.log('Waiting for router to be ready...');
for (let i = 0; i < 15; i++) {
if (isRouterReady()) {
console.log('Router is ready!');
return;
}
await sleep(2000);
}
console.error('Timeout waiting for router');
process.exit(1);
}
}
/**
* Build the worker image locally (local mode only).
*/
export function buildImage(noCache: boolean): void {
console.log(`Building ${DEV_IMAGE}...`);
const args = ['build'];
if (noCache) args.push('--no-cache');
args.push('-t', DEV_IMAGE, '.');
execFileSync('docker', args, { stdio: 'inherit' });
console.log(`Build complete: ${DEV_IMAGE}`);
}
/**
* Ensure the worker image is available.
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
*/
export function ensureImage(version: string): void {
const image = getWorkerImage(version);
const exists = runQuiet('docker', ['image', 'inspect', image]);
if (exists) return;
if (getMode() === 'local') {
console.log('Worker image not found, building...');
buildImage(false);
} else {
console.log(`Pulling ${image}...`);
try {
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
} catch {
console.error(`\nERROR: Failed to pull ${image}`);
console.error('The image may not be available for your platform yet.');
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
process.exit(1);
}
pruneOldImages(version);
}
}
/**
* Detect if --add-host is needed (Linux without Podman).
* macOS has host.docker.internal built in.
@@ -182,140 +73,259 @@ function addHostFlag(): string[] {
return [];
}
export interface WorkerOptions {
version: string;
url: string;
repo: { hostPath: string; containerPath: string };
workspacesDir: string;
taskQueue: string;
containerName: string;
envFlags: string[];
config?: { hostPath: string; containerPath: string };
credentials?: string;
promptsDir?: string;
outputDir?: string;
workspace: string;
pipelineTesting?: boolean;
}
/**
* Spawn the worker container in detached mode and return the process.
*/
export function spawnWorker(opts: WorkerOptions): ChildProcess {
const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net'];
// Add host flag for Linux
args.push(...addHostFlag());
// UID remapping for Linux bind mounts
if (os.platform() === 'linux' && process.getuid && process.getgid) {
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
}
// Volume mounts
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
// Local mode: mount prompts for live editing
if (opts.promptsDir) {
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
}
if (opts.config) {
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
}
// Output directory for deliverables copy
if (opts.outputDir) {
args.push('-v', `${opts.outputDir}:/app/output`);
}
// Mount credentials file to fixed container path
if (opts.credentials) {
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
}
// Environment
args.push(...opts.envFlags);
// Container settings
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
// Image
args.push(getWorkerImage(opts.version));
// Worker command
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
args.push('--task-queue', opts.taskQueue);
if (opts.config) {
args.push('--config', opts.config.containerPath);
}
if (opts.outputDir) {
args.push('--output', '/app/output');
}
args.push('--workspace', opts.workspace);
if (opts.pipelineTesting) {
args.push('--pipeline-testing');
}
// Prevent MSYS/Git Bash from converting Unix paths (e.g. /repos/my-repo) to Windows paths
return spawn('docker', args, {
stdio: 'pipe',
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
});
}
/**
* Stop all running shannon-worker-* containers.
*/
export function stopWorkers(): void {
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
if (!workers) return;
const ids = workers.split('\n').filter(Boolean);
console.log('Stopping worker containers...');
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
}
/**
* Tear down the compose stack.
*/
export function stopInfra(clean: boolean): void {
const composeFile = getComposeFile();
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
if (clean) args.push('-v');
execFileSync('docker', args, { stdio: 'inherit' });
}
/**
* Remove old keygraph/shannon images that don't match the current version.
*/
/** Remove old keygraph/shannon images that don't match the current version. */
function pruneOldImages(currentVersion: string): void {
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
if (!output) return;
const currentTag = currentVersion;
const stale = output.split('\n').filter((tag) => tag && tag !== currentTag);
const stale = output.split('\n').filter((tag) => tag && tag !== currentVersion);
for (const tag of stale) {
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
}
}
/**
* List running worker containers.
*/
export function listRunningWorkers(): string {
return runOutput('docker', [
'ps',
'--filter',
'name=shannon-worker-',
'--format',
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
]);
// === DockerOrchestrator ===
/** Docker-based orchestration backend. */
export class DockerOrchestrator implements Orchestrator {
getWorkerImage(version: string): string {
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
}
isTemporalReady(): boolean {
const output = runOutput('docker', [
'exec',
'shannon-temporal',
'temporal',
'operator',
'cluster',
'health',
'--address',
'localhost:7233',
]);
return output.includes('SERVING');
}
async ensureInfra(useRouter: boolean): Promise<void> {
const temporalReady = this.isTemporalReady();
const routerNeeded = useRouter && !isRouterReady();
if (temporalReady && !routerNeeded) {
return;
}
const composeFile = getComposeFile();
const composeArgs = ['compose', '-f', composeFile];
if (useRouter) composeArgs.push('--profile', 'router');
composeArgs.push('up', '-d');
if (temporalReady && routerNeeded) {
console.log('Starting router...');
} else {
console.log('Starting Shannon infrastructure...');
}
execFileSync('docker', composeArgs, { stdio: 'inherit' });
// Wait for Temporal if it wasn't already running
if (!temporalReady) {
console.log('Waiting for Temporal to be ready...');
for (let i = 0; i < 30; i++) {
if (this.isTemporalReady()) {
console.log('Temporal is ready!');
break;
}
if (i === 29) {
console.error('Timeout waiting for Temporal');
process.exit(1);
}
await sleep(2000);
}
}
// Wait for router if needed
if (routerNeeded) {
console.log('Waiting for router to be ready...');
for (let i = 0; i < 15; i++) {
if (isRouterReady()) {
console.log('Router is ready!');
return;
}
await sleep(2000);
}
console.error('Timeout waiting for router');
process.exit(1);
}
}
ensureImage(version: string): void {
const image = this.getWorkerImage(version);
const exists = runQuiet('docker', ['image', 'inspect', image]);
if (exists) return;
if (getMode() === 'local') {
console.log('Worker image not found, building...');
this.buildImage(false);
} else {
console.log(`Pulling ${image}...`);
try {
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
} catch {
console.error(`\nERROR: Failed to pull ${image}`);
console.error('The image may not be available for your platform yet.');
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
process.exit(1);
}
pruneOldImages(version);
}
}
spawnWorker(opts: WorkerOptions): WorkerHandle {
const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net'];
// Add host flag for Linux
args.push(...addHostFlag());
// UID remapping for Linux bind mounts
if (os.platform() === 'linux' && process.getuid && process.getgid) {
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
}
// Volume mounts
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
args.push(
'-v',
`${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`,
);
// Local mode: mount prompts for live editing
if (opts.promptsDir) {
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
}
if (opts.config) {
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
}
// Output directory for deliverables copy
if (opts.outputDir) {
args.push('-v', `${opts.outputDir}:/app/output`);
}
// Mount credentials file to fixed container path
if (opts.credentials) {
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
}
// Environment
args.push(...opts.envFlags);
// Container settings
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
// Image
args.push(this.getWorkerImage(opts.version));
// Worker command
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
args.push('--task-queue', opts.taskQueue);
if (opts.config) {
args.push('--config', opts.config.containerPath);
}
if (opts.outputDir) {
args.push('--output', '/app/output');
}
args.push('--workspace', opts.workspace);
if (opts.pipelineTesting) {
args.push('--pipeline-testing');
}
// Prevent MSYS/Git Bash from converting Unix paths (e.g. /repos/my-repo) to Windows paths
const proc = spawn('docker', args, {
stdio: 'pipe',
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
});
return new DockerWorkerHandle(proc, opts.containerName);
}
stopWorkers(): void {
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
if (!workers) return;
const ids = workers.split('\n').filter(Boolean);
console.log('Stopping worker containers...');
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
}
stopInfra(clean: boolean): void {
const composeFile = getComposeFile();
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
if (clean) args.push('-v');
execFileSync('docker', args, { stdio: 'inherit' });
}
listRunningWorkers(): string {
return runOutput('docker', [
'ps',
'--filter',
'name=shannon-worker-',
'--format',
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
]);
}
runEphemeral(image: string, args: string[], mounts: string[]): void {
const dockerArgs = ['run', '--rm'];
for (const mount of mounts) {
dockerArgs.push('-v', mount);
}
dockerArgs.push(image, ...args);
execFileSync('docker', dockerArgs, {
stdio: 'inherit',
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
});
}
/** Build the worker image locally (local mode only). */
buildImage(noCache: boolean): void {
console.log(`Building ${DEV_IMAGE}...`);
const args = ['build'];
if (noCache) args.push('--no-cache');
args.push('-t', DEV_IMAGE, '.');
execFileSync('docker', args, { stdio: 'inherit' });
console.log(`Build complete: ${DEV_IMAGE}`);
}
}
/** WorkerHandle wrapping a Docker container's ChildProcess. */
class DockerWorkerHandle implements WorkerHandle {
constructor(
private readonly proc: ChildProcess,
private readonly containerName: string,
) {}
onError(cb: (err: Error) => void): void {
this.proc.on('error', cb);
}
kill(): void {
try {
execFileSync('docker', ['stop', this.containerName], { stdio: 'pipe' });
} catch {
// Container may have already exited
}
}
}
// === Backward-compatible exports ===
// NOTE: Used by commands/build.ts which doesn't go through the orchestrator
export function buildImage(noCache: boolean): void {
new DockerOrchestrator().buildImage(noCache);
}
+18 -1
View File
@@ -10,7 +10,7 @@ import { resolveConfig } from './config/resolver.js';
import { getMode } from './mode.js';
/** Environment variables forwarded to worker containers. */
const FORWARD_VARS = [
export const FORWARD_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
@@ -61,6 +61,23 @@ export function buildEnvFlags(): string[] {
return flags;
}
/**
* Build a key-value record of env vars to forward to workers.
* Used by the K8s backend to create Secrets instead of Docker `-e` flags.
*/
export function buildEnvRecord(): Record<string, string> {
const env: Record<string, string> = { TEMPORAL_ADDRESS: 'shannon-temporal:7233' };
for (const key of FORWARD_VARS) {
const value = process.env[key];
if (value) {
env[key] = value;
}
}
return env;
}
interface CredentialValidation {
valid: boolean;
error?: string;
+16 -2
View File
@@ -12,6 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { setBackend } from './backend.js';
import { build } from './commands/build.js';
import { logs } from './commands/logs.js';
import { setup } from './commands/setup.js';
@@ -179,6 +180,19 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
// === Main Dispatch ===
const args = process.argv.slice(2);
// Parse --backend flag before command dispatch
const backendIdx = args.indexOf('--backend');
if (backendIdx !== -1) {
const backendVal = args[backendIdx + 1];
if (backendVal === 'k8s' || backendVal === 'kubernetes') {
setBackend('k8s');
} else if (backendVal === 'docker') {
setBackend('docker');
}
args.splice(backendIdx, 2);
}
const command = args[0];
switch (command) {
@@ -201,10 +215,10 @@ switch (command) {
break;
}
case 'workspaces':
workspaces(getVersion());
await workspaces(getVersion());
break;
case 'status':
status();
await status();
break;
case 'setup':
if (getMode() === 'local') {
+494
View File
@@ -0,0 +1,494 @@
/**
* Kubernetes orchestration backend.
*
* Replaces Docker CLI commands with Kubernetes API calls:
* - `docker compose up` → apply Deployments, Services, PVCs
* - `docker run --rm` → K8s Job per scan
* - `docker stop` → delete Jobs
*/
import fs from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import * as k8s from '@kubernetes/client-node';
import { buildEnvRecord } from './env.js';
import { getMode } from './mode.js';
import type { Orchestrator, WorkerHandle, WorkerOptions } from './orchestrator.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAMESPACE = 'shannon';
const NPX_IMAGE_REPO = 'keygraph/shannon';
const DEV_IMAGE = 'shannon-worker';
const WORKER_LABEL = 'shannon-worker';
const K8S_MANIFESTS_DIR = path.resolve(__dirname, '..', 'infra', 'k8s');
// === K8s Client Setup ===
function loadKubeConfig(): k8s.KubeConfig {
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
return kc;
}
/** Detect if running on kind or minikube (local K8s). */
function isLocalCluster(kc: k8s.KubeConfig): boolean {
const context = kc.getCurrentContext();
return context.startsWith('kind-') || context === 'minikube' || context.startsWith('minikube');
}
// === K8sOrchestrator ===
/** Kubernetes-based orchestration backend. */
export class K8sOrchestrator implements Orchestrator {
private readonly kc: k8s.KubeConfig;
private readonly coreApi: k8s.CoreV1Api;
private readonly appsApi: k8s.AppsV1Api;
private readonly batchApi: k8s.BatchV1Api;
constructor() {
this.kc = loadKubeConfig();
this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api);
this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api);
this.batchApi = this.kc.makeApiClient(k8s.BatchV1Api);
}
getWorkerImage(version: string): string {
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
}
// === Infrastructure ===
async ensureInfra(useRouter: boolean): Promise<void> {
// 1. Create namespace if it doesn't exist
await this.ensureNamespace();
// 2. Create or update credentials secret
await this.ensureCredentialsSecret();
// 3. Apply Temporal manifests
await this.applyManifest('temporal.yaml');
// 4. Apply workspaces PVC
await this.applyManifest('workspaces-pvc.yaml');
// 5. Optionally apply router
if (useRouter) {
await this.applyManifest('router.yaml');
}
// 6. Wait for Temporal to be ready
if (!(await this.isTemporalReadyAsync())) {
console.log('Waiting for Temporal to be ready...');
for (let i = 0; i < 30; i++) {
if (await this.isTemporalReadyAsync()) {
console.log('Temporal is ready!');
break;
}
if (i === 29) {
console.error('Timeout waiting for Temporal');
process.exit(1);
}
await sleep(2000);
}
}
}
ensureImage(_version: string): void {
// K8s pulls images via imagePullPolicy — no-op for remote clusters.
// For kind, users must run `kind load docker-image shannon-worker` manually.
if (getMode() === 'local' && isLocalCluster(this.kc)) {
console.log('NOTE: For kind/minikube, ensure the worker image is loaded:');
console.log(' kind load docker-image shannon-worker');
}
}
isTemporalReady(): boolean {
// K8s API is async — synchronous check returns false, ensureInfra uses async polling
return false;
}
private async isTemporalReadyAsync(): Promise<boolean> {
try {
const response = await this.coreApi.listNamespacedPod({
namespace: NAMESPACE,
labelSelector: 'app=shannon-temporal',
});
return response.items.some((pod) => {
const conditions = pod.status?.conditions ?? [];
return conditions.some((c) => c.type === 'Ready' && c.status === 'True');
});
} catch {
return false;
}
}
// === Worker Lifecycle ===
spawnWorker(opts: WorkerOptions): WorkerHandle {
const image = this.getWorkerImage(opts.version);
const jobName = opts.containerName;
// Build command + args for the worker
const command = ['node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath];
const args: string[] = ['--task-queue', opts.taskQueue, '--workspace', opts.workspace];
if (opts.config) {
args.push('--config', opts.config.containerPath);
}
if (opts.outputDir) {
args.push('--output', '/app/output');
}
if (opts.pipelineTesting) {
args.push('--pipeline-testing');
}
// Build volume mounts and volumes
const volumeMounts: k8s.V1VolumeMount[] = [
{ name: 'workspaces', mountPath: '/app/workspaces' },
{ name: 'shm', mountPath: '/dev/shm' },
];
const volumes: k8s.V1Volume[] = [
{
name: 'workspaces',
persistentVolumeClaim: { claimName: 'shannon-workspaces' },
},
{
name: 'shm',
emptyDir: { medium: 'Memory', sizeLimit: '2Gi' },
},
];
// Repo volume — hostPath for local clusters, PVC for managed
if (isLocalCluster(this.kc)) {
volumes.push({
name: 'repo',
hostPath: { path: opts.repo.hostPath, type: 'Directory' },
});
} else {
volumes.push({
name: 'repo',
persistentVolumeClaim: { claimName: `shannon-repo-${jobName}` },
});
}
volumeMounts.push({
name: 'repo',
mountPath: opts.repo.containerPath,
readOnly: true,
});
// Overlay dirs for deliverables/scratchpad/playwright (writable areas over :ro repo)
for (const overlay of ['deliverables', 'scratchpad', '.playwright-cli']) {
const volName = `overlay-${overlay.replace('.', '')}`;
volumes.push({
name: volName,
emptyDir: {},
});
volumeMounts.push({
name: volName,
mountPath: `${opts.repo.containerPath}/.shannon/${overlay}`,
});
}
// Optional volume mounts
if (opts.config) {
// Config would need a ConfigMap — for now, pass via env or mount differently
}
// Build env vars from the secret + TEMPORAL_ADDRESS
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'shannon-temporal:7233' }];
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: jobName,
namespace: NAMESPACE,
labels: {
app: WORKER_LABEL,
'shannon.io/workspace': opts.workspace,
},
},
spec: {
backoffLimit: 0,
ttlSecondsAfterFinished: 3600,
template: {
metadata: {
labels: {
app: WORKER_LABEL,
'shannon.io/workspace': opts.workspace,
},
},
spec: {
restartPolicy: 'Never',
securityContext: {
seccompProfile: { type: 'Unconfined' },
},
containers: [
{
name: 'worker',
image,
command,
args,
env,
envFrom: [{ secretRef: { name: 'shannon-credentials' } }],
volumeMounts,
resources: {
requests: { memory: '2Gi' },
},
},
],
volumes,
},
},
},
};
// Create the Job asynchronously — errors are reported via the handle
const createPromise = this.batchApi.createNamespacedJob({ namespace: NAMESPACE, body: job }).then(() => {
console.log(`Worker job ${jobName} created in namespace ${NAMESPACE}`);
});
return new K8sWorkerHandle(jobName, this.batchApi, createPromise);
}
stopWorkers(): void {
// Delete all worker jobs — fire and forget
this.batchApi
.deleteCollectionNamespacedJob({
namespace: NAMESPACE,
labelSelector: `app=${WORKER_LABEL}`,
propagationPolicy: 'Background',
})
.then(() => {
console.log('Worker jobs deleted.');
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to stop workers: ${message}`);
});
}
stopInfra(clean: boolean): void {
if (clean) {
// Delete the entire namespace (removes everything)
this.coreApi
.deleteNamespace({ name: NAMESPACE })
.then(() => {
console.log(`Namespace ${NAMESPACE} deleted.`);
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to delete namespace: ${message}`);
});
} else {
// Just delete the Temporal deployment and services
this.appsApi.deleteNamespacedDeployment({ name: 'shannon-temporal', namespace: NAMESPACE }).catch(() => {});
this.coreApi.deleteNamespacedService({ name: 'shannon-temporal', namespace: NAMESPACE }).catch(() => {});
this.appsApi.deleteNamespacedDeployment({ name: 'shannon-router', namespace: NAMESPACE }).catch(() => {});
this.coreApi.deleteNamespacedService({ name: 'shannon-router', namespace: NAMESPACE }).catch(() => {});
console.log('Infrastructure resources deleted.');
}
}
listRunningWorkers(): string {
// This is called synchronously by the status command — return empty for now,
// actual implementation needs async refactor of the status command
return '';
}
runEphemeral(image: string, args: string[], mounts: string[]): void {
// For K8s, run an ephemeral pod and wait for completion
const podName = `shannon-ephemeral-${Date.now()}`;
const volumeMounts: k8s.V1VolumeMount[] = [];
const volumes: k8s.V1Volume[] = [];
// Parse Docker-style mount strings (src:dst)
for (let i = 0; i < mounts.length; i++) {
const mount = mounts[i];
if (!mount) continue;
const parts = mount.split(':');
const dst = parts[1];
if (parts.length >= 2 && dst) {
const volName = `vol-${i}`;
volumeMounts.push({ name: volName, mountPath: dst });
volumes.push({
name: volName,
persistentVolumeClaim: { claimName: 'shannon-workspaces' },
});
}
}
const pod: k8s.V1Pod = {
apiVersion: 'v1',
kind: 'Pod',
metadata: {
name: podName,
namespace: NAMESPACE,
},
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'ephemeral',
image,
command: args,
volumeMounts,
env: [{ name: 'WORKSPACES_DIR', value: '/app/workspaces' }],
},
],
volumes,
},
};
// Create pod and wait for completion
this.coreApi
.createNamespacedPod({ namespace: NAMESPACE, body: pod })
.then(async () => {
// Poll for completion
for (let i = 0; i < 30; i++) {
const status = await this.coreApi.readNamespacedPod({ name: podName, namespace: NAMESPACE });
if (status.status?.phase === 'Succeeded' || status.status?.phase === 'Failed') {
// Read logs
const log = await this.coreApi.readNamespacedPodLog({ name: podName, namespace: NAMESPACE });
console.log(log);
// Clean up
await this.coreApi.deleteNamespacedPod({ name: podName, namespace: NAMESPACE });
return;
}
await sleep(2000);
}
console.error('Timeout waiting for ephemeral pod');
await this.coreApi.deleteNamespacedPod({ name: podName, namespace: NAMESPACE });
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to run ephemeral pod: ${message}`);
});
}
// === Private Helpers ===
private async ensureNamespace(): Promise<void> {
try {
await this.coreApi.readNamespace({ name: NAMESPACE });
} catch {
console.log(`Creating namespace ${NAMESPACE}...`);
await this.coreApi.createNamespace({
body: {
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: NAMESPACE, labels: { 'app.kubernetes.io/part-of': 'shannon' } },
},
});
}
}
private async ensureCredentialsSecret(): Promise<void> {
const envRecord = buildEnvRecord();
const stringData: Record<string, string> = {};
for (const [key, value] of Object.entries(envRecord)) {
if (key !== 'TEMPORAL_ADDRESS') {
stringData[key] = value;
}
}
const secret: k8s.V1Secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: 'shannon-credentials',
namespace: NAMESPACE,
},
stringData,
};
try {
await this.coreApi.replaceNamespacedSecret({
name: 'shannon-credentials',
namespace: NAMESPACE,
body: secret,
});
} catch {
await this.coreApi.createNamespacedSecret({ namespace: NAMESPACE, body: secret });
}
}
private async applyManifest(filename: string): Promise<void> {
const manifestPath = path.join(K8S_MANIFESTS_DIR, filename);
const content = fs.readFileSync(manifestPath, 'utf-8');
// Split multi-document YAML
const docs = content.split(/^---$/m).filter((doc) => doc.trim());
for (const doc of docs) {
await this.applyResource(doc);
}
}
private async applyResource(yamlDoc: string): Promise<void> {
const objects = k8s.loadAllYaml(yamlDoc) as k8s.KubernetesObject[];
const objectApi = k8s.KubernetesObjectApi.makeApiClient(this.kc);
for (const obj of objects) {
if (!obj || !obj.kind || !obj.metadata?.name) continue;
// Ensure metadata has required fields for the typed API
const spec = {
...obj,
metadata: { ...obj.metadata, name: obj.metadata.name },
};
try {
await objectApi.read(spec);
await objectApi.patch(spec);
} catch {
try {
await objectApi.create(spec);
} catch (createErr: unknown) {
const message = createErr instanceof Error ? createErr.message : String(createErr);
console.error(`Failed to apply ${obj.kind}/${obj.metadata.name}: ${message}`);
}
}
}
}
}
// === K8sWorkerHandle ===
/** WorkerHandle wrapping a K8s Job. */
class K8sWorkerHandle implements WorkerHandle {
private errorCallback: ((err: Error) => void) | undefined;
constructor(
private readonly jobName: string,
private readonly batchApi: k8s.BatchV1Api,
createPromise: Promise<void>,
) {
// Wire up creation errors to the error callback
createPromise.catch((err: unknown) => {
const error = err instanceof Error ? err : new Error(String(err));
if (this.errorCallback) {
this.errorCallback(error);
} else {
console.error(`Worker job creation failed: ${error.message}`);
}
});
}
onError(cb: (err: Error) => void): void {
this.errorCallback = cb;
}
kill(): void {
this.batchApi
.deleteNamespacedJob({
name: this.jobName,
namespace: NAMESPACE,
propagationPolicy: 'Background',
})
.catch(() => {
// Job may have already completed
});
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Orchestrator interface — abstraction over container orchestration backends.
*
* Docker and Kubernetes implement this interface so the CLI commands
* can swap backends without changing their logic.
*/
export interface WorkerOptions {
version: string;
url: string;
repo: { hostPath: string; containerPath: string };
workspacesDir: string;
taskQueue: string;
containerName: string;
envFlags: string[];
config?: { hostPath: string; containerPath: string };
credentials?: string;
promptsDir?: string;
outputDir?: string;
workspace: string;
pipelineTesting?: boolean;
}
/** Handle to a running worker, returned by Orchestrator.spawnWorker(). */
export interface WorkerHandle {
onError(cb: (err: Error) => void): void;
kill(): void;
}
/** Container orchestration backend. */
export interface Orchestrator {
ensureInfra(useRouter: boolean): Promise<void>;
ensureImage(version: string): void;
spawnWorker(opts: WorkerOptions): WorkerHandle;
stopWorkers(): void;
stopInfra(clean: boolean): void;
listRunningWorkers(): string;
isTemporalReady(): boolean;
getWorkerImage(version: string): string;
/**
* Run a one-shot ephemeral container and inherit stdio.
* Used by commands like `workspaces` that need to run worker-side scripts.
*/
runEphemeral(image: string, args: string[], mounts: string[]): void;
}
+1 -1
View File
@@ -6,6 +6,6 @@ export default defineConfig({
target: 'node18',
outDir: 'dist',
clean: true,
deps: { neverBundle: ['@clack/prompts', 'dotenv', 'smol-toml'] },
deps: { neverBundle: ['@clack/prompts', 'dotenv', 'smol-toml', '@kubernetes/client-node'] },
banner: { js: '#!/usr/bin/env node' },
});
+3 -2
View File
@@ -5,13 +5,14 @@
* within their own workflow context.
*/
export { pentestPipeline } from './workflows.js';
export type { ActivityInput } from './activities.js';
export type {
AgentMetrics,
PipelineInput,
PipelineProgress,
PipelineState,
PipelineSummary,
ResumeState,
VulnExploitPipelineResult,
} from './shared.js';
export type { ActivityInput } from './activities.js';
export { pentestPipeline } from './workflows.js';