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--.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://: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 }