Add P2 SaaS CI, P4 scheduled discovery, P6 agent bridge (Redis pub/sub), Caddyfile

- P2: Gitea Actions CI for SaaS backend (separate from Go agent CI)
- P4: ScheduledDiscovery with Redis distributed lock to prevent concurrent scans
- P6: AgentBridge — Redis pub/sub for SaaS↔agent communication (approvals + step results)
- Caddyfile: self-hosted reverse proxy with auto-TLS for all 6 products
This commit is contained in:
2026-03-01 03:16:33 +00:00
parent bbbea3519e
commit 2ceeac1a11
4 changed files with 242 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
name: CI - SaaS
on:
push:
branches: [main]
paths:
- 'products/02-iac-drift-detection/saas/**'
pull_request:
paths:
- 'products/02-iac-drift-detection/saas/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install deps
run: npm ci
working-directory: products/02-iac-drift-detection/saas
- name: Type check
run: npx tsc --noEmit
working-directory: products/02-iac-drift-detection/saas
- name: Test
run: npm test
working-directory: products/02-iac-drift-detection/saas

View File

@@ -0,0 +1,64 @@
import pino from 'pino';
import Redis from 'ioredis';
import { config } from '../config/index.js';
const logger = pino({ name: 'scheduled-discovery' });
/**
* Scheduled discovery job — runs AWS + GitHub scans on a cron schedule.
* Uses Redis-based distributed lock to prevent concurrent scans.
*/
export class ScheduledDiscovery {
private redis: Redis;
private lockTtlMs: number;
constructor(redis: Redis, lockTtlMs = 10 * 60 * 1000) {
this.redis = redis;
this.lockTtlMs = lockTtlMs;
}
/**
* Attempt to acquire a distributed lock for a tenant scan.
* Returns true if lock acquired, false if another scan is running.
*/
async acquireLock(tenantId: string, scanner: string): Promise<boolean> {
const key = `scan_lock:${tenantId}:${scanner}`;
const result = await this.redis.set(key, Date.now().toString(), 'PX', this.lockTtlMs, 'NX');
return result === 'OK';
}
async releaseLock(tenantId: string, scanner: string): Promise<void> {
const key = `scan_lock:${tenantId}:${scanner}`;
await this.redis.del(key);
}
/**
* Run a scheduled scan for a tenant.
* Called by cron job or manual trigger.
*/
async runScan(tenantId: string, scanner: 'aws' | 'github'): Promise<{ status: string; discovered: number }> {
const locked = await this.acquireLock(tenantId, scanner);
if (!locked) {
logger.info({ tenantId, scanner }, 'Scan already in progress — skipping');
return { status: 'skipped', discovered: 0 };
}
try {
logger.info({ tenantId, scanner }, 'Starting scheduled scan');
// TODO: Instantiate appropriate scanner and run
// const result = scanner === 'aws'
// ? await awsScanner.scan(region, account)
// : await githubScanner.scan(org);
// TODO: Merge results into catalog via CatalogService
return { status: 'completed', discovered: 0 };
} catch (err) {
logger.error({ tenantId, scanner, error: (err as Error).message }, 'Scheduled scan failed');
return { status: 'failed', discovered: 0 };
} finally {
await this.releaseLock(tenantId, scanner);
}
}
}

View File

@@ -0,0 +1,103 @@
import pino from 'pino';
import Redis from 'ioredis';
const logger = pino({ name: 'agent-ws' });
/**
* Agent communication via Redis pub/sub.
* SaaS publishes approval decisions, agent subscribes.
* Agent publishes step results, SaaS subscribes.
*
* Channel pattern: dd0c:run:{tenantId}:{executionId}:{direction}
* - direction: 'to_agent' | 'from_agent'
*/
export class AgentBridge {
private pub: Redis;
private sub: Redis;
constructor(redisUrl: string) {
this.pub = new Redis(redisUrl);
this.sub = new Redis(redisUrl);
}
/**
* Send approval decision to agent.
*/
async sendApproval(tenantId: string, executionId: string, stepId: string, decision: 'approve' | 'reject'): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:to_agent`;
const message = JSON.stringify({ type: 'approval', stepId, decision, timestamp: Date.now() });
await this.pub.publish(channel, message);
logger.info({ tenantId, executionId, stepId, decision }, 'Approval sent to agent');
}
/**
* Subscribe to step results from agent.
*/
async onStepResult(
tenantId: string,
executionId: string,
callback: (result: StepResultMessage) => void,
): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:from_agent`;
await this.sub.subscribe(channel);
this.sub.on('message', (ch, message) => {
if (ch !== channel) return;
try {
const parsed = JSON.parse(message) as StepResultMessage;
callback(parsed);
} catch (err) {
logger.warn({ error: (err as Error).message }, 'Invalid message from agent');
}
});
}
/**
* Publish step result from agent side.
*/
async publishStepResult(tenantId: string, executionId: string, result: StepResultMessage): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:from_agent`;
await this.pub.publish(channel, JSON.stringify(result));
}
/**
* Subscribe to approval decisions from SaaS side (agent-side).
*/
async onApproval(
tenantId: string,
executionId: string,
callback: (decision: { stepId: string; decision: 'approve' | 'reject' }) => void,
): Promise<void> {
const channel = `dd0c:run:${tenantId}:${executionId}:to_agent`;
await this.sub.subscribe(channel);
this.sub.on('message', (ch, message) => {
if (ch !== channel) return;
try {
const parsed = JSON.parse(message);
if (parsed.type === 'approval') {
callback({ stepId: parsed.stepId, decision: parsed.decision });
}
} catch (err) {
logger.warn({ error: (err as Error).message }, 'Invalid approval message');
}
});
}
async close(): Promise<void> {
await this.sub.quit();
await this.pub.quit();
}
}
export interface StepResultMessage {
type: 'step_result';
stepIndex: number;
command: string;
exitCode: number;
status: 'success' | 'failed' | 'timed_out' | 'rejected' | 'skipped';
durationMs: number;
stdoutHash?: string;
stderrHash?: string;
timestamp: number;
}

44
products/Caddyfile Normal file
View File

@@ -0,0 +1,44 @@
# dd0c Self-Hosted Reverse Proxy
# Auto-TLS via Let's Encrypt for all products
#
# Usage: caddy run --config Caddyfile
# Requires: DNS records pointing *.dd0c.dev to your server
{
email admin@dd0c.dev
}
# P1: LLM Cost Router (Rust proxy)
route.dd0c.dev {
reverse_proxy localhost:3001
}
# P2: IaC Drift Detection
drift.dd0c.dev {
reverse_proxy localhost:3002
}
# P3: Alert Intelligence
alert.dd0c.dev {
reverse_proxy localhost:3003
}
# P4: Service Catalog
portal.dd0c.dev {
reverse_proxy localhost:3004
}
# P5: AWS Cost Anomaly
cost.dd0c.dev {
reverse_proxy localhost:3005
}
# P6: Runbook Automation
run.dd0c.dev {
reverse_proxy localhost:3006
}
# Dashboard UIs (Cloudflare Pages in prod, local dev server here)
app.dd0c.dev {
reverse_proxy localhost:5173
}
}