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,341 @@
|
||||
package nodesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pve-confsync/internal/model"
|
||||
)
|
||||
|
||||
// NodeInfo is what was read from one node.
|
||||
type NodeInfo struct {
|
||||
Key string `json:"name"` // "cluster/node", "name" like the cluster cards
|
||||
Cluster string `json:"cluster"`
|
||||
Node string `json:"node"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ReadAt time.Time `json:"readAt"`
|
||||
Millis int64 `json:"ms"`
|
||||
}
|
||||
|
||||
// Field compares one field of an interface or of the DNS servers.
|
||||
type Field struct {
|
||||
Name string `json:"name"`
|
||||
Policy Policy `json:"policy"`
|
||||
Ref any `json:"ref,omitempty"` // master
|
||||
Mapping any `json:"mapping,omitempty"` // value of the target mapping
|
||||
Current any `json:"current,omitempty"` // target
|
||||
Target any `json:"target,omitempty"` // after the replication
|
||||
Change string `json:"change,omitempty"` // add, set, unset or empty
|
||||
}
|
||||
|
||||
// Row statuses (same as the cluster rows).
|
||||
const (
|
||||
StatusSame = "same"
|
||||
StatusCreate = "create"
|
||||
StatusUpdate = "update"
|
||||
StatusDelete = "delete"
|
||||
StatusExtra = "extra" // only on the target, kept
|
||||
StatusError = "error"
|
||||
)
|
||||
|
||||
// Row is one object of a target: an interface, the DNS servers or a file.
|
||||
type Row struct {
|
||||
Kind string `json:"kind"` // item id
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Status string `json:"status"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
Diff []Line `json:"diff,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Target is the state of one target node.
|
||||
type Target struct {
|
||||
NodeInfo
|
||||
Plan Plan `json:"plan"`
|
||||
Digest string `json:"digest"`
|
||||
Rows []Row `json:"rows"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
}
|
||||
|
||||
// State is a full comparison of the master with its targets.
|
||||
type State struct {
|
||||
At time.Time `json:"at"`
|
||||
Profile string `json:"profile"`
|
||||
Items []string `json:"items"`
|
||||
Prune bool `json:"prune"`
|
||||
Master NodeInfo `json:"reference"` // "reference" like the cluster state
|
||||
Targets []Target `json:"clients"`
|
||||
|
||||
prof *Profile
|
||||
master Snapshot
|
||||
targets map[string]Snapshot
|
||||
eps map[string]Endpoint
|
||||
}
|
||||
|
||||
// Analyze reads the master and the targets (in parallel) and compares them.
|
||||
// only limits the targets (empty: all). A node that cannot be read is
|
||||
// reported in its Error field, it does not stop the others.
|
||||
func Analyze(ctx context.Context, prof *Profile, eps map[string]Endpoint, only []string) *State {
|
||||
st := &State{At: time.Now().UTC(), Profile: prof.Name, Items: prof.Items, Prune: prof.Prune, Targets: []Target{},
|
||||
prof: prof, targets: map[string]Snapshot{}, eps: eps}
|
||||
st.Master, st.master = read(ctx, eps, prof.Master, prof.Items)
|
||||
want := map[string]bool{}
|
||||
for _, n := range only {
|
||||
want[n] = true
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
for _, key := range prof.Targets {
|
||||
if len(only) > 0 && !want[key] {
|
||||
continue
|
||||
}
|
||||
st.Targets = append(st.Targets, Target{NodeInfo: info(key)})
|
||||
idx := len(st.Targets) - 1
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ni, snap := read(ctx, eps, key, prof.Items)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
st.Targets[idx].NodeInfo = ni
|
||||
if ni.Error == "" {
|
||||
st.targets[key] = snap
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for i := range st.Targets {
|
||||
t := &st.Targets[i]
|
||||
t.Counts = map[string]int{}
|
||||
if t.Error != "" || st.Master.Error != "" {
|
||||
continue
|
||||
}
|
||||
t.Plan = MakePlan(prof, st.master, st.targets[t.Key])
|
||||
t.Digest = t.Plan.Digest()
|
||||
t.Rows = rows(prof, st.master, st.targets[t.Key], t.Plan)
|
||||
for _, r := range t.Rows {
|
||||
t.Counts[r.Status]++
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func info(key string) NodeInfo {
|
||||
ni := NodeInfo{Key: key}
|
||||
for i := len(key) - 1; i >= 0; i-- {
|
||||
if key[i] == '/' {
|
||||
ni.Cluster, ni.Node = key[:i], key[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
return ni
|
||||
}
|
||||
|
||||
func read(ctx context.Context, eps map[string]Endpoint, key string, items []string) (NodeInfo, Snapshot) {
|
||||
ni := info(key)
|
||||
ni.ReadAt = time.Now().UTC()
|
||||
start := time.Now()
|
||||
ep, ok := eps[key]
|
||||
if !ok {
|
||||
ni.Error = fmt.Sprintf("unknown node %s (discover the nodes of its cluster)", key)
|
||||
return ni, Snapshot{}
|
||||
}
|
||||
snap, err := Read(ctx, ep, items)
|
||||
if err != nil {
|
||||
ni.Error = err.Error()
|
||||
}
|
||||
ni.Version = snap.Version
|
||||
ni.Warnings = snap.Warnings
|
||||
ni.Millis = time.Since(start).Milliseconds()
|
||||
return ni, snap
|
||||
}
|
||||
|
||||
// rows describes every object of the master and of the target.
|
||||
func rows(prof *Profile, master, target Snapshot, plan Plan) []Row {
|
||||
changes := map[string]Change{}
|
||||
for _, c := range plan.Changes {
|
||||
changes[c.Key()] = c
|
||||
}
|
||||
errs := map[string][]string{}
|
||||
for _, e := range plan.Errors {
|
||||
if e.Key != "" {
|
||||
errs[e.Key] = append(errs[e.Key], e.Message)
|
||||
}
|
||||
}
|
||||
tm := prof.Mapping[target.Key]
|
||||
var out []Row
|
||||
seen := map[string]bool{}
|
||||
status := func(key string, inMaster bool) string {
|
||||
ch, has := changes[key]
|
||||
switch {
|
||||
case len(errs[key]) > 0:
|
||||
return StatusError
|
||||
case has:
|
||||
return string(ch.Op)
|
||||
case !inMaster:
|
||||
return StatusExtra
|
||||
}
|
||||
return StatusSame
|
||||
}
|
||||
addAPI := func(item, id string, ref, cur map[string]any) {
|
||||
key := item + "/" + id
|
||||
seen[key] = true
|
||||
ch := changes[key]
|
||||
r := Row{Kind: item, ID: id, Key: key, Status: status(key, ref != nil), Errors: errs[key]}
|
||||
names := map[string]bool{}
|
||||
for _, m := range []map[string]any{ref, cur, ch.Set} {
|
||||
for f := range m {
|
||||
names[f] = true
|
||||
}
|
||||
}
|
||||
for f := range tm.Values[key] {
|
||||
names[f] = true
|
||||
}
|
||||
list := make([]string, 0, len(names))
|
||||
for f := range names {
|
||||
list = append(list, f)
|
||||
}
|
||||
sort.Strings(list)
|
||||
unset := map[string]bool{}
|
||||
for _, f := range ch.Unset {
|
||||
unset[f] = true
|
||||
}
|
||||
for _, f := range list {
|
||||
fd := Field{Name: f, Policy: prof.Policy(item, f), Ref: ref[f], Current: cur[f]}
|
||||
if v, ok := tm.Values[key][f]; ok {
|
||||
fd.Mapping = v
|
||||
}
|
||||
switch {
|
||||
case ch.Op == OpDelete || unset[f]:
|
||||
fd.Change = "unset"
|
||||
case ch.Set[f] != nil:
|
||||
fd.Target = ch.Set[f]
|
||||
fd.Change = "set"
|
||||
if fd.Current == nil {
|
||||
fd.Change = "add"
|
||||
}
|
||||
case fd.Current != nil:
|
||||
fd.Target = fd.Current
|
||||
}
|
||||
r.Fields = append(r.Fields, fd)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
for _, id := range prof.Items {
|
||||
it, _ := ItemByID(id)
|
||||
switch it.Kind {
|
||||
case KindNetwork:
|
||||
cur := map[string]model.Object{}
|
||||
for _, o := range target.Network {
|
||||
cur[o.ID] = o
|
||||
}
|
||||
for _, m := range master.Network {
|
||||
tid := rename(m.ID, tm.NICs)
|
||||
addAPI("network", tid, m.Fields, cur[tid].Fields)
|
||||
}
|
||||
for _, o := range target.Network {
|
||||
if !seen["network/"+o.ID] {
|
||||
addAPI("network", o.ID, nil, o.Fields)
|
||||
}
|
||||
}
|
||||
case KindDNS:
|
||||
addAPI("dns", "dns", master.DNS, target.DNS)
|
||||
case KindFile:
|
||||
paths := map[string]bool{}
|
||||
for p := range master.Files[id] {
|
||||
paths[p] = true
|
||||
}
|
||||
for p := range target.Files[id] {
|
||||
paths[p] = true
|
||||
}
|
||||
if len(master.Files[id]) == 0 && !it.Glob {
|
||||
continue
|
||||
}
|
||||
for p := range paths {
|
||||
key := id + "/" + p
|
||||
_, inMaster := master.Files[id][p]
|
||||
r := Row{Kind: id, ID: p, Key: key, Status: status(key, inMaster), Errors: errs[key], Diff: changes[key].Diff}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return itemRank(out[i].Kind) < itemRank(out[j].Kind)
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// TargetPlan is the plan of one target restricted to a selection of objects.
|
||||
type TargetPlan struct {
|
||||
Client string `json:"client"` // "client" like the cluster plans
|
||||
Plan Plan `json:"plan"`
|
||||
Digest string `json:"digest"`
|
||||
Network bool `json:"network"`
|
||||
Reloads []string `json:"reloads,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Plans returns the plans of the targets restricted to the given objects
|
||||
// (empty: everything), as a dry-run shows them and an apply checks them.
|
||||
func (st *State) Plans(keys []string) []TargetPlan {
|
||||
var out []TargetPlan
|
||||
for _, t := range st.Targets {
|
||||
tp := TargetPlan{Client: t.Key}
|
||||
switch {
|
||||
case st.Master.Error != "":
|
||||
tp.Error = "master: " + st.Master.Error
|
||||
case t.Error != "":
|
||||
tp.Error = t.Error
|
||||
default:
|
||||
tp.Plan = Filter(t.Plan, keys)
|
||||
tp.Digest = tp.Plan.Digest()
|
||||
tp.Network = tp.Plan.Network()
|
||||
tp.Reloads = tp.Plan.Reloads()
|
||||
}
|
||||
out = append(out, tp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Apply writes one plan on its target, then reads the target again and checks
|
||||
// that the selected objects are in line with the master.
|
||||
func (st *State) Apply(ctx context.Context, tp TargetPlan, keys []string, log func(Step)) error {
|
||||
ep, ok := st.eps[tp.Client]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown node %q", tp.Client)
|
||||
}
|
||||
if err := Apply(ctx, ep, tp.Plan, log); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
err := st.verify(ctx, ep, keys)
|
||||
s := Step{At: start.UTC(), Action: "verify"}
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
log(s)
|
||||
return err
|
||||
}
|
||||
|
||||
func (st *State) verify(ctx context.Context, ep Endpoint, keys []string) error {
|
||||
snap, err := Read(ctx, ep, st.prof.Items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := Filter(MakePlan(st.prof, st.master, snap), keys)
|
||||
var errs []error
|
||||
for _, c := range p.Changes {
|
||||
errs = append(errs, fmt.Errorf("still different after apply: %s", c.Key()))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
Reference in new issue
Block a user