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>
420 lines
10 KiB
Go
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[:])
|
|
}
|