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:
cedricandClaude Opus 5.5 committed 2026-10-23 20:29:55 +02:00
1 parent dc1a7ff40e
commit 1e667ce8b9
24 files changed
+4973 -65

No files matched your search

+341
View File
@@ -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...)
}
+333
View File
@@ -0,0 +1,333 @@
package nodesync
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"pve-confsync/internal/pve"
)
// Step is the outcome of one action, for the execution journal.
type Step struct {
At time.Time `json:"at"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
// Timing of an apply; variables for the tests.
var (
// RestoreDelay: when the network was reloaded, the node restores its
// previous configuration by itself after this delay unless pve-confsync
// reaches it again over SSH and cancels the restore.
RestoreDelay = 120 * time.Second
// ProbeTries and ProbeWait: attempts to reach the node after the reload.
ProbeTries = 6
ProbeWait = 5 * time.Second
TaskPoll = 2 * time.Second
)
// BackupRoot is where the previous files are kept on the node, one folder
// per apply, with the restore.sh script that puts them back.
const BackupRoot = "/var/lib/pve-confsync/backup"
// Apply writes a plan on a target node:
// 1. one SSH script checks that every file is still the one of the dry-run,
// backs up the files (and /etc/network/interfaces, /etc/resolv.conf when
// the API writes them), writes restore.sh, then writes the new files
// atomically (temporary file then mv, mode and owner kept);
// 2. the API writes the interfaces (in interfaces.new) and the DNS servers;
// 3. when the network is reloaded, a systemd timer is armed on the node: it
// runs restore.sh and ifreload -a after RestoreDelay;
// 4. the reload commands run (sysctl, chrony, multipath, network last);
// 5. the node must answer again over SSH and the API: the timer is stopped.
//
// A failure before the network reload restores the backups (and drops
// interfaces.new); a reload failure restores them and runs the reloads again.
func Apply(ctx context.Context, ep Endpoint, plan Plan, notify func(Step)) error {
log := func(action string, err error) error {
s := Step{At: time.Now().UTC(), Action: action}
if err != nil {
s.Error = err.Error()
}
if notify != nil {
notify(s)
}
return err
}
if len(plan.Errors) > 0 {
return log("plan check", fmt.Errorf("plan has %d error(s), nothing applied", len(plan.Errors)))
}
if plan.Empty() {
return nil
}
if ep.Shell == nil {
return log("ssh", fmt.Errorf("%s: SSH host key not recorded yet, test the node first", ep.Key))
}
if _, err := NodeVersion(ctx, ep); err != nil {
return log("version check", err)
}
stamp := time.Now().UTC().Format("20060102-150405")
dir := BackupRoot + "/" + stamp
unit := "pve-confsync-restore-" + stamp
var netAPI, dns bool
for _, c := range plan.Changes {
netAPI = netAPI || c.Item == "network"
dns = dns || c.Item == "dns"
}
if _, err := ep.Shell.Run(ctx, prepareScript(dir), prepareInput(plan, netAPI, dns)); err != nil {
return log("check, back up and write the files ("+dir+")", err)
}
log("check, back up and write the files ("+dir+")", nil)
restore := func(cause error) error {
_, rerr := ep.Shell.Run(ctx, restoreScript(dir), nil)
log("restore the previous files", rerr)
if rerr != nil {
return errors.Join(cause, fmt.Errorf("restore failed, run sh %s/restore.sh on the node: %w", dir, rerr))
}
return cause
}
for _, c := range plan.Changes {
if c.Item != "network" && c.Item != "dns" {
continue
}
if err := log(describe(ep, c), writeAPI(ctx, ep, c)); err != nil {
return restore(err)
}
}
network := plan.Network()
if network {
_, err := ep.Shell.Run(ctx, armScript(dir, unit), nil)
if err := log(fmt.Sprintf("arm the automatic restore (%s, %s)", unit, RestoreDelay), err); err != nil {
return restore(err)
}
}
var done []string // reload commands already run
for _, cmd := range plan.Reloads() {
var err error
action := cmd
if cmd == "ifreload -a" && netAPI {
action = "PUT /nodes/" + ep.Node + "/network (ifreload -a)"
err = reloadNetworkAPI(ctx, ep)
} else {
_, err = ep.Shell.Run(ctx, reloadScript(cmd), nil)
}
done = append(done, cmd)
if log("reload: "+action, err) == nil {
continue
}
cause := fmt.Errorf("reload %q: %w", cmd, err)
if network && !reachable(ctx, ep) {
return log("node unreachable", fmt.Errorf("%w; the node is unreachable: it restores its previous configuration by itself within %s", cause, RestoreDelay))
}
// back to the previous files, which the reloads put in effect again
err = restore(cause)
for _, c := range done {
_, rerr := ep.Shell.Run(ctx, reloadScript(c), nil)
log("reload after restore: "+c, rerr)
}
if network {
_, derr := ep.Shell.Run(ctx, disarmScript(unit), nil)
log("stop the automatic restore", derr)
}
return err
}
if network {
if !reachable(ctx, ep) {
return log("reach the node again", fmt.Errorf("%s unreachable after the network reload: it restores its previous configuration by itself within %s", ep.Key, RestoreDelay))
}
log("reach the node again (SSH and API)", nil)
_, err := ep.Shell.Run(ctx, disarmScript(unit), nil)
if err := log("stop the automatic restore", err); err != nil {
return fmt.Errorf("stop the automatic restore (systemctl stop %s.timer on the node): %w", unit, err)
}
}
return nil
}
func describe(ep Endpoint, c Change) string {
if c.Item == "dns" {
return "PUT /nodes/" + ep.Node + "/dns"
}
return fmt.Sprintf("%s %s/%s", c.Op, c.Item, c.ID)
}
// reachable opens a new SSH connection and calls the API, a few times.
func reachable(ctx context.Context, ep Endpoint) bool {
for i := 0; i < ProbeTries; i++ {
if i > 0 {
select {
case <-ctx.Done():
return false
case <-time.After(ProbeWait):
}
}
sh := ep.Shell
if ep.Dial != nil {
var err error
if sh, err = ep.Dial(); err != nil {
continue
}
}
if _, err := sh.Run(ctx, "# confsync:probe\ntrue", nil); err != nil {
continue
}
if _, err := NodeVersion(ctx, ep); err == nil {
return true
}
}
return false
}
func nodePath(ep Endpoint, rest string) string {
return "nodes/" + url.PathEscape(ep.Node) + "/" + rest
}
// writeAPI writes one interface (POST, PUT with every field, DELETE) or the
// DNS servers (PUT with every server).
func writeAPI(ctx context.Context, ep Endpoint, c Change) error {
if c.Item == "dns" {
f := url.Values{}
for k, v := range c.Full {
f.Set(k, pve.Str(v))
}
return ep.API.Put(ctx, nodePath(ep, "dns"), f, nil)
}
switch c.Op {
case OpCreate:
f := form(c.Full)
f.Set("iface", c.ID)
return ep.API.Post(ctx, nodePath(ep, "network"), f, nil)
case OpUpdate:
f := form(c.Full)
if len(c.Unset) > 0 {
f.Set("delete", strings.Join(c.Unset, ","))
}
return ep.API.Put(ctx, nodePath(ep, "network/"+url.PathEscape(c.ID)), f, nil)
case OpDelete:
return ep.API.Delete(ctx, nodePath(ep, "network/"+url.PathEscape(c.ID)), nil, nil)
}
return fmt.Errorf("unknown op %q", c.Op)
}
func form(fields map[string]any) url.Values {
f := url.Values{}
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
f.Set(k, pve.Str(fields[k]))
}
return f
}
// reloadNetworkAPI applies interfaces.new (PUT /nodes/{node}/network: rename
// then ifreload -a in a task) and waits for the task.
func reloadNetworkAPI(ctx context.Context, ep Endpoint) error {
var upid string
if err := ep.API.Put(ctx, nodePath(ep, "network"), url.Values{}, &upid); err != nil {
return err
}
return ep.API.WaitTask(ctx, upid, TaskPoll)
}
// ---- scripts run on the node (the first line is a marker for the fake node
// of the tests) ----
// prepareInput is the plan given to prepareScript on its standard input:
// "W <mode> <owner> <hash> <path>" then the content in base64 on one line,
// "D - - <hash> <path>" (delete) or "B - - - <path>" (backup only).
func prepareInput(plan Plan, netAPI, dns bool) []byte {
var b bytes.Buffer
if netAPI {
b.WriteString("B - - - /etc/network/interfaces\nB - - - /etc/network/interfaces.new\n")
}
if dns {
b.WriteString("B - - - /etc/resolv.conf\n")
}
for _, c := range plan.Changes {
switch {
case c.Item == "network" || c.Item == "dns":
case c.Op == OpDelete:
fmt.Fprintf(&b, "D - - %s %s\n", c.Hash, c.ID)
default:
fmt.Fprintf(&b, "W %s %s %s %s\n%s\n", c.Mode, c.Owner, c.Hash, c.ID, base64.StdEncoding.EncodeToString(c.Content))
}
}
return b.Bytes()
}
func prepareScript(dir string) string { return prepareScriptIn(dir, "") }
// prepareScriptIn prefixes every path with root (empty on a node): the tests
// run the script for real in a temporary folder.
func prepareScriptIn(dir, root string) string {
return `# confsync:prepare ` + dir + `
set -u
R='` + root + `'
B="$R` + dir + `"
umask 077
mkdir -p "$B/files" || exit 1
cat > "$B/plan" || exit 1
sum() { if [ -e "$1" ]; then sha256sum < "$1" | cut -d' ' -f1; else echo absent; fi; }
# 1. every file must be the one read by the dry-run
while read -r op mode owner h p; do
case "$op" in W|D) ;; *) continue ;; esac
[ "$(sum "$R$p")" = "$h" ] || { echo "changed since the dry-run: $p" >&2; exit 3; }
done < "$B/plan"
# 2. backups and restore script
: > "$B/restore.sh"
while read -r op mode owner h p; do
case "$op" in W|D|B) ;; *) continue ;; esac
if [ -e "$R$p" ]; then
mkdir -p "$B/files$(dirname "$p")" && cp -p "$R$p" "$B/files$p" || exit 1
printf "cp -p '%s' '%s'\n" "$B/files$p" "$R$p" >> "$B/restore.sh"
else
printf "rm -f '%s'\n" "$R$p" >> "$B/restore.sh"
fi
done < "$B/plan"
# 3. writes: temporary file in the same folder, then mv
while read -r op mode owner h p; do
case "$op" in
W)
read -r data
mkdir -p "$(dirname "$R$p")" || exit 4
t=$(mktemp "$R$p.confsync.XXXXXX") || exit 4
if printf '%s' "$data" | base64 -d > "$t" && chmod "$mode" "$t" && chown "$owner" "$t" && mv -f "$t" "$R$p"; then :; else
rm -f "$t"; sh "$B/restore.sh"; echo "write failed: $p" >&2; exit 4
fi ;;
D)
rm -f "$R$p" || { sh "$B/restore.sh"; echo "delete failed: $p" >&2; exit 4; } ;;
esac
done < "$B/plan"
echo ok
`
}
func restoreScript(dir string) string {
return "# confsync:restore " + dir + "\nsh '" + dir + "/restore.sh'\n"
}
func armScript(dir, unit string) string {
return fmt.Sprintf("# confsync:arm %s\nsystemd-run --quiet --unit=%s --on-active=%ds --timer-property=AccuracySec=1s /bin/sh -c 'sh %s/restore.sh; ifreload -a'\n",
unit, unit, int(RestoreDelay.Seconds()), dir)
}
func disarmScript(unit string) string {
return "# confsync:disarm " + unit + "\nsystemctl stop " + unit + ".timer\n"
}
func reloadScript(cmd string) string {
return "# confsync:reload " + cmd + "\n" + cmd + "\n"
}
+148
View File
@@ -0,0 +1,148 @@
package nodesync
import (
"strconv"
"strings"
)
// Line is one line of a unified diff: Op is ' ' (context), '-' (target only)
// or '+' (written by the replication). A hunk starts with Op '@' and Text
// "@@ -a,b +c,d @@".
type Line struct {
Op string `json:"op"`
Text string `json:"text"`
}
const diffContext = 3
// maxCells bounds the LCS table; beyond, the diff shows every line replaced.
const maxCells = 4_000_000
// Diff compares the current content of a file with the content to write.
func Diff(oldText, newText []byte) []Line {
a, b := lines(oldText), lines(newText)
ops := editScript(a, b)
// group the changes into hunks with their context
var out []Line
n := len(ops)
i := 0
for i < n {
if ops[i].Op == " " {
i++
continue
}
start := i - diffContext
if start < 0 {
start = 0
}
end := i
for end < n {
if ops[end].Op != " " {
end++
continue
}
// a run of context: the hunk ends when it is longer than 2*context
j := end
for j < n && ops[j].Op == " " {
j++
}
if j == n || j-end > 2*diffContext {
end += diffContext
if end > n {
end = n
}
break
}
end = j
}
oa, ob := ops[start].A, ops[start].B
ca, cb := 0, 0
for _, o := range ops[start:end] {
if o.Op != "+" {
ca++
}
if o.Op != "-" {
cb++
}
}
out = append(out, Line{Op: "@", Text: hunkHeader(oa, ca, ob, cb)})
for _, o := range ops[start:end] {
out = append(out, Line{Op: o.Op, Text: o.Text})
}
i = end
}
return out
}
type editOp struct {
Op string
Text string
A, B int // line numbers (1-based) where this op starts in a and b
}
func editScript(a, b []string) []editOp {
var ops []editOp
if len(a)*len(b) > maxCells {
for i, l := range a {
ops = append(ops, editOp{"-", l, i + 1, 1})
}
for i, l := range b {
ops = append(ops, editOp{"+", l, len(a) + 1, i + 1})
}
return ops
}
// lcs[i][j]: length of the LCS of a[i:] and b[j:]
lcs := make([][]int, len(a)+1)
for i := range lcs {
lcs[i] = make([]int, len(b)+1)
}
for i := len(a) - 1; i >= 0; i-- {
for j := len(b) - 1; j >= 0; j-- {
if a[i] == b[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}
i, j := 0, 0
for i < len(a) || j < len(b) {
switch {
case i < len(a) && j < len(b) && a[i] == b[j]:
ops = append(ops, editOp{" ", a[i], i + 1, j + 1})
i++
j++
case j < len(b) && (i == len(a) || lcs[i][j+1] > lcs[i+1][j]):
ops = append(ops, editOp{"+", b[j], i + 1, j + 1})
j++
default:
ops = append(ops, editOp{"-", a[i], i + 1, j + 1})
i++
}
}
return ops
}
func lines(b []byte) []string {
s := string(b)
if s == "" {
return nil
}
s = strings.TrimSuffix(s, "\n")
return strings.Split(s, "\n")
}
func hunkHeader(a, ca, b, cb int) string {
r := func(start, n int) string {
if n == 0 {
start--
}
if n == 1 {
return strconv.Itoa(start)
}
return strconv.Itoa(start) + "," + strconv.Itoa(n)
}
return "@@ -" + r(a, ca) + " +" + r(b, cb) + " @@"
}
+100
View File
@@ -0,0 +1,100 @@
// Package nodesync replicates the system configuration of one PVE node (the
// master) to other nodes (the targets), of the same cluster or not. It is the
// second replication mode of pve-confsync, next to the cluster to cluster one
// (package engine). What the PVE API covers goes through the API: the network
// interfaces (/nodes/{node}/network, written in interfaces.new then reloaded)
// and the DNS servers (/nodes/{node}/dns). The other files go through SSH as
// root on the node: multipath, chrony, sysctl, interfaces.d. Same pipeline as
// the engine: read, normalize, diff, plan, apply with backup, reload, verify.
package nodesync
import "fmt"
// ItemKind tells how an item is read and written.
type ItemKind string
const (
KindNetwork ItemKind = "network" // API /nodes/{node}/network
KindDNS ItemKind = "dns" // API /nodes/{node}/dns
KindFile ItemKind = "file" // files over SSH
)
// Item is one replicable element.
type Item struct {
ID string `json:"id"`
Kind ItemKind `json:"kind"`
// Files: paths or glob patterns read on the nodes, and paths never touched.
Paths []string `json:"paths,omitempty"`
Exclude []string `json:"exclude,omitempty"`
// Glob items hold several files: a file only on the target is kept, or
// deleted with the prune option. Single file items are never deleted.
Glob bool `json:"glob,omitempty"`
// Additive files (multipath wwids) only get the lines of the master they
// miss: nothing is removed from the target.
Additive bool `json:"additive,omitempty"`
// Reload is the command run on the node after a write (empty: none).
Reload string `json:"reload,omitempty"`
// Network items reload the network of the node: typed confirmation,
// automatic restore when the node is unreachable afterwards.
Network bool `json:"network,omitempty"`
}
// Items is the catalog, in apply order (reloads follow this order, the
// network last).
var Items = []Item{
{ID: "sysctl", Kind: KindFile, Paths: []string{"/etc/sysctl.d/*.conf"}, Glob: true, Reload: "sysctl --system"},
{ID: "chrony", Kind: KindFile, Paths: []string{"/etc/chrony/chrony.conf", "/etc/chrony/sources.d/*.sources"}, Glob: true,
Reload: "systemctl restart chrony"},
{ID: "multipath", Kind: KindFile, Paths: []string{"/etc/multipath.conf"}, Reload: "multipathd reconfigure"},
{ID: "wwids", Kind: KindFile, Paths: []string{"/etc/multipath/wwids"}, Additive: true, Reload: "multipathd reconfigure"},
{ID: "dns", Kind: KindDNS},
// interfaces.d/sdn is generated by the SDN of PVE: never replicated
{ID: "interfaces-d", Kind: KindFile, Paths: []string{"/etc/network/interfaces.d/*"}, Exclude: []string{"/etc/network/interfaces.d/sdn"},
Glob: true, Reload: "ifreload -a", Network: true},
{ID: "network", Kind: KindNetwork, Network: true},
}
// ItemByID returns an item of the catalog.
func ItemByID(id string) (Item, error) {
for _, it := range Items {
if it.ID == id {
return it, nil
}
}
return Item{}, fmt.Errorf("unknown item %q", id)
}
func itemRank(id string) int {
for i, it := range Items {
if it.ID == id {
return i
}
}
return len(Items)
}
// Policy of a field of an API item (network, dns).
type Policy string
const (
Sync Policy = "sync" // copied from the master
Ignore Policy = "ignore" // never written
Override Policy = "override" // only written from the target mapping
)
// defaultPolicy: the addresses and gateways are those of each node; they are
// only written from the mapping of a target.
func defaultPolicy(item, field string) Policy {
if item == "network" {
switch field {
case "cidr", "cidr6", "gateway", "gateway6":
return Override
}
}
return Sync
}
// DefaultPolicies lists the fields whose default is not "sync", for the UI.
var DefaultPolicies = map[string]map[string]Policy{
"network": {"cidr": Override, "cidr6": Override, "gateway": Override, "gateway6": Override},
}
+304
View File
@@ -0,0 +1,304 @@
package nodesync
import (
"context"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"time"
"pve-confsync/internal/config"
"pve-confsync/internal/pve/pvetest"
"pve-confsync/internal/sshx"
)
func init() {
ProbeWait, TaskPoll = time.Millisecond, time.Millisecond
}
// endpoint connects to a node of a fake cluster like the web UI does.
func endpoint(t *testing.T, s *pvetest.Server, cluster string, n *pvetest.Node) Endpoint {
t.Helper()
sum := sha256.Sum256(s.Certificate().Raw)
c, err := config.Cluster{Name: cluster, URL: s.URL, User: "root@pam", Password: pvetest.Password,
Fingerprint: hex.EncodeToString(sum[:])}.PVE()
if err != nil {
t.Fatal(err)
}
host, port := n.SSHAddr()
o := sshx.Options{Host: host, Port: port, Password: pvetest.Password, HostKey: n.SSHHostKey()}
sh, err := sshx.New(o)
if err != nil {
t.Fatal(err)
}
return Endpoint{Key: cluster + "/" + n.Name, Cluster: cluster, Node: n.Name, API: c, Shell: sh}
}
const multipath = "defaults {\n user_friendly_names yes\n find_multipaths no\n}\nblacklist {\n devnode \"^sda\"\n}\n"
// lab: one cluster, a master pve1 and a target pve2 with differences.
func lab(t *testing.T) (*pvetest.Server, *pvetest.Node, *pvetest.Node, map[string]Endpoint) {
s := pvetest.New("9.1.4")
t.Cleanup(s.Close)
m, c := s.AddNode("pve1"), s.AddNode("pve2")
m.Net = map[string]map[string]any{
"eno1": {"type": "eth", "mtu": "9000"},
"eno2": {"type": "eth", "mtu": "9000"},
"bond0": {"type": "bond", "slaves": "eno1 eno2", "bond_mode": "802.3ad", "bond_xmit_hash_policy": "layer3+4", "mtu": "9000"},
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "bridge_vlan_aware": "1", "cidr": "10.0.0.11/24", "gateway": "10.0.0.1", "autostart": "1"},
"vmbr1": {"type": "bridge", "bridge_ports": "bond0.50", "cidr": "10.5.0.11/24", "comments": "stockage"},
"bond0.50": {"type": "vlan", "mtu": "9000"},
}
// the target has other NIC names and an active-backup bond
c.Net = map[string]map[string]any{
"enp1s0f0": {"type": "eth"},
"enp1s0f1": {"type": "eth"},
"bond0": {"type": "bond", "slaves": "enp1s0f0 enp1s0f1", "bond_mode": "active-backup"},
"vmbr0": {"type": "bridge", "bridge_ports": "bond0", "cidr": "10.0.0.12/24", "gateway": "10.0.0.1", "autostart": "1"},
}
m.DNS = map[string]string{"search": "lab.local", "dns1": "10.0.0.53", "dns2": "10.0.0.54"}
c.DNS = map[string]string{"search": "lab.local", "dns1": "8.8.8.8"}
m.Files["/etc/multipath.conf"] = pvetest.FakeFile{Content: multipath}
m.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383030/\n/3600a0980383031/\n"}
c.Files["/etc/multipath/wwids"] = pvetest.FakeFile{Content: "# wwids\n/3600a0980383031/\n/36000local/\n"}
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 16777216\n"}
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "net.core.rmem_max = 8388608\n", Mode: "600"}
c.Files["/etc/sysctl.d/99-local.conf"] = pvetest.FakeFile{Content: "vm.swappiness = 10\n"}
m.Files["/etc/network/interfaces.d/sdn"] = pvetest.FakeFile{Content: "auto vnet1\n"}
eps := map[string]Endpoint{"lab/pve1": endpoint(t, s, "lab", m), "lab/pve2": endpoint(t, s, "lab", c)}
return s, m, c, eps
}
func profile() *Profile {
return &Profile{Name: "stockage", Master: "lab/pve1", Targets: []string{"lab/pve2"},
Items: []string{"network", "dns", "multipath", "wwids", "sysctl", "interfaces-d"},
Mapping: map[string]TargetMap{"lab/pve2": {
NICs: map[string]string{"eno1": "enp1s0f0", "eno2": "enp1s0f1"},
Values: map[string]map[string]string{"network/vmbr1": {"cidr": "10.5.0.12/24"}},
}}}
}
func keys(p Plan) []string {
var out []string
for _, c := range p.Changes {
out = append(out, string(c.Op)+" "+c.Key())
}
return out
}
func TestPlan(t *testing.T) {
_, _, _, eps := lab(t)
prof := profile()
st := Analyze(context.Background(), prof, eps, nil)
if st.Master.Error != "" || st.Targets[0].Error != "" {
t.Fatal(st.Master.Error, st.Targets[0].Error)
}
p := st.Targets[0].Plan
if len(p.Errors) > 0 {
t.Fatalf("errors: %+v", p.Errors)
}
got := strings.Join(keys(p), "\n")
want := strings.Join([]string{
"update sysctl//etc/sysctl.d/90-net.conf",
"create multipath//etc/multipath.conf",
"update wwids//etc/multipath/wwids",
"update dns/dns",
"update network/enp1s0f0",
"update network/enp1s0f1",
"update network/bond0",
"create network/bond0.50",
"update network/vmbr0",
"create network/vmbr1",
}, "\n")
if got != want {
t.Fatalf("plan:\n%s\nwant:\n%s", got, want)
}
for _, c := range p.Changes {
switch c.Key() {
case "network/bond0":
if c.Set["slaves"] != "enp1s0f0 enp1s0f1" && c.Set["slaves"] != nil {
t.Errorf("slaves not renamed: %v", c.Set["slaves"])
}
if c.Set["bond_mode"] != "802.3ad" {
t.Errorf("bond0: %+v", c.Set)
}
case "network/vmbr0":
// the address of the target is kept (override without mapping)
if _, ok := c.Set["cidr"]; ok || c.Full["cidr"] != "10.0.0.12/24" {
t.Errorf("vmbr0: set %+v full %+v", c.Set, c.Full)
}
case "network/vmbr1":
if c.Full["cidr"] != "10.5.0.12/24" || c.Full["bridge_ports"] != "bond0.50" {
t.Errorf("vmbr1: %+v", c.Full)
}
case "wwids//etc/multipath/wwids":
// additive: the local WWID of the target stays
if string(c.Content) != "# wwids\n/3600a0980383031/\n/36000local/\n/3600a0980383030/\n" {
t.Errorf("wwids: %q", c.Content)
}
case "sysctl//etc/sysctl.d/90-net.conf":
if c.Mode != "600" || len(c.Diff) == 0 {
t.Errorf("sysctl: mode %s diff %+v", c.Mode, c.Diff)
}
}
}
// the file only on the target is kept without prune; interfaces.d/sdn is never read
for _, r := range st.Targets[0].Rows {
if r.ID == "/etc/sysctl.d/99-local.conf" && r.Status != StatusExtra {
t.Errorf("99-local.conf: %s", r.Status)
}
if strings.Contains(r.ID, "interfaces.d/sdn") {
t.Errorf("sdn file listed")
}
}
if !p.Network() {
t.Error("network plan not flagged")
}
if strings.Join(p.Reloads(), ",") != "sysctl --system,multipathd reconfigure,ifreload -a" {
t.Errorf("reloads: %v", p.Reloads())
}
}
func TestApplyAndVerify(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
st := Analyze(context.Background(), prof, eps, nil)
tp := st.Plans(nil)[0]
var steps []string
if err := st.Apply(context.Background(), tp, nil, func(s Step) {
steps = append(steps, s.Action+" "+s.Error)
}); err != nil {
t.Fatalf("apply: %v\n%s", err, strings.Join(steps, "\n"))
}
if c.Net["vmbr0"]["cidr"] != "10.0.0.12/24" || c.Net["vmbr1"]["cidr"] != "10.5.0.12/24" {
t.Errorf("addresses: vmbr0 %v vmbr1 %v", c.Net["vmbr0"], c.Net["vmbr1"])
}
if c.Net["vmbr0"]["bridge_vlan_aware"] != "1" || c.Pending != nil {
t.Errorf("vmbr0 %v pending %v", c.Net["vmbr0"], c.Pending)
}
if c.DNS["dns2"] != "10.0.0.54" || c.Files["/etc/multipath.conf"].Content != multipath {
t.Errorf("dns %v multipath %q", c.DNS, c.Files["/etc/multipath.conf"].Content)
}
if len(c.Armed) != 0 {
t.Errorf("restore timer still armed: %v", c.Armed)
}
if got := strings.Join(c.Reloads, ","); got != "sysctl --system,multipathd reconfigure,network" {
t.Errorf("reloads: %s", got)
}
if len(c.Backups()) != 1 {
t.Errorf("backups: %v", c.Backups())
}
st = Analyze(context.Background(), prof, eps, nil)
if p := st.Targets[0].Plan; !p.Empty() {
t.Errorf("still to do: %v", keys(p))
}
}
func TestReloadFailureRestores(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"multipath", "sysctl"}
c.FailOn = "reload multipathd reconfigure"
st := Analyze(context.Background(), prof, eps, nil)
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "multipathd reconfigure") {
t.Fatalf("err = %v", err)
}
if _, ok := c.Files["/etc/multipath.conf"]; ok {
t.Error("multipath.conf not removed by the restore")
}
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "net.core.rmem_max = 8388608\n" {
t.Errorf("sysctl not restored: %q", c.Files["/etc/sysctl.d/90-net.conf"].Content)
}
}
func TestUnreachableAfterReloadKeepsTimer(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"network"}
st := Analyze(context.Background(), prof, eps, nil)
c.FailOn = "probe"
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("err = %v", err)
}
if len(c.Armed) != 1 {
t.Fatalf("timer: %v", c.Armed)
}
// systemd runs the restore: the previous interfaces come back
c.Fire()
if c.Net["bond0"]["bond_mode"] != "active-backup" || c.Net["vmbr1"] != nil {
t.Errorf("not restored: %v", c.Net)
}
}
func TestStaleFileRefused(t *testing.T) {
_, _, c, eps := lab(t)
prof := profile()
prof.Items = []string{"sysctl"}
st := Analyze(context.Background(), prof, eps, nil)
c.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "changed by hand\n"}
err := st.Apply(context.Background(), st.Plans(nil)[0], nil, func(Step) {})
if err == nil || !strings.Contains(err.Error(), "changed since the dry-run") {
t.Fatalf("err = %v", err)
}
if c.Files["/etc/sysctl.d/90-net.conf"].Content != "changed by hand\n" {
t.Error("file overwritten")
}
}
func TestPendingNetworkBlocks(t *testing.T) {
_, _, c, eps := lab(t)
c.Pending = map[string]map[string]any{"vmbr0": {"type": "bridge"}}
st := Analyze(context.Background(), profile(), eps, nil)
if len(st.Targets[0].Plan.Errors) == 0 {
t.Fatal("pending changes not blocking")
}
}
func TestMissingNICIsAnError(t *testing.T) {
_, _, _, eps := lab(t)
prof := profile()
prof.Items = []string{"network"}
prof.Mapping = nil
st := Analyze(context.Background(), prof, eps, nil)
p := st.Targets[0].Plan
if len(p.Errors) != 2 || p.Errors[0].Key != "network/eno1" {
t.Fatalf("errors: %+v", p.Errors)
}
// the selection of another object is not blocked
if f := Filter(p, []string{"network/vmbr0"}); len(f.Errors) != 0 || len(f.Changes) != 1 {
t.Errorf("filter: %+v", f)
}
}
func TestPruneAndProtect(t *testing.T) {
_, m, _, eps := lab(t)
m.Files["/etc/sysctl.d/90-net.conf"] = pvetest.FakeFile{Content: "# host pve1\nnet.core.rmem_max = 8388608\n"}
prof := profile()
prof.Items = []string{"sysctl"}
prof.Prune = true
prof.Protect = map[string][]string{"sysctl": {"^# host "}}
st := Analyze(context.Background(), prof, eps, nil)
got := strings.Join(keys(st.Targets[0].Plan), ",")
// the protected line is absent on the target: dropped, the rest is equal
if got != "delete sysctl//etc/sysctl.d/99-local.conf" {
t.Fatalf("plan: %s", got)
}
}
func TestDiff(t *testing.T) {
d := Diff([]byte("a\nb\nc\nd\ne\nf\ng\nh\ni\n"), []byte("a\nb\nc\nd\nE\nf\ng\nh\ni\nj\n"))
var b strings.Builder
for _, l := range d {
b.WriteString(l.Op + l.Text + "\n")
}
want := "@@@ -2,8 +2,9 @@\n b\n c\n d\n-e\n+E\n f\n g\n h\n i\n+j\n"
if b.String() != want {
t.Fatalf("diff:\n%s\nwant:\n%s", b.String(), want)
}
if d := Diff(nil, []byte("x\n")); len(d) != 2 || d[0].Text != "@@ -0,0 +1 @@" {
t.Errorf("creation diff: %+v", d)
}
}
+661
View File
@@ -0,0 +1,661 @@
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])
}
+337
View File
@@ -0,0 +1,337 @@
package nodesync
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"math/bits"
"net/url"
"path"
"sort"
"strconv"
"strings"
"time"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
)
// Shell runs a command as root on the node, stdin fed with in (sshx.Client).
type Shell interface {
Run(ctx context.Context, cmd string, in []byte) ([]byte, error)
}
// Endpoint is one node: the API of its cluster (calls proxied to the node)
// and SSH as root on the node itself.
type Endpoint struct {
Key string // "cluster/node"
Cluster string
Node string
API *pve.Client
Shell Shell // nil: SSH host key not recorded yet
// Dial opens a new SSH access, to check that the node still answers after
// a network reload (nil: Shell is used).
Dial func() (Shell, error)
}
// File is a file read on a node.
type File struct {
Path string `json:"path"`
Mode string `json:"mode"` // octal, e.g. 644
Owner string `json:"owner"` // user:group
Hash string `json:"hash"` // SHA-256 of the content
Content []byte `json:"-"`
}
// Snapshot is what was read on one node.
type Snapshot struct {
Key string `json:"key"`
Version string `json:"version,omitempty"`
Network []model.Object `json:"-"` // interfaces (id = iface), canonical fields
Options map[string][]string `json:"-"` // iface -> lines the API does not write (bond-miimon…)
DNS map[string]any `json:"-"`
Files map[string]map[string]File `json:"-"` // item -> path -> file
// Pending: /etc/network/interfaces.new exists (network changes not applied yet).
Pending bool `json:"pending,omitempty"`
Warnings []string `json:"warnings,omitempty"`
ReadAt time.Time `json:"readAt"`
}
// maxFile: larger files are not replicated (configuration files are small).
const maxFile = 1 << 20
// Read loads the given items from a node.
func Read(ctx context.Context, ep Endpoint, items []string) (Snapshot, error) {
snap := Snapshot{Key: ep.Key, Files: map[string]map[string]File{}, ReadAt: time.Now().UTC()}
v, err := NodeVersion(ctx, ep)
if err != nil {
return snap, err
}
snap.Version = v
var files []Item
for _, id := range items {
it, err := ItemByID(id)
if err != nil {
return snap, err
}
switch it.Kind {
case KindNetwork:
if snap.Network, snap.Options, err = readNetwork(ctx, ep); err != nil {
return snap, fmt.Errorf("%s: read network: %w", ep.Key, err)
}
case KindDNS:
if snap.DNS, err = readDNS(ctx, ep); err != nil {
return snap, fmt.Errorf("%s: read dns: %w", ep.Key, err)
}
case KindFile:
files = append(files, it)
}
}
// SSH is needed for the files, and for the pending network changes
if ep.Shell == nil {
return snap, fmt.Errorf("%s: SSH host key not recorded yet, test the node first", ep.Key)
}
out, err := ep.Shell.Run(ctx, readScript(files), nil)
if err != nil {
return snap, fmt.Errorf("%s: read files: %w", ep.Key, err)
}
got, pending, warns, err := parseRead(out)
if err != nil {
return snap, fmt.Errorf("%s: read files: %w", ep.Key, err)
}
snap.Pending = pending
snap.Warnings = append(snap.Warnings, warns...)
for _, it := range files {
m := map[string]File{}
for p, f := range got {
if matchItem(it, p) {
m[p] = f
}
}
snap.Files[it.ID] = m
}
return snap, nil
}
// NodeVersion reads the pve-manager version of the node and refuses anything
// but PVE 9.x.
func NodeVersion(ctx context.Context, ep Endpoint) (string, error) {
var v struct {
Version string `json:"version"`
Release string `json:"release"`
}
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/version", nil, &v); err != nil {
return "", fmt.Errorf("%s: version check: %w", ep.Key, err)
}
ref := v.Release
if ref == "" {
ref = v.Version
}
if major, err := strconv.Atoi(strings.SplitN(ref, ".", 2)[0]); err != nil || major != pve.SupportedMajor {
return v.Version, fmt.Errorf("%s: node runs PVE %q, only %d.x is supported", ep.Key, ref, pve.SupportedMajor)
}
return v.Version, nil
}
func matchItem(it Item, p string) bool {
for _, x := range it.Exclude {
if x == p {
return false
}
}
for _, pat := range it.Paths {
if ok, _ := path.Match(pat, p); ok {
return true
}
}
return false
}
// readScript lists the files of the items: one header line per file
// ("F <mode> <user:group> <path>", "L <size> <path>" when too large) then
// its content in base64 on one line; "PENDING" when interfaces.new exists.
// The first line is a marker the fake node of the tests understands.
func readScript(files []Item) string {
var pats []string
for _, it := range files {
pats = append(pats, it.Paths...)
}
var b strings.Builder
b.WriteString("# confsync:read\n")
if len(pats) > 0 {
fmt.Fprintf(&b, "for f in %s; do\n", strings.Join(pats, " "))
b.WriteString(` [ -f "$f" ] && [ ! -L "$f" ] || continue
s=$(stat -c %s "$f")
if [ "$s" -gt ` + strconv.Itoa(maxFile) + ` ]; then printf 'L %s %s\n' "$s" "$f"; continue; fi
printf 'F %s %s\n' "$(stat -c '%a %U:%G' "$f")" "$f"
base64 -w0 < "$f"; echo
done
`)
}
b.WriteString("[ -e /etc/network/interfaces.new ] && echo PENDING\ntrue\n")
return b.String()
}
func parseRead(out []byte) (map[string]File, bool, []string, error) {
files := map[string]File{}
pending := false
var warns []string
sc := bufio.NewScanner(bytes.NewReader(out))
sc.Buffer(make([]byte, 64<<10), 4*maxFile)
for sc.Scan() {
line := sc.Text()
switch {
case line == "PENDING":
pending = true
case strings.HasPrefix(line, "L "):
p := strings.SplitN(line, " ", 3)
if len(p) == 3 {
warns = append(warns, fmt.Sprintf("%s: %s bytes, too large, not replicated", p[2], p[1]))
}
case strings.HasPrefix(line, "F "):
p := strings.SplitN(line, " ", 4)
if len(p) != 4 || !sc.Scan() {
return nil, false, nil, fmt.Errorf("unexpected output %q", line)
}
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sc.Text()))
if err != nil {
return nil, false, nil, fmt.Errorf("%s: %w", p[3], err)
}
if !safePath(p[3]) {
warns = append(warns, fmt.Sprintf("%s: unexpected characters in the path, not replicated", p[3]))
continue
}
files[p[3]] = File{Path: p[3], Mode: p[1], Owner: p[2], Hash: hash(data), Content: data}
}
}
return files, pending, warns, sc.Err()
}
// safePath: the paths go into shell scripts between single quotes.
func safePath(p string) bool {
if !strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
return false
}
for _, r := range p {
if !(r == '/' || r == '.' || r == '-' || r == '_' || r == '+' || r == '@' ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
func hash(b []byte) string {
s := sha256.Sum256(b)
return hex.EncodeToString(s[:])
}
// ---- network (GET /nodes/{node}/network) ----
// netWritable are the parameters of POST/PUT /nodes/{node}/network[/{iface}]
// (pve-manager PVE/API2/Network.pm, $confdesc), in their cidr form: the GET
// returns address/netmask and cidr, the canonical form keeps cidr only.
var netWritable = map[string]bool{
"type": true, "comments": true, "comments6": true, "autostart": true,
"bridge_vlan_aware": true, "bridge_vids": true, "bridge_ports": true,
"ovs_ports": true, "ovs_tag": true, "ovs_options": true, "ovs_bridge": true, "ovs_bonds": true,
"slaves": true, "bond_mode": true, "bond-primary": true, "bond_xmit_hash_policy": true,
"vlan-raw-device": true, "vlan-id": true,
"gateway": true, "cidr": true, "gateway6": true, "cidr6": true, "mtu": true,
}
// netTypes are the interface types handled (fabric, vnet and unknown are not).
var netTypes = map[string]bool{
"eth": true, "bond": true, "bridge": true, "vlan": true, "alias": true,
"OVSBridge": true, "OVSBond": true, "OVSPort": true, "OVSIntPort": true,
}
// physical interfaces cannot be created: they must exist on the target.
func physical(typ string) bool { return typ == "eth" }
func readNetwork(ctx context.Context, ep Endpoint) ([]model.Object, map[string][]string, error) {
var raw []map[string]any
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/network", nil, &raw); err != nil {
return nil, nil, err
}
var objs []model.Object
opts := map[string][]string{}
for _, r := range raw {
id := pve.Str(r["iface"])
typ := pve.Str(r["type"])
if id == "" || id == "lo" || !netTypes[typ] {
continue
}
f := map[string]any{}
for k, v := range r {
if !netWritable[k] || v == nil {
continue
}
s := strings.TrimRight(pve.Str(v), "\n")
if s == "" {
continue
}
f[k] = s
}
// a static address without cidr (older parsers): rebuild it
if _, ok := f["cidr"]; !ok {
if a, m := pve.Str(r["address"]), pve.Str(r["netmask"]); a != "" && m != "" {
f["cidr"] = a + "/" + maskBits(m)
}
}
if l := listOf(r["options"]); len(l) > 0 {
opts[id] = l
}
if l := listOf(r["options6"]); len(l) > 0 {
opts[id] = append(opts[id], l...)
}
objs = append(objs, model.Object{Kind: "network", ID: id, Fields: f})
}
sort.Slice(objs, func(i, j int) bool { return objs[i].ID < objs[j].ID })
return objs, opts, nil
}
func listOf(v any) []string {
l, _ := v.([]any)
out := make([]string, 0, len(l))
for _, e := range l {
out = append(out, pve.Str(e))
}
sort.Strings(out)
return out
}
// maskBits converts 255.255.255.0 to 24 (a prefix length is kept).
func maskBits(m string) string {
if !strings.Contains(m, ".") {
return m
}
n := 0
for _, p := range strings.Split(m, ".") {
b, _ := strconv.Atoi(p)
n += bits.OnesCount8(uint8(b))
}
return strconv.Itoa(n)
}
// ---- dns (GET /nodes/{node}/dns) ----
var dnsFields = []string{"search", "dns1", "dns2", "dns3"}
func readDNS(ctx context.Context, ep Endpoint) (map[string]any, error) {
var raw map[string]any
if err := ep.API.Get(ctx, "nodes/"+url.PathEscape(ep.Node)+"/dns", nil, &raw); err != nil {
return nil, err
}
out := map[string]any{}
for _, k := range dnsFields {
if s := pve.Str(raw[k]); s != "" {
out[k] = s
}
}
return out, nil
}
+77
View File
@@ -0,0 +1,77 @@
package nodesync
import (
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"testing"
)
// TestPrepareScriptForReal runs the prepare script and restore.sh with the
// local shell in a temporary folder (every path prefixed with it).
func TestPrepareScriptForReal(t *testing.T) {
if _, err := exec.LookPath("sha256sum"); err != nil {
t.Skip("sha256sum not installed")
}
root := t.TempDir()
u, _ := user.Current()
g, _ := user.LookupGroupId(u.Gid)
owner := u.Username + ":" + g.Name
write := func(p, s string) {
if err := os.MkdirAll(filepath.Dir(root+p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(root+p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
read := func(p string) string {
b, err := os.ReadFile(root + p)
if err != nil {
return "<absent>"
}
return string(b)
}
write("/etc/sysctl.d/90-net.conf", "old\n")
write("/etc/sysctl.d/99-local.conf", "local\n")
write("/etc/network/interfaces", "auto lo\n")
plan := Plan{Changes: []Change{
{Item: "sysctl", ID: "/etc/sysctl.d/90-net.conf", Op: OpUpdate, Hash: hash([]byte("old\n")), Mode: "640", Owner: owner, Content: []byte("new\n")},
{Item: "multipath", ID: "/etc/multipath.conf", Op: OpCreate, Hash: "absent", Mode: "644", Owner: owner, Content: []byte("defaults {}\n")},
{Item: "sysctl", ID: "/etc/sysctl.d/99-local.conf", Op: OpDelete, Hash: hash([]byte("local\n"))},
{Item: "network", ID: "vmbr0", Op: OpUpdate},
}}
run := func(script string, in []byte) (string, error) {
cmd := exec.Command("sh", "-c", script)
cmd.Stdin = strings.NewReader(string(in))
out, err := cmd.CombinedOutput()
return string(out), err
}
const dir = "/var/lib/pve-confsync/backup/test"
if out, err := run(prepareScriptIn(dir, root), prepareInput(plan, true, false)); err != nil || strings.TrimSpace(out) != "ok" {
t.Fatalf("prepare: %v %s", err, out)
}
if read("/etc/sysctl.d/90-net.conf") != "new\n" || read("/etc/multipath.conf") != "defaults {}\n" || read("/etc/sysctl.d/99-local.conf") != "<absent>" {
t.Fatalf("writes: %q %q %q", read("/etc/sysctl.d/90-net.conf"), read("/etc/multipath.conf"), read("/etc/sysctl.d/99-local.conf"))
}
if fi, _ := os.Stat(root + "/etc/sysctl.d/90-net.conf"); fi.Mode().Perm() != 0o640 {
t.Errorf("mode %v", fi.Mode())
}
if m, _ := filepath.Glob(root + "/etc/sysctl.d/*.confsync.*"); len(m) > 0 {
t.Errorf("temporary files left: %v", m)
}
// a second run with the same plan: the files changed, nothing is written
if out, err := run(prepareScriptIn(dir+"2", root), prepareInput(plan, false, false)); err == nil || !strings.Contains(out, "changed since the dry-run") {
t.Errorf("stale check: %v %s", err, out)
}
write("/etc/network/interfaces.new", "pending\n")
if out, err := run("sh '"+root+dir+"/restore.sh'", nil); err != nil {
t.Fatalf("restore: %v %s", err, out)
}
if read("/etc/sysctl.d/90-net.conf") != "old\n" || read("/etc/multipath.conf") != "<absent>" || read("/etc/sysctl.d/99-local.conf") != "local\n" ||
read("/etc/network/interfaces") != "auto lo\n" || read("/etc/network/interfaces.new") != "<absent>" {
t.Errorf("restore: %q %q %q %q", read("/etc/sysctl.d/90-net.conf"), read("/etc/multipath.conf"), read("/etc/sysctl.d/99-local.conf"), read("/etc/network/interfaces.new"))
}
}