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