Files
Anand fadc1f24e3 Wave 3 (3/3): scheduled fleet health digests + Terraform/Ansible export
- internal/digest: fleet summary builder + scheduler, delivered via the existing notify.Notifier (SMTP/Gotify), admin settings + send-now endpoint
- internal/export: Terraform (proxmox_vm_qemu/proxmox_lxc) and Ansible YAML inventory generators from live fleet inventory, downloadable via /connections/{id}/export/{terraform,ansible}
- Migration renumbered 00031 to avoid colliding with 00026-00030 already on this branch
2026-09-10 15:08:22 +05:30

171 lines
5.3 KiB
Go

package api
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
"ferrum/internal/export"
"ferrum/internal/pve"
)
// exportFanoutLimit bounds how many per-guest GuestConfig (and, for the
// Ansible export, guest-agent network) calls run concurrently — a large
// fleet can have hundreds of guests, and this is a one-shot admin action,
// not a poll tick, so it can afford real concurrency without the tighter
// caps used elsewhere.
const exportFanoutLimit = 16
// gatherExportGuests fetches the connection's live inventory (like
// overview.go) plus each guest's full config (like getGuestConfig), and
// hands both to internal/export as a []export.Guest — the shape that
// package's generators actually consume. withAgentIPs additionally queries
// each running guest's agent-reported IP for the Ansible export; skipped for
// Terraform, which has no use for it and would otherwise pay that cost for
// nothing.
func (s *Server) gatherExportGuests(ctx context.Context, connID string, withAgentIPs bool) ([]export.Guest, error) {
client, err := s.clientFor(ctx, connID)
if err != nil {
return nil, err
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return nil, err
}
var guestResources []pve.ClusterResource
for _, res := range resources {
if (res.Type == "qemu" || res.Type == "lxc") && res.Template != 1 {
guestResources = append(guestResources, res)
}
}
guests := make([]export.Guest, len(guestResources))
var wg sync.WaitGroup
sem := make(chan struct{}, exportFanoutLimit)
for i, res := range guestResources {
wg.Add(1)
sem <- struct{}{}
go func(i int, res pve.ClusterResource) {
defer wg.Done()
defer func() { <-sem }()
g := export.Guest{Resource: res}
if cfg, err := client.GuestConfig(ctx, res.Type, res.Node, res.VMID); err == nil {
g.Config = cfg
}
if withAgentIPs && res.Status == "running" {
g.IP = firstAgentIP(ctx, client, res)
}
guests[i] = g
}(i, res)
}
wg.Wait()
return guests, nil
}
// firstAgentIP best-effort resolves a guest's live IP for the Ansible
// export. Any failure (agent not installed/running for QEMU, container
// stopped, etc) just means no IP — never surfaced as an export-blocking
// error, since most fleets have guests without the agent enabled.
func firstAgentIP(ctx context.Context, client *pve.Client, res pve.ClusterResource) string {
var ifaces []pve.AgentNetworkInterface
var err error
if res.Type == "qemu" {
ifaces, err = client.GuestAgentNetworkInterfaces(ctx, res.Node, res.VMID)
} else {
ifaces, err = client.LXCInterfaces(ctx, res.Node, res.VMID)
}
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Name == "lo" {
continue
}
for _, ip := range iface.IPAddresses {
if ip != "" {
return ip
}
}
}
return ""
}
func (s *Server) connectionName(ctx context.Context, id string) string {
var name string
if err := s.db.QueryRowContext(ctx, `SELECT name FROM connections WHERE id = ?`, id).Scan(&name); err != nil {
return id
}
return name
}
func writeDownload(w http.ResponseWriter, filename, contentType, body string) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
// exportTerraform generates Terraform HCL (Telmate/proxmox provider shapes)
// for the connection's current live inventory. Admin-only and audited —
// exporting the full guest inventory (hardware sizing, network layout,
// storage placement) is sensitive infrastructure detail.
func (s *Server) exportTerraform(w http.ResponseWriter, r *http.Request) {
connID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
guests, err := s.gatherExportGuests(ctx, connID, false)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
name := s.connectionName(ctx, connID)
body := export.GenerateTerraform(name, guests)
s.audit(r, "export.terraform", "connection", connID)
writeDownload(w, fmt.Sprintf("%s.tf", slugForFilename(name)), "text/plain; charset=utf-8", body)
}
// exportAnsible generates a YAML Ansible inventory for the connection's
// current live inventory. Admin-only and audited, same rationale as
// exportTerraform.
func (s *Server) exportAnsible(w http.ResponseWriter, r *http.Request) {
connID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
guests, err := s.gatherExportGuests(ctx, connID, true)
if err != nil {
s.writeError(w, http.StatusBadGateway, err)
return
}
name := s.connectionName(ctx, connID)
body := export.GenerateAnsibleInventory(name, guests)
s.audit(r, "export.ansible", "connection", connID)
writeDownload(w, fmt.Sprintf("%s-inventory.yml", slugForFilename(name)), "application/x-yaml; charset=utf-8", body)
}
// slugForFilename is a minimal, dependency-free filename-safe transform —
// good enough for a downloaded file's name, not meant to be a general slug
// utility.
func slugForFilename(name string) string {
out := make([]rune, 0, len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
out = append(out, r)
case r == ' ':
out = append(out, '-')
}
}
if len(out) == 0 {
return "export"
}
return string(out)
}