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. // // When the spec has no Tasks (the single-process case, the historical // shape), 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. // // When the spec has a task group (P06, spec.Tasks non-empty), the // alloc is multi-process and Render emits one systemd unit per task // (`orca-v1-alloc--.service`) plus a single // grouping target unit (`orca-v1-alloc-.target`) that // starts/stops all tasks together. Each per-task unit carries // `PartOf=orca-v1-alloc-.target` and is // `WantedBy=multi-user.target` so the task starts at boot. Tasks // that omit their own runtime inherit the top-level spec.Runtime as // the per-group default. // // The rendered shape (single-process) 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). For task groups, // returns an error if any task has no resolvable runtime command. 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 len(spec.Tasks) > 0 { return renderTaskGroup(spec, node) } 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 } // renderTaskGroup renders one systemd unit per task plus the grouping // target unit. Each task's runtime falls back to the top-level // spec.Runtime when the task omits its own. Tasks with no resolvable // command (no task.Command, no task.Runtime.Command, no top-level // Runtime) return an error. func renderTaskGroup(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) { allocID := spec.Name targetUnit := fmt.Sprintf("%salloc-%s.target", unitNamePrefix, allocID) targetPath := fmt.Sprintf("/etc/systemd/system/%s", targetUnit) var files []File for _, task := range spec.Tasks { rt := taskRuntime(spec, &task) if rt == nil { return nil, fmt.Errorf("emitter/systemd: task %q has no runtime (set tasks[].runtime or top-level runtime)", task.Name) } cmd := taskCommand(spec, &task, rt) if strings.TrimSpace(cmd) == "" { return nil, fmt.Errorf("emitter/systemd: task %q command is empty", task.Name) } unitName := fmt.Sprintf("%salloc-%s-%s.service", unitNamePrefix, allocID, task.Name) path := fmt.Sprintf("/etc/systemd/system/%s", unitName) content := renderTaskUnit(spec, &task, rt, cmd, targetUnit) files = append(files, File{Path: path, Content: content, Mode: "0644"}) } files = append(files, File{ Path: targetPath, Content: renderTargetUnit(targetUnit, spec, allocID), Mode: "0644", }) return files, nil } // taskRuntime returns the effective runtime for a task: the task's own // runtime when set, otherwise the top-level spec.Runtime (the per-group // default). Returns nil when neither is set. func taskRuntime(spec *jobspec.WorkloadSpec, task *jobspec.TaskGroupTask) *jobspec.RuntimeBlock { if task.Runtime != nil { return task.Runtime } return spec.Runtime } // taskCommand returns the ExecStart command for a task. A task-level // Command takes precedence; otherwise the task's runtime command is // used; otherwise the top-level runtime command is used. Returns an // empty string when none is set. func taskCommand(spec *jobspec.WorkloadSpec, task *jobspec.TaskGroupTask, rt *jobspec.RuntimeBlock) string { if strings.TrimSpace(task.Command) != "" { return task.Command } if rt != nil && strings.TrimSpace(rt.Command) != "" { return rt.Command } return "" } // renderTaskUnit renders a single per-task systemd [Unit]+[Service] // block. The unit is `PartOf=` the alloc target and // `WantedBy=multi-user.target` so it starts at boot and stops with the // group. The [Service] block carries the task's ExecStart and the // socket-plumbing lines derived from the spec's ports. func renderTaskUnit(spec *jobspec.WorkloadSpec, task *jobspec.TaskGroupTask, rt *jobspec.RuntimeBlock, cmd, targetUnit string) string { var b strings.Builder b.WriteString("[Unit]\n") b.WriteString(fmt.Sprintf("Description=orca alloc task %s\n", task.Name)) b.WriteString(fmt.Sprintf("PartOf=%s\n", targetUnit)) b.WriteString("\n[Service]\n") b.WriteString(fmt.Sprintf("ExecStart=%s\n", cmd)) for _, line := range (SocketEmitter{}).RenderSocketLines(spec) { b.WriteString(line) b.WriteString("\n") } b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } // renderTargetUnit renders the grouping target unit // (`orca-v1-alloc-.target`) that starts/stops all tasks // together. The [Unit] block lists every per-task unit under Wants= // so `systemctl start ` brings them all up, and // `systemctl stop ` tears them down (PartOf= propagates stop). func renderTargetUnit(targetUnit string, spec *jobspec.WorkloadSpec, allocID string) string { var b strings.Builder b.WriteString("[Unit]\n") b.WriteString(fmt.Sprintf("Description=orca alloc %s task group\n", allocID)) for _, task := range spec.Tasks { b.WriteString(fmt.Sprintf("Wants=%salloc-%s-%s.service\n", unitNamePrefix, allocID, task.Name)) } b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } // 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 }