Files
orca/internal/emitter/syncthing_test.go
T
Jon Chery 675feabf0c 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---
2026-08-05 18:38:49 +00:00

173 lines
5.1 KiB
Go

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")
}
}