fix: align backend API routes with console frontend contract
Some checks failed
CI — P3 Alert / test (push) Successful in 34s
CI — P4 Portal / test (push) Successful in 37s
CI — P5 Cost / test (push) Successful in 35s
CI — P6 Run / saas (push) Successful in 33s
CI — P5 Cost / build-push (push) Failing after 5s
CI — P6 Run / build-push (push) Failing after 4s
CI — P2 Drift (Go + Node) / agent (push) Successful in 1m5s
CI — P2 Drift (Go + Node) / saas (push) Successful in 37s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 16s
CI — P3 Alert / build-push (push) Failing after 14s
CI — P4 Portal / build-push (push) Failing after 27s
Some checks failed
CI — P3 Alert / test (push) Successful in 34s
CI — P4 Portal / test (push) Successful in 37s
CI — P5 Cost / test (push) Successful in 35s
CI — P6 Run / saas (push) Successful in 33s
CI — P5 Cost / build-push (push) Failing after 5s
CI — P6 Run / build-push (push) Failing after 4s
CI — P2 Drift (Go + Node) / agent (push) Successful in 1m5s
CI — P2 Drift (Go + Node) / saas (push) Successful in 37s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 16s
CI — P3 Alert / build-push (push) Failing after 14s
CI — P4 Portal / build-push (push) Failing after 27s
This commit is contained in:
@@ -74,6 +74,30 @@ export async function registerApiRoutes(app: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get latest report for a stack (console route: GET /api/v1/stacks/:name/report)
|
||||||
|
app.get('/api/v1/stacks/:name/report', async (request, reply) => {
|
||||||
|
const tenantId = (request as any).tenantId;
|
||||||
|
const { name } = request.params as { name: string };
|
||||||
|
const pool = (app as any).pool;
|
||||||
|
|
||||||
|
const result = await withTenant(pool, tenantId, async (client) => {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`SELECT * FROM drift_reports
|
||||||
|
WHERE tenant_id = $1 AND stack_name = $2
|
||||||
|
ORDER BY scanned_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, name]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return reply.status(404).send({ error: 'No report found for stack' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
// Get stack drift history
|
// Get stack drift history
|
||||||
app.get('/api/v1/stacks/:stackName/history', async (request, reply) => {
|
app.get('/api/v1/stacks/:stackName/history', async (request, reply) => {
|
||||||
const tenantId = (request as any).tenantId;
|
const tenantId = (request as any).tenantId;
|
||||||
@@ -96,7 +120,7 @@ export async function registerApiRoutes(app: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single drift report
|
// Get single drift report (legacy route)
|
||||||
app.get('/api/v1/reports/:reportId', async (request, reply) => {
|
app.get('/api/v1/reports/:reportId', async (request, reply) => {
|
||||||
const tenantId = (request as any).tenantId;
|
const tenantId = (request as any).tenantId;
|
||||||
const { reportId } = request.params as { reportId: string };
|
const { reportId } = request.params as { reportId: string };
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ const listQuerySchema = z.object({
|
|||||||
service: z.string().optional(),
|
service: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusUpdateSchema = z.object({
|
||||||
|
status: z.enum(['acknowledged', 'resolved', 'suppressed']),
|
||||||
|
});
|
||||||
|
|
||||||
export function registerIncidentRoutes(app: FastifyInstance) {
|
export function registerIncidentRoutes(app: FastifyInstance) {
|
||||||
// List incidents
|
// List incidents
|
||||||
app.get('/api/v1/incidents', async (req, reply) => {
|
app.get('/api/v1/incidents', async (req, reply) => {
|
||||||
const query = listQuerySchema.parse(req.query);
|
const query = listQuerySchema.parse(req.query);
|
||||||
const tenantId = (req as any).tenantId; // Set by auth middleware
|
const tenantId = (req as any).tenantId;
|
||||||
const offset = (query.page - 1) * query.limit;
|
const offset = (query.page - 1) * query.limit;
|
||||||
|
|
||||||
const result = await withTenant(tenantId, async (client) => {
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
@@ -50,7 +54,24 @@ export function registerIncidentRoutes(app: FastifyInstance) {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Acknowledge incident
|
// Unified status update (console route: POST /api/v1/incidents/:id/status)
|
||||||
|
app.post('/api/v1/incidents/:id/status', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { status } = statusUpdateSchema.parse(req.body);
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
await withTenant(tenantId, async (client) => {
|
||||||
|
if (status === 'resolved') {
|
||||||
|
await client.query("UPDATE incidents SET status = 'resolved', resolved_at = now() WHERE id = $1", [id]);
|
||||||
|
} else {
|
||||||
|
await client.query('UPDATE incidents SET status = $1 WHERE id = $2', [status, id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledge incident (legacy route)
|
||||||
app.post('/api/v1/incidents/:id/acknowledge', async (req, reply) => {
|
app.post('/api/v1/incidents/:id/acknowledge', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
@@ -62,7 +83,7 @@ export function registerIncidentRoutes(app: FastifyInstance) {
|
|||||||
return { status: 'acknowledged' };
|
return { status: 'acknowledged' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve incident
|
// Resolve incident (legacy route)
|
||||||
app.post('/api/v1/incidents/:id/resolve', async (req, reply) => {
|
app.post('/api/v1/incidents/:id/resolve', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
@@ -74,7 +95,7 @@ export function registerIncidentRoutes(app: FastifyInstance) {
|
|||||||
return { status: 'resolved' };
|
return { status: 'resolved' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Suppress incident (snooze)
|
// Suppress incident (legacy route)
|
||||||
app.post('/api/v1/incidents/:id/suppress', async (req, reply) => {
|
app.post('/api/v1/incidents/:id/suppress', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
@@ -86,7 +107,25 @@ export function registerIncidentRoutes(app: FastifyInstance) {
|
|||||||
return { status: 'suppressed' };
|
return { status: 'suppressed' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dashboard summary
|
// Dashboard summary (console route: GET /api/v1/incidents/summary)
|
||||||
|
app.get('/api/v1/incidents/summary', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const counts = await client.query(`
|
||||||
|
SELECT status, severity, COUNT(*)::int as count
|
||||||
|
FROM incidents
|
||||||
|
WHERE created_at > now() - interval '24 hours'
|
||||||
|
GROUP BY status, severity
|
||||||
|
`);
|
||||||
|
const total = await client.query(`SELECT COUNT(*)::int as total FROM incidents WHERE status = 'open'`);
|
||||||
|
return { breakdown: counts.rows, open_total: total.rows[0]?.total ?? 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dashboard summary (legacy route)
|
||||||
app.get('/api/v1/summary', async (req, reply) => {
|
app.get('/api/v1/summary', async (req, reply) => {
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { withTenant } from '../data/db.js';
|
|||||||
const logger = pino({ name: 'api-notifications' });
|
const logger = pino({ name: 'api-notifications' });
|
||||||
|
|
||||||
const notifConfigSchema = z.object({
|
const notifConfigSchema = z.object({
|
||||||
channel: z.enum(['slack', 'email', 'webhook']),
|
channel: z.enum(['slack', 'email', 'webhook']).optional(),
|
||||||
config: z.object({
|
config: z.object({
|
||||||
slack_webhook_url: z.string().url().optional(),
|
slack_webhook_url: z.string().url().optional(),
|
||||||
email_to: z.string().email().optional(),
|
email_to: z.string().email().optional(),
|
||||||
@@ -17,7 +17,39 @@ const notifConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function registerNotificationRoutes(app: FastifyInstance) {
|
export function registerNotificationRoutes(app: FastifyInstance) {
|
||||||
// List notification configs
|
// Get all notification configs (console route: GET /api/v1/notifications/config)
|
||||||
|
app.get('/api/v1/notifications/config', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
return client.query('SELECT * FROM notification_configs ORDER BY channel');
|
||||||
|
});
|
||||||
|
return { configs: result.rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all notification configs (console route: PUT /api/v1/notifications/config)
|
||||||
|
app.put('/api/v1/notifications/config', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const body = req.body as { configs?: Array<{ channel: string; config: any; min_severity?: string; enabled?: boolean }> };
|
||||||
|
|
||||||
|
if (body.configs && Array.isArray(body.configs)) {
|
||||||
|
await withTenant(tenantId, async (client) => {
|
||||||
|
for (const cfg of body.configs!) {
|
||||||
|
const parsed = notifConfigSchema.parse(cfg);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO notification_configs (tenant_id, channel, config, min_severity, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (tenant_id, channel) DO UPDATE SET config = $3, min_severity = $4, enabled = $5`,
|
||||||
|
[tenantId, cfg.channel, JSON.stringify(parsed.config), parsed.min_severity, parsed.enabled],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ tenantId }, 'Notification configs updated via bulk');
|
||||||
|
return { status: 'updated' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List notification configs (legacy route)
|
||||||
app.get('/api/v1/notifications', async (req, reply) => {
|
app.get('/api/v1/notifications', async (req, reply) => {
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
const result = await withTenant(tenantId, async (client) => {
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
@@ -26,7 +58,7 @@ export function registerNotificationRoutes(app: FastifyInstance) {
|
|||||||
return { configs: result.rows };
|
return { configs: result.rows };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upsert notification config
|
// Upsert notification config (legacy route)
|
||||||
app.put('/api/v1/notifications/:channel', async (req, reply) => {
|
app.put('/api/v1/notifications/:channel', async (req, reply) => {
|
||||||
const { channel } = req.params as { channel: string };
|
const { channel } = req.params as { channel: string };
|
||||||
const body = notifConfigSchema.parse({ ...req.body as any, channel });
|
const body = notifConfigSchema.parse({ ...req.body as any, channel });
|
||||||
|
|||||||
@@ -61,7 +61,44 @@ export function registerServiceRoutes(app: FastifyInstance) {
|
|||||||
return { service: result.rows[0] };
|
return { service: result.rows[0] };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create/update service (manual entry)
|
// Create service (console route: POST /api/v1/services)
|
||||||
|
app.post('/api/v1/services', async (req, reply) => {
|
||||||
|
const body = upsertServiceSchema.parse(req.body);
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
return client.query(
|
||||||
|
`INSERT INTO services (tenant_id, name, type, owner, owner_source, description, tier, lifecycle, links, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, 'config', $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send({ service: result.rows[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update service by id (console route: PUT /api/v1/services/:id)
|
||||||
|
app.put('/api/v1/services/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = upsertServiceSchema.parse(req.body);
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
return client.query(
|
||||||
|
`UPDATE services SET name = $1, type = $2, owner = $3, owner_source = 'config',
|
||||||
|
description = $4, tier = $5, lifecycle = $6, links = $7, tags = $8, updated_at = now()
|
||||||
|
WHERE id = $9 AND tenant_id = $10
|
||||||
|
RETURNING *`,
|
||||||
|
[body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags), id, tenantId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.rows[0]) return reply.status(404).send({ error: 'Not found' });
|
||||||
|
return { service: result.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create/update service via upsert (legacy route)
|
||||||
app.put('/api/v1/services', async (req, reply) => {
|
app.put('/api/v1/services', async (req, reply) => {
|
||||||
const body = upsertServiceSchema.parse(req.body);
|
const body = upsertServiceSchema.parse(req.body);
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|||||||
@@ -11,7 +11,31 @@ const listQuerySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function registerAnomalyRoutes(app: FastifyInstance) {
|
export function registerAnomalyRoutes(app: FastifyInstance) {
|
||||||
// List anomalies
|
// List anomalies (console route: GET /api/v1/cost/anomalies)
|
||||||
|
app.get('/api/v1/cost/anomalies', async (req, reply) => {
|
||||||
|
const query = listQuerySchema.parse(req.query);
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const offset = (query.page - 1) * query.limit;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
let sql = 'SELECT * FROM anomalies WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (query.status) { sql += ` AND status = $${idx++}`; params.push(query.status); }
|
||||||
|
if (query.account_id) { sql += ` AND account_id = $${idx++}`; params.push(query.account_id); }
|
||||||
|
if (query.min_score) { sql += ` AND score >= $${idx++}`; params.push(query.min_score); }
|
||||||
|
|
||||||
|
sql += ` ORDER BY detected_at DESC LIMIT $${idx++} OFFSET $${idx++}`;
|
||||||
|
params.push(query.limit, offset);
|
||||||
|
|
||||||
|
return client.query(sql, params);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { anomalies: result.rows, page: query.page, limit: query.limit };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List anomalies (legacy route)
|
||||||
app.get('/api/v1/anomalies', async (req, reply) => {
|
app.get('/api/v1/anomalies', async (req, reply) => {
|
||||||
const query = listQuerySchema.parse(req.query);
|
const query = listQuerySchema.parse(req.query);
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
@@ -35,7 +59,18 @@ export function registerAnomalyRoutes(app: FastifyInstance) {
|
|||||||
return { anomalies: result.rows, page: query.page, limit: query.limit };
|
return { anomalies: result.rows, page: query.page, limit: query.limit };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Acknowledge anomaly
|
// Acknowledge anomaly (console route: POST /api/v1/cost/anomalies/:id/acknowledge)
|
||||||
|
app.post('/api/v1/cost/anomalies/:id/acknowledge', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
await withTenant(tenantId, async (client) => {
|
||||||
|
await client.query("UPDATE anomalies SET status = 'acknowledged' WHERE id = $1 AND status = 'open'", [id]);
|
||||||
|
});
|
||||||
|
return { status: 'acknowledged' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Acknowledge anomaly (legacy route)
|
||||||
app.post('/api/v1/anomalies/:id/acknowledge', async (req, reply) => {
|
app.post('/api/v1/anomalies/:id/acknowledge', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
@@ -72,7 +107,33 @@ export function registerAnomalyRoutes(app: FastifyInstance) {
|
|||||||
return { status: 'expected' };
|
return { status: 'expected' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dashboard summary
|
// Cost summary (console route: GET /api/v1/cost/summary)
|
||||||
|
app.get('/api/v1/cost/summary', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const openCount = await client.query("SELECT COUNT(*)::int as count FROM anomalies WHERE status = 'open'");
|
||||||
|
const topResources = await client.query(`
|
||||||
|
SELECT resource_type, COUNT(*)::int as anomaly_count, AVG(score)::numeric(5,2) as avg_score
|
||||||
|
FROM anomalies WHERE status = 'open'
|
||||||
|
GROUP BY resource_type ORDER BY anomaly_count DESC LIMIT 10
|
||||||
|
`);
|
||||||
|
const recentTrend = await client.query(`
|
||||||
|
SELECT date_trunc('hour', detected_at) as hour, COUNT(*)::int as count
|
||||||
|
FROM anomalies WHERE detected_at > now() - interval '24 hours'
|
||||||
|
GROUP BY hour ORDER BY hour
|
||||||
|
`);
|
||||||
|
return {
|
||||||
|
open_anomalies: openCount.rows[0]?.count ?? 0,
|
||||||
|
top_resources: topResources.rows,
|
||||||
|
hourly_trend: recentTrend.rows,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dashboard summary (legacy route)
|
||||||
app.get('/api/v1/dashboard', async (req, reply) => {
|
app.get('/api/v1/dashboard', async (req, reply) => {
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,58 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { withTenant } from '../data/db.js';
|
import { withTenant } from '../data/db.js';
|
||||||
|
|
||||||
export function registerBaselineRoutes(app: FastifyInstance) {
|
export function registerBaselineRoutes(app: FastifyInstance) {
|
||||||
// List baselines
|
// List baselines (console route: GET /api/v1/cost/baselines)
|
||||||
|
app.get('/api/v1/cost/baselines', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
return client.query(`
|
||||||
|
SELECT id, account_id, resource_type, welford_count as sample_count,
|
||||||
|
welford_mean::numeric(12,4) as mean,
|
||||||
|
CASE WHEN welford_count > 1
|
||||||
|
THEN sqrt(welford_m2 / welford_count)::numeric(12,4)
|
||||||
|
ELSE 0 END as stddev,
|
||||||
|
updated_at
|
||||||
|
FROM baselines ORDER BY account_id, resource_type
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { baselines: result.rows };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update baseline by id (console route: PUT /api/v1/cost/baselines/:id)
|
||||||
|
app.put('/api/v1/cost/baselines/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const body = req.body as { welford_mean?: number; welford_m2?: number; welford_count?: number };
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const existing = await client.query('SELECT * FROM baselines WHERE id = $1', [id]);
|
||||||
|
if (!existing.rows[0]) return null;
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (body.welford_mean !== undefined) { updates.push(`welford_mean = $${idx++}`); params.push(body.welford_mean); }
|
||||||
|
if (body.welford_m2 !== undefined) { updates.push(`welford_m2 = $${idx++}`); params.push(body.welford_m2); }
|
||||||
|
if (body.welford_count !== undefined) { updates.push(`welford_count = $${idx++}`); params.push(body.welford_count); }
|
||||||
|
updates.push(`updated_at = now()`);
|
||||||
|
updates.push(`version = version + 1`);
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`UPDATE baselines SET ${updates.join(', ')} WHERE id = $${idx++} RETURNING *`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return reply.status(404).send({ error: 'Baseline not found' });
|
||||||
|
return { baseline: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
// List baselines (legacy route)
|
||||||
app.get('/api/v1/baselines', async (req, reply) => {
|
app.get('/api/v1/baselines', async (req, reply) => {
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
@@ -21,7 +72,7 @@ export function registerBaselineRoutes(app: FastifyInstance) {
|
|||||||
return { baselines: result.rows };
|
return { baselines: result.rows };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset baseline for a specific resource
|
// Reset baseline for a specific resource (legacy route)
|
||||||
app.delete('/api/v1/baselines/:accountId/:resourceType', async (req, reply) => {
|
app.delete('/api/v1/baselines/:accountId/:resourceType', async (req, reply) => {
|
||||||
const { accountId, resourceType } = req.params as { accountId: string; resourceType: string };
|
const { accountId, resourceType } = req.params as { accountId: string; resourceType: string };
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|||||||
@@ -1,11 +1,71 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import { withTenant } from '../data/db.js';
|
import { withTenant } from '../data/db.js';
|
||||||
import { GovernanceEngine } from '../governance/engine.js';
|
import { GovernanceEngine } from '../governance/engine.js';
|
||||||
|
|
||||||
const engine = new GovernanceEngine();
|
const engine = new GovernanceEngine();
|
||||||
|
|
||||||
|
const governanceRuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
rule_type: z.string(),
|
||||||
|
threshold: z.number().optional(),
|
||||||
|
action: z.string().default('alert'),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
config: z.record(z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
export function registerGovernanceRoutes(app: FastifyInstance) {
|
export function registerGovernanceRoutes(app: FastifyInstance) {
|
||||||
// Get governance status
|
// Get governance status (console route: GET /api/v1/cost/governance)
|
||||||
|
app.get('/api/v1/cost/governance', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
return client.query('SELECT governance_mode, governance_started_at FROM tenants WHERE id = $1', [tenantId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tenant = result.rows[0];
|
||||||
|
if (!tenant) return reply.status(404).send({ error: 'Tenant not found' });
|
||||||
|
|
||||||
|
const daysInMode = Math.floor((Date.now() - new Date(tenant.governance_started_at).getTime()) / (86400 * 1000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: tenant.governance_mode,
|
||||||
|
days_in_mode: daysInMode,
|
||||||
|
started_at: tenant.governance_started_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create governance rule (console route: POST /api/v1/cost/governance)
|
||||||
|
app.post('/api/v1/cost/governance', async (req, reply) => {
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const body = governanceRuleSchema.parse(req.body);
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO governance_rules (tenant_id, name, rule_type, threshold, action, enabled, config)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, body.name, body.rule_type, body.threshold, body.action, body.enabled, JSON.stringify(body.config)],
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send({ rule: result });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete governance rule (console route: DELETE /api/v1/cost/governance/:id)
|
||||||
|
app.delete('/api/v1/cost/governance/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
await withTenant(tenantId, async (client) => {
|
||||||
|
await client.query('DELETE FROM governance_rules WHERE id = $1 AND tenant_id = $2', [id, tenantId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'deleted', id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get governance status (legacy route)
|
||||||
app.get('/api/v1/governance', async (req, reply) => {
|
app.get('/api/v1/governance', async (req, reply) => {
|
||||||
const tenantId = (req as any).tenantId;
|
const tenantId = (req as any).tenantId;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,80 @@ export function registerApprovalRoutes(app: FastifyInstance) {
|
|||||||
return { approvals: result.rows };
|
return { approvals: result.rows };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Approve or reject a step
|
// Approve a step (console route: POST /api/v1/approvals/:id/approve)
|
||||||
|
app.post('/api/v1/approvals/:id/approve', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const reason = ((req.body as any)?.reason as string) ?? undefined;
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const entry = await client.query(
|
||||||
|
`SELECT ae.id, ae.execution_id, ae.status, e.runbook_id
|
||||||
|
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
||||||
|
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry.rows[0]) return null;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE audit_entries SET status = 'approved', approved_by = $1, approval_method = 'api'
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return entry.rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return reply.status(404).send({ error: 'Pending approval not found' });
|
||||||
|
|
||||||
|
await bridge.sendApproval(tenantId, result.execution_id, id, 'approve');
|
||||||
|
|
||||||
|
logger.info({ stepId: id, decision: 'approve', userId }, 'Approval decision recorded');
|
||||||
|
return { step_id: id, decision: 'approve', reason };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reject a step (console route: POST /api/v1/approvals/:id/reject)
|
||||||
|
app.post('/api/v1/approvals/:id/reject', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const reason = ((req.body as any)?.reason as string) ?? undefined;
|
||||||
|
const tenantId = (req as any).tenantId;
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
|
const entry = await client.query(
|
||||||
|
`SELECT ae.id, ae.execution_id, ae.status, e.runbook_id
|
||||||
|
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
||||||
|
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry.rows[0]) return null;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE audit_entries SET status = 'rejected', approved_by = $1, approval_method = 'api'
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`,
|
||||||
|
[entry.rows[0].execution_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return entry.rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return reply.status(404).send({ error: 'Pending approval not found' });
|
||||||
|
|
||||||
|
await bridge.sendApproval(tenantId, result.execution_id, id, 'reject');
|
||||||
|
|
||||||
|
logger.info({ stepId: id, decision: 'reject', userId }, 'Approval decision recorded');
|
||||||
|
return { step_id: id, decision: 'reject', reason };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve or reject a step (legacy route)
|
||||||
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
|
app.post('/api/v1/approvals/:stepId', async (req, reply) => {
|
||||||
const { stepId } = req.params as { stepId: string };
|
const { stepId } = req.params as { stepId: string };
|
||||||
const body = approvalDecisionSchema.parse(req.body);
|
const body = approvalDecisionSchema.parse(req.body);
|
||||||
@@ -41,7 +114,6 @@ export function registerApprovalRoutes(app: FastifyInstance) {
|
|||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
const result = await withTenant(tenantId, async (client) => {
|
const result = await withTenant(tenantId, async (client) => {
|
||||||
// Get the audit entry and its execution
|
|
||||||
const entry = await client.query(
|
const entry = await client.query(
|
||||||
`SELECT ae.id, ae.execution_id, ae.status, e.runbook_id
|
`SELECT ae.id, ae.execution_id, ae.status, e.runbook_id
|
||||||
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
||||||
@@ -59,7 +131,6 @@ export function registerApprovalRoutes(app: FastifyInstance) {
|
|||||||
[newStatus, userId, stepId],
|
[newStatus, userId, stepId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// If rejected, abort the execution
|
|
||||||
if (body.decision === 'reject') {
|
if (body.decision === 'reject') {
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`,
|
`UPDATE executions SET status = 'aborted', completed_at = now() WHERE id = $1`,
|
||||||
@@ -72,7 +143,6 @@ export function registerApprovalRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (!result) return reply.status(404).send({ error: 'Pending approval not found' });
|
if (!result) return reply.status(404).send({ error: 'Pending approval not found' });
|
||||||
|
|
||||||
// Notify agent via Redis pub/sub
|
|
||||||
await bridge.sendApproval(tenantId, result.execution_id, stepId, body.decision);
|
await bridge.sendApproval(tenantId, result.execution_id, stepId, body.decision);
|
||||||
|
|
||||||
logger.info({ stepId, decision: body.decision, userId }, 'Approval decision recorded');
|
logger.info({ stepId, decision: body.decision, userId }, 'Approval decision recorded');
|
||||||
|
|||||||
Reference in New Issue
Block a user