package adapter import ( "context" "fmt" "net/url" "sort" "strings" "pve-confsync/internal/model" "pve-confsync/internal/pve" ) // ---- zones: /cluster/sdn/zones ---- // Zone types handled in v1. EVPN, simple, QinQ, faucet are left aside (warning). var supportedZoneTypes = map[string]bool{"vlan": true, "vxlan": true} // Zone fields that describe the local cluster (bridges, nodes, underlay, DNS/IPAM // plugins defined per cluster): taken from the client mapping, never copied. var zoneLocalFields = map[string]bool{ "nodes": true, "bridge": true, "peers": true, "fabric": true, "dns": true, "reversedns": true, "dnszone": true, } type zone struct{} func (zone) Kind() model.Kind { return model.KindZone } func (zone) SDN() bool { return true } func (zone) Singleton() bool { return false } func (zone) List(ctx context.Context, c *pve.Client, mode ReadMode, _ []model.Object) ([]model.Object, []string, error) { var raw []map[string]any if err := c.Get(ctx, "cluster/sdn/zones", modeQuery(mode), &raw); err != nil { return nil, nil, err } var objs []model.Object var warns []string for _, r := range raw { id := pve.Str(r["zone"]) f := normalizeAPI(r, "zone") if t, _ := f["type"].(string); !supportedZoneTypes[t] { warns = append(warns, fmt.Sprintf("zone %s: type %q not handled in v1, skipped", id, t)) continue } zone{}.Canonicalize(f) objs = append(objs, model.Object{Kind: model.KindZone, ID: id, Fields: f}) } return objs, warns, nil } func (zone) DefaultPolicy(f string) Policy { if zoneLocalFields[f] { return Override } return Sync } func (zone) Canonicalize(f map[string]any) { sortList(f, "nodes", "peers") } func (zone) Forbidden(string) bool { return false } func (zone) Immutable(f string) bool { return f == "type" } func (zone) Required(o model.Object) []string { if o.Fields["type"] == "vlan" { return []string{"type", "bridge"} } return []string{"type"} } func (zone) Create(ctx context.Context, c *pve.Client, o model.Object, lock string) error { f := form(o.Fields, nil, lock) f.Set("zone", o.ID) return c.Post(ctx, "cluster/sdn/zones", f, nil) } func (zone) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, lock string) error { return c.Put(ctx, "cluster/sdn/zones/"+url.PathEscape(t.ID), form(without(set, "type"), unset, lock), nil) } func (zone) Delete(ctx context.Context, c *pve.Client, o model.Object, lock string) error { return c.Delete(ctx, "cluster/sdn/zones/"+url.PathEscape(o.ID), form(nil, nil, lock), nil) } // ---- vnets: /cluster/sdn/vnets ---- type vnet struct{} func (vnet) Kind() model.Kind { return model.KindVNet } func (vnet) SDN() bool { return true } func (vnet) Singleton() bool { return false } func (vnet) List(ctx context.Context, c *pve.Client, mode ReadMode, known []model.Object) ([]model.Object, []string, error) { var raw []map[string]any if err := c.Get(ctx, "cluster/sdn/vnets", modeQuery(mode), &raw); err != nil { return nil, nil, err } zones := ids(known, model.KindZone) var objs []model.Object var warns []string for _, r := range raw { id := pve.Str(r["vnet"]) f := normalizeAPI(r, "vnet", "type") if z, _ := f["zone"].(string); !zones[z] { warns = append(warns, fmt.Sprintf("vnet %s: zone %q not handled, skipped", id, z)) continue } objs = append(objs, model.Object{Kind: model.KindVNet, ID: id, Fields: f}) } return objs, warns, nil } func (vnet) DefaultPolicy(string) Policy { return Sync } func (vnet) Canonicalize(map[string]any) {} func (vnet) Forbidden(string) bool { return false } func (vnet) Immutable(string) bool { return false } func (vnet) Required(model.Object) []string { return []string{"zone"} } func (vnet) Create(ctx context.Context, c *pve.Client, o model.Object, lock string) error { f := form(o.Fields, nil, lock) f.Set("vnet", o.ID) return c.Post(ctx, "cluster/sdn/vnets", f, nil) } func (vnet) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, lock string) error { return c.Put(ctx, "cluster/sdn/vnets/"+url.PathEscape(t.ID), form(set, unset, lock), nil) } func (vnet) Delete(ctx context.Context, c *pve.Client, o model.Object, lock string) error { return c.Delete(ctx, "cluster/sdn/vnets/"+url.PathEscape(o.ID), form(nil, nil, lock), nil) } // ---- subnets: /cluster/sdn/vnets/{vnet}/subnets ---- // The subnet id is "-
-" (SubnetPlugin.pm parse_subnet_id), // the CIDR is given as "subnet" on creation only. type subnet struct{} func (subnet) Kind() model.Kind { return model.KindSubnet } func (subnet) SDN() bool { return true } func (subnet) Singleton() bool { return false } func (subnet) List(ctx context.Context, c *pve.Client, mode ReadMode, known []model.Object) ([]model.Object, []string, error) { var objs []model.Object for v := range ids(known, model.KindVNet) { var raw []map[string]any if err := c.Get(ctx, "cluster/sdn/vnets/"+url.PathEscape(v)+"/subnets", modeQuery(mode), &raw); err != nil { return nil, nil, err } for _, r := range raw { id := pve.Str(r["subnet"]) // computed by PVE from the id f := normalizeAPI(r, "subnet", "type", "cidr", "network", "mask", "zone", "id") if f["vnet"] == nil { f["vnet"] = v } subnet{}.Canonicalize(f) objs = append(objs, model.Object{Kind: model.KindSubnet, ID: id, Fields: f}) } } return objs, nil, nil } func (subnet) DefaultPolicy(string) Policy { return Sync } func (subnet) Forbidden(string) bool { return false } func (subnet) Immutable(f string) bool { return f == "vnet" } func (subnet) Required(model.Object) []string { return []string{"vnet"} } // Canonicalize: dhcp-range is an array in the schema, even with a single range. func (subnet) Canonicalize(f map[string]any) { if s, ok := f["dhcp-range"].(string); ok { f["dhcp-range"] = []string{s} } } // SubnetCIDR derives the CIDR from a subnet id ("zone-10.0.0.0-24" -> "10.0.0.0/24"). func SubnetCIDR(id string) (string, error) { i := strings.LastIndex(id, "-") j := strings.Index(id, "-") if i <= 0 || j == i { return "", fmt.Errorf("invalid subnet id %q", id) } return id[j+1:i] + "/" + id[i+1:], nil } func (subnet) Create(ctx context.Context, c *pve.Client, o model.Object, lock string) error { cidr, err := SubnetCIDR(o.ID) if err != nil { return err } v, _ := o.Fields["vnet"].(string) f := form(without(o.Fields, "vnet"), nil, lock) f.Set("subnet", cidr) f.Set("type", "subnet") return c.Post(ctx, "cluster/sdn/vnets/"+url.PathEscape(v)+"/subnets", f, nil) } func (subnet) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, lock string) error { v, _ := t.Fields["vnet"].(string) return c.Put(ctx, "cluster/sdn/vnets/"+url.PathEscape(v)+"/subnets/"+url.PathEscape(t.ID), form(without(set, "vnet"), unset, lock), nil) } func (subnet) Delete(ctx context.Context, c *pve.Client, o model.Object, lock string) error { v, _ := o.Fields["vnet"].(string) return c.Delete(ctx, "cluster/sdn/vnets/"+url.PathEscape(v)+"/subnets/"+url.PathEscape(o.ID), form(nil, nil, lock), nil) } // ---- helpers ---- func ids(objs []model.Object, k model.Kind) map[string]bool { m := map[string]bool{} for _, o := range objs { if o.Kind == k { m[o.ID] = true } } return m } func without(m map[string]any, keys ...string) map[string]any { out := make(map[string]any, len(m)) for k, v := range m { out[k] = v } for _, k := range keys { delete(out, k) } return out } // sortList canonicalizes comma separated lists whose order is irrelevant. func sortList(f map[string]any, keys ...string) { for _, k := range keys { s, ok := f[k].(string) if !ok || s == "" { continue } l := strings.Split(s, ",") for i := range l { l[i] = strings.TrimSpace(l[i]) } sort.Strings(l) f[k] = strings.Join(l, ",") } }