From 471df91646511cdc3d3273ec3eada74d5d34108b Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 13 Oct 2026 14:48:20 +0200 Subject: [PATCH] Phase 0 : adaptateurs options datacenter et SDN (zones, vnets, subnets) --- internal/adapter/adapter.go | 168 ++++++++++++++++++++++ internal/adapter/datacenter.go | 98 +++++++++++++ internal/adapter/sdn.go | 247 +++++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 internal/adapter/adapter.go create mode 100644 internal/adapter/datacenter.go create mode 100644 internal/adapter/sdn.go diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go new file mode 100644 index 0000000..b418641 --- /dev/null +++ b/internal/adapter/adapter.go @@ -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 +} diff --git a/internal/adapter/datacenter.go b/internal/adapter/datacenter.go new file mode 100644 index 0000000..b1a1ecf --- /dev/null +++ b/internal/adapter/datacenter.go @@ -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) +} diff --git a/internal/adapter/sdn.go b/internal/adapter/sdn.go new file mode 100644 index 0000000..52d4e6c --- /dev/null +++ b/internal/adapter/sdn.go @@ -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 "-
-" (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, ",") + } +}