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---
176 lines
6.7 KiB
Go
176 lines
6.7 KiB
Go
package emitter
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
|
"git.cloudinit.dev/coreci/orca/internal/storage"
|
|
)
|
|
|
|
// SyncthingEmitter is the Layer-4 emitter for the per-namespace
|
|
// Syncthing config files (REQ-081). For every volume in spec.Volumes
|
|
// that carries a `replicate:` list, the emitter renders one Syncthing
|
|
// `config.xml` at /etc/syncthing/orca-<ns>-<volume>.xml containing the
|
|
// content-addressed folder (storage.FolderID), the device list (all
|
|
// peers in the namespace), and the volume path.
|
|
//
|
|
// Syncthing configs are kind-agnostic — they apply to any workload
|
|
// (Job, Service, DaemonSet) that declares a replicated volume. The
|
|
// emitter is therefore registered on the Registry under every
|
|
// kind:runtime key the other emitters use, but it is intended to be
|
|
// composed by the caller (the caller renders both the systemd unit and
|
|
// the Syncthing config for the same spec). For the v0.9-P09 spike the
|
|
// emitter is invoked directly; the composition lands in a later phase.
|
|
//
|
|
// The emitter is CLI-side only: it renders the XML; the SSH-push
|
|
// transport SCPs the file to each peer; the Syncthing apt package on
|
|
// the peer reads it. No Syncthing Go client is linked.
|
|
type SyncthingEmitter struct{}
|
|
|
|
// syncthingConfigDir is the canonical directory for rendered Syncthing
|
|
// configs on a peer (R-005). The emitter writes one file per
|
|
// replicated volume.
|
|
const syncthingConfigDir = "/etc/syncthing"
|
|
|
|
// localDeviceAddress is the address the local peer's own device entry
|
|
// uses. "dynamic" tells Syncthing this peer is the listener (it does
|
|
// not dial out to itself).
|
|
const localDeviceAddress = "dynamic"
|
|
|
|
// peerDeviceAddressTemplate renders the Sync listen address for a
|
|
// remote peer. The peer hostname (from the ReplicateTo list) is used
|
|
// as the host; the default Sync port is 22000.
|
|
const peerDeviceAddressTemplate = "tcp://%s:22000"
|
|
|
|
// Render renders one Syncthing config XML file per replicated volume
|
|
// in the spec. A volume is "replicated" when its VolumeSpec carries a
|
|
// non-empty `replicate:` list — encoded in VolumeSpec.Source as the
|
|
// comma-separated peer list prefixed with `replicate:` (e.g.
|
|
// `replicate:peer-b,peer-c`). This keeps the VolumeSpec shape stable
|
|
// (the v0.9 VolumeSpec has no explicit Replicate field; the emitter
|
|
// parses it from Source).
|
|
//
|
|
// For each replicated volume, the emitter:
|
|
//
|
|
// 1. Builds a VolumeReplication (namespace = spec.Name's namespace,
|
|
// volume name, source path = VolumeSpec.Target).
|
|
// 2. Builds the peer device list (the local peer + every peer in the
|
|
// `replicate:` list). The local peer's device ID is derived
|
|
// deterministically from the node hostname (the real device ID is
|
|
// discovered from the peer registry in a later phase; for the
|
|
// spike a deterministic placeholder keeps the rendered config
|
|
// byte-stable).
|
|
// 3. Calls storage.RenderSyncthingConfig + storage.RenderSyncthingXML
|
|
// to produce the config file content.
|
|
//
|
|
// Returns an error if the spec is nil or the node is nil (the node is
|
|
// required to identify the local peer). Workloads with no replicated
|
|
// volumes return an empty (non-nil) slice — the emitter is a no-op for
|
|
// them.
|
|
func (SyncthingEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
|
if spec == nil {
|
|
return nil, errors.New("emitter/syncthing: spec is nil")
|
|
}
|
|
if node == nil {
|
|
return nil, errors.New("emitter/syncthing: node is nil (local peer unknown)")
|
|
}
|
|
var files []File
|
|
for _, vol := range spec.Volumes {
|
|
peers, ok := parseReplicateList(vol.Source)
|
|
if !ok || len(peers) == 0 {
|
|
continue
|
|
}
|
|
path := vol.Target
|
|
if strings.TrimSpace(path) == "" {
|
|
path = vol.Source
|
|
}
|
|
rep := storage.VolumeReplication{
|
|
Namespace: spec.Name,
|
|
VolumeName: vol.Name,
|
|
SourcePath: path,
|
|
ReplicateTo: peers,
|
|
SyncMode: "sendreceive",
|
|
}
|
|
devices := buildSyncthingDevices(node, peers)
|
|
cfg, err := storage.RenderSyncthingConfig(rep, devices)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("emitter/syncthing: render config for volume %q: %w", vol.Name, err)
|
|
}
|
|
xml, err := storage.RenderSyncthingXML(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("emitter/syncthing: render xml for volume %q: %w", vol.Name, err)
|
|
}
|
|
files = append(files, File{
|
|
Path: fmt.Sprintf("%s/orca-%s-%s.xml", syncthingConfigDir, spec.Name, vol.Name),
|
|
Content: xml,
|
|
Mode: "0644",
|
|
})
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// parseReplicateList extracts the peer list from a VolumeSpec.Source
|
|
// value of the form `replicate:peer-b,peer-c`. Returns the peer list
|
|
// and true when the Source carries a replicate directive; returns nil
|
|
// and false otherwise (the volume is not replicated).
|
|
func parseReplicateList(source string) ([]string, bool) {
|
|
s := strings.TrimSpace(source)
|
|
if !strings.HasPrefix(s, "replicate:") {
|
|
return nil, false
|
|
}
|
|
rest := strings.TrimPrefix(s, "replicate:")
|
|
parts := strings.Split(rest, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// buildSyncthingDevices builds the Syncthing device list for the
|
|
// rendered config. The local peer (the node the config is being
|
|
// rendered for) is first, with the local device address ("dynamic").
|
|
// Each remote peer in the replicate list follows, with a
|
|
// tcp://<peer>:22000 address. Device IDs are deterministic placeholders
|
|
// derived from the peer name (the real device IDs are discovered from
|
|
// the peer registry in a later phase; the placeholder keeps the
|
|
// rendered config byte-stable across re-runs).
|
|
func buildSyncthingDevices(node *Node, peers []string) []storage.SyncthingDevice {
|
|
devices := make([]storage.SyncthingDevice, 0, len(peers)+1)
|
|
devices = append(devices, storage.SyncthingDevice{
|
|
ID: syncthingDeviceID(node.Hostname),
|
|
Name: node.Hostname,
|
|
Address: localDeviceAddress,
|
|
})
|
|
for _, p := range peers {
|
|
devices = append(devices, storage.SyncthingDevice{
|
|
ID: syncthingDeviceID(p),
|
|
Name: p,
|
|
Address: fmt.Sprintf(peerDeviceAddressTemplate, p),
|
|
})
|
|
}
|
|
return devices
|
|
}
|
|
|
|
// syncthingDeviceID returns a deterministic, stable device-ID
|
|
// placeholder for the given peer name. The placeholder is a fixed
|
|
// 52-char string (Syncthing device IDs are 52-char base32) derived by
|
|
// padding the peer name. The real device ID (discovered from the peer
|
|
// registry / cluster/peers/) replaces this in a later phase; for the
|
|
// v0.9 spike the placeholder keeps the rendered config byte-stable so
|
|
// the SSH-push idempotency check works.
|
|
func syncthingDeviceID(peerName string) string {
|
|
const idLen = 52
|
|
name := strings.TrimSpace(peerName)
|
|
if len(name) >= idLen {
|
|
return name[:idLen]
|
|
}
|
|
pad := strings.Repeat("X", idLen-len(name))
|
|
return name + pad
|
|
}
|