Files
2026-10-23 17:43:20 +02:00

239 lines
8.1 KiB
Go

package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"pve-confsync/internal/adapter"
"pve-confsync/internal/config"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
"pve-confsync/internal/pve/pvetest"
)
// client connects to a fake cluster like the web UI does: API as root@pam,
// pinned certificate, SSH with the pinned host key.
func client(t *testing.T, s *pvetest.Server, name string) *pve.Client {
t.Helper()
sum := sha256.Sum256(s.Certificate().Raw)
host, port := s.SSHAddr()
c, err := config.Cluster{Name: name, URL: s.URL, User: "root@pam", Password: pvetest.Password,
Fingerprint: hex.EncodeToString(sum[:]), SSHHost: host, SSHPort: port, SSHHostKey: s.SSHHostKey()}.PVE()
if err != nil {
t.Fatal(err)
}
return c
}
// sdnKinds: the kinds of the SDN and datacenter tests.
var sdnKinds = []model.Kind{model.KindDatacenter, model.KindZone, model.KindVNet, model.KindSubnet}
// reference: two zones (vlan, vxlan), one unsupported (evpn), vnets, subnets, tags.
func reference(t *testing.T) *pvetest.Server {
ref := pvetest.New("9.1.4")
t.Cleanup(ref.Close)
ref.Options = map[string]string{
"registered-tags": "prod;dev",
"tag-style": "color-map=prod:ff0000,shape=full",
"next-id": "lower=100,upper=9999",
"keyboard": "fr",
}
ref.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr0", "mtu": 1500, "ipam": "pve"}
ref.Pending.Zones["vxz"] = map[string]any{"type": "vxlan", "peers": "10.0.0.2,10.0.0.1", "ipam": "pve"}
ref.Pending.Zones["evpnz"] = map[string]any{"type": "evpn", "controller": "c1"}
ref.Pending.VNets["v100"] = map[string]any{"zone": "vlanz", "tag": 100, "alias": "prod"}
ref.Pending.VNets["vx1"] = map[string]any{"zone": "vxz", "tag": 10001}
ref.Pending.VNets["ev1"] = map[string]any{"zone": "evpnz", "tag": 5000}
ref.Pending.Subnets["vlanz-10.1.0.0-24"] = map[string]any{"vnet": "v100", "gateway": "10.1.0.1",
"dhcp-range": []any{"start-address=10.1.0.100,end-address=10.1.0.200"}}
ref.Commit()
return ref
}
func testConfig() *config.Config {
return &config.Config{
Kinds: sdnKinds,
Clients: []config.Client{{
Cluster: config.Cluster{Name: "site-a"},
Mapping: map[model.Kind]map[string]map[string]any{
model.KindZone: {
"vlanz": {"bridge": "vmbr1", "nodes": "a2,a1"},
"vxz": {"peers": "192.168.0.1,192.168.0.2"},
},
},
}},
}
}
func TestSyncEndToEnd(t *testing.T) {
ctx := context.Background()
ref := reference(t)
cli := pvetest.New("9.0.10")
defer cli.Close()
cli.Options = map[string]string{"registered-tags": "old", "keyboard": "de"}
cfg := testConfig()
cl := &cfg.Clients[0]
rc, cc := client(t, ref, "ref"), client(t, cli, "site-a")
src, err := Read(ctx, rc, cfg.Kinds, adapter.ReadRunning)
if err != nil {
t.Fatal(err)
}
if len(src.Warnings) != 2 { // evpn zone + its vnet
t.Fatalf("warnings = %v", src.Warnings)
}
cur, err := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent)
if err != nil {
t.Fatal(err)
}
plan := MakePlan(cfg, cl, src.Objects, cur.Objects)
if len(plan.Errors) > 0 {
t.Fatal(plan.Errors)
}
// 1 datacenter update + 2 zones + 2 vnets + 1 subnet
if len(plan.Changes) != 6 {
t.Fatalf("changes = %+v", plan.Changes)
}
dc := plan.Changes[0]
if dc.Kind != model.KindDatacenter || dc.Set["registered-tags"] != "dev;prod" || dc.Set["keyboard"] != nil {
t.Fatalf("datacenter change = %+v", dc)
}
res := Apply(ctx, cc, plan)
if res.Err != nil {
t.Fatalf("apply: %v (%+v)", res.Err, res.Steps)
}
if cli.Lock != "" {
t.Fatal("SDN lock not released")
}
z := cli.Running.Zones["vlanz"]
if z["bridge"] != "vmbr1" || z["nodes"] != "a1,a2" || z["mtu"] != "1500" {
t.Fatalf("vlan zone = %v (mapping not applied)", z)
}
if cli.Running.Zones["evpnz"] != nil {
t.Fatal("evpn zone must not be synchronized")
}
if cli.Options["keyboard"] != "de" || cli.Options["tag-style"] != "color-map=prod:ff0000,shape=full" {
t.Fatalf("options = %v", cli.Options)
}
if cli.Running.Subnets["vlanz-10.1.0.0-24"]["gateway"] != "10.1.0.1" {
t.Fatalf("subnets = %v", cli.Running.Subnets)
}
replan := func(actual []model.Object) Plan { return MakePlan(cfg, cl, src.Objects, actual) }
if err := Verify(ctx, cc, replan, cfg.Kinds); err != nil {
t.Fatalf("verify: %v", err)
}
}
func TestMissingMappingIsAnError(t *testing.T) {
ref := reference(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
cfg := testConfig()
cfg.Clients[0].Mapping = nil
ctx := context.Background()
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "c"), cfg.Kinds, adapter.ReadCurrent)
plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)
if len(plan.Errors) != 1 || !strings.Contains(plan.Errors[0], `"bridge"`) {
t.Fatalf("errors = %v", plan.Errors)
}
if res := Apply(ctx, client(t, cli, "c"), plan); res.Err == nil || len(cli.Calls) != 0 {
t.Fatalf("a plan in error must not be applied: %v %v", res.Err, cli.Calls)
}
}
func TestRollbackOnFailure(t *testing.T) {
ref := reference(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
cli.FailOn = "POST cluster/sdn/vnets"
cfg := testConfig()
ctx := context.Background()
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cc := client(t, cli, "c")
cur, _ := Read(ctx, cc, cfg.Kinds, adapter.ReadCurrent)
res := Apply(ctx, cc, MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects))
if res.Err == nil {
t.Fatal("expected an error")
}
if len(cli.Pending.Zones) != 0 || cli.Lock != "" {
t.Fatalf("pending not rolled back or lock kept: %v lock=%q", cli.Pending.Zones, cli.Lock)
}
last := cli.Calls[len(cli.Calls)-1]
if last != "POST cluster/sdn/rollback" {
t.Fatalf("last call = %s", last)
}
}
func TestRefuseNonPVE9(t *testing.T) {
s := pvetest.New("8.4.1")
defer s.Close()
if _, err := client(t, s, "old").CheckVersion(context.Background()); err == nil {
t.Fatal("PVE 8 must be refused")
}
}
func TestPruneAndFieldRemoval(t *testing.T) {
ref := reference(t)
cli := pvetest.New("9.1.0")
defer cli.Close()
cli.Pending.Zones["vlanz"] = map[string]any{"type": "vlan", "bridge": "vmbr1", "mtu": "1500", "ipam": "pve", "vlan-protocol": "802.1q"}
cli.Pending.Zones["local"] = map[string]any{"type": "vlan", "bridge": "vmbr9"}
cli.Pending.VNets["lv"] = map[string]any{"zone": "local", "tag": 9}
cli.Commit()
cfg := testConfig()
cfg.Prune = true
cfg.Objects = map[model.Kind]config.Selection{model.KindZone: {Exclude: []string{"keep-*"}}}
ctx := context.Background()
src, _ := Read(ctx, client(t, ref, "ref"), cfg.Kinds, adapter.ReadRunning)
cur, _ := Read(ctx, client(t, cli, "c"), cfg.Kinds, adapter.ReadCurrent)
plan := MakePlan(cfg, &cfg.Clients[0], src.Objects, cur.Objects)
var ops []string
for _, c := range plan.Changes {
ops = append(ops, string(c.Op)+" "+c.ID)
if c.ID == "vlanz" && (len(c.Unset) != 1 || c.Unset[0] != "vlan-protocol") {
t.Fatalf("vlanz unset = %v", c.Unset)
}
}
got := strings.Join(ops, "|")
// deletions at the end, vnet before its zone
if !strings.HasSuffix(got, "delete lv|delete local") {
t.Fatalf("order = %s", got)
}
}
func TestDatacenterCanonicalForm(t *testing.T) {
s := pvetest.New("9.1.0")
defer s.Close()
s.Options = map[string]string{"user-tag-access": "user-allow=list,user-allow-list=b;a", "next-id": "upper=500,lower=200"}
snap, err := Read(context.Background(), client(t, s, "c"), []model.Kind{model.KindDatacenter}, adapter.ReadCurrent)
if err != nil {
t.Fatal(err)
}
f := snap.Objects[0].Fields
if f["user-tag-access"] != "user-allow=list,user-allow-list=a;b" || f["next-id"] != "lower=200,upper=500" {
t.Fatalf("fields = %v", f)
}
if _, ok := f["mac_prefix"]; ok {
t.Fatal("default mac_prefix injected by PVE must be dropped")
}
}
func TestSubnetCanonicalForm(t *testing.T) {
a, _ := adapter.ByKind(model.KindSubnet)
f := model.Normalize(map[string]any{"dhcp-range": "start-address=10.0.0.10,end-address=10.0.0.20"})
a.Canonicalize(f)
if l, ok := f["dhcp-range"].([]string); !ok || len(l) != 1 {
t.Fatalf("dhcp-range = %#v", f["dhcp-range"])
}
if cidr, err := adapter.SubnetCIDR("vxz-fd00:1::-64"); err != nil || cidr != "fd00:1::/64" {
t.Fatalf("cidr = %q %v", cidr, err)
}
}