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)
This commit is contained in:
62
products/02-iac-drift-detection/.gitea/workflows/ci.yml
Normal file
62
products/02-iac-drift-detection/.gitea/workflows/ci.yml
Normal file
@@ -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
|
||||||
19
products/02-iac-drift-detection/.gitea/workflows/deploy.yml
Normal file
19
products/02-iac-drift-detection/.gitea/workflows/deploy.yml
Normal file
@@ -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 }}
|
||||||
13
products/02-iac-drift-detection/agent/Dockerfile
Normal file
13
products/02-iac-drift-detection/agent/Dockerfile
Normal file
@@ -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"]
|
||||||
14
products/02-iac-drift-detection/saas/Dockerfile
Normal file
14
products/02-iac-drift-detection/saas/Dockerfile
Normal file
@@ -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"]
|
||||||
27
products/02-iac-drift-detection/saas/fly.toml
Normal file
27
products/02-iac-drift-detection/saas/fly.toml
Normal file
@@ -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
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string>;
|
||||||
|
minSeverity: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityRank: Record<string, number> = {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user