ae6eb5a27b
P03 — Update stanza (rolling/canary/blue-green): - internal/spec/schema/update.go: UpdateValidator (strategy enum, max_parallel 1..count, duration parsing, canary int/% forms, auto_promote). 98.2% cov. - internal/emitter/update.go: RenderUpdatePlan computes the step sequence (rolling batches, canary 1+promote+rest, blue-green all+cutover). Pure plan, no execution (v0.10-P10 is transactional). 73.7-100% cov. P04 — Lifecycle hooks (systemd ExecStop semantics): - Extended internal/emitter/systemd.go: post_start -> ExecStartPost=, pre_stop -> ExecStop=. Order: ExecStart -> ExecStartPost -> ExecStop -> socket lines. 8 lifecycle tests. 100% cov on systemd.go. P08 — Socket plumbing (R-007): - internal/emitter/socket.go: SocketEmitter renders RuntimeDirectory=orca/ alloc-<id> per port (mode 0750, orca:orca). ExecStartPre TCP-bind marker when service.bind=127.0.0.1. SocketPath(allocID,portName) helper. 100% cov. - Alloc-id is spec.Name placeholder; real id assigned by scheduler at submit. 22 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent. ---ci--- project: orca phase: P03/P04/P08 milestone: v0.9 status: execute ---/ci---
129 lines
4.7 KiB
Go
129 lines
4.7 KiB
Go
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-<job>.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-<job>.service`; the v0.9 path uses
|
|
// `orca-v1-<spec.Name>.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/<unitNamePrefix><spec.Name>.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=<runtime command>
|
|
// ExecStartPost=<post_start command 1>
|
|
// ExecStartPost=<post_start command 2>
|
|
// ExecStop=<pre_stop command 1>
|
|
// ExecStop=<pre_stop command 2>
|
|
// RuntimeDirectory=orca/alloc-<alloc-id>
|
|
// # socket: /run/orca/alloc-<alloc-id>/port-<name>.sock
|
|
// ExecStartPre=/bin/echo orca: bind 127.0.0.1 port <name> (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
|
|
}
|