Scaffold dd0c/run: Rust agent (classifier, executor, audit) + TypeScript SaaS
- Rust agent: clap CLI, command classifier (read-only/modifying/destructive), executor with approval gates, audit log entries - Classifier: pattern-based safety classification for shell, AWS, kubectl, terraform/tofu commands - 6 Rust tests: read-only, destructive, modifying, empty, terraform apply, tofu destroy - SaaS backend: Fastify server, runbook CRUD API, approval API, Slack interactive handler - Slack integration: signature verification, block_actions for approve/reject buttons - PostgreSQL schema with RLS: runbooks, executions, audit_entries (append-only), agents - Dual Dockerfiles: Rust multi-stage (agent), Node multi-stage (SaaS) - Gitea Actions CI: Rust test+clippy, Node typecheck+test - Fly.io config for SaaS
This commit is contained in:
14
products/06-runbook-automation/saas/Dockerfile
Normal file
14
products/06-runbook-automation/saas/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
94
products/06-runbook-automation/saas/migrations/001_init.sql
Normal file
94
products/06-runbook-automation/saas/migrations/001_init.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- dd0c/run V1 schema
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Tenants
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Runbooks
|
||||
CREATE TABLE runbooks (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
yaml_content TEXT NOT NULL,
|
||||
step_count INT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_runbooks_tenant ON runbooks(tenant_id, status);
|
||||
|
||||
-- Executions
|
||||
CREATE TABLE executions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
runbook_id UUID NOT NULL REFERENCES runbooks(id) ON DELETE CASCADE,
|
||||
triggered_by TEXT NOT NULL,
|
||||
trigger_source TEXT NOT NULL DEFAULT 'api' CHECK (trigger_source IN ('api', 'slack', 'schedule', 'webhook')),
|
||||
dry_run BOOLEAN NOT NULL DEFAULT false,
|
||||
variables JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'awaiting_approval', 'completed', 'failed', 'aborted')),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_executions_tenant ON executions(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_executions_runbook ON executions(runbook_id);
|
||||
|
||||
-- Audit entries (append-only, immutable — BMad must-have)
|
||||
CREATE TABLE audit_entries (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
execution_id UUID NOT NULL REFERENCES executions(id) ON DELETE CASCADE,
|
||||
step_index INT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
safety_level TEXT NOT NULL CHECK (safety_level IN ('read_only', 'modifying', 'destructive')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'awaiting_approval', 'approved', 'rejected', 'executing', 'completed', 'failed', 'timed_out')),
|
||||
approved_by TEXT,
|
||||
approval_method TEXT CHECK (approval_method IN ('slack_button', 'api', 'auto')),
|
||||
exit_code INT,
|
||||
stdout_hash TEXT,
|
||||
stderr_hash TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms INT
|
||||
);
|
||||
CREATE INDEX idx_audit_execution ON audit_entries(execution_id, step_index);
|
||||
CREATE INDEX idx_audit_tenant ON audit_entries(tenant_id, started_at DESC);
|
||||
|
||||
-- Agent registrations
|
||||
CREATE TABLE agents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
api_key_hash TEXT NOT NULL,
|
||||
api_key_prefix TEXT NOT NULL,
|
||||
last_heartbeat_at TIMESTAMPTZ,
|
||||
version TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE runbooks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE executions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit_entries ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE agents ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_iso_runbooks ON runbooks
|
||||
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_iso_executions ON executions
|
||||
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_iso_audit ON audit_entries
|
||||
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_iso_agents ON agents
|
||||
USING (tenant_id::text = current_setting('app.tenant_id', true));
|
||||
38
products/06-runbook-automation/saas/package.json
Normal file
38
products/06-runbook-automation/saas/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "dd0c-run",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint src/ tests/"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/helmet": "^11.1.0",
|
||||
"@fastify/websocket": "^10.0.0",
|
||||
"pg": "^8.12.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"zod": "^3.23.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pino": "^9.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"@slack/web-api": "^7.1.0",
|
||||
"@slack/bolt": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0",
|
||||
"tsx": "^4.15.0",
|
||||
"vitest": "^1.6.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/ws": "^8.5.10",
|
||||
"eslint": "^9.5.0"
|
||||
}
|
||||
}
|
||||
33
products/06-runbook-automation/saas/src/api/approvals.ts
Normal file
33
products/06-runbook-automation/saas/src/api/approvals.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'api-approvals' });
|
||||
|
||||
const approvalDecisionSchema = z.object({
|
||||
decision: z.enum(['approve', 'reject']),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export function registerApprovalRoutes(app: FastifyInstance) {
|
||||
// List pending approvals for tenant
|
||||
app.get('/api/v1/approvals', async (req, reply) => {
|
||||
// TODO: SELECT from audit_entries WHERE status = 'awaiting_approval'
|
||||
return { approvals: [] };
|
||||
});
|
||||
|
||||
// Approve or reject a step
|
||||
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
|
||||
const { stepId } = req.params as { stepId: string };
|
||||
const body = approvalDecisionSchema.parse(req.body);
|
||||
|
||||
// TODO: Update audit entry, notify agent via WebSocket/Redis pub-sub
|
||||
logger.info({ stepId, decision: body.decision }, 'Approval decision recorded');
|
||||
|
||||
return {
|
||||
step_id: stepId,
|
||||
decision: body.decision,
|
||||
reason: body.reason,
|
||||
};
|
||||
});
|
||||
}
|
||||
73
products/06-runbook-automation/saas/src/api/runbooks.ts
Normal file
73
products/06-runbook-automation/saas/src/api/runbooks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'api-runbooks' });
|
||||
|
||||
const createRunbookSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
yaml_content: z.string().min(1),
|
||||
});
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
status: z.enum(['active', 'archived']).optional(),
|
||||
});
|
||||
|
||||
export function registerRunbookRoutes(app: FastifyInstance) {
|
||||
// List runbooks
|
||||
app.get('/api/v1/runbooks', async (req, reply) => {
|
||||
const query = listQuerySchema.parse(req.query);
|
||||
// TODO: SELECT from runbooks with RLS tenant context
|
||||
return { runbooks: [], page: query.page, limit: query.limit, total: 0 };
|
||||
});
|
||||
|
||||
// Get single runbook
|
||||
app.get('/api/v1/runbooks/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// TODO: SELECT by id
|
||||
return { runbook: null };
|
||||
});
|
||||
|
||||
// Create runbook
|
||||
app.post('/api/v1/runbooks', async (req, reply) => {
|
||||
const body = createRunbookSchema.parse(req.body);
|
||||
// TODO: INSERT into runbooks, parse YAML, validate steps
|
||||
logger.info({ name: body.name }, 'Runbook created');
|
||||
return reply.status(201).send({ id: 'placeholder', ...body });
|
||||
});
|
||||
|
||||
// Trigger runbook execution
|
||||
app.post('/api/v1/runbooks/:id/execute', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = z.object({
|
||||
dry_run: z.boolean().default(false),
|
||||
variables: z.record(z.string()).optional(),
|
||||
}).parse(req.body ?? {});
|
||||
|
||||
// TODO: Create execution record, dispatch to agent via WebSocket/queue
|
||||
logger.info({ runbookId: id, dryRun: body.dry_run }, 'Execution triggered');
|
||||
return reply.status(202).send({
|
||||
execution_id: 'placeholder',
|
||||
runbook_id: id,
|
||||
status: 'pending',
|
||||
dry_run: body.dry_run,
|
||||
});
|
||||
});
|
||||
|
||||
// Get execution history
|
||||
app.get('/api/v1/runbooks/:id/executions', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// TODO: SELECT from executions
|
||||
return { executions: [] };
|
||||
});
|
||||
|
||||
// Get execution detail (with step-by-step audit trail)
|
||||
app.get('/api/v1/executions/:executionId', async (req, reply) => {
|
||||
const { executionId } = req.params as { executionId: string };
|
||||
// TODO: SELECT execution + JOIN audit_entries
|
||||
return { execution: null, steps: [] };
|
||||
});
|
||||
}
|
||||
15
products/06-runbook-automation/saas/src/config/index.ts
Normal file
15
products/06-runbook-automation/saas/src/config/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3000),
|
||||
DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_run'),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
|
||||
SLACK_BOT_TOKEN: z.string().optional(),
|
||||
SLACK_SIGNING_SECRET: z.string().optional(),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
export const config = envSchema.parse(process.env);
|
||||
export type Config = z.infer<typeof envSchema>;
|
||||
31
products/06-runbook-automation/saas/src/index.ts
Normal file
31
products/06-runbook-automation/saas/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import pino from 'pino';
|
||||
import { config } from './config/index.js';
|
||||
import { registerRunbookRoutes } from './api/runbooks.js';
|
||||
import { registerApprovalRoutes } from './api/approvals.js';
|
||||
import { registerSlackRoutes } from './slackbot/handler.js';
|
||||
|
||||
const logger = pino({ name: 'dd0c-run', level: config.LOG_LEVEL });
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||
await app.register(helmet);
|
||||
|
||||
// Health check
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' }));
|
||||
|
||||
// API routes
|
||||
registerRunbookRoutes(app);
|
||||
registerApprovalRoutes(app);
|
||||
registerSlackRoutes(app);
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: '0.0.0.0' });
|
||||
logger.info({ port: config.PORT }, 'dd0c/run SaaS started');
|
||||
} catch (err) {
|
||||
logger.fatal(err, 'Failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
78
products/06-runbook-automation/saas/src/slackbot/handler.ts
Normal file
78
products/06-runbook-automation/saas/src/slackbot/handler.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import pino from 'pino';
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const logger = pino({ name: 'slackbot' });
|
||||
|
||||
/**
|
||||
* Slack interactive message handler.
|
||||
* Receives button clicks for approve/reject from Slack Block Kit messages.
|
||||
*/
|
||||
export function registerSlackRoutes(app: FastifyInstance) {
|
||||
// Slack Events API verification + interactive payloads
|
||||
app.post('/slack/events', async (req, reply) => {
|
||||
// Verify Slack signature
|
||||
if (config.SLACK_SIGNING_SECRET) {
|
||||
const timestamp = req.headers['x-slack-request-timestamp'] as string;
|
||||
const signature = req.headers['x-slack-signature'] as string;
|
||||
const body = JSON.stringify(req.body);
|
||||
|
||||
if (!verifySlackSignature(body, timestamp, signature, config.SLACK_SIGNING_SECRET)) {
|
||||
return reply.status(401).send({ error: 'Invalid signature' });
|
||||
}
|
||||
}
|
||||
|
||||
const payload = req.body as any;
|
||||
|
||||
// URL verification challenge
|
||||
if (payload.type === 'url_verification') {
|
||||
return { challenge: payload.challenge };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Slack interactive components (button clicks)
|
||||
app.post('/slack/interactions', async (req, reply) => {
|
||||
// Slack sends form-encoded payload
|
||||
const rawPayload = (req.body as any)?.payload;
|
||||
if (!rawPayload) return reply.status(400).send({ error: 'Missing payload' });
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch {
|
||||
return reply.status(400).send({ error: 'Invalid payload' });
|
||||
}
|
||||
|
||||
if (payload.type === 'block_actions') {
|
||||
for (const action of payload.actions ?? []) {
|
||||
const [actionType, stepId] = (action.action_id ?? '').split(':');
|
||||
|
||||
if (actionType === 'approve_step') {
|
||||
logger.info({ stepId, user: payload.user?.id }, 'Step approved via Slack');
|
||||
// TODO: Update audit entry, resume execution
|
||||
} else if (actionType === 'reject_step') {
|
||||
logger.info({ stepId, user: payload.user?.id }, 'Step rejected via Slack');
|
||||
// TODO: Update audit entry, abort execution
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
function verifySlackSignature(body: string, timestamp: string, signature: string, secret: string): boolean {
|
||||
if (!timestamp || !signature) return false;
|
||||
|
||||
// Reject stale requests (>5 min)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;
|
||||
|
||||
const sigBasestring = `v0:${timestamp}:${body}`;
|
||||
const expected = 'v0=' + crypto.createHmac('sha256', secret).update(sigBasestring).digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
}
|
||||
19
products/06-runbook-automation/saas/tsconfig.json
Normal file
19
products/06-runbook-automation/saas/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user