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---
302 lines
8.6 KiB
Go
302 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|