import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { DatabaseService } from '../services/DatabaseService'; import { authMiddleware } from '../middleware/auth'; import { requirePermission } from '../middleware/permissions'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; const AutoHealPolicyCreateSchema = z.object({ stack_name: z.string().min(1).max(255), service_name: z.string().min(1).max(255).nullable().optional(), unhealthy_duration_mins: z.coerce.number().int().min(1).max(1440), cooldown_mins: z.coerce.number().int().min(1).max(1440).default(5), max_restarts_per_hour: z.coerce.number().int().min(1).max(60).default(3), auto_disable_after_failures: z.coerce.number().int().min(1).max(100).default(5), }); const AutoHealPolicyUpdateSchema = AutoHealPolicyCreateSchema .partial() .omit({ stack_name: true }) .extend({ enabled: z.coerce.number().int().min(0).max(1).optional(), }); export const autoHealRouter = Router(); const PROXY_ENTITLEMENT_LEASE_MS = 5 * 60_000; function proxyEntitlementUntil(req: Request): number { return req.proxyTier === 'paid' ? Date.now() + PROXY_ENTITLEMENT_LEASE_MS : 0; } autoHealRouter.get('/policies', authMiddleware, (req: Request, res: Response): void => { const stackName = typeof req.query.stackName === 'string' ? req.query.stackName : undefined; if (stackName) { if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; } else { if (!requirePermission(req, res, 'stack:read')) return; } try { const db = DatabaseService.getInstance(); const policies = db.getAutoHealPolicies(stackName, req.nodeId); const leaseUntil = proxyEntitlementUntil(req); if (leaseUntil > 0) { for (const policy of policies) { if (policy.id !== undefined) db.updateAutoHealPolicy(policy.id, { proxy_entitled_until: leaseUntil }); } res.json(db.getAutoHealPolicies(stackName, req.nodeId)); return; } res.json(policies); } catch (err) { console.error('[AutoHeal] Failed to list policies:', getErrorMessage(err, 'unknown')); res.status(500).json({ error: 'Internal server error' }); } }); autoHealRouter.post('/policies', authMiddleware, (req: Request, res: Response): void => { const parsed = AutoHealPolicyCreateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }); return; } if (!requirePermission(req, res, 'stack:edit', 'stack', parsed.data.stack_name)) return; const { stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures } = parsed.data; const now = Date.now(); try { const policy = DatabaseService.getInstance().addAutoHealPolicy({ node_id: req.nodeId, proxy_entitled_until: proxyEntitlementUntil(req), stack_name, service_name: service_name ?? null, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures, enabled: 1, consecutive_failures: 0, last_fired_at: 0, created_at: now, updated_at: now, }); res.status(201).json(policy); } catch (err) { console.error('[AutoHeal] Failed to create policy:', getErrorMessage(err, 'unknown')); res.status(500).json({ error: 'Internal server error' }); } }); autoHealRouter.patch('/policies/:id', authMiddleware, (req: Request, res: Response): void => { const id = parseIntParam(req, res, 'id'); if (id === null) return; const parsed = AutoHealPolicyUpdateSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }); return; } try { const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.updateAutoHealPolicy(id, { ...parsed.data, proxy_entitled_until: Math.max(policy.proxy_entitled_until, proxyEntitlementUntil(req)) }); res.json(db.getAutoHealPolicy(id)); } catch (err) { console.error('[AutoHeal] Failed to update policy:', getErrorMessage(err, 'unknown')); res.status(500).json({ error: 'Internal server error' }); } }); autoHealRouter.delete('/policies/:id', authMiddleware, (req: Request, res: Response): void => { const id = parseIntParam(req, res, 'id'); if (id === null) return; try { const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } if (!requirePermission(req, res, 'stack:edit', 'stack', policy.stack_name)) return; db.deleteAutoHealPolicy(id); res.json({ success: true }); } catch (err) { console.error('[AutoHeal] Failed to delete policy:', getErrorMessage(err, 'unknown')); res.status(500).json({ error: 'Internal server error' }); } }); autoHealRouter.get('/policies/:id/history', authMiddleware, (req: Request, res: Response): void => { const id = parseIntParam(req, res, 'id'); if (id === null) return; const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 100); try { const db = DatabaseService.getInstance(); const policy = db.getAutoHealPolicy(id); if (!policy || policy.node_id !== req.nodeId) { res.status(404).json({ error: 'Policy not found' }); return; } if (!requirePermission(req, res, 'stack:read', 'stack', policy.stack_name)) return; res.json(db.getAutoHealHistory(id, limit)); } catch (err) { console.error('[AutoHeal] Failed to fetch history:', getErrorMessage(err, 'unknown')); res.status(500).json({ error: 'Internal server error' }); } });