Phase 0 : adaptateurs options datacenter et SDN (zones, vnets, subnets)
This commit is contained in:
1 parent
69c340e4c3
commit
471df91646
3 files changed
+513
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ",")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user