675feabf0c
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---
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
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
|
|
}
|