// 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 // entry per element inside and one 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 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 element (both inside 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 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 element (disabled — no GUI). type syncthingXMLGUI struct { XMLName xml.Name `xml:"gui"` Enabled bool `xml:"enabled,attr"` } // syncthingXMLConfig is the root 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 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 }