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

156 lines
4.7 KiB
Go

// Package export generates infrastructure-as-code (Terraform HCL, Ansible
// inventory YAML) from a live fleet's current inventory. It takes the guest
// data as input rather than fetching it — callers (internal/api/export.go)
// already have it from pve.ClusterResource and pve.GuestConfig calls made
// elsewhere, and this package stays a pure text generator so it's trivially
// unit-testable without a PVE client.
package export
import (
"fmt"
"regexp"
"strings"
"ferrum/internal/pve"
)
// Guest is one VM/LXC to render, combining the fleet inventory row with its
// full config.
type Guest struct {
Resource pve.ClusterResource // Type ("qemu"|"lxc"), Node, VMID, Name, Status, Tags, ...
Config *pve.GuestConfig // nil is tolerated — the guest still gets a minimal block
// IP is the guest's live IP address as last reported by the QEMU/LXC
// guest agent, when known. Empty when the agent isn't installed/running
// or was never queried — that's a normal, expected state, not an error;
// callers that want it populate it via GuestAgentNetworkInterfaces
// before calling into this package.
IP string
}
var nonSlugChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
// slugify turns a guest name into a safe identifier for both a Terraform
// resource label and an Ansible inventory hostname.
func slugify(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = nonSlugChars.ReplaceAllString(s, "-")
s = strings.Trim(s, "-_")
if s == "" {
return "guest"
}
return s
}
// splitTags parses Proxmox's guest tag string. The web UI joins tags with
// ";" but older API responses and hand-edited configs sometimes use ",", so
// both are accepted.
func splitTags(raw string) []string {
if raw == "" {
return nil
}
fields := strings.FieldsFunc(raw, func(r rune) bool { return r == ';' || r == ',' })
out := make([]string, 0, len(fields))
for _, f := range fields {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
// parseCSVKV parses Proxmox's comma-separated "key=value,key=value" config
// line syntax (disk/net/rootfs/mp values) into a map. Non-empty segments
// without an "=" are ignored — none of what this package reads out of these
// lines needs them.
func parseCSVKV(value string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(value, ",") {
if part == "" {
continue
}
if k, v, ok := strings.Cut(part, "="); ok {
out[k] = v
}
}
return out
}
// parseDiskValue splits a disk/rootfs config value's leading
// "storage:volume" segment from its "size=" attribute, e.g.
// "local-lvm:vm-100-disk-0,size=32G,ssd=1" -> ("local-lvm", "32G").
func parseDiskValue(value string) (storage, size string) {
parts := strings.Split(value, ",")
if len(parts) > 0 {
if before, _, ok := strings.Cut(parts[0], ":"); ok {
storage = before
}
}
if len(parts) > 1 {
size = parseCSVKV(strings.Join(parts[1:], ","))["size"]
}
return storage, size
}
// firstDisk picks the guest's primary disk — the first disk device that
// isn't an LXC secondary mountpoint ("mpN") — and returns its storage and
// size. ("", "") when the guest has no disk devices at all.
func firstDisk(disks []pve.DiskDevice) (storage, size string) {
for _, d := range disks {
if strings.HasPrefix(d.Key, "mp") {
continue
}
return parseDiskValue(d.Value)
}
return "", ""
}
// rootfsDisk returns the LXC rootfs device, if the guest has one.
func rootfsDisk(disks []pve.DiskDevice) (storage, size string, ok bool) {
for _, d := range disks {
if d.Key == "rootfs" {
storage, size = parseDiskValue(d.Value)
return storage, size, true
}
}
return "", "", false
}
var qemuNetModels = []string{"virtio", "e1000", "e1000e", "rtl8139", "vmxnet3", "ne2k_pci", "pcnet"}
// parseQemuNet extracts the NIC model, bridge, and VLAN tag from a QEMU
// "netN" config value, e.g. "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=10".
func parseQemuNet(value string) (model, bridge, tag string) {
kv := parseCSVKV(value)
bridge = kv["bridge"]
tag = kv["tag"]
for _, m := range qemuNetModels {
if _, ok := kv[m]; ok {
model = m
break
}
}
if model == "" {
model = "virtio"
}
return model, bridge, tag
}
// parseLXCNet extracts the interface name, bridge, and static/DHCP IP from
// an LXC "netN" config value, e.g. "name=eth0,bridge=vmbr0,ip=dhcp".
func parseLXCNet(value string) (name, bridge, ip string) {
kv := parseCSVKV(value)
return kv["name"], kv["bridge"], kv["ip"]
}
// primaryNet returns the guest's first network device, if any.
func primaryNet(nets []pve.NetDevice) (pve.NetDevice, bool) {
if len(nets) == 0 {
return pve.NetDevice{}, false
}
return nets[0], true
}
func quote(s string) string {
return fmt.Sprintf("%q", s)
}