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:
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