Files
orca/internal/cli/job_verify.go
T
Jon Chery 020aa01623 feat(P11,P12): orca job lint (REQ-084) + orca job verify (dry-run txn)
P11: orca job lint <spec.md> — schema/CEL/body/migration/best-practice
checks; --explain, --format json; exit 0/1 by errors found.
P12: orca job verify <spec.md> — dry-run txn (render + stage + verify
without apply); reports planned allocs/files/units; no side effects;
--namespace, --json.

---ci---
project: orca
phase: 11
milestone: v0.11
status: execute
---/ci---
2026-08-07 07:29:44 +00:00

397 lines
13 KiB
Go

// Package cli: job_verify.go implements `orca job verify` (P12,
// v0.11 milestone). It performs a dry-run transaction through the lead:
// parse the jobspec, render the emitter plan (allocs/files/units),
// render a txn bundle with the desired state, stage it on the lead
// (idempotent; NO apply), run verify.sh on the lead to capture what
// WOULD be applied, and report the plan. Pre-flight drift (R-020) is
// reported but does not fail a dry-run.
//
// There are no side effects beyond the staged bundle files in
// /run/orca/txns/<txn-id>/ on the lead (idempotent; no .applied marker
// is written, so orca-pull.sh never picks it up).
package cli
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/emitter"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/secrets"
"git.cloudinit.dev/coreci/orca/internal/spec/schema"
"git.cloudinit.dev/coreci/orca/internal/txn"
)
var (
jobVerifyLead string
jobVerifyNamespace string
jobVerifyJSON bool
)
// jobVerifyTransport is the SSH-push surface `job verify` needs. It
// is satisfied by *sshpush.Transport; tests substitute a mock (same
// pattern as txn.go / drain.go).
type jobVerifyTransport interface {
WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
// jobVerifyTransportOverride is the package-level seam. When non-nil
// it replaces the production transport; tests set it and restore nil.
var jobVerifyTransportOverride jobVerifyTransport
func jobVerifyTransportFromCtx() (jobVerifyTransport, error) {
if jobVerifyTransportOverride != nil {
return jobVerifyTransportOverride, nil
}
return txnTransportFromCtx()
}
// verifyReport is the structured result of `orca job verify`. It is
// rendered to JSON when --json is set, or as a human-readable summary
// otherwise.
type verifyReport struct {
TxnID string `json:"txn_id"`
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Lead string `json:"lead"`
PlannedAllocs []plannedAlloc `json:"planned_allocs"`
PlannedFiles []plannedFile `json:"planned_files"`
VerifyOutput string `json:"verify_output,omitempty"`
Drift []string `json:"drift,omitempty"`
Status string `json:"status"`
}
type plannedAlloc struct {
Name string `json:"name"`
Kind string `json:"kind"`
Count int `json:"count"`
}
type plannedFile struct {
Path string `json:"path"`
Mode string `json:"mode"`
Kind string `json:"kind"`
}
var jobVerifyCmd = &cobra.Command{
Use: "verify <spec>",
Short: "Dry-run a jobspec through the lead (no apply)",
Long: `Dry-run a jobspec as a transaction through the lead peer.
Steps (P12):
1. Parse the jobspec
2. Render the emitter plan (allocs, config files, systemd units)
3. Render a txn bundle (RenderBundle) with the desired state
4. Stage the bundle on the lead (NO apply; idempotent)
5. Run verify.sh on the lead to capture what WOULD be applied
6. Report planned allocs / files / units
Pre-flight drift (R-020) is reported but does not fail a dry-run.
There are no side effects beyond the staged bundle files in
/run/orca/txns/<txn-id>/ on the lead (no .applied marker is written).
Flags:
--lead <peer> lead peer address (host:port) (required)
--namespace <ns> namespace scope
--json JSON output
Exit codes: 0 = verify passed (no issues), 1 = verification failed.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
report, err := runJobVerify(cmd.Context(), args[0])
if err != nil {
if jobVerifyJSON {
if report != nil {
_ = printVerifyJSON(report)
}
} else if report != nil {
printVerifyText(report)
}
return err
}
if jobVerifyJSON {
return printVerifyJSON(report)
}
printVerifyText(report)
return nil
},
}
// runJobVerify performs the dry-run. It returns the report and an
// error: when err is non-nil the report may still be populated with
// partial results (e.g. drift was detected but the verify step ran).
// The caller renders the report then surfaces the error.
func runJobVerify(ctx context.Context, path string) (*verifyReport, error) {
if jobVerifyLead == "" {
return nil, fmt.Errorf("--lead is required for job verify")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read spec file: %w", err)
}
spec, err := jobspec.Dispatch(data, fileBase(path))
if err != nil {
return nil, fmt.Errorf("parse spec: %w", err)
}
if verr := validateForVerify(spec); verr != nil {
return nil, verr
}
files, err := renderPlan(spec)
if err != nil {
return nil, fmt.Errorf("render plan: %w", err)
}
mk, err := secrets.LoadMasterKey(paths.MasterKeyPath())
if err != nil {
return nil, fmt.Errorf("load master key: %w", err)
}
desired := buildDesiredState(spec, files)
bundle, err := txn.RenderBundle(desired, mk)
if err != nil {
return nil, fmt.Errorf("render bundle: %w", err)
}
transport, err := jobVerifyTransportFromCtx()
if err != nil {
return nil, fmt.Errorf("ssh transport: %w", err)
}
if err := txn.Stage(bundle, jobVerifyLead, asTxnTransport(transport)); err != nil {
return nil, fmt.Errorf("stage bundle: %w", err)
}
slog.Info("job verify: bundle staged (no apply)", "txn_id", bundle.ID, "lead", jobVerifyLead)
verifyOut, verifyErr := runVerifySh(ctx, transport, bundle.ID, jobVerifyLead, jobVerifyNamespace)
drift := parseDriftLines(string(verifyOut))
report := &verifyReport{
TxnID: string(bundle.ID),
Kind: spec.Kind,
Name: spec.Name,
Namespace: jobVerifyNamespace,
Lead: jobVerifyLead,
PlannedAllocs: buildPlannedAllocs(spec),
PlannedFiles: buildPlannedFiles(files),
VerifyOutput: string(verifyOut),
Drift: drift,
Status: "verified",
}
if verifyErr != nil {
report.Status = "verify-failed"
return report, fmt.Errorf("verify.sh on %s: %w (output: %s)", jobVerifyLead, verifyErr, string(verifyOut))
}
return report, nil
}
// validateForVerify runs the schema validator (the dry-run should fail
// fast on an invalid spec, same as `orca job lint` errors).
func validateForVerify(spec *jobspec.WorkloadSpec) error {
if spec == nil {
return fmt.Errorf("spec is nil")
}
v, err := schema.ValidatorFor(spec.Kind)
if err != nil {
return err
}
if verr := v.Validate(spec); verr != nil {
return fmt.Errorf("schema validation: %w", verr)
}
return nil
}
// renderPlan renders the spec into the emitter File artifacts that
// represent what WOULD be written on apply. The registry is populated
// with the SystemdEmitter for the "process" runtime (the only runtime
// P0c ships); other runtimes return an error so the dry-run reports
// the gap instead of pretending success.
func renderPlan(spec *jobspec.WorkloadSpec) ([]emitter.File, error) {
if spec.Runtime == nil {
return nil, fmt.Errorf("spec runtime is nil (verify needs a runtime to render)")
}
reg := emitter.NewRegistry()
reg.Register("job:process", emitter.SystemdEmitter{})
reg.Register("service:process", emitter.SystemdEmitter{})
reg.Register("daemonset:process", emitter.SystemdEmitter{})
node := &emitter.Node{Hostname: jobVerifyLead}
return reg.Render(spec, node)
}
// buildDesiredState assembles the desired-state object the txn apply
// script consumes. It is a list of artifact dicts (path/content/mode)
// derived from the emitter plan, plus metadata so verify.sh and
// orca-pull.sh can report what would change.
func buildDesiredState(spec *jobspec.WorkloadSpec, files []emitter.File) any {
type artifact struct {
Path string `json:"path"`
Content string `json:"content"`
Mode string `json:"mode"`
}
arts := make([]artifact, len(files))
for i, f := range files {
arts[i] = artifact{Path: f.Path, Content: f.Content, Mode: f.Mode}
}
return map[string]any{
"kind": spec.Kind,
"name": spec.Name,
"namespace": jobVerifyNamespace,
"artifacts": arts,
}
}
// buildPlannedAllocs reports the alloc(s) the apply would create. For
// the single-process path it is one alloc named after the spec; for a
// task group it is one alloc with N task units. Count > 1 (Service)
// expands to N allocs.
func buildPlannedAllocs(spec *jobspec.WorkloadSpec) []plannedAlloc {
count := spec.Count
if count < 1 {
count = 1
}
out := make([]plannedAlloc, 0, count)
for i := 0; i < count; i++ {
name := spec.Name
if count > 1 {
name = fmt.Sprintf("%s-%d", spec.Name, i)
}
out = append(out, plannedAlloc{Name: name, Kind: spec.Kind, Count: 1})
}
return out
}
// buildPlannedFiles classifies the emitter artifacts into config files
// and systemd units by path. Anything under /etc/systemd/system/ is a
// unit; everything else is a config file.
func buildPlannedFiles(files []emitter.File) []plannedFile {
out := make([]plannedFile, 0, len(files))
for _, f := range files {
kind := "config"
if strings.HasPrefix(f.Path, "/etc/systemd/system/") {
kind = "unit"
}
out = append(out, plannedFile{Path: f.Path, Mode: f.Mode, Kind: kind})
}
return out
}
// runVerifySh runs verify.sh on the lead for the staged bundle. The
// verify script reports missing files (the ones that WOULD be written
// on apply). A non-zero exit is expected for a dry-run (the files are
// not applied yet), so the caller treats the output as informational.
func runVerifySh(ctx context.Context, transport jobVerifyTransport, id txn.TxnID, lead, namespace string) ([]byte, error) {
dir := "/run/orca/txns/" + string(id)
cmd := fmt.Sprintf("bash %s/verify.sh", dir)
out, err := transport.Exec(ctx, lead, cmd)
if err != nil {
return out, err
}
return out, nil
}
// parseDriftLines extracts pre-flight drift (R-020) notices from the
// verify.sh output. The verify script emits "verify: drift <detail>"
// lines when the on-disk state has drifted from a prior apply; in a
// dry-run these are reported but do not fail.
func parseDriftLines(out string) []string {
var drift []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "verify: drift") {
drift = append(drift, strings.TrimSpace(strings.TrimPrefix(line, "verify:")))
}
}
return drift
}
func printVerifyText(r *verifyReport) {
w := rootCmd.OutOrStdout()
fmt.Fprintf(w, "Txn: %s\n", r.TxnID)
fmt.Fprintf(w, "Kind: %s Name: %s\n", r.Kind, r.Name)
if r.Namespace != "" {
fmt.Fprintf(w, "Namespace: %s\n", r.Namespace)
}
fmt.Fprintf(w, "Lead: %s\n", r.Lead)
fmt.Fprintf(w, "Status: %s\n", r.Status)
fmt.Fprintf(w, "\nPlanned allocations (%d):\n", len(r.PlannedAllocs))
for _, a := range r.PlannedAllocs {
fmt.Fprintf(w, " - %s (kind=%s)\n", a.Name, a.Kind)
}
units := 0
configs := 0
for _, f := range r.PlannedFiles {
if f.Kind == "unit" {
units++
} else {
configs++
}
}
fmt.Fprintf(w, "\nPlanned files: %d config, %d systemd units\n", configs, units)
for _, f := range r.PlannedFiles {
fmt.Fprintf(w, " - [%s] %s (mode %s)\n", f.Kind, f.Path, f.Mode)
}
if len(r.Drift) > 0 {
fmt.Fprintf(w, "\nPre-flight drift (R-020, reported -- dry-run does not fail):\n")
for _, d := range r.Drift {
fmt.Fprintf(w, " ! %s\n", d)
}
}
if r.VerifyOutput != "" {
fmt.Fprintf(w, "\nverify.sh output:\n%s\n", r.VerifyOutput)
}
}
func printVerifyJSON(r *verifyReport) error {
w := rootCmd.OutOrStdout()
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(r)
}
// asTxnTransport adapts the jobVerifyTransport seam to the txn.Transport
// interface (they have the same shape; this is a thin wrapper so the
// two packages stay decoupled).
type txnTransportAdapter struct {
inner jobVerifyTransport
}
func (a txnTransportAdapter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
return a.inner.WriteFileIdempotent(ctx, peer, path, content, mode)
}
func (a txnTransportAdapter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
return a.inner.Exec(ctx, peer, cmd)
}
func asTxnTransport(t jobVerifyTransport) txn.Transport {
return txnTransportAdapter{inner: t}
}
// fileBase returns filepath.Base(path) without importing filepath in
// the top of the file (kept here so the import block stays small).
func fileBase(path string) string {
if i := strings.LastIndexAny(path, "/\\"); i >= 0 {
return path[i+1:]
}
return path
}
func init() {
jobVerifyCmd.Flags().StringVar(&jobVerifyLead, "lead", "", "lead peer address (host:port) (required)")
jobVerifyCmd.Flags().StringVar(&jobVerifyNamespace, "namespace", "", "namespace scope")
jobVerifyCmd.Flags().BoolVar(&jobVerifyJSON, "json", false, "JSON output")
jobCmd.AddCommand(jobVerifyCmd)
}