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---
384 lines
14 KiB
Go
384 lines
14 KiB
Go
// 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
|
|
}
|