Files
pve-confsync/internal/pve/pvetest/nodes.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

420 lines
10 KiB
Go

package pvetest
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net"
"net/http"
"path"
"sort"
"strings"
"golang.org/x/crypto/ssh"
)
// FakeFile is a file of a fake node.
type FakeFile struct {
Content string
Mode string // 644 by default
Owner string // root:root by default
}
// Node is a node of a fake cluster for the node replication: its own SSH
// server (files, reloads, restore timer), and its network interfaces and DNS
// servers served by the API of the cluster (/nodes/{node}/network, /dns).
// Like PVE, the interface writes go to a pending configuration
// (interfaces.new) applied by PUT /nodes/{node}/network.
type Node struct {
Name string
Hostname string // answered by hostname (default: Name)
Version string // pve-manager version (default: the cluster's)
Files map[string]FakeFile
Net map[string]map[string]any // iface -> fields as written (cidr, type…)
Pending map[string]map[string]any // interfaces.new; nil: no pending change
DNS map[string]string
Reloads []string // reload commands run, "network" for PUT /network
Armed map[string]bool // restore timers armed (systemd-run units)
FailOn string // "reload <cmd>", "prepare" or "probe": that step fails
Calls []string // SSH steps run
backups map[string]backup
srv *Server
ssh *sshServer
}
type backup struct {
files map[string]*FakeFile // nil: absent before
net map[string]map[string]any
pending map[string]map[string]any
dns map[string]string
hasNet bool
hasDNS bool
}
// AddNode adds a node to the fake cluster (listed by GET /cluster/status).
func (s *Server) AddNode(name string) *Node {
n := &Node{Name: name, Hostname: name, Files: map[string]FakeFile{}, Net: map[string]map[string]any{},
DNS: map[string]string{}, Armed: map[string]bool{}, backups: map[string]backup{}, srv: s}
n.ssh = newSSH(func(user, pass string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return user == "root" && pass == s.Password
}, n.exec)
s.mu.Lock()
s.Nodes = append(s.Nodes, n)
s.mu.Unlock()
return n
}
// SSHAddr returns the host and port of the SSH server of the node.
func (n *Node) SSHAddr() (string, int) {
a := n.ssh.ln.Addr().(*net.TCPAddr)
return a.IP.String(), a.Port
}
// SSHHostKey returns the host key of the node (authorized_keys format).
func (n *Node) SSHHostKey() string {
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(n.ssh.key)))
}
func (s *Server) node(name string) *Node {
for _, n := range s.Nodes {
if n.Name == name {
return n
}
}
return nil
}
func (n *Node) version() string {
if n.Version != "" {
return n.Version
}
return n.srv.Version
}
func cloneNet(m map[string]map[string]any) map[string]map[string]any {
if m == nil {
return nil
}
out := map[string]map[string]any{}
for k, v := range m {
f := map[string]any{}
for x, y := range v {
f[x] = y
}
out[k] = f
}
return out
}
// ---- API (called with s.mu held) ----
func (s *Server) nodeAPI(w http.ResponseWriter, r *http.Request, n *Node, seg []string) {
switch {
case len(seg) == 3 && seg[2] == "dns" && r.Method == "GET":
out := map[string]any{}
for k, v := range n.DNS {
out[k] = v
}
reply(w, out)
case len(seg) == 3 && seg[2] == "dns" && r.Method == "PUT":
if r.PostForm.Get("search") == "" {
fail(w, 400, "search: property is missing and it is not optional")
return
}
n.DNS = map[string]string{}
for _, k := range []string{"search", "dns1", "dns2", "dns3"} {
if v := r.PostForm.Get(k); v != "" {
n.DNS[k] = v
}
}
reply(w, nil)
case len(seg) == 3 && seg[2] == "network" && r.Method == "GET":
cfg := n.Net
if n.Pending != nil {
cfg = n.Pending
}
var out []any
prio := 0
for _, id := range sortedKeys(cfg) {
prio++
f := map[string]any{"iface": id, "priority": prio, "families": []any{"inet"}, "method": "manual", "active": 1}
for k, v := range cfg[id] {
f[k] = v
}
if c, ok := f["cidr"].(string); ok {
a, m, _ := strings.Cut(c, "/")
f["address"], f["netmask"], f["method"] = a, m, "static"
}
if f["type"] == "eth" {
f["exists"] = 1
}
out = append(out, f)
}
reply(w, out)
case len(seg) == 3 && seg[2] == "network" && r.Method == "DELETE":
n.Pending = nil
reply(w, nil)
case len(seg) == 3 && seg[2] == "network" && r.Method == "PUT":
if n.Pending != nil {
n.Net, n.Pending = n.Pending, nil
}
n.Reloads = append(n.Reloads, "network")
reply(w, "UPID:"+n.Name+":00001234:00005678:65000000:srvreload:networking:root@pam:")
case len(seg) == 3 && seg[2] == "network" && r.Method == "POST":
id := r.PostForm.Get("iface")
cfg := n.pending()
if cfg[id] != nil {
fail(w, 400, "iface: interface already exists")
return
}
cfg[id] = fields(r.PostForm, "iface")
reply(w, nil)
case len(seg) == 4 && seg[2] == "network":
cfg := n.pending()
cur := cfg[seg[3]]
if cur == nil {
fail(w, 400, "iface: interface does not exist")
return
}
switch r.Method {
case "DELETE":
delete(cfg, seg[3])
case "PUT":
if r.PostForm.Get("type") == "" {
fail(w, 400, "type: property is missing and it is not optional")
return
}
for _, d := range strings.Split(r.PostForm.Get("delete"), ",") {
delete(cur, d)
}
// like PVE: without an address in the parameters, the interface
// becomes "manual" and loses its address
if r.PostForm.Get("cidr") == "" {
delete(cur, "cidr")
}
for k, v := range fields(r.PostForm, "delete") {
cur[k] = v
}
default:
fail(w, 501, "not implemented in fake")
return
}
reply(w, nil)
default:
fail(w, 501, "not implemented in fake: "+r.Method+" "+strings.Join(seg, "/"))
}
}
// pending returns interfaces.new, created from the current configuration.
func (n *Node) pending() map[string]map[string]any {
if n.Pending == nil {
n.Pending = cloneNet(n.Net)
}
return n.Pending
}
// ---- SSH ----
func (n *Node) exec(cmd string, in io.Reader, out, errw io.Writer) int {
s := n.srv
s.mu.Lock()
defer s.mu.Unlock()
first, _, _ := strings.Cut(cmd, "\n")
verb, arg, _ := strings.Cut(strings.TrimPrefix(first, "# confsync:"), " ")
if strings.HasPrefix(first, "# confsync:") {
n.Calls = append(n.Calls, verb)
if n.FailOn == verb || n.FailOn == verb+" "+arg {
fmt.Fprintln(errw, "injected failure")
return 1
}
}
switch {
case strings.HasPrefix(cmd, "pveversion"):
fmt.Fprintf(out, "pve-manager/%s/abcdef (running kernel: 6.14.8-2-pve)\n", n.version())
if strings.Contains(cmd, "hostname") {
fmt.Fprintln(out, n.Hostname)
}
return 0
case verb == "read":
return n.read(cmd, out)
case verb == "prepare":
data, _ := io.ReadAll(in)
return n.prepare(arg, data, errw)
case verb == "restore":
b, ok := n.backups[arg]
if !ok {
fmt.Fprintln(errw, "no backup "+arg)
return 1
}
n.restore(b)
return 0
case verb == "arm":
n.Armed[arg] = true
return 0
case verb == "disarm":
delete(n.Armed, arg)
return 0
case verb == "reload", verb == "probe":
if verb == "reload" {
n.Reloads = append(n.Reloads, arg)
}
return 0
}
fmt.Fprintf(errw, "fake node: unknown command %q\n", first)
return 127
}
// Fire runs the armed restore timers (as systemd would after the delay).
func (n *Node) Fire() {
n.srv.mu.Lock()
defer n.srv.mu.Unlock()
for unit := range n.Armed {
dir := strings.Replace(unit, "pve-confsync-restore-", "/var/lib/pve-confsync/backup/", 1)
if b, ok := n.backups[dir]; ok {
n.restore(b)
}
n.Reloads = append(n.Reloads, "ifreload -a")
delete(n.Armed, unit)
}
}
func (n *Node) read(cmd string, out io.Writer) int {
var pats []string
for _, l := range strings.Split(cmd, "\n") {
if strings.HasPrefix(l, "for f in ") {
pats = strings.Fields(strings.TrimSuffix(strings.TrimPrefix(l, "for f in "), "; do"))
}
}
for _, p := range sortedKeys(n.Files) {
for _, pat := range pats {
if ok, _ := path.Match(pat, p); ok {
f := n.Files[p]
fmt.Fprintf(out, "F %s %s %s\n%s\n", def(f.Mode, "644"), def(f.Owner, "root:root"), p,
base64.StdEncoding.EncodeToString([]byte(f.Content)))
break
}
}
}
if n.Pending != nil {
fmt.Fprintln(out, "PENDING")
}
return 0
}
func def(v, d string) string {
if v == "" {
return d
}
return v
}
// prepare emulates the prepare script: checks, backups, writes.
func (n *Node) prepare(dir string, plan []byte, errw io.Writer) int {
type op struct{ op, mode, owner, hash, path, data string }
var ops []op
sc := bufio.NewScanner(bytes.NewReader(plan))
sc.Buffer(make([]byte, 64<<10), 8<<20)
for sc.Scan() {
p := strings.SplitN(sc.Text(), " ", 5)
if len(p) != 5 {
continue
}
o := op{p[0], p[1], p[2], p[3], p[4], ""}
if o.op == "W" && sc.Scan() {
o.data = sc.Text()
}
ops = append(ops, o)
}
for _, o := range ops {
if o.op != "W" && o.op != "D" {
continue
}
h := "absent"
if f, ok := n.Files[o.path]; ok {
h = sha([]byte(f.Content))
}
if h != o.hash {
fmt.Fprintf(errw, "changed since the dry-run: %s\n", o.path)
return 3
}
}
b := backup{files: map[string]*FakeFile{}}
for _, o := range ops {
switch o.path {
case "/etc/network/interfaces", "/etc/network/interfaces.new":
b.hasNet, b.net, b.pending = true, cloneNet(n.Net), cloneNet(n.Pending)
continue
case "/etc/resolv.conf":
b.hasDNS, b.dns = true, map[string]string{}
for k, v := range n.DNS {
b.dns[k] = v
}
continue
}
if f, ok := n.Files[o.path]; ok {
c := f
b.files[o.path] = &c
} else {
b.files[o.path] = nil
}
}
n.backups[dir] = b
for _, o := range ops {
switch o.op {
case "W":
data, err := base64.StdEncoding.DecodeString(o.data)
if err != nil {
n.restore(b)
fmt.Fprintln(errw, "write failed: "+o.path)
return 4
}
n.Files[o.path] = FakeFile{Content: string(data), Mode: o.mode, Owner: o.owner}
case "D":
delete(n.Files, o.path)
}
}
return 0
}
func (n *Node) restore(b backup) {
for p, f := range b.files {
if f == nil {
delete(n.Files, p)
} else {
n.Files[p] = *f
}
}
if b.hasNet {
n.Net, n.Pending = cloneNet(b.net), cloneNet(b.pending)
}
if b.hasDNS {
n.DNS = map[string]string{}
for k, v := range b.dns {
n.DNS[k] = v
}
}
}
// Backups lists the backup folders written on the node.
func (n *Node) Backups() []string {
n.srv.mu.Lock()
defer n.srv.mu.Unlock()
var out []string
for d := range n.backups {
out = append(out, d)
}
sort.Strings(out)
return out
}
func sha(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}