mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
fadc1f24e3
- 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
171 lines
5.3 KiB
Go
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)
|
|
}
|