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:
31
products/02-iac-drift-detection/.gitea/workflows/ci-saas.yml
Normal file
31
products/02-iac-drift-detection/.gitea/workflows/ci-saas.yml
Normal 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
|
||||
64
products/04-lightweight-idp/src/discovery/scheduler.ts
Normal file
64
products/04-lightweight-idp/src/discovery/scheduler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
products/06-runbook-automation/saas/src/bridge/agent-bridge.ts
Normal file
103
products/06-runbook-automation/saas/src/bridge/agent-bridge.ts
Normal 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
44
products/Caddyfile
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user