Files
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

662 lines
17 KiB
Go

package nodesync
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"pve-confsync/internal/model"
)
// Profile is one node replication: a master node, target nodes, the
// replicated items and their rules.
type Profile struct {
Name string `json:"name"`
Master string `json:"master"` // "cluster/node"
Targets []string `json:"targets"` // "cluster/node"
Items []string `json:"items"`
// Prune deletes on the targets what the master does not have: network
// interfaces (never the physical ones) and the files of the glob items.
Prune bool `json:"prune"`
// Policies of the fields of the API items (item -> field -> policy),
// over the defaults (addresses and gateways: override).
Policies map[string]map[string]Policy `json:"policies"`
// Protect: per file item, regular expressions of the lines kept from the
// target (hostname, local address…).
Protect map[string][]string `json:"protect"`
// Mapping: per target, its interface names and its own values.
Mapping map[string]TargetMap `json:"mapping"`
Position int `json:"position"`
}
// TargetMap holds what is specific to one target.
type TargetMap struct {
// NICs renames the interfaces of the master (eno1 -> enp1s0f0), in the
// interface names and in the fields that list interfaces.
NICs map[string]string `json:"nics,omitempty"`
// Values: "item/id" -> field -> value written on this target (cidr of
// vmbr0…), whatever the master has.
Values map[string]map[string]string `json:"values,omitempty"`
}
// Validate checks a profile.
func (p *Profile) Validate() error {
var errs []error
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if p.Master == "" {
errs = append(errs, errors.New("master node is required"))
}
seen := map[string]bool{}
for _, t := range p.Targets {
if t == p.Master {
errs = append(errs, fmt.Errorf("target %s is the master", t))
}
if seen[t] {
errs = append(errs, fmt.Errorf("target %s listed twice", t))
}
seen[t] = true
}
if len(p.Items) == 0 {
errs = append(errs, errors.New("select at least one item"))
}
for _, id := range p.Items {
if _, err := ItemByID(id); err != nil {
errs = append(errs, err)
}
}
for item, fields := range p.Policies {
for f, pol := range fields {
switch pol {
case Sync, Ignore, Override:
default:
errs = append(errs, fmt.Errorf("%s.%s: unknown policy %q", item, f, pol))
}
if item == "network" && f == "type" && pol != Sync {
errs = append(errs, errors.New("network.type: always synchronized"))
}
}
}
for item, res := range p.Protect {
for _, re := range res {
if _, err := regexp.Compile(re); err != nil {
errs = append(errs, fmt.Errorf("%s: protected lines: %w", item, err))
}
}
}
return errors.Join(errs...)
}
// Policy resolves the policy of a field.
func (p *Profile) Policy(item, field string) Policy {
if item == "network" && field == "type" {
return Sync
}
if pol, ok := p.Policies[item][field]; ok {
return pol
}
return defaultPolicy(item, field)
}
// Has tells whether an item is replicated.
func (p *Profile) Has(item string) bool {
for _, x := range p.Items {
if x == item {
return true
}
}
return false
}
// Op is the action planned on one object.
type Op string
const (
OpCreate Op = "create"
OpUpdate Op = "update"
OpDelete Op = "delete"
)
// Change is one planned action: an interface or the DNS servers (Set, Unset,
// Old), or a file (Diff, Hash of the target file, content to write).
type Change struct {
Item string `json:"kind"` // item id ("kind" like the cluster plans, for the UI)
ID string `json:"id"` // interface, "dns" or file path
Op Op `json:"op"`
Set map[string]any `json:"set,omitempty"`
Unset []string `json:"unset,omitempty"`
Old map[string]any `json:"old,omitempty"`
Diff []Line `json:"diff,omitempty"`
Hash string `json:"hash,omitempty"` // current file on the target ("absent" when created)
Mode string `json:"mode,omitempty"`
Owner string `json:"owner,omitempty"`
// Full is the whole writable configuration of an interface after the
// change: PUT /nodes/{node}/network/{iface} recomputes the method and the
// families from the parameters, so every field is sent again.
Full map[string]any `json:"-"`
Content []byte `json:"-"`
}
// Key identifies the object of a change ("item/id").
func (c Change) Key() string { return c.Item + "/" + c.ID }
// Issue is a blocking error or a warning, about one object (Key) or the
// whole plan.
type Issue struct {
Key string `json:"key,omitempty"`
Message string `json:"message"`
}
// Plan is the list of changes for one target, in execution order.
type Plan struct {
Target string `json:"client"`
Changes []Change `json:"changes"`
Errors []Issue `json:"errors,omitempty"`
Warnings []Issue `json:"warnings,omitempty"`
}
// Empty tells whether there is nothing to do.
func (p Plan) Empty() bool { return len(p.Changes) == 0 }
// Network tells whether the plan reloads the network of the node.
func (p Plan) Network() bool {
for _, c := range p.Changes {
if it, _ := ItemByID(c.Item); it.Network {
return true
}
}
return false
}
// Reloads lists the commands run after the writes, in order.
func (p Plan) Reloads() []string {
seen := map[string]bool{}
var out []string
for _, it := range Items {
for _, c := range p.Changes {
if c.Item != it.ID {
continue
}
cmd := it.Reload
if it.Kind == KindNetwork {
cmd = "ifreload -a"
}
if cmd != "" && !seen[cmd] {
seen[cmd] = true
out = append(out, cmd)
}
break
}
}
return out
}
func (p *Plan) errorf(key, format string, a ...any) {
p.Errors = append(p.Errors, Issue{key, fmt.Sprintf(format, a...)})
}
func (p *Plan) warnf(key, format string, a ...any) {
p.Warnings = append(p.Warnings, Issue{key, fmt.Sprintf(format, a...)})
}
// MakePlan computes the changes that bring the target in line with the master.
func MakePlan(prof *Profile, master, target Snapshot) Plan {
plan := Plan{Target: target.Key}
tm := prof.Mapping[target.Key]
if target.Pending && prof.Has("network") {
plan.errorf("", "pending network changes on %s (interfaces.new): apply or revert them in PVE first", target.Key)
}
if master.Pending && prof.Has("network") {
plan.warnf("", "the master has pending network changes: its pending configuration is replicated")
}
for _, id := range prof.Items {
it, _ := ItemByID(id)
switch it.Kind {
case KindNetwork:
planNetwork(&plan, prof, tm, master, target)
case KindDNS:
planDNS(&plan, prof, tm, master, target)
case KindFile:
planFiles(&plan, prof, it, master.Files[it.ID], target.Files[it.ID])
}
}
sortChanges(plan.Changes)
return plan
}
// rank orders the interfaces: what is referenced first (ports, bonds, VLAN
// devices), bridges last; deletions run in the reverse order.
func netRank(typ string) int {
switch typ {
case "eth", "alias":
return 0
case "bond", "OVSBond":
return 1
case "vlan":
return 2
case "bridge", "OVSBridge":
return 3
}
return 4
}
func sortChanges(chs []Change) {
typ := func(c Change) string {
if t, ok := c.Full["type"].(string); ok {
return t
}
t, _ := c.Old["type"].(string)
return t
}
sort.SliceStable(chs, func(i, j int) bool {
a, b := chs[i], chs[j]
if (a.Op == OpDelete) != (b.Op == OpDelete) {
return b.Op == OpDelete
}
if a.Item != b.Item {
return itemRank(a.Item) < itemRank(b.Item)
}
if a.Item == "network" {
ra, rb := netRank(typ(a)), netRank(typ(b))
if a.Op == OpDelete {
ra, rb = rb, ra
}
if ra != rb {
return ra < rb
}
}
return a.ID < b.ID
})
}
// netLists are the fields naming other interfaces (space separated lists or
// one name): renamed with the NIC mapping of the target.
var netLists = map[string]bool{
"bridge_ports": true, "slaves": true, "ovs_ports": true, "ovs_bonds": true,
"bond-primary": true, "vlan-raw-device": true, "ovs_bridge": true,
}
// rename maps an interface name of the master to the target: eno1, and the
// VLAN interfaces built on it (eno1.100).
func rename(name string, nics map[string]string) string {
if to, ok := nics[name]; ok {
return to
}
if base, vlan, ok := strings.Cut(name, "."); ok {
if to, ok := nics[base]; ok {
return to + "." + vlan
}
}
return name
}
func renameList(v string, nics map[string]string) string {
parts := strings.Fields(v)
for i, p := range parts {
parts[i] = rename(p, nics)
}
return strings.Join(parts, " ")
}
func planNetwork(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) {
cur := map[string]model.Object{}
for _, o := range target.Network {
cur[o.ID] = o
}
inMaster := map[string]bool{}
for _, m := range master.Network {
id := rename(m.ID, tm.NICs)
key := "network/" + id
inMaster[id] = true
desired := map[string]any{}
managed := map[string]bool{}
for f, v := range m.Fields {
if prof.Policy("network", f) != Sync {
continue
}
if netLists[f] {
v = renameList(v.(string), tm.NICs)
}
desired[f] = v
}
for f := range netWritable {
if prof.Policy("network", f) == Sync {
managed[f] = true
}
}
for f, v := range tm.Values[key] {
if prof.Policy("network", f) == Ignore || !netWritable[f] || f == "type" {
continue
}
desired[f] = v
managed[f] = true
}
typ, _ := desired["type"].(string)
o, exists := cur[id]
if !exists {
if physical(typ) {
plan.errorf(key, "physical interface %s absent on %s: add it to the NIC mapping of the target", id, target.Key)
continue
}
plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpCreate, Set: desired, Full: desired})
continue
}
if t, _ := o.Fields["type"].(string); t != typ {
plan.errorf(key, "type differs (%s on the target, %s on the master): re-create the interface by hand", t, typ)
continue
}
if mo, to := strings.Join(master.Options[m.ID], "\n"), strings.Join(target.Options[id], "\n"); mo != to {
plan.warnf(key, "options the API does not write differ (%s): not replicated", optionNames(master.Options[m.ID], target.Options[id]))
}
ch := Change{Item: "network", ID: id, Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}}
full := map[string]any{}
for f, v := range o.Fields {
full[f] = v
}
for f, v := range desired {
if !model.Equal(v, o.Fields[f]) {
ch.Set[f] = v
if old, ok := o.Fields[f]; ok {
ch.Old[f] = old
}
}
full[f] = v
}
for f := range managed {
if _, want := desired[f]; want {
continue
}
if old, has := o.Fields[f]; has {
ch.Unset = append(ch.Unset, f)
ch.Old[f] = old
delete(full, f)
}
}
sort.Strings(ch.Unset)
if len(ch.Set) > 0 || len(ch.Unset) > 0 {
ch.Full = full
plan.Changes = append(plan.Changes, ch)
}
}
if !prof.Prune {
return
}
for id, o := range cur {
if inMaster[id] {
continue
}
if t, _ := o.Fields["type"].(string); physical(t) {
continue
}
plan.Changes = append(plan.Changes, Change{Item: "network", ID: id, Op: OpDelete, Old: o.Fields})
}
}
// optionNames gives the option keywords that differ (bond-miimon, post-up…).
func optionNames(a, b []string) string {
set := map[string]bool{}
count := map[string]int{}
for _, l := range a {
count[l]++
}
for _, l := range b {
count[l]--
}
for l, n := range count {
if n != 0 {
set[strings.Fields(l + " ")[0]] = true
}
}
var out []string
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
func planDNS(plan *Plan, prof *Profile, tm TargetMap, master, target Snapshot) {
const key = "dns/dns"
desired := map[string]any{}
for _, f := range dnsFields {
pol := prof.Policy("dns", f)
if v, ok := master.DNS[f]; ok && pol == Sync {
desired[f] = v
}
if pol != Sync {
if v, ok := target.DNS[f]; ok {
desired[f] = v // PUT /nodes/{node}/dns rewrites every server
}
}
if v, ok := tm.Values[key][f]; ok && pol != Ignore {
desired[f] = v
}
}
ch := Change{Item: "dns", ID: "dns", Op: OpUpdate, Set: map[string]any{}, Old: map[string]any{}, Full: desired}
for _, f := range dnsFields {
v, want := desired[f]
old, has := target.DNS[f]
switch {
case want && !model.Equal(v, old):
ch.Set[f] = v
if has {
ch.Old[f] = old
}
case !want && has:
ch.Unset = append(ch.Unset, f)
ch.Old[f] = old
}
}
if len(ch.Set) == 0 && len(ch.Unset) == 0 {
return
}
if desired["search"] == nil {
plan.errorf(key, "a search domain is required by the API (PUT /nodes/{node}/dns)")
return
}
plan.Changes = append(plan.Changes, ch)
}
func planFiles(plan *Plan, prof *Profile, it Item, master, target map[string]File) {
var protect []*regexp.Regexp
for _, s := range prof.Protect[it.ID] {
if re, err := regexp.Compile(s); err == nil {
protect = append(protect, re)
}
}
if len(master) == 0 && !it.Glob {
plan.warnf(it.ID+"/"+it.Paths[0], "%s absent on the master: not replicated", it.Paths[0])
return
}
paths := make([]string, 0, len(master))
for p := range master {
paths = append(paths, p)
}
sort.Strings(paths)
for _, p := range paths {
m := master[p]
t, exists := target[p]
var content []byte
switch {
case it.Additive && exists:
content = additive(t.Content, m.Content)
case exists:
content = protectLines(m.Content, t.Content, protect)
default:
content = protectLines(m.Content, nil, protect)
}
if exists && hash(content) == t.Hash {
continue
}
ch := Change{Item: it.ID, ID: p, Content: content, Mode: m.Mode, Owner: m.Owner, Hash: "absent", Op: OpCreate}
var old []byte
if exists {
// an update keeps the mode and owner of the target file
ch.Op, ch.Hash, ch.Mode, ch.Owner, old = OpUpdate, t.Hash, t.Mode, t.Owner, t.Content
}
ch.Diff = Diff(old, content)
plan.Changes = append(plan.Changes, ch)
}
if !prof.Prune || !it.Glob {
return
}
for p, t := range target {
if _, ok := master[p]; ok {
continue
}
plan.Changes = append(plan.Changes, Change{Item: it.ID, ID: p, Op: OpDelete, Hash: t.Hash, Diff: Diff(t.Content, nil)})
}
}
// protectLines: each line of the master matching a protected expression takes
// the value of the first line of the target matching the same expression
// (dropped when the target has none).
func protectLines(master, target []byte, protect []*regexp.Regexp) []byte {
if len(protect) == 0 {
return master
}
tl := lines(target)
used := map[int]bool{}
var out []string
for _, l := range lines(master) {
var re *regexp.Regexp
for _, r := range protect {
if r.MatchString(l) {
re = r
break
}
}
if re == nil {
out = append(out, l)
continue
}
for i, x := range tl {
if !used[i] && re.MatchString(x) {
used[i] = true
out = append(out, x)
break
}
}
}
return joinLines(out)
}
// additive appends to the target the lines of the master it does not have.
func additive(target, master []byte) []byte {
tl := lines(target)
have := map[string]bool{}
for _, l := range tl {
have[l] = true
}
out := tl
for _, l := range lines(master) {
if !have[l] {
have[l] = true
out = append(out, l)
}
}
return joinLines(out)
}
func joinLines(l []string) []byte {
if len(l) == 0 {
return nil
}
return []byte(strings.Join(l, "\n") + "\n")
}
// Filter keeps the changes of the given objects ("item/id"), plus the
// interfaces they need that the plan creates (ports of a bridge, raw device
// of a VLAN). Issues about other objects do not block the selection.
func Filter(p Plan, keys []string) Plan {
if len(keys) == 0 {
return p
}
byKey := map[string]Change{}
for _, c := range p.Changes {
byKey[c.Key()] = c
}
want := map[string]bool{}
for _, k := range keys {
want[k] = true
}
for changed := true; changed; {
changed = false
for k := range want {
c, ok := byKey[k]
if !ok || c.Item != "network" || c.Op == OpDelete {
continue
}
for f, v := range c.Full {
if !netLists[f] {
continue
}
for _, n := range strings.Fields(v.(string)) {
dep := "network/" + n
if d, ok := byKey[dep]; ok && d.Op == OpCreate && !want[dep] {
want[dep] = true
changed = true
}
}
}
}
}
out := Plan{Target: p.Target}
for _, e := range p.Errors {
if e.Key == "" || want[e.Key] {
out.Errors = append(out.Errors, e)
}
}
for _, w := range p.Warnings {
if w.Key == "" || want[w.Key] {
out.Warnings = append(out.Warnings, w)
}
}
for _, c := range p.Changes {
if want[c.Key()] {
out.Changes = append(out.Changes, c)
}
}
return out
}
// Digest identifies the content of a plan: the apply only runs when the plan
// computed again is the one reviewed in the dry-run.
func (p Plan) Digest() string {
type ch struct {
Op Op `json:"op"`
Key string `json:"key"`
Set map[string]any `json:"set,omitempty"`
Unset []string `json:"unset,omitempty"`
Hash string `json:"hash,omitempty"`
New string `json:"new,omitempty"`
}
l := make([]ch, 0, len(p.Changes))
for _, c := range p.Changes {
x := ch{Op: c.Op, Key: c.Key(), Set: c.Set, Unset: c.Unset, Hash: c.Hash}
if c.Content != nil || c.Op != OpDelete && c.Hash != "" {
x.New = hash(c.Content)
}
l = append(l, x)
}
errs := make([]string, 0, len(p.Errors))
for _, e := range p.Errors {
errs = append(errs, e.Key+" "+e.Message)
}
sort.Strings(errs)
b, _ := json.Marshal(struct {
Target string `json:"target"`
Changes []ch `json:"changes"`
Errors []string `json:"errors"`
}{p.Target, l, errs})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:12])
}