diff --git a/products/02-iac-drift-detection/.gitea/workflows/ci-saas.yml b/products/02-iac-drift-detection/.gitea/workflows/ci-saas.yml new file mode 100644 index 0000000..e95b943 --- /dev/null +++ b/products/02-iac-drift-detection/.gitea/workflows/ci-saas.yml @@ -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 diff --git a/products/04-lightweight-idp/src/discovery/scheduler.ts b/products/04-lightweight-idp/src/discovery/scheduler.ts new file mode 100644 index 0000000..4001c7f --- /dev/null +++ b/products/04-lightweight-idp/src/discovery/scheduler.ts @@ -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 { + 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 { + 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); + } + } +} diff --git a/products/06-runbook-automation/saas/src/bridge/agent-bridge.ts b/products/06-runbook-automation/saas/src/bridge/agent-bridge.ts new file mode 100644 index 0000000..85a346c --- /dev/null +++ b/products/06-runbook-automation/saas/src/bridge/agent-bridge.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/products/Caddyfile b/products/Caddyfile new file mode 100644 index 0000000..6f7f2cf --- /dev/null +++ b/products/Caddyfile @@ -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 +}