Files
pve-confsync/internal/nodesync/nodesync_test.go
T
cedricandClaude Opus 5.5 1e667ce8b9 Réplication de nœuds : réseau et DNS par l'API, multipath, chrony, sysctl par SSH
Deuxième mode de réplication, séparé de la réplication cluster vers cluster
(sélecteur « Clusters (API) | Nœuds (fichiers) » en haut de page) : un profil
réplique la configuration système d'un nœud maître vers des nœuds cibles.

- réseau par /nodes/{node}/network (interfaces.new puis rechargement), adresses
  et passerelles propres à chaque nœud, mapping des noms d'interfaces par cible ;
  DNS par /nodes/{node}/dns ; multipath.conf, wwids (ajout seul), chrony,
  sysctl.d, interfaces.d (hors sdn) par SSH
- découverte des nœuds (/cluster/status), accès SSH par nœud (mot de passe du
  cluster ou propre), clé d'hôte épinglée, contrôle du nom du nœud
- dry-run avec diff des fichiers ; apply (admin) avec contrôle SHA-256,
  sauvegarde et restore.sh sur le nœud, écriture atomique, rechargements,
  restauration si un rechargement échoue, timer systemd de restauration si le
  nœud est injoignable après le rechargement du réseau, confirmation tapée
- vue Nœuds et profils, journal séparé par mode, démo avec nœuds simulés

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:29:55 +02:00

305 lines
11 KiB
Go

