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:
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user