Phase 0 : adaptateurs options datacenter et SDN (zones, vnets, subnets)

This commit is contained in:
claude Bot committed 2026-10-13 14:48:20 +02:00
1 parent 69c340e4c3
commit 471df91646
3 files changed
+513

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
// Package adapter maps each object kind to its PVE API endpoints and knows
// which fields are computed by PVE, which are specific to a cluster and how a
// canonical value is written back. Paths and schemas: see docs/api-pve.md.
package adapter
import (
"context"
"fmt"
"net/url"
"sort"
"strings"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
)
// Policy of one field.
type Policy string
const (
Sync Policy = "sync" // the reference value is copied to the clients
Ignore Policy = "ignore" // never read nor written
Override Policy = "override" // value comes from the client mapping only
)
// ReadMode chooses which SDN configuration is read: the applied one (reference)
// or the current one including pending changes (clients, which are written).
type ReadMode int
const (
ReadCurrent ReadMode = iota
ReadRunning
)
// Adapter handles one object kind.
type Adapter interface {
Kind() model.Kind
// SDN reports whether writes must go through the SDN lock and reload.
SDN() bool
// List reads and normalizes every object of this kind. known holds the objects
// already read for previous kinds (subnets need the vnets).
List(ctx context.Context, c *pve.Client, mode ReadMode, known []model.Object) ([]model.Object, []string, error)
// DefaultPolicy is the built-in policy of a field when the configuration says nothing.
DefaultPolicy(field string) Policy
// Forbidden fields are never synchronized, whatever the configuration says.
Forbidden(field string) bool
// Required lists the fields an object must have to be created.
Required(obj model.Object) []string
// Immutable fields cannot be changed by an update (re-creation needed).
Immutable(field string) bool
// Canonicalize puts user supplied values (mapping, YAML) in the canonical form of List.
Canonicalize(fields map[string]any)
// Singleton kinds have exactly one object that is never created nor deleted.
Singleton() bool
Create(ctx context.Context, c *pve.Client, obj model.Object, lock string) error
// Update changes fields of an existing object (target carries the id and current fields).
Update(ctx context.Context, c *pve.Client, target model.Object, set map[string]any, unset []string, lock string) error
Delete(ctx context.Context, c *pve.Client, obj model.Object, lock string) error
}
// All returns the adapters in dependency order.
func All() []Adapter {
return []Adapter{datacenter{}, zone{}, vnet{}, subnet{}}
}
// ByKind returns the adapter of a kind.
func ByKind(k model.Kind) (Adapter, error) {
for _, a := range All() {
if a.Kind() == k {
return a, nil
}
}
return nil, fmt.Errorf("unknown kind %q", k)
}
// canon converts one decoded API value into its canonical form:
// objects become property strings with sorted keys, lists sorted []string.
func canon(v any) any {
switch t := v.(type) {
case map[string]any:
return propertyString(t)
case []any:
l := make([]string, 0, len(t))
for _, e := range t {
if m, ok := e.(map[string]any); ok {
l = append(l, propertyString(m))
} else {
l = append(l, pve.Str(e))
}
}
sort.Strings(l)
return l
default:
return pve.Str(v)
}
}
// propertyString prints a PVE property string ("k1=v1,k2=v2"), keys sorted.
// PVE parses property strings independently of the key order.
func propertyString(m map[string]any) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
v := m[k]
if l, ok := v.([]any); ok {
s := make([]string, 0, len(l))
for _, e := range l {
s = append(s, pve.Str(e))
}
sort.Strings(s)
parts = append(parts, k+"="+strings.Join(s, ";"))
continue
}
parts = append(parts, k+"="+pve.Str(v))
}
return strings.Join(parts, ",")
}
// normalizeAPI builds the canonical field map of an API object, without the given keys.
func normalizeAPI(raw map[string]any, drop ...string) map[string]any {
skip := map[string]bool{"digest": true, "state": true, "pending": true}
for _, d := range drop {
skip[d] = true
}
out := map[string]any{}
for k, v := range raw {
if skip[k] || v == nil {
continue
}
out[k] = canon(v)
}
return out
}
// form encodes canonical values for a write: lists become repeated parameters.
func form(set map[string]any, unset []string, lock string) url.Values {
f := url.Values{}
for k, v := range set {
switch t := v.(type) {
case []string:
for _, e := range t {
f.Add(k, e)
}
default:
f.Set(k, fmt.Sprint(t))
}
}
if len(unset) > 0 {
u := append([]string(nil), unset...)
sort.Strings(u)
f.Set("delete", strings.Join(u, ","))
}
if lock != "" {
f.Set("lock-token", lock)
}
return f
}
func modeQuery(mode ReadMode) url.Values {
if mode == ReadRunning {
return url.Values{"running": {"1"}}
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package adapter
import (
"context"
"errors"
"strings"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
)
// DatacenterID is the id of the single datacenter options object.
const DatacenterID = "datacenter"
// pveDefaultMACPrefix is injected by PVE on read when mac_prefix is unset
// (PROXMOX_OUI in pve-cluster DataCenterConfig.pm); it is not a real setting.
const pveDefaultMACPrefix = "BC:24:11"
// datacenter handles GET/PUT /cluster/options (datacenter.cfg).
// GET returns several options parsed into objects/lists, PUT expects property strings:
// the canonical form is the property string (lists joined with ';' like datacenter.cfg).
type datacenter struct{}
func (datacenter) Kind() model.Kind { return model.KindDatacenter }
func (datacenter) SDN() bool { return false }
func (datacenter) Singleton() bool { return true }
func (datacenter) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
var raw map[string]any
if err := c.Get(ctx, "cluster/options", nil, &raw); err != nil {
return nil, nil, err
}
// allowed-tags: read-only, computed for the calling user.
fields := normalizeAPI(raw, "allowed-tags")
for k, v := range fields {
// top level lists (registered-tags) are stored as "a;b;c" in datacenter.cfg
if l, ok := v.([]string); ok {
fields[k] = strings.Join(l, ";")
}
}
if fields["description"] == "" {
delete(fields, "description")
}
if s, _ := fields["mac_prefix"].(string); strings.EqualFold(s, pveDefaultMACPrefix) {
delete(fields, "mac_prefix")
}
return []model.Object{{Kind: model.KindDatacenter, ID: DatacenterID, Fields: fields}}, nil, nil
}
// Fields synchronized by default; any other option is ignored unless the
// configuration says "sync".
var dcDefaultSync = map[string]bool{
"tag-style": true,
"registered-tags": true,
"user-tag-access": true,
"next-id": true,
}
func (datacenter) DefaultPolicy(f string) Policy {
if dcDefaultSync[f] {
return Sync
}
return Ignore
}
// Identity of the cluster or possible secrets (http_proxy may hold credentials).
func (datacenter) Forbidden(f string) bool {
switch f {
case "webauthn", "u2f", "http_proxy":
return true
}
return false
}
func (datacenter) Canonicalize(map[string]any) {}
func (datacenter) Required(model.Object) []string { return nil }
func (datacenter) Immutable(string) bool { return false }
func (datacenter) Create(context.Context, *pve.Client, model.Object, string) error {
return errors.New("datacenter options cannot be created")
}
func (datacenter) Delete(context.Context, *pve.Client, model.Object, string) error {
return errors.New("datacenter options cannot be deleted")
}
func (datacenter) Update(ctx context.Context, c *pve.Client, _ model.Object, set map[string]any, unset []string, _ string) error {
flat := map[string]any{}
for k, v := range set {
if l, ok := v.([]string); ok {
flat[k] = strings.Join(l, ";")
} else {
flat[k] = v
}
}
return c.Put(ctx, "cluster/options", form(flat, unset, ""), nil)
}
+247
View File
@@ -0,0 +1,247 @@
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 "<zone>-<address>-<prefix length>" (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, ",")
}
}