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