package nodesync
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"pve-confsync/internal/config"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/sshx"
)
func init() {
ProbeWait, TaskPoll = time.Millisecond, time.Millisecond
}
// endpoint connects to a node of a fake cluster like the web UI does.
func endpoint(t *testing.T, s *pvetest.Server, cluster string, n *pvetest.Node) Endpoint {
t.Helper()
sum := sha256.Sum256(s.Certificate().Raw)
c, err := config.Cluster{Name: cluster, URL: s.URL, User: "root@pam", Password: pvetest.Password,
Fingerprint: hex.EncodeToString(sum[:])}.PVE()
if err != nil {
t.Fatal(err)
}
host, port := n.SSHAddr()
o := sshx.Options{Host: host, Port: port, Password: pvetest.Password, HostKey: n.SSHHostKey()}
sh, err := sshx.New(o)
if err != nil {
t.Fatal(err)
}
return Endpoint{Key: cluster + "/" + n.Name, Cluster: cluster, Node: n.Name, API: c, Shell: sh}
}
const multipath = "defaults {\n user_friendly_names yes\n find_multipaths no\n}\nblacklist {\n devnode \"^sda\"\n}\n"
// lab: one cluster, a master pve1 and a target pve2 with differences.
func lab(t *testing.T) (*pvetest.Server, *pvetest.Node, *pvetest.Node, map[string]Endpoint) {
s := pvetest.New("9.1.4")
t.Cleanup(s.Close)
m, c := s.AddNode("pve1"), s.AddNode("pve2")
m.Net = map[string]map[string]any{
"eno1": {"type": "eth", "mtu": "9000"},
"eno2": {"type": "eth", "mtu": "9000"},
"bond0": {"type": "bond", "slaves": "eno1 eno2", "bond_mode": "802.3ad", "bond_xmit_hash_policy": "layer3+4", "mtu": "9000"},
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "bridge_vlan_aware": "1", "cidr": "10.0.0.11/24", "gateway": "10.0.0.1", "autostart": "1"},
"vmbr1": {"type": "bridge", "bridge_ports": "bond0.50", "cidr": "10.5.0.11/24", "comments": "stockage"},
"bond0.50": {"type": "vlan", "mtu": "9000"},
}
// the target has other NIC names and an active-backup bond
c.Net = map[string]map[string]any{
"enp1s0f0": {"type": "eth"},
"enp1s0f1": {"type": "eth"},
"bond0": {"type": "bond", "slaves": "enp1s0f0 enp1s0f1", "bond_mode": "active-backup"},
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "cidr": "10.0.0.12/24", "gateway": "10.0.0.1", "autostart": "1"},
}
m.DNS = map[string]string{"search": "lab.local", "dns1": "10.0.0.53", "dns2": "10.0.0.54"}
c.DNS = map[string]string{"search": "lab.local", "dns1": "8.8.8.8"}
m.Files["/etc/multipath.conf"] = pvetest.FakeFile{Content: multipath}
m.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383030/\n/3600a0980383031/\n"}
c.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383031/\n/36000local/\n"}
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 16777216\n"}
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 8388608\n", Mode: "600"}
c.Files["/etc/sysctl.d/99-local.conf"] = pvetest.FakeFile{Content: "vm.swappiness = 10\n"}
m.Files["/etc/network/interfaces.d/sdn"] = pvetest.FakeFile{Content: "auto vnet1\n"}
eps := map[string]Endpoint{"lab/pve1": endpoint(t, s, "lab", m), "lab/pve2": endpoint(t, s, "lab", c)}
return s, m, c, eps
}
func profile() *Profile {
return &Profile{Name: "stockage", Master: "lab/pve1", Targets: []string{"lab/pve2"},
Items: []string{"network", "dns", "multipath", "wwids", "sysctl", "interfaces-d"},
Mapping: map[string]TargetMap{"lab/pve2": {
NICs: map[string]string{"eno1": "enp1s0f0", "eno2": "enp1s0f1"},
Values: map[string]map[string]string{"network/vmbr1": {"cidr": "10.5.0.12/24"}},
}}}
}
func keys(p Plan) []string {
var out []string
for _, c := range p.Changes {
out = append(out, string(c.Op)+" "+c.Key())
}
return out
}
func TestPlan(t *testing.T) {
_, _, _, eps := lab(t)
prof := profile()
st := Analyze(context.Background(), prof, eps, nil)
if st.Master.Error != "" || st.Targets[0].Error != "" {
t.Fatal(st.Master.Error, st.Targets[0].Error)
}
p := st.Targets[0].Plan
if len(p.Errors) > 0 {
t.Fatalf("errors: %+v", p.Errors)
}
got := strings.Join(keys(p), "\n")
want := strings.Join([]string{
"update sysctl//etc/sysctl.d/90-net.conf",
"create multipath//etc/multipath.conf",
"update wwids//etc/multipath/wwids",
"update dns/dns",
"update network/enp1s0f0",
"update network/enp1s0f1",
"update network/bond0",
"create network/bond0.50",
"update network/vmbr0",
"create network/vmbr1",
}, "\n")
if got != want {
t.Fatalf("plan:\n%s\nwant:\n%s", got, want)
}
for _, c := range p.Changes {
switch c.Key() {
case "network/bond0":
if c.Set["slaves"] != "enp1s0f0 enp1s0f1" && c.Set["slaves"] != nil {
t.Errorf("slaves not renamed: %v", c.Set["slaves"])
}
if c.Set["bond_mode"] != "802.3ad" {
t.Errorf("bond0: %+v", c.Set)
}
case "network/vmbr0":
// the address of the target is kept (override without mapping)
if _, ok := c.Set["cidr"]; ok || c.Full["cidr"] != "10.0.0.12/24" {
t.Errorf("vmbr0: set %+v full %+v", c.Set, c.Full)
}
case "network/vmbr1":
if c.Full["cidr"] != "10.5.0.12/24" || c.Full["bridge_ports"] != "bond0.50" {
t.Errorf("vmbr1: %+v", c.Full)
}
case "wwids//etc/multipath/wwids":
// additive: the local WWID of the target stays
if string(c.Content) != "# wwids\n/3600a0980383031/\n/36000local/\n/3600a0980383030/\n" {
t.Errorf("wwids: %q", c.Content)
}
case "sysctl//etc/sysctl.d/90-net.conf":
if c.Mode != "600" || len(c.Diff) == 0 {
t.Errorf("sysctl: mode %s diff %+v", c.Mode, c.Diff)
}
}
}
// the file only on the target is kept without prune; interfaces.d/sdn is never read
for _, r := range st.Targets[0].Rows {
if r.ID == "/etc/sysctl.d/99-local.conf" && r.Status != StatusExtra {
t.Errorf("99-local.conf: %s", r.Status)
}
if strings.Contains(r.ID, "interfaces.d/sdn") {
t.Errorf("sdn file listed")
}
}
if !p.Network() {
t.Error("network plan not flagged")
}
if strings.Join(p.Reloads(), ",") != "sysctl --system,multipathd reconfigure,ifreload -a" {
t.Errorf("reloads: %v", p.Reloads())
}
}
func TestApplyAndVerify(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
st := Analyze(context.Background(), prof, eps, nil)
tp := st.Plans(nil)[0]
var steps []string
if err := st.Apply(context.Background(), tp, nil, func(s Step) {
steps = append(steps, s.Action+" "+s.Error)
}); err != nil {
t.Fatalf("apply: %v\n%s", err, strings.Join(steps, "\n"))
}
if c.Net["vmbr0"]["cidr"] != "10.0.0.12/24" || c.Net["vmbr1"]["cidr"] != "10.5.0.12/24" {
t.Errorf("addresses: vmbr0 %v vmbr1 %v", c.Net["vmbr0"], c.Net["vmbr1"])
}
if c.Net["vmbr0"]["bridge_vlan_aware"] != "1" || c.Pending != nil {
t.Errorf("vmbr0 %v pending %v", c.Net["vmbr0"], c.Pending)
}
if c.DNS["dns2"] != "10.0.0.54" || c.Files["/etc/multipath.conf"].Content != multipath {
t.Errorf("dns %v multipath %q", c.DNS, c.Files["/etc/multipath.conf"].Content)
}
if len(c.Armed) != 0 {
t.Errorf("restore timer still armed: %v", c.Armed)
}
if got := strings.Join(c.Reloads, ","); got != "sysctl --system,multipathd reconfigure,network" {
t.Errorf("reloads: %s", got)
}
if len(c.Backups()) != 1 {
t.Errorf("backups: %v", c.Backups())
}
st = Analyze(context.Background(), prof, eps, nil)
if p := st.Targets[0].Plan; !p.Empty() {
t.Errorf("still to do: %v", keys(p))
}
}
func TestReloadFailureRestores(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"multipath", "sysctl"}
c.FailOn = "reload multipathd reconfigure"
st := Analyze(context.Background(), prof, eps, nil)
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "multipathd reconfigure") {
t.Fatalf("err = %v", err)
}
if _, ok := c.Files["/etc/multipath.conf"]; ok {
t.Error("multipath.conf not removed by the restore")
}
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "net.core.rmem_max = 8388608\n" {
t.Errorf("sysctl not restored: %q", c.Files["/etc/sysctl.d/90-net.conf"].Content)
}
}
func TestUnreachableAfterReloadKeepsTimer(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"network"}
st := Analyze(context.Background(), prof, eps, nil)
c.FailOn = "probe"
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("err = %v", err)
}
if len(c.Armed) != 1 {
t.Fatalf("timer: %v", c.Armed)
}
// systemd runs the restore: the previous interfaces come back
c.Fire()
if c.Net["bond0"]["bond_mode"] != "active-backup" || c.Net["vmbr1"] != nil {
t.Errorf("not restored: %v", c.Net)
}
}
func TestStaleFileRefused(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"sysctl"}
st := Analyze(context.Background(), prof, eps, nil)
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "changed by hand\n"}
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "changed since the dry-run") {
t.Fatalf("err = %v", err)
}
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "changed by hand\n" {
t.Error("file overwritten")
}
}
func TestPendingNetworkBlocks(t *testing.T) {
_, _, c, eps := lab(t)
c.Pending = map[string]map[string]any{"vmbr0": {"type": "bridge"}}
st := Analyze(context.Background(), profile(), eps, nil)
if len(st.Targets[0].Plan.Errors) == 0 {
t.Fatal("pending changes not blocking")
}
}
func TestMissingNICIsAnError(t *testing.T) {
_, _, _, eps := lab(t)
prof := profile()
prof.Items = []string{"network"}
prof.Mapping = nil
st := Analyze(context.Background(), prof, eps, nil)
p := st.Targets[0].Plan
if len(p.Errors) != 2 || p.Errors[0].Key != "network/eno1" {
t.Fatalf("errors: %+v", p.Errors)
}
// the selection of another object is not blocked
if f := Filter(p, []string{"network/vmbr0"}); len(f.Errors) != 0 || len(f.Changes) != 1 {
t.Errorf("filter: %+v", f)
}
}
func TestPruneAndProtect(t *testing.T) {
_, m, _, eps := lab(t)
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "# host pve1\nnet.core.rmem_max = 8388608\n"}
prof := profile()
prof.Items = []string{"sysctl"}
prof.Prune = true
prof.Protect = map[string][]string{"sysctl": {"^# host "}}
st := Analyze(context.Background(), prof, eps, nil)
got := strings.Join(keys(st.Targets[0].Plan), ",")
// the protected line is absent on the target: dropped, the rest is equal
if got != "delete sysctl//etc/sysctl.d/99-local.conf" {
t.Fatalf("plan: %s", got)
}
}
func TestDiff(t *testing.T) {
d := Diff([]byte("a\nb\nc\nd\ne\nf\ng\nh\ni\n"), []byte("a\nb\nc\nd\nE\nf\ng\nh\ni\nj\n"))
var b strings.Builder
for _, l := range d {
b.WriteString(l.Op + l.Text + "\n")
}
want := "@@@ -2,8 +2,9 @@\n b\n c\n d\n-e\n+E\n f\n g\n h\n i\n+j\n"
if b.String() != want {
t.Fatalf("diff:\n%s\nwant:\n%s", b.String(), want)
}
if d := Diff(nil, []byte("x\n")); len(d) != 2 || d[0].Text != "@@ -0,0 +1 @@" {
t.Errorf("creation diff: %+v", d)
}
}