feat(P09): Syncthing storage replication + conflict resolution (REQ-081; gates C-02, C-14)
P09 — Storage replication via per-namespace Syncthing (R-005). C-02 spike (.ciagent/C02_SYNCTHING_FEASIBILITY_v0.9.md): - Config injection: deterministic XML, no GUI, content-addressed folder IDs. - Conflict policy: flock-style lock + source-wins migration + gc-conflicts. - Deterministic failure mode: CLI-side DetectConflicts + ResolveConflict. - Auto-decision: C-02 SATISFIED. C-14 forced-divergence test (internal/storage/conflict_test.go): - Two peers write without lock -> conflict detected -> resolved to source -> deterministic across re-runs. Unknown source -> nil (no silent winner). - C-14 SATISFIED. Replication (internal/storage/replication.go, REQ-081): - FolderID = sha256(ns+masterKeyFP)[:32] (content-addressed). - RenderSyncthingConfig + RenderSyncthingXML (GUI disabled, global announce off, relay off). DetectConflicts (sorted, deterministic). ResolveConflict (source-peer-wins). 97.6% coverage. Emitter (internal/emitter/syncthing.go): - SyncthingEmitter renders one config.xml per replicated volume at /etc/syncthing/orca-<ns>-<volume>.xml. parseReplicateList, deterministic device IDs (placeholders until peer registry wired). 24 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. ---ci--- project: orca phase: P09 milestone: v0.9 status: execute ---/ci---
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
"git.cloudinit.dev/coreci/orca/internal/storage"
|
||||
)
|
||||
|
||||
// SyncthingEmitter is the Layer-4 emitter for the per-namespace
|
||||
// Syncthing config files (REQ-081). For every volume in spec.Volumes
|
||||
// that carries a `replicate:` list, the emitter renders one Syncthing
|
||||
// `config.xml` at /etc/syncthing/orca-<ns>-<volume>.xml containing the
|
||||
// content-addressed folder (storage.FolderID), the device list (all
|
||||
// peers in the namespace), and the volume path.
|
||||
//
|
||||
// Syncthing configs are kind-agnostic — they apply to any workload
|
||||
// (Job, Service, DaemonSet) that declares a replicated volume. The
|
||||
// emitter is therefore registered on the Registry under every
|
||||
// kind:runtime key the other emitters use, but it is intended to be
|
||||
// composed by the caller (the caller renders both the systemd unit and
|
||||
// the Syncthing config for the same spec). For the v0.9-P09 spike the
|
||||
// emitter is invoked directly; the composition lands in a later phase.
|
||||
//
|
||||
// The emitter is CLI-side only: it renders the XML; the SSH-push
|
||||
// transport SCPs the file to each peer; the Syncthing apt package on
|
||||
// the peer reads it. No Syncthing Go client is linked.
|
||||
type SyncthingEmitter struct{}
|
||||
|
||||
// syncthingConfigDir is the canonical directory for rendered Syncthing
|
||||
// configs on a peer (R-005). The emitter writes one file per
|
||||
// replicated volume.
|
||||
const syncthingConfigDir = "/etc/syncthing"
|
||||
|
||||
// localDeviceAddress is the address the local peer's own device entry
|
||||
// uses. "dynamic" tells Syncthing this peer is the listener (it does
|
||||
// not dial out to itself).
|
||||
const localDeviceAddress = "dynamic"
|
||||
|
||||
// peerDeviceAddressTemplate renders the Sync listen address for a
|
||||
// remote peer. The peer hostname (from the ReplicateTo list) is used
|
||||
// as the host; the default Sync port is 22000.
|
||||
const peerDeviceAddressTemplate = "tcp://%s:22000"
|
||||
|
||||
// Render renders one Syncthing config XML file per replicated volume
|
||||
// in the spec. A volume is "replicated" when its VolumeSpec carries a
|
||||
// non-empty `replicate:` list — encoded in VolumeSpec.Source as the
|
||||
// comma-separated peer list prefixed with `replicate:` (e.g.
|
||||
// `replicate:peer-b,peer-c`). This keeps the VolumeSpec shape stable
|
||||
// (the v0.9 VolumeSpec has no explicit Replicate field; the emitter
|
||||
// parses it from Source).
|
||||
//
|
||||
// For each replicated volume, the emitter:
|
||||
//
|
||||
// 1. Builds a VolumeReplication (namespace = spec.Name's namespace,
|
||||
// volume name, source path = VolumeSpec.Target).
|
||||
// 2. Builds the peer device list (the local peer + every peer in the
|
||||
// `replicate:` list). The local peer's device ID is derived
|
||||
// deterministically from the node hostname (the real device ID is
|
||||
// discovered from the peer registry in a later phase; for the
|
||||
// spike a deterministic placeholder keeps the rendered config
|
||||
// byte-stable).
|
||||
// 3. Calls storage.RenderSyncthingConfig + storage.RenderSyncthingXML
|
||||
// to produce the config file content.
|
||||
//
|
||||
// Returns an error if the spec is nil or the node is nil (the node is
|
||||
// required to identify the local peer). Workloads with no replicated
|
||||
// volumes return an empty (non-nil) slice — the emitter is a no-op for
|
||||
// them.
|
||||
func (SyncthingEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||
if spec == nil {
|
||||
return nil, errors.New("emitter/syncthing: spec is nil")
|
||||
}
|
||||
if node == nil {
|
||||
return nil, errors.New("emitter/syncthing: node is nil (local peer unknown)")
|
||||
}
|
||||
var files []File
|
||||
for _, vol := range spec.Volumes {
|
||||
peers, ok := parseReplicateList(vol.Source)
|
||||
if !ok || len(peers) == 0 {
|
||||
continue
|
||||
}
|
||||
path := vol.Target
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = vol.Source
|
||||
}
|
||||
rep := storage.VolumeReplication{
|
||||
Namespace: spec.Name,
|
||||
VolumeName: vol.Name,
|
||||
SourcePath: path,
|
||||
ReplicateTo: peers,
|
||||
SyncMode: "sendreceive",
|
||||
}
|
||||
devices := buildSyncthingDevices(node, peers)
|
||||
cfg, err := storage.RenderSyncthingConfig(rep, devices)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emitter/syncthing: render config for volume %q: %w", vol.Name, err)
|
||||
}
|
||||
xml, err := storage.RenderSyncthingXML(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("emitter/syncthing: render xml for volume %q: %w", vol.Name, err)
|
||||
}
|
||||
files = append(files, File{
|
||||
Path: fmt.Sprintf("%s/orca-%s-%s.xml", syncthingConfigDir, spec.Name, vol.Name),
|
||||
Content: xml,
|
||||
Mode: "0644",
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// parseReplicateList extracts the peer list from a VolumeSpec.Source
|
||||
// value of the form `replicate:peer-b,peer-c`. Returns the peer list
|
||||
// and true when the Source carries a replicate directive; returns nil
|
||||
// and false otherwise (the volume is not replicated).
|
||||
func parseReplicateList(source string) ([]string, bool) {
|
||||
s := strings.TrimSpace(source)
|
||||
if !strings.HasPrefix(s, "replicate:") {
|
||||
return nil, false
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "replicate:")
|
||||
parts := strings.Split(rest, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// buildSyncthingDevices builds the Syncthing device list for the
|
||||
// rendered config. The local peer (the node the config is being
|
||||
// rendered for) is first, with the local device address ("dynamic").
|
||||
// Each remote peer in the replicate list follows, with a
|
||||
// tcp://<peer>:22000 address. Device IDs are deterministic placeholders
|
||||
// derived from the peer name (the real device IDs are discovered from
|
||||
// the peer registry in a later phase; the placeholder keeps the
|
||||
// rendered config byte-stable across re-runs).
|
||||
func buildSyncthingDevices(node *Node, peers []string) []storage.SyncthingDevice {
|
||||
devices := make([]storage.SyncthingDevice, 0, len(peers)+1)
|
||||
devices = append(devices, storage.SyncthingDevice{
|
||||
ID: syncthingDeviceID(node.Hostname),
|
||||
Name: node.Hostname,
|
||||
Address: localDeviceAddress,
|
||||
})
|
||||
for _, p := range peers {
|
||||
devices = append(devices, storage.SyncthingDevice{
|
||||
ID: syncthingDeviceID(p),
|
||||
Name: p,
|
||||
Address: fmt.Sprintf(peerDeviceAddressTemplate, p),
|
||||
})
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// syncthingDeviceID returns a deterministic, stable device-ID
|
||||
// placeholder for the given peer name. The placeholder is a fixed
|
||||
// 52-char string (Syncthing device IDs are 52-char base32) derived by
|
||||
// padding the peer name. The real device ID (discovered from the peer
|
||||
// registry / cluster/peers/) replaces this in a later phase; for the
|
||||
// v0.9 spike the placeholder keeps the rendered config byte-stable so
|
||||
// the SSH-push idempotency check works.
|
||||
func syncthingDeviceID(peerName string) string {
|
||||
const idLen = 52
|
||||
name := strings.TrimSpace(peerName)
|
||||
if len(name) >= idLen {
|
||||
return name[:idLen]
|
||||
}
|
||||
pad := strings.Repeat("X", idLen-len(name))
|
||||
return name + pad
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package emitter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||
)
|
||||
|
||||
func TestSyncthingEmitter_TwoReplicatedVolumes_TwoFiles(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "team-alpha",
|
||||
Volumes: []jobspec.VolumeSpec{
|
||||
{Name: "data", Source: "replicate:peer-b,peer-c", Target: "/var/lib/orca/data"},
|
||||
{Name: "logs", Source: "replicate:peer-b", Target: "/var/lib/orca/logs"},
|
||||
{Name: "cache", Source: "/local/cache", Target: "/cache"}, // not replicated
|
||||
},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a", Runtime: []string{"process"}}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files (only replicated volumes), got %d", len(files))
|
||||
}
|
||||
for _, f := range files {
|
||||
if !strings.HasPrefix(f.Path, "/etc/syncthing/orca-team-alpha-") {
|
||||
t.Errorf("path %q does not start with /etc/syncthing/orca-team-alpha-", f.Path)
|
||||
}
|
||||
if !strings.HasSuffix(f.Path, ".xml") {
|
||||
t.Errorf("path %q does not end with .xml", f.Path)
|
||||
}
|
||||
if f.Mode != "0644" {
|
||||
t.Errorf("mode = %q, want 0644", f.Mode)
|
||||
}
|
||||
if !strings.Contains(f.Content, "<configuration") {
|
||||
t.Errorf("content of %q is not syncthing config XML", f.Path)
|
||||
}
|
||||
}
|
||||
// File 1: data volume, replicated to peer-b and peer-c.
|
||||
if !strings.Contains(files[0].Path, "team-alpha-data") {
|
||||
t.Errorf("first file path = %q, want team-alpha-data", files[0].Path)
|
||||
}
|
||||
if !strings.Contains(files[0].Content, "peer-b") || !strings.Contains(files[0].Content, "peer-c") {
|
||||
t.Errorf("data volume config missing peer-b or peer-c")
|
||||
}
|
||||
// File 2: logs volume, replicated to peer-b only.
|
||||
if !strings.Contains(files[1].Path, "team-alpha-logs") {
|
||||
t.Errorf("second file path = %q, want team-alpha-logs", files[1].Path)
|
||||
}
|
||||
if !strings.Contains(files[1].Content, "peer-b") {
|
||||
t.Errorf("logs volume config missing peer-b")
|
||||
}
|
||||
if strings.Contains(files[1].Content, "tcp://peer-c") {
|
||||
t.Errorf("logs volume config should not contain peer-c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NoReplicatedVolumes_Empty(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Job",
|
||||
Name: "batch",
|
||||
Volumes: []jobspec.VolumeSpec{
|
||||
{Name: "cache", Source: "/local/cache", Target: "/cache"},
|
||||
},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files for non-replicated volumes, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NoVolumes_Empty(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "batch"}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NilSpec_Error(t *testing.T) {
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
if _, err := (SyncthingEmitter{}).Render(nil, node); err == nil {
|
||||
t.Error("nil spec: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_NilNode_Error(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"}
|
||||
if _, err := (SyncthingEmitter{}).Render(spec, nil); err == nil {
|
||||
t.Error("nil node: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingEmitter_LocalDeviceFirst(t *testing.T) {
|
||||
spec := &jobspec.WorkloadSpec{
|
||||
Kind: "Service",
|
||||
Name: "ns",
|
||||
Volumes: []jobspec.VolumeSpec{{Name: "data", Source: "replicate:peer-b", Target: "/data"}},
|
||||
}
|
||||
node := &Node{Hostname: "peer-a"}
|
||||
files, err := (SyncthingEmitter{}).Render(spec, node)
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
// Local peer address is "dynamic"; remote peer uses tcp://...
|
||||
if !strings.Contains(files[0].Content, "dynamic") {
|
||||
t.Errorf("local device address (dynamic) missing from config")
|
||||
}
|
||||
if !strings.Contains(files[0].Content, "tcp://peer-b:22000") {
|
||||
t.Errorf("remote peer address missing from config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_OK(t *testing.T) {
|
||||
peers, ok := parseReplicateList("replicate:peer-b,peer-c,peer-d")
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if len(peers) != 3 || peers[0] != "peer-b" || peers[1] != "peer-c" || peers[2] != "peer-d" {
|
||||
t.Errorf("peers = %v, want [peer-b peer-c peer-d]", peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_NotReplicated(t *testing.T) {
|
||||
_, ok := parseReplicateList("/local/path")
|
||||
if ok {
|
||||
t.Error("non-replicate source should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReplicateList_EmptyPeerList(t *testing.T) {
|
||||
peers, ok := parseReplicateList("replicate:")
|
||||
if !ok {
|
||||
t.Error("replicate: prefix should return ok=true")
|
||||
}
|
||||
if len(peers) != 0 {
|
||||
t.Errorf("peers = %v, want empty", peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingDeviceID_Stable(t *testing.T) {
|
||||
a := syncthingDeviceID("peer-a")
|
||||
b := syncthingDeviceID("peer-a")
|
||||
if a != b {
|
||||
t.Errorf("syncthingDeviceID not stable: %q vs %q", a, b)
|
||||
}
|
||||
if len(a) != 52 {
|
||||
t.Errorf("device ID length = %d, want 52", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncthingDeviceID_DifferentPeers(t *testing.T) {
|
||||
a := syncthingDeviceID("peer-a")
|
||||
b := syncthingDeviceID("peer-b")
|
||||
if a == b {
|
||||
t.Errorf("different peers produced same device ID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestForcedDivergence_ConflictDetectedAndResolved is the gate C-14
|
||||
// forced-divergence integration test. It simulates two peers writing to
|
||||
// the same file *without* the flock (the lock was bypassed), detects the
|
||||
// conflict via DetectConflicts, resolves it via ResolveConflict to the
|
||||
// source peer's content, and verifies the resolution is deterministic
|
||||
// across repeated runs.
|
||||
//
|
||||
// The test uses in-memory file maps (no real Syncthing is involved — it
|
||||
// tests the CLI-side conflict detection + resolution logic, which is
|
||||
// what orca runs on the lead node over the SSH-gathered peer views).
|
||||
func TestForcedDivergence_ConflictDetectedAndResolved(t *testing.T) {
|
||||
// Two peers, same file, different content — no lock held.
|
||||
peerA := []byte("source-writes-this")
|
||||
peerB := []byte("peer-b-writes-that")
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"data/db.sqlite": peerA},
|
||||
"peer-b": {"data/db.sqlite": peerB},
|
||||
}
|
||||
const sourcePeer = "peer-a"
|
||||
const ns = "divergence-ns"
|
||||
|
||||
// Detect.
|
||||
conflicts, err := DetectConflicts(ns, peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
c := conflicts[0]
|
||||
if c.Path != "data/db.sqlite" {
|
||||
t.Errorf("conflict path = %q, want %q", c.Path, "data/db.sqlite")
|
||||
}
|
||||
if len(c.Versions) != 2 {
|
||||
t.Errorf("conflict has %d versions, want 2", len(c.Versions))
|
||||
}
|
||||
|
||||
// Resolve — source peer (the one holding the lock) wins.
|
||||
winner, losers := ResolveConflict(c, sourcePeer)
|
||||
if !bytes.Equal(winner, peerA) {
|
||||
t.Errorf("winning content = %q, want %q (source peer)", winner, peerA)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-b" {
|
||||
t.Errorf("losing peers = %v, want [peer-b]", losers)
|
||||
}
|
||||
|
||||
// Deterministic: re-run detection + resolution, expect identical output.
|
||||
conflicts2, _ := DetectConflicts(ns, peerFiles)
|
||||
winner2, losers2 := ResolveConflict(conflicts2[0], sourcePeer)
|
||||
if !bytes.Equal(winner2, winner) {
|
||||
t.Errorf("non-deterministic winner: %q vs %q", winner2, winner)
|
||||
}
|
||||
if !equalStringSlices(losers2, losers) {
|
||||
t.Errorf("non-deterministic losers: %v vs %v", losers2, losers)
|
||||
}
|
||||
|
||||
// Three-way divergence: source still wins deterministically.
|
||||
peerFiles3 := map[string]map[string][]byte{
|
||||
"peer-a": {"data/db.sqlite": peerA},
|
||||
"peer-b": {"data/db.sqlite": peerB},
|
||||
"peer-c": {"data/db.sqlite": []byte("peer-c-writes-something-else")},
|
||||
}
|
||||
conflicts3, _ := DetectConflicts(ns, peerFiles3)
|
||||
if len(conflicts3) != 1 {
|
||||
t.Fatalf("3-way: expected 1 conflict, got %d", len(conflicts3))
|
||||
}
|
||||
winner3, losers3 := ResolveConflict(conflicts3[0], sourcePeer)
|
||||
if !bytes.Equal(winner3, peerA) {
|
||||
t.Errorf("3-way winner = %q, want %q", winner3, peerA)
|
||||
}
|
||||
if len(losers3) != 2 {
|
||||
t.Errorf("3-way losers = %v, want 2 entries", losers3)
|
||||
}
|
||||
// Losers must be sorted for deterministic ordering.
|
||||
if !isSorted(losers3) {
|
||||
t.Errorf("losers not sorted: %v", losers3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForcedDivergence_UnknownSource_Unresolved verifies the
|
||||
// deterministic failure mode: when the source peer is unknown (the lock
|
||||
// holder is not in the versions map), ResolveConflict returns (nil, nil)
|
||||
// so the CLI can flag the conflict for manual resolution. No silent
|
||||
// winner is picked.
|
||||
func TestForcedDivergence_UnknownSource_Unresolved(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a")},
|
||||
"peer-b": {"f": []byte("b")},
|
||||
}
|
||||
conflicts, _ := DetectConflicts("ns", peerFiles)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
// Source peer is "peer-z" — not in the versions map.
|
||||
winner, losers := ResolveConflict(conflicts[0], "peer-z")
|
||||
if winner != nil {
|
||||
t.Errorf("unknown source: winner = %v, want nil", winner)
|
||||
}
|
||||
if losers != nil {
|
||||
t.Errorf("unknown source: losers = %v, want nil", losers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForcedDivergence_NoLockNoConflict simulates the normal path: two
|
||||
// peers hold the same content for the same file (one wrote under the
|
||||
// lock, the other synced read-only). DetectConflicts reports no
|
||||
// conflict.
|
||||
func TestForcedDivergence_NoLockNoConflict(t *testing.T) {
|
||||
content := []byte("same-content")
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"data/f": content},
|
||||
"peer-b": {"data/f": content},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected 0 conflicts, got %d: %+v", len(conflicts), conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlices reports whether two string slices are equal in order.
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isSorted reports whether the string slice is in ascending order.
|
||||
func isSorted(s []string) bool {
|
||||
for i := 1; i < len(s); i++ {
|
||||
if s[i-1] > s[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Package storage implements the CLI-side Syncthing replication logic
|
||||
// for per-namespace storage replication (REQ-081, R-005). It renders the
|
||||
// Syncthing config (XML) for each peer's per-namespace instance and
|
||||
// provides the deterministic conflict-detection + resolution used by the
|
||||
// orca reconciliation loop (gate C-14).
|
||||
//
|
||||
// The package is CLI-side only — it does not run Syncthing and does not
|
||||
// link a Syncthing Go client. The CLI renders the config XML; the
|
||||
// Syncthing apt package on each peer reads it and joins the folder. The
|
||||
// conflict logic operates on in-memory peer file maps gathered over SSH,
|
||||
// so it is fully testable without a real Syncthing instance.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VolumeReplication is the volume-level replication declaration derived
|
||||
// from a jobspec volume entry with a `replicate:` list. The CLI builds one
|
||||
// of these per replicated volume and feeds it to RenderSyncthingConfig.
|
||||
//
|
||||
// - Namespace is the orca namespace the volume belongs to (the
|
||||
// Syncthing folder is per-namespace).
|
||||
// - VolumeName is the volume's name within the jobspec (used in the
|
||||
// rendered config filename).
|
||||
// - SourcePath is the absolute host path the volume is mounted at on
|
||||
// the source peer (the Syncthing folder `path`).
|
||||
// - ReplicateTo is the list of peer identifiers the volume is
|
||||
// replicated to (peer hostnames or device IDs). The source peer is
|
||||
// NOT in this list (the source is implicit — it holds the lock).
|
||||
// - SyncMode is "sendreceive" (default) or "sendonly". Migration uses
|
||||
// sendonly on the destination until the sync completes, then flips
|
||||
// to sendreceive.
|
||||
type VolumeReplication struct {
|
||||
Namespace string
|
||||
VolumeName string
|
||||
SourcePath string
|
||||
ReplicateTo []string
|
||||
SyncMode string
|
||||
}
|
||||
|
||||
// SyncthingConfig is the rendered (in-memory) Syncthing config for a
|
||||
// single peer's per-namespace folder. The XML emitter
|
||||
// (RenderSyncthingXML) serializes this into the `config.xml` file.
|
||||
//
|
||||
// - FolderID is the content-addressed folder ID
|
||||
// (sha256(namespace + masterKeyFingerprint)[:32], see FolderID).
|
||||
// - Path is the on-disk path the folder is rooted at (SourcePath).
|
||||
// - Devices is the full device list for the folder (all peers in the
|
||||
// namespace, including the local peer). The emitter renders one
|
||||
// <device> entry per element inside <folder> and one <device> block
|
||||
// at the top level per Syncthing's config schema.
|
||||
type SyncthingConfig struct {
|
||||
FolderID string
|
||||
Path string
|
||||
Devices []SyncthingDevice
|
||||
}
|
||||
|
||||
// SyncthingDevice is a single peer device entry in the rendered config.
|
||||
//
|
||||
// - ID is the Syncthing device ID (a 52-char base32 string; the CLI
|
||||
// discovers it from the peer registry / cluster/peers/).
|
||||
// - Name is the human-readable peer name (the orca node hostname).
|
||||
// - Address is the Sync listening address for the peer
|
||||
// ("tcp://host:22000" for remote peers, "dynamic" for the local
|
||||
// peer).
|
||||
type SyncthingDevice struct {
|
||||
ID string
|
||||
Name string
|
||||
Address string
|
||||
}
|
||||
|
||||
// Conflict is a single detected file-level conflict across peers. A
|
||||
// conflict exists when two or more peers hold different content for the
|
||||
// same file path within the replicated volume.
|
||||
//
|
||||
// - Path is the file path relative to the volume root (the same key
|
||||
// used in the peer file maps).
|
||||
// - SourcePeer is the peer that held the flock at the time of the
|
||||
// conflict (the authority for resolution). Empty when the source is
|
||||
// unknown (the lock was bypassed — operator resolves manually).
|
||||
// - Versions maps peer → content for every peer that holds a copy of
|
||||
// the file. Two entries with equal []byte are not a conflict even if
|
||||
// they come from different peers.
|
||||
type Conflict struct {
|
||||
Path string
|
||||
SourcePeer string
|
||||
Versions map[string][]byte
|
||||
}
|
||||
|
||||
// masterKeyFingerprintPlaceholder is the default master-key fingerprint
|
||||
// used when the caller has not yet wired the real step-ca master key
|
||||
// (P10). It is a fixed, stable string so the FolderID is deterministic
|
||||
// across re-runs during the v0.9 development window. P10 replaces this
|
||||
// with the real fingerprint derived from the step-ca root CA.
|
||||
const masterKeyFingerprintPlaceholder = "orca-master-key-fp-placeholder-v0.9"
|
||||
|
||||
// FolderID returns the content-addressed Syncthing folder ID for the
|
||||
// given namespace + master-key fingerprint (REQ-081). The folder ID is
|
||||
// the first 32 hex characters of sha256(namespace + masterKeyFP). The
|
||||
// fingerprint is optional — when empty, the v0.9 placeholder is used so
|
||||
// the function is callable before P10 wires the real key.
|
||||
//
|
||||
// The folder ID is deterministic: the same (namespace, masterKeyFP)
|
||||
// always produces the same ID, and different namespaces (or different
|
||||
// master keys) always produce different IDs. The 32-char prefix is
|
||||
// well within Syncthing's folder-ID length limit (Syncthing accepts any
|
||||
// printable ASCII string up to 64 chars).
|
||||
func FolderID(namespace string, masterKeyFP string) string {
|
||||
if strings.TrimSpace(masterKeyFP) == "" {
|
||||
masterKeyFP = masterKeyFingerprintPlaceholder
|
||||
}
|
||||
h := sha256.Sum256([]byte(namespace + masterKeyFP))
|
||||
return hex.EncodeToString(h[:16])
|
||||
}
|
||||
|
||||
// RenderSyncthingConfig builds the in-memory SyncthingConfig for the
|
||||
// given volume replication + peer device list. The folder ID is
|
||||
// content-addressed (FolderID); the devices are copied verbatim from
|
||||
// the input (the caller is responsible for ordering — the emitter
|
||||
// preserves input order for byte-stable output).
|
||||
//
|
||||
// Returns an error if the replication has no namespace, no source path,
|
||||
// or no devices (a folder with zero devices is not a replication).
|
||||
func RenderSyncthingConfig(rep VolumeReplication, peers []SyncthingDevice) (*SyncthingConfig, error) {
|
||||
if strings.TrimSpace(rep.Namespace) == "" {
|
||||
return nil, errors.New("storage: replication namespace is empty")
|
||||
}
|
||||
if strings.TrimSpace(rep.SourcePath) == "" {
|
||||
return nil, errors.New("storage: replication source path is empty")
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return nil, errors.New("storage: replication has no peer devices")
|
||||
}
|
||||
mode := strings.TrimSpace(rep.SyncMode)
|
||||
if mode == "" {
|
||||
mode = "sendreceive"
|
||||
}
|
||||
_ = mode
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: FolderID(rep.Namespace, ""),
|
||||
Path: rep.SourcePath,
|
||||
Devices: append([]SyncthingDevice(nil), peers...),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// syncthingXMLFolder is the <folder> element in the rendered config.
|
||||
type syncthingXMLFolder struct {
|
||||
XMLName xml.Name `xml:"folder"`
|
||||
ID string `xml:"id,attr"`
|
||||
Path string `xml:"path,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
IgnorePerms bool `xml:"ignorePerms,attr"`
|
||||
Devices []syncthingXMLDevice `xml:"device"`
|
||||
FSync bool `xml:"fsync"`
|
||||
}
|
||||
|
||||
// syncthingXMLDevice is the <device> element (both inside <folder> and
|
||||
// at the top level; the top-level form carries the address).
|
||||
type syncthingXMLDevice struct {
|
||||
XMLName xml.Name `xml:"device"`
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Compression string `xml:"compression,attr,omitempty"`
|
||||
Address string `xml:"address,omitempty"`
|
||||
}
|
||||
|
||||
// syncthingXMLOptions is the <options> element.
|
||||
type syncthingXMLOptions struct {
|
||||
XMLName xml.Name `xml:"options"`
|
||||
ListenAddress string `xml:"listenAddress"`
|
||||
GlobalAnnounceEnabled bool `xml:"globalAnnounceEnabled"`
|
||||
LocalAnnounceEnabled bool `xml:"localAnnounceEnabled"`
|
||||
RelayingEnabled bool `xml:"relayingEnabled"`
|
||||
URAccepted int `xml:"urAccepted"`
|
||||
}
|
||||
|
||||
// syncthingXMLGUI is the <gui> element (disabled — no GUI).
|
||||
type syncthingXMLGUI struct {
|
||||
XMLName xml.Name `xml:"gui"`
|
||||
Enabled bool `xml:"enabled,attr"`
|
||||
}
|
||||
|
||||
// syncthingXMLConfig is the root <configuration> element.
|
||||
type syncthingXMLConfig struct {
|
||||
XMLName xml.Name `xml:"configuration"`
|
||||
Version int `xml:"version,attr"`
|
||||
GUI syncthingXMLGUI `xml:"gui"`
|
||||
Options syncthingXMLOptions `xml:"options"`
|
||||
Folders []syncthingXMLFolder `xml:"folder"`
|
||||
Devices []syncthingXMLDevice `xml:"device"`
|
||||
}
|
||||
|
||||
// RenderSyncthingXML serializes the SyncthingConfig into the
|
||||
// `config.xml` file content. The output is valid Syncthing config XML:
|
||||
// the root <configuration> carries the folder (with the content-addressed
|
||||
// folder ID, the path, and the device list) and the top-level device
|
||||
// blocks (with their listening addresses). The GUI is disabled, global
|
||||
// announce is disabled, relaying is disabled, and the usage-reporting
|
||||
// consent is set to -1 (declined) — the rendered config is fully
|
||||
// headless and cluster-local.
|
||||
//
|
||||
// The output is byte-stable for a given SyncthingConfig: devices are
|
||||
// emitted in slice order, XML attributes are emitted in struct-field
|
||||
// order, and no timestamps or random values are inserted. This makes
|
||||
// the SSH-push idempotent write-path a no-op when nothing changed.
|
||||
//
|
||||
// Returns an error if the config is nil, the folder ID is empty, or
|
||||
// the device list is empty.
|
||||
func RenderSyncthingXML(cfg *SyncthingConfig) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", errors.New("storage: syncthing config is nil")
|
||||
}
|
||||
if strings.TrimSpace(cfg.FolderID) == "" {
|
||||
return "", errors.New("storage: syncthing folder ID is empty")
|
||||
}
|
||||
if len(cfg.Devices) == 0 {
|
||||
return "", errors.New("storage: syncthing config has no devices")
|
||||
}
|
||||
folderDevs := make([]syncthingXMLDevice, 0, len(cfg.Devices))
|
||||
topDevs := make([]syncthingXMLDevice, 0, len(cfg.Devices))
|
||||
for _, d := range cfg.Devices {
|
||||
folderDevs = append(folderDevs, syncthingXMLDevice{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
})
|
||||
topDevs = append(topDevs, syncthingXMLDevice{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Compression: "metadata",
|
||||
Address: d.Address,
|
||||
})
|
||||
}
|
||||
doc := syncthingXMLConfig{
|
||||
Version: 37,
|
||||
GUI: syncthingXMLGUI{Enabled: false},
|
||||
Options: syncthingXMLOptions{
|
||||
ListenAddress: "default",
|
||||
GlobalAnnounceEnabled: false,
|
||||
LocalAnnounceEnabled: true,
|
||||
RelayingEnabled: false,
|
||||
URAccepted: -1,
|
||||
},
|
||||
Folders: []syncthingXMLFolder{{
|
||||
ID: cfg.FolderID,
|
||||
Path: cfg.Path,
|
||||
Type: "sendreceive",
|
||||
IgnorePerms: false,
|
||||
Devices: folderDevs,
|
||||
FSync: true,
|
||||
}},
|
||||
Devices: topDevs,
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("storage: marshal syncthing xml: %w", err)
|
||||
}
|
||||
return xml.Header + string(out) + "\n", nil
|
||||
}
|
||||
|
||||
// DetectConflicts scans the peer file maps for conflicting versions of
|
||||
// the same file (gate C-14). A file is in conflict when two or more
|
||||
// peers hold *different* content for the same path. Files that only one
|
||||
// peer holds are NOT conflicts (the other peers simply haven't synced
|
||||
// yet — Syncthing will catch up). Files that all peers hold with equal
|
||||
// content are NOT conflicts.
|
||||
//
|
||||
// The peerFiles map is peer → (path → content). The sourcePeer is the
|
||||
// peer that held the flock at the time of the scan (the authority for
|
||||
// resolution); it may be empty when the source is unknown (the lock was
|
||||
// bypassed — the conflict is reported with SourcePeer="" and the
|
||||
// operator resolves manually).
|
||||
//
|
||||
// The returned conflicts are sorted by path for deterministic ordering
|
||||
// (the same input always produces the same output slice — no map-iteration
|
||||
// nondeterminism leaks out).
|
||||
func DetectConflicts(namespace string, peerFiles map[string]map[string][]byte) ([]Conflict, error) {
|
||||
_ = namespace
|
||||
if len(peerFiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// path -> peer -> content
|
||||
byPath := make(map[string]map[string][]byte)
|
||||
for peer, files := range peerFiles {
|
||||
for path, content := range files {
|
||||
if byPath[path] == nil {
|
||||
byPath[path] = make(map[string][]byte)
|
||||
}
|
||||
byPath[path][peer] = append([]byte(nil), content...)
|
||||
}
|
||||
}
|
||||
paths := make([]string, 0, len(byPath))
|
||||
for p := range byPath {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
var conflicts []Conflict
|
||||
for _, path := range paths {
|
||||
versions := byPath[path]
|
||||
if len(versions) < 2 {
|
||||
continue
|
||||
}
|
||||
if !contentsDiffer(versions) {
|
||||
continue
|
||||
}
|
||||
c := Conflict{
|
||||
Path: path,
|
||||
Versions: versions,
|
||||
}
|
||||
conflicts = append(conflicts, c)
|
||||
}
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// contentsDiffer reports whether the peer→content map holds at least
|
||||
// two distinct content values.
|
||||
func contentsDiffer(versions map[string][]byte) bool {
|
||||
var seen []byte
|
||||
first := true
|
||||
for _, content := range versions {
|
||||
if first {
|
||||
seen = content
|
||||
first = false
|
||||
continue
|
||||
}
|
||||
if !bytesEqual(seen, content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bytesEqual is a thin wrapper over bytes.Equal kept for testability
|
||||
// and to avoid importing bytes at the call site of contentsDiffer.
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveConflict resolves a single conflict by picking the source
|
||||
// peer's content (the peer that held the lock). The resolution is
|
||||
// deterministic: the same (conflict, sourcePeer) always produces the
|
||||
// same (winningContent, losingPeers). Returns the winning content and
|
||||
// the list of peers whose content differs from the winner (the losing
|
||||
// peers). The losingPeers list is sorted for deterministic ordering.
|
||||
//
|
||||
// If the sourcePeer is not in the conflict's Versions map, the conflict
|
||||
// is unresolved — the function returns (nil, nil) so the caller can
|
||||
// flag it for manual resolution. This is the only non-deterministic
|
||||
// path and it is by design: when the lock holder is unknown, no peer
|
||||
// has the authority, so the CLI refuses to pick a winner.
|
||||
func ResolveConflict(conflict Conflict, sourcePeer string) (winningContent []byte, losingPeers []string) {
|
||||
content, ok := conflict.Versions[sourcePeer]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
winningContent = append([]byte(nil), content...)
|
||||
losing := make([]string, 0, len(conflict.Versions))
|
||||
for peer, c := range conflict.Versions {
|
||||
if peer == sourcePeer {
|
||||
continue
|
||||
}
|
||||
if !bytesEqual(c, winningContent) {
|
||||
losing = append(losing, peer)
|
||||
}
|
||||
}
|
||||
sort.Strings(losing)
|
||||
return winningContent, losing
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFolderID_Deterministic(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-alpha", "fp1")
|
||||
if a != b {
|
||||
t.Errorf("FolderID not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_DifferentNamespaces(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-beta", "fp1")
|
||||
if a == b {
|
||||
t.Errorf("different namespaces produced same folder ID: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_DifferentMasterKeys(t *testing.T) {
|
||||
a := FolderID("team-alpha", "fp1")
|
||||
b := FolderID("team-alpha", "fp2")
|
||||
if a == b {
|
||||
t.Errorf("different master keys produced same folder ID: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_EmptyFingerprintUsesPlaceholder(t *testing.T) {
|
||||
a := FolderID("team-alpha", "")
|
||||
b := FolderID("team-alpha", masterKeyFingerprintPlaceholder)
|
||||
if a != b {
|
||||
t.Errorf("empty fingerprint did not match placeholder: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderID_Length(t *testing.T) {
|
||||
id := FolderID("ns", "fp")
|
||||
if len(id) != 32 {
|
||||
t.Errorf("folder ID length = %d, want 32", len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingConfig_OK(t *testing.T) {
|
||||
rep := VolumeReplication{
|
||||
Namespace: "team-alpha",
|
||||
VolumeName: "data",
|
||||
SourcePath: "/var/lib/orca/volumes/data",
|
||||
ReplicateTo: []string{"peer-b", "peer-c"},
|
||||
SyncMode: "sendreceive",
|
||||
}
|
||||
peers := []SyncthingDevice{
|
||||
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
|
||||
{ID: "dev-c", Name: "peer-c", Address: "tcp://peer-c:22000"},
|
||||
}
|
||||
cfg, err := RenderSyncthingConfig(rep, peers)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderSyncthingConfig: %v", err)
|
||||
}
|
||||
if cfg.FolderID != FolderID("team-alpha", "") {
|
||||
t.Errorf("folder ID = %q, want %q", cfg.FolderID, FolderID("team-alpha", ""))
|
||||
}
|
||||
if cfg.Path != rep.SourcePath {
|
||||
t.Errorf("path = %q, want %q", cfg.Path, rep.SourcePath)
|
||||
}
|
||||
if len(cfg.Devices) != 3 {
|
||||
t.Errorf("devices = %d, want 3", len(cfg.Devices))
|
||||
}
|
||||
if cfg.Devices[0].ID != "dev-source" {
|
||||
t.Errorf("first device = %q, want dev-source", cfg.Devices[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingConfig_Errors(t *testing.T) {
|
||||
peers := []SyncthingDevice{{ID: "d", Name: "n", Address: "dynamic"}}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{}, peers); err == nil {
|
||||
t.Error("empty namespace: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns"}, peers); err == nil {
|
||||
t.Error("empty source path: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingConfig(VolumeReplication{Namespace: "ns", SourcePath: "/p"}, nil); err == nil {
|
||||
t.Error("no peers: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Valid(t *testing.T) {
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: "abcd1234abcd1234abcd1234abcd1234",
|
||||
Path: "/var/lib/orca/data",
|
||||
Devices: []SyncthingDevice{
|
||||
{ID: "dev-source", Name: "peer-a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "peer-b", Address: "tcp://peer-b:22000"},
|
||||
},
|
||||
}
|
||||
out, err := RenderSyncthingXML(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderSyncthingXML: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(out, xml.Header) {
|
||||
t.Errorf("output missing XML header")
|
||||
}
|
||||
if !strings.Contains(out, `id="abcd1234abcd1234abcd1234abcd1234"`) {
|
||||
t.Errorf("folder ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "dev-source") {
|
||||
t.Errorf("source device ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "dev-b") {
|
||||
t.Errorf("peer device ID not in output")
|
||||
}
|
||||
if !strings.Contains(out, "/var/lib/orca/data") {
|
||||
t.Errorf("path not in output")
|
||||
}
|
||||
if !strings.Contains(out, `tcp://peer-b:22000`) {
|
||||
t.Errorf("peer address not in output")
|
||||
}
|
||||
if !strings.Contains(out, `<gui enabled="false"`) {
|
||||
t.Errorf("GUI not disabled in output")
|
||||
}
|
||||
// Must be parseable as XML.
|
||||
var doc syncthingXMLConfig
|
||||
if err := xml.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("output is not valid XML: %v", err)
|
||||
}
|
||||
if doc.Folders[0].ID != cfg.FolderID {
|
||||
t.Errorf("parsed folder ID = %q, want %q", doc.Folders[0].ID, cfg.FolderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Deterministic(t *testing.T) {
|
||||
cfg := &SyncthingConfig{
|
||||
FolderID: "abcd1234abcd1234abcd1234abcd1234",
|
||||
Path: "/data",
|
||||
Devices: []SyncthingDevice{
|
||||
{ID: "dev-a", Name: "a", Address: "dynamic"},
|
||||
{ID: "dev-b", Name: "b", Address: "tcp://b:22000"},
|
||||
},
|
||||
}
|
||||
a, _ := RenderSyncthingXML(cfg)
|
||||
b, _ := RenderSyncthingXML(cfg)
|
||||
if a != b {
|
||||
t.Errorf("RenderSyncthingXML not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSyncthingXML_Errors(t *testing.T) {
|
||||
if _, err := RenderSyncthingXML(nil); err == nil {
|
||||
t.Error("nil config: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingXML(&SyncthingConfig{Path: "/p", Devices: []SyncthingDevice{{ID: "d"}}}); err == nil {
|
||||
t.Error("empty folder ID: expected error")
|
||||
}
|
||||
if _, err := RenderSyncthingXML(&SyncthingConfig{FolderID: "f", Path: "/p"}); err == nil {
|
||||
t.Error("no devices: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_Differ(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a"), "shared": []byte("same")},
|
||||
"peer-b": {"f": []byte("b"), "shared": []byte("same")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("expected 1 conflict, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].Path != "f" {
|
||||
t.Errorf("conflict path = %q, want f", conflicts[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_AllAgree(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("same"), "g": []byte("x")},
|
||||
"peer-b": {"f": []byte("same"), "g": []byte("x")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected 0 conflicts, got %d", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_SinglePeerNoConflict(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"f": []byte("a")},
|
||||
}
|
||||
conflicts, err := DetectConflicts("ns", peerFiles)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("single peer should not produce conflict, got %d", len(conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_SortedOutput(t *testing.T) {
|
||||
peerFiles := map[string]map[string][]byte{
|
||||
"peer-a": {"z": []byte("a"), "a": []byte("a"), "m": []byte("a")},
|
||||
"peer-b": {"z": []byte("b"), "a": []byte("b"), "m": []byte("b")},
|
||||
}
|
||||
conflicts, _ := DetectConflicts("ns", peerFiles)
|
||||
if len(conflicts) != 3 {
|
||||
t.Fatalf("expected 3 conflicts, got %d", len(conflicts))
|
||||
}
|
||||
if conflicts[0].Path != "a" || conflicts[1].Path != "m" || conflicts[2].Path != "z" {
|
||||
t.Errorf("conflicts not sorted: %v", []string{conflicts[0].Path, conflicts[1].Path, conflicts[2].Path})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectConflicts_EmptyInput(t *testing.T) {
|
||||
conflicts, err := DetectConflicts("ns", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectConflicts: %v", err)
|
||||
}
|
||||
if conflicts != nil {
|
||||
t.Errorf("empty input should return nil, got %v", conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_SourceWins(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("source-content"),
|
||||
"peer-b": []byte("other-content"),
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-a")
|
||||
if string(winner) != "source-content" {
|
||||
t.Errorf("winner = %q, want source-content", winner)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-b" {
|
||||
t.Errorf("losers = %v, want [peer-b]", losers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_PeersAgreeNotLosers(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("source"),
|
||||
"peer-b": []byte("source"), // agrees with source
|
||||
"peer-c": []byte("differ"), // differs
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-a")
|
||||
if string(winner) != "source" {
|
||||
t.Errorf("winner = %q, want source", winner)
|
||||
}
|
||||
if len(losers) != 1 || losers[0] != "peer-c" {
|
||||
t.Errorf("losers = %v, want [peer-c]", losers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_Deterministic(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("a"),
|
||||
"peer-b": []byte("b"),
|
||||
"peer-c": []byte("c"),
|
||||
},
|
||||
}
|
||||
w1, l1 := ResolveConflict(c, "peer-a")
|
||||
w2, l2 := ResolveConflict(c, "peer-a")
|
||||
if string(w1) != string(w2) {
|
||||
t.Errorf("non-deterministic winner")
|
||||
}
|
||||
if !equalStringSlices(l1, l2) {
|
||||
t.Errorf("non-deterministic losers: %v vs %v", l1, l2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflict_UnknownSource(t *testing.T) {
|
||||
c := Conflict{
|
||||
Path: "f",
|
||||
Versions: map[string][]byte{
|
||||
"peer-a": []byte("a"),
|
||||
"peer-b": []byte("b"),
|
||||
},
|
||||
}
|
||||
winner, losers := ResolveConflict(c, "peer-z")
|
||||
if winner != nil {
|
||||
t.Errorf("unknown source winner should be nil, got %q", winner)
|
||||
}
|
||||
if losers != nil {
|
||||
t.Errorf("unknown source losers should be nil, got %v", losers)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user