package emitter import ( "errors" "fmt" "strings" "git.cloudinit.dev/coreci/orca/internal/jobspec" ) // SystemdEmitter is the Emitter implementation for the "process" // runtime. It renders the systemd unit file for the workload, // including the lifecycle hooks (P04) and the R-007 socket plumbing // (P08). // // Lifecycle hooks map to systemd semantics (PRD §10.1): // // - lifecycle.pre_stop → ExecStop= (the command run on stop; systemd // runs ExecStop, then kills the main process after the deadline). // - lifecycle.post_start → ExecStartPost= (runs after the main // process starts). // // systemd has no ExecStartPre equivalent for a "pre_start" hook; the // spec does not define pre_start (only pre_stop and post_start per // PRD §10.1), so no mapping is needed. // // The unit name carries the `orca-v1-` prefix per the dual-write // window (REQ-090) so the v0.9 SSH-push path does not collide with the // v0.8 daemon's `orca-.service` units during the migration // window. // // Later phases extend this emitter: // // - v0.10-P03: secrets via EnvironmentFile= + LoadCredential= type SystemdEmitter struct{} // unitNamePrefix is the v0.9 SSH-push unit-name prefix. The v0.8 // daemon uses `orca-.service`; the v0.9 path uses // `orca-v1-.service` so the two never overlap (REQ-090, // I-C-006). The prefix is load-bearing — do not change it without // updating the dual-write window contract. const unitNamePrefix = "orca-v1-" // Render renders the systemd unit file for a process-runtime workload. // The unit name is /etc/systemd/system/.service // and the content is a [Service] block with ExecStart, optional // ExecStartPost (lifecycle.post_start), optional ExecStop // (lifecycle.pre_stop), and the R-007 socket-plumbing lines // (RuntimeDirectory=, optional TCP-bind ExecStartPre). Mode is 0644. // // The rendered shape is: // // [Service] // ExecStart= // ExecStartPost= // ExecStartPost= // ExecStop= // ExecStop= // RuntimeDirectory=orca/alloc- // # socket: /run/orca/alloc-/port-.sock // ExecStartPre=/bin/echo orca: bind 127.0.0.1 port (tcp, R-007 opt-in) // // Returns an error if the spec is nil, the spec is missing its name, // the runtime block is nil, or the runtime command is empty (a // workload with no command has nothing to ExecStart). func (SystemdEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) { if spec == nil { return nil, errors.New("emitter/systemd: spec is nil") } if strings.TrimSpace(spec.Name) == "" { return nil, errors.New("emitter/systemd: spec name is empty") } if spec.Runtime == nil { return nil, errors.New("emitter/systemd: runtime block is nil") } if strings.TrimSpace(spec.Runtime.Command) == "" { return nil, errors.New("emitter/systemd: runtime command is empty") } path := fmt.Sprintf("/etc/systemd/system/%s%s.service", unitNamePrefix, spec.Name) content := renderSystemdUnit(spec) return []File{{Path: path, Content: content, Mode: "0644"}}, nil } // renderSystemdUnit renders the full [Service] block for the spec, // including ExecStart, lifecycle hooks (ExecStartPost, ExecStop), and // the R-007 socket-plumbing lines (RuntimeDirectory=, optional // TCP-bind ExecStartPre). The output is a single string with a // trailing newline per line. func renderSystemdUnit(spec *jobspec.WorkloadSpec) string { var b strings.Builder b.WriteString("[Service]\n") b.WriteString(fmt.Sprintf("ExecStart=%s\n", spec.Runtime.Command)) // Lifecycle: post_start → ExecStartPost (runs after start). for _, cmd := range lifecyclePostStart(spec) { b.WriteString(fmt.Sprintf("ExecStartPost=%s\n", cmd)) } // Lifecycle: pre_stop → ExecStop (runs before the process is killed). for _, cmd := range lifecyclePreStop(spec) { b.WriteString(fmt.Sprintf("ExecStop=%s\n", cmd)) } // R-007 socket plumbing: RuntimeDirectory= per port + optional // TCP-bind ExecStartPre. for _, line := range (SocketEmitter{}).RenderSocketLines(spec) { b.WriteString(line) b.WriteString("\n") } return b.String() } // lifecyclePostStart returns the post_start lifecycle commands for // the spec, or nil when the spec has no lifecycle block or no // post_start commands. func lifecyclePostStart(spec *jobspec.WorkloadSpec) []string { if spec.Lifecycle == nil { return nil } return spec.Lifecycle.PostStart } // lifecyclePreStop returns the pre_stop lifecycle commands for the // spec, or nil when the spec has no lifecycle block or no pre_stop // commands. func lifecyclePreStop(spec *jobspec.WorkloadSpec) []string { if spec.Lifecycle == nil { return nil } return spec.Lifecycle.PreStop }