// 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// 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 ", 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// on the lead (no .applied marker is written). Flags: --lead lead peer address (host:port) (required) --namespace 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 " // 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) }