Files
Anand 542a9b2eb5 Wave 3 (2/3): config drift detection + guest lifecycle policies
- internal/api/drift.go: per-guest config/firewall baseline capture + diff, fleet-wide drift summary
- internal/poller/lifecycle.go + internal/api/lifecycle.go: snapshot retention sweep (tag or fleet default, dry-run until explicitly enforced) and orphaned-disk detection (report-only, surfaced as alerts)
- Migrations renumbered 00029/00030 to avoid colliding with 00026-00028 already on this branch
2026-09-10 15:04:30 +05:30

166 lines
5.1 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
)
// lifecycleRow is the single (id=1) lifecycle_settings row — the fleet-wide
// snapshot retention window and whether the retention sweep (internal/poller)
// is allowed to actually delete anything yet. Same single-row pattern as
// system_settings/security_settings.
type lifecycleRow struct {
retentionDays int
enforce bool
}
func defaultLifecycleRow() lifecycleRow {
return lifecycleRow{} // retentionDays: 0 (no fleet-wide default), enforce: false (dry-run only)
}
func (s *Server) loadLifecycleRow(ctx context.Context) (lifecycleRow, error) {
row := defaultLifecycleRow()
var enforce int
err := s.db.QueryRowContext(ctx, `SELECT retention_days, enforce FROM lifecycle_settings WHERE id = 1`).
Scan(&row.retentionDays, &enforce)
if err == sql.ErrNoRows {
return defaultLifecycleRow(), nil
}
if err != nil {
return lifecycleRow{}, err
}
row.enforce = enforce == 1
return row, nil
}
func (s *Server) saveLifecycleRow(ctx context.Context, row lifecycleRow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO lifecycle_settings (id, retention_days, enforce, updated_at)
VALUES (1, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET retention_days = excluded.retention_days, enforce = excluded.enforce, updated_at = excluded.updated_at`,
row.retentionDays, boolToInt(row.enforce), time.Now().UTC().Format(time.RFC3339))
return err
}
type lifecycleSettingsResponse struct {
RetentionDays int `json:"retentionDays"`
Enforce bool `json:"enforce"`
}
func toLifecycleSettingsResponse(row lifecycleRow) lifecycleSettingsResponse {
return lifecycleSettingsResponse{RetentionDays: row.retentionDays, Enforce: row.enforce}
}
func (s *Server) getLifecycleSettings(w http.ResponseWriter, r *http.Request) {
row, err := s.loadLifecycleRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, toLifecycleSettingsResponse(row))
}
type lifecycleSettingsPatch struct {
RetentionDays *int `json:"retentionDays"`
Enforce *bool `json:"enforce"`
}
func (s *Server) putLifecycleSettings(w http.ResponseWriter, r *http.Request) {
var patch lifecycleSettingsPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
return
}
row, err := s.loadLifecycleRow(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if patch.RetentionDays != nil {
if *patch.RetentionDays < 0 || *patch.RetentionDays > 3650 {
writeErrorMsg(w, http.StatusBadRequest, "retention window must be between 0 (disabled) and 3650 days")
return
}
row.retentionDays = *patch.RetentionDays
}
if patch.Enforce != nil {
row.enforce = *patch.Enforce
}
if err := s.saveLifecycleRow(r.Context(), row); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "settings.lifecycle", "settings", "updated")
writeJSON(w, http.StatusOK, toLifecycleSettingsResponse(row))
}
// --- Action log ---
type lifecycleActionDTO struct {
ID string `json:"id"`
ConnectionID string `json:"connectionId"`
ConnectionName string `json:"connectionName"`
GuestType string `json:"guestType"`
Node string `json:"node"`
VMID int `json:"vmid"`
GuestName string `json:"guestName"`
Action string `json:"action"`
Detail string `json:"detail"`
DryRun bool `json:"dryRun"`
Status string `json:"status"`
Error *string `json:"error,omitempty"`
CreatedAt string `json:"createdAt"`
}
// lifecycleActions lists the retention sweep's action log, most recent
// first — simple limit/offset pagination is enough here, there's no need for
// cursor pagination on an internal audit trail like this.
func (s *Server) lifecycleActions(w http.ResponseWriter, r *http.Request) {
limit := 50
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
rows, err := s.db.QueryContext(r.Context(), `
SELECT id, connection_id, connection_name, guest_type, node, vmid, guest_name, action, detail, dry_run, status, error, created_at
FROM lifecycle_actions ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
defer rows.Close()
out := []lifecycleActionDTO{}
for rows.Next() {
var a lifecycleActionDTO
var dryRun int
if err := rows.Scan(&a.ID, &a.ConnectionID, &a.ConnectionName, &a.GuestType, &a.Node, &a.VMID, &a.GuestName,
&a.Action, &a.Detail, &dryRun, &a.Status, &a.Error, &a.CreatedAt); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
a.DryRun = dryRun == 1
out = append(out, a)
}
if err := rows.Err(); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, out)
}