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---
173 lines
5.1 KiB
Go
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")
|
|
}
|
|
}
|