From 5d67de648680bf587e7d8fa6cd7fcb00974a66f3 Mon Sep 17 00:00:00 2001 From: Max Mayfield Date: Sun, 1 Mar 2026 02:46:47 +0000 Subject: [PATCH] Add dd0c/drift notifications, infra, CI: Slack Block Kit, Dockerfiles, Gitea Actions - Notification service: Slack Block Kit (remediate/accept buttons), webhook delivery, rate limit handling - Dispatcher with severity-based channel filtering - Agent Dockerfile: multi-stage Go build, static binary - SaaS Dockerfile: multi-stage Node build - Fly.io config: scale-to-zero, shared-cpu - Gitea Actions: Go test+vet, Node typecheck+test, cross-compile agent (linux/darwin/windows) --- .../.gitea/workflows/ci.yml | 62 +++++++ .../.gitea/workflows/deploy.yml | 19 ++ .../02-iac-drift-detection/agent/Dockerfile | 13 ++ .../02-iac-drift-detection/saas/Dockerfile | 14 ++ products/02-iac-drift-detection/saas/fly.toml | 27 +++ .../saas/src/notifications/service.ts | 169 ++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 products/02-iac-drift-detection/.gitea/workflows/ci.yml create mode 100644 products/02-iac-drift-detection/.gitea/workflows/deploy.yml create mode 100644 products/02-iac-drift-detection/agent/Dockerfile create mode 100644 products/02-iac-drift-detection/saas/Dockerfile create mode 100644 products/02-iac-drift-detection/saas/fly.toml create mode 100644 products/02-iac-drift-detection/saas/src/notifications/service.ts diff --git a/products/02-iac-drift-detection/.gitea/workflows/ci.yml b/products/02-iac-drift-detection/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7e69ec8 --- /dev/null +++ b/products/02-iac-drift-detection/.gitea/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI +on: + push: + branches: [main] + pull_request: + +jobs: + agent-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Test agent + run: go test ./... + working-directory: products/02-iac-drift-detection/agent + + - name: Vet + run: go vet ./... + working-directory: products/02-iac-drift-detection/agent + + saas-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 + + agent-build: + runs-on: ubuntu-latest + needs: agent-test + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build agent binaries + run: | + GOOS=linux GOARCH=amd64 go build -o dist/drift-linux-amd64 ./cmd/drift + GOOS=darwin GOARCH=arm64 go build -o dist/drift-darwin-arm64 ./cmd/drift + GOOS=windows GOARCH=amd64 go build -o dist/drift-windows-amd64.exe ./cmd/drift + working-directory: products/02-iac-drift-detection/agent diff --git a/products/02-iac-drift-detection/.gitea/workflows/deploy.yml b/products/02-iac-drift-detection/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f5d557a --- /dev/null +++ b/products/02-iac-drift-detection/.gitea/workflows/deploy.yml @@ -0,0 +1,19 @@ +name: Deploy +on: + push: + branches: [main] + paths: ['products/02-iac-drift-detection/saas/**'] + +jobs: + deploy-saas: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy SaaS to Fly.io + run: flyctl deploy --config fly.toml --remote-only + working-directory: products/02-iac-drift-detection/saas + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/products/02-iac-drift-detection/agent/Dockerfile b/products/02-iac-drift-detection/agent/Dockerfile new file mode 100644 index 0000000..0c2bd2c --- /dev/null +++ b/products/02-iac-drift-detection/agent/Dockerfile @@ -0,0 +1,13 @@ +# --- Build stage --- +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /drift ./cmd/drift + +# --- Runtime stage --- +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +COPY --from=builder /drift /usr/local/bin/drift +ENTRYPOINT ["drift"] diff --git a/products/02-iac-drift-detection/saas/Dockerfile b/products/02-iac-drift-detection/saas/Dockerfile new file mode 100644 index 0000000..690069b --- /dev/null +++ b/products/02-iac-drift-detection/saas/Dockerfile @@ -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"] diff --git a/products/02-iac-drift-detection/saas/fly.toml b/products/02-iac-drift-detection/saas/fly.toml new file mode 100644 index 0000000..ea66b67 --- /dev/null +++ b/products/02-iac-drift-detection/saas/fly.toml @@ -0,0 +1,27 @@ +app = "dd0c-drift" +primary_region = "iad" + +[build] + dockerfile = "Dockerfile" + +[env] + NODE_ENV = "production" + PORT = "3000" + LOG_LEVEL = "info" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = true + auto_start_machines = true + min_machines_running = 0 + + [http_service.concurrency] + type = "requests" + hard_limit = 100 + soft_limit = 80 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 256 diff --git a/products/02-iac-drift-detection/saas/src/notifications/service.ts b/products/02-iac-drift-detection/saas/src/notifications/service.ts new file mode 100644 index 0000000..e1b656c --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/notifications/service.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import pino from 'pino'; + +const logger = pino({ name: 'notifications' }); + +export interface DriftNotification { + tenantId: string; + stackName: string; + driftScore: number; + criticalCount: number; + highCount: number; + totalDrifted: number; + totalResources: number; + reportUrl: string; +} + +// --- Slack --- + +const slackBlockKit = (n: DriftNotification) => ({ + blocks: [ + { + type: 'header', + text: { type: 'plain_text', text: `🔍 Drift Detected: ${n.stackName}` }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Drift Score:* ${n.driftScore}/100` }, + { type: 'mrkdwn', text: `*Drifted:* ${n.totalDrifted}/${n.totalResources} resources` }, + { type: 'mrkdwn', text: `*Critical:* ${n.criticalCount}` }, + { type: 'mrkdwn', text: `*High:* ${n.highCount}` }, + ], + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: '📋 View Report' }, + url: n.reportUrl, + style: 'primary', + }, + { + type: 'button', + text: { type: 'plain_text', text: '🔧 Remediate' }, + action_id: `remediate_${n.stackName}`, + style: 'danger', + confirm: { + title: { type: 'plain_text', text: 'Generate Remediation Plan?' }, + text: { type: 'mrkdwn', text: `This will run \`terraform plan\` for *${n.stackName}*` }, + confirm: { type: 'plain_text', text: 'Yes, plan it' }, + deny: { type: 'plain_text', text: 'Cancel' }, + }, + }, + { + type: 'button', + text: { type: 'plain_text', text: '✅ Accept Drift' }, + action_id: `accept_${n.stackName}`, + }, + ], + }, + ], +}); + +export async function sendSlackNotification(webhookUrl: string, notification: DriftNotification): Promise { + const payload = slackBlockKit(notification); + + const resp = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + const text = await resp.text(); + // Slack 429 — respect retry-after + if (resp.status === 429) { + const retryAfter = parseInt(resp.headers.get('retry-after') ?? '5'); + logger.warn({ retryAfter }, 'Slack rate limited — backing off'); + throw new SlackRateLimitError(retryAfter); + } + throw new Error(`Slack webhook failed: ${resp.status} ${text}`); + } +} + +export class SlackRateLimitError extends Error { + retryAfterSeconds: number; + constructor(retryAfter: number) { + super(`Slack rate limited, retry after ${retryAfter}s`); + this.retryAfterSeconds = retryAfter; + } +} + +// --- Webhook --- + +export async function sendWebhookNotification(url: string, notification: DriftNotification): Promise { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + event: 'drift.detected', + ...notification, + timestamp: new Date().toISOString(), + }), + }); + + if (!resp.ok) { + throw new Error(`Webhook delivery failed: ${resp.status}`); + } +} + +// --- Dispatcher --- + +export interface NotificationChannel { + channel: 'slack' | 'email' | 'webhook' | 'pagerduty'; + config: Record; + minSeverity: string; + enabled: boolean; +} + +const severityRank: Record = { + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + +export function shouldNotify(channel: NotificationChannel, driftScore: number, maxSeverity: string): boolean { + if (!channel.enabled) return false; + const channelRank = severityRank[channel.minSeverity] ?? 2; + const eventRank = severityRank[maxSeverity] ?? 2; + return eventRank >= channelRank; +} + +export async function dispatchNotifications( + channels: NotificationChannel[], + notification: DriftNotification, + maxSeverity: string, +): Promise { + for (const ch of channels) { + if (!shouldNotify(ch, notification.driftScore, maxSeverity)) continue; + + try { + switch (ch.channel) { + case 'slack': + await sendSlackNotification(ch.config.webhook_url!, notification); + break; + case 'webhook': + await sendWebhookNotification(ch.config.url!, notification); + break; + case 'email': + // TODO: Resend integration + logger.info({ channel: 'email' }, 'Email notifications not yet implemented'); + break; + case 'pagerduty': + // TODO: PagerDuty Events API v2 + logger.info({ channel: 'pagerduty' }, 'PagerDuty notifications not yet implemented'); + break; + } + } catch (err) { + if (err instanceof SlackRateLimitError) { + // TODO: Queue for retry after backoff + logger.warn({ retryAfter: err.retryAfterSeconds }, 'Slack notification queued for retry'); + } else { + logger.error({ channel: ch.channel, error: (err as Error).message }, 'Notification delivery failed'); + } + } + } +}