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:
2026-03-01 02:46:47 +00:00
parent e67cef518e
commit 5d67de6486
6 changed files with 304 additions and 0 deletions

View 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

View 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 }}

View 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"]

View 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"]

View 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

View File

@@ -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');
}
}
}
}