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>
104 lines
3.2 KiB
Go
104 lines
3.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"pve-confsync/internal/config"
|
|
"pve-confsync/internal/pve"
|
|
"pve-confsync/internal/sshx"
|
|
)
|
|
|
|
// DiscoveredNode is a node listed by GET /cluster/status.
|
|
type DiscoveredNode struct {
|
|
Name string `json:"name"`
|
|
IP string `json:"ip"`
|
|
Online bool `json:"online"`
|
|
}
|
|
|
|
// DiscoverNodes lists the nodes of a cluster with their address in the
|
|
// cluster. A standalone node without address gets the host of the API URL.
|
|
func DiscoverNodes(ctx context.Context, cl config.Cluster) ([]DiscoveredNode, error) {
|
|
cl.SSHHostKey = ""
|
|
c, err := cl.PVE()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var status []map[string]any
|
|
if err := c.Get(ctx, "cluster/status", nil, &status); err != nil {
|
|
return nil, err
|
|
}
|
|
var out []DiscoveredNode
|
|
for _, s := range status {
|
|
if pve.Str(s["type"]) != "node" {
|
|
continue
|
|
}
|
|
n := DiscoveredNode{Name: pve.Str(s["name"]), IP: pve.Str(s["ip"]), Online: pve.Str(s["online"]) == "1"}
|
|
if n.IP == "" {
|
|
if u, err := url.Parse(cl.URL); err == nil {
|
|
n.IP = u.Hostname()
|
|
}
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// NodeCheck is the result of the SSH test of a node.
|
|
type NodeCheck struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
SSHHostKey string `json:"-"`
|
|
SSHFingerprint string `json:"sshFingerprint,omitempty"`
|
|
SSHMismatch bool `json:"sshMismatch,omitempty"`
|
|
}
|
|
|
|
// CheckNode tests SSH as root on a node: the host key (recorded on the first
|
|
// connection, trust accepts a new one), pveversion, and the host name, which
|
|
// must be the name of the node in its cluster (a wrong address is refused).
|
|
func CheckNode(ctx context.Context, node string, o sshx.Options, trust bool) NodeCheck {
|
|
var res NodeCheck
|
|
fail := func(err error) NodeCheck {
|
|
res.Error = err.Error()
|
|
return res
|
|
}
|
|
if o.HostKey == "" || trust {
|
|
seen, fp, err := sshx.Scan(ctx, o)
|
|
if err != nil {
|
|
return fail(fmt.Errorf("SSH: %w", err))
|
|
}
|
|
o.HostKey, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp
|
|
}
|
|
sh, err := sshx.New(o)
|
|
if err != nil {
|
|
return fail(fmt.Errorf("SSH: %w", err))
|
|
}
|
|
out, err := sh.Run(ctx, "pveversion; hostname", nil)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "host key mismatch") {
|
|
_, fp, _ := sshx.Scan(ctx, o)
|
|
res.SSHMismatch, res.SSHFingerprint = true, fp
|
|
return fail(fmt.Errorf("SSH: node %s presents host key %s instead of the recorded %s", o.Host, fp, sshx.Fingerprint(o.HostKey)))
|
|
}
|
|
return fail(fmt.Errorf("SSH: %w", err))
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
if !strings.HasPrefix(lines[0], "pve-manager/") {
|
|
return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, lines[0]))
|
|
}
|
|
res.Version = strings.SplitN(strings.TrimPrefix(lines[0], "pve-manager/"), "/", 2)[0]
|
|
host := ""
|
|
if len(lines) > 1 {
|
|
host = strings.TrimSpace(lines[len(lines)-1])
|
|
}
|
|
if short, _, _ := strings.Cut(host, "."); !strings.EqualFold(short, node) {
|
|
res.SSHHostKey = "" // not the expected node: nothing is recorded
|
|
return fail(fmt.Errorf("SSH: %s answers as %q, not as node %s", o.Host, host, node))
|
|
}
|
|
res.OK = true
|
|
return res
|
|
}
|