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>
This commit is contained in:
1 parent
dc1a7ff40e
commit
1e667ce8b9
24 files changed
+4973
-65
No files matched your search
@@ -0,0 +1,419 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in new issue
Block a user