Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea00158fa5 | |||
| ae6eb5a27b | |||
| 19542dd8c9 | |||
| 436641782c | |||
| 075d2f6459 | |||
| e92b18197c | |||
| d379d19deb | |||
| 60b0357eb6 | |||
| af2fa59172 | |||
| 667f20a7b3 | |||
| fef03c5b56 | |||
| 7bb31d4c09 | |||
| 7b5193674e |
@@ -1,10 +1 @@
|
|||||||
{
|
{ "phase": "P03/P04/P08", "stage": "verify", "milestone": "v0.9", "phase_role": "execution", "updated_at": "2026-08-05T04:05:00Z", "milestone_complete": false, "verify": { "build": "pass", "go_test": "22/22", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" } }
|
||||||
"phase": "P0a1",
|
|
||||||
"stage": "verify",
|
|
||||||
"milestone": "v0.9",
|
|
||||||
"phase_role": "execution",
|
|
||||||
"updated_at": "2026-08-05T03:00:00Z",
|
|
||||||
"milestone_complete": false,
|
|
||||||
"gates_cleared_this_phase": ["C-07"],
|
|
||||||
"verify": { "build": "pass", "go_test": "17/17", "bats": "20/20", "gofmt": "clean", "verify_reqs": "90 consistent" }
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-3
@@ -74,7 +74,7 @@ var jobRunCmd = &cobra.Command{
|
|||||||
peers := engine.NewPeerRegistry()
|
peers := engine.NewPeerRegistry()
|
||||||
dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec)
|
dispatcher := engine.NewDispatcher(newLogger(), store.NewCapacityRepo(db), peers, exec)
|
||||||
specBytes, _ := json.Marshal(map[string]any{
|
specBytes, _ := json.Marshal(map[string]any{
|
||||||
"name": spec.Job.Name,
|
"name": spec.Name,
|
||||||
"command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase
|
"command": "/bin/true", // placeholder; full HCL dispatch lands in a later phase
|
||||||
})
|
})
|
||||||
jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey)
|
jobID, nodeID, err := dispatcher.Submit(ctx, runTarget, specBytes, runIDKey)
|
||||||
@@ -93,11 +93,11 @@ var jobRunCmd = &cobra.Command{
|
|||||||
|
|
||||||
job := &model.Job{
|
job := &model.Job{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Name: spec.Job.Name,
|
Name: spec.Name,
|
||||||
Spec: args[0],
|
Spec: args[0],
|
||||||
Status: model.JobStatusPending,
|
Status: model.JobStatusPending,
|
||||||
}
|
}
|
||||||
if err := exec.Run(ctx, job, toTaskSpecs(spec.Tasks)); err != nil {
|
if err := exec.Run(ctx, job, workloadToTaskSpecs(spec)); err != nil {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
_ = printJSON(map[string]any{"id": job.ID, "status": "failed", "error": err.Error()})
|
||||||
return err
|
return err
|
||||||
@@ -330,3 +330,22 @@ func toTaskSpecs(in []jobspec.TaskSpec) []engine.TaskSpec {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workloadToTaskSpecs converts a *WorkloadSpec into the engine.TaskSpec
|
||||||
|
// slice consumed by the executor. For the HCL adapter path the runtime
|
||||||
|
// block carries the legacy task[0].Command; for the Markdown path the
|
||||||
|
// runtime block is the canonical runtime abstraction (P07 will expand
|
||||||
|
// this). When Runtime is nil we emit a single no-op task to preserve
|
||||||
|
// the legacy "at least one task" invariant.
|
||||||
|
func workloadToTaskSpecs(spec *jobspec.WorkloadSpec) []engine.TaskSpec {
|
||||||
|
if spec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if spec.Runtime == nil {
|
||||||
|
return []engine.TaskSpec{{Name: spec.Name, Command: "/bin/true"}}
|
||||||
|
}
|
||||||
|
return []engine.TaskSpec{{
|
||||||
|
Name: spec.Name,
|
||||||
|
Command: spec.Runtime.Command,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func resetCommandFlags() {
|
|||||||
stopID, runTarget, runIDKey, jobWatch = "", "", "", false
|
stopID, runTarget, runIDKey, jobWatch = "", "", "", false
|
||||||
capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, ""
|
capSetCPU, capSetMem, capSetDisk, capNodeID = 0, 0, 0, ""
|
||||||
auditLimit = 50
|
auditLimit = 50
|
||||||
|
resetNSFlags()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNamespaceDefaultsToUserHome(t *testing.T) {
|
func TestNamespaceDefaultsToUserHome(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
// Package cli: ns.go implements the `orca ns` subcommand family
|
||||||
|
// (REQ-082, D-176). Subcommands:
|
||||||
|
//
|
||||||
|
// orca ns list — list all namespaces under ORCA_HOME
|
||||||
|
// orca ns create <name> — create a namespace dir + ns.md
|
||||||
|
// orca ns delete <name> — remove an empty namespace dir
|
||||||
|
// orca ns inspect <name> — print effective chain + merged env
|
||||||
|
// orca ns validate <name> — cycle + missing-parent + schema checks
|
||||||
|
//
|
||||||
|
// All subcommands honor $ORCA_HOME via internal/paths. The inheritance
|
||||||
|
// resolver (internal/ns) is a pure function shared by inspect + validate.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/ns"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nsCmd = &cobra.Command{
|
||||||
|
Use: "ns",
|
||||||
|
Short: "Manage orca namespaces",
|
||||||
|
Long: `Manage orca namespaces under ORCA_HOME (R-002).
|
||||||
|
|
||||||
|
Each namespace is a directory with ns.md, .env, .env.secrets, db/,
|
||||||
|
jobs/, alloc/. The implicit root namespace _defaults always exists
|
||||||
|
(D-159); every namespace inherits from _defaults (D-185) and cannot
|
||||||
|
opt out (D-187).`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
nsCreateParent string
|
||||||
|
nsCreateInheritsEnv bool
|
||||||
|
nsCreateInheritsSecret bool
|
||||||
|
)
|
||||||
|
|
||||||
|
var nsListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all namespaces under ORCA_HOME",
|
||||||
|
Long: `List all namespaces under ORCA_HOME (directories containing ns.md, plus the implicit _defaults).`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
root := paths.Root()
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read ORCA_HOME %s: %w", root, err)
|
||||||
|
}
|
||||||
|
type nsRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
}
|
||||||
|
var rows []nsRow
|
||||||
|
for _, ent := range entries {
|
||||||
|
if !ent.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ent.Name() == "cluster" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nsMd := filepath.Join(root, ent.Name(), "ns.md")
|
||||||
|
if _, err := os.Stat(nsMd); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, nsRow{
|
||||||
|
Name: ent.Name(),
|
||||||
|
Path: filepath.Join(root, ent.Name()),
|
||||||
|
Default: ent.Name() == paths.DefaultNamespace(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Name == paths.DefaultNamespace() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rows[j].Name == paths.DefaultNamespace() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return rows[i].Name < rows[j].Name
|
||||||
|
})
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(rows)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "No namespaces found. Run 'orca init' first.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", "NAME", "DEFAULT", "PATH")
|
||||||
|
for _, r := range rows {
|
||||||
|
def := ""
|
||||||
|
if r.Default {
|
||||||
|
def = "*"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-10s %s\n", r.Name, def, r.Path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsCreateCmd = &cobra.Command{
|
||||||
|
Use: "create <name>",
|
||||||
|
Short: "Create a namespace directory + ns.md",
|
||||||
|
Long: `Create a namespace under ORCA_HOME. Builds the dir structure
|
||||||
|
(db/, jobs/, alloc/) and writes ns.md frontmatter. --parent may be
|
||||||
|
repeated to declare inheritance; _defaults is always appended last.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
if name == paths.DefaultNamespace() {
|
||||||
|
return fmt.Errorf("cannot create the implicit root namespace %q with `ns create` (it is auto-managed)", name)
|
||||||
|
}
|
||||||
|
if name == "cluster" {
|
||||||
|
return fmt.Errorf("name %q is reserved for the cluster-wide dir", name)
|
||||||
|
}
|
||||||
|
if nsCreateParent == "" {
|
||||||
|
nsCreateParent = paths.DefaultNamespace()
|
||||||
|
}
|
||||||
|
nsDir := paths.NamespaceDir(name)
|
||||||
|
if _, err := os.Stat(nsDir); err == nil {
|
||||||
|
if _, statErr := os.Stat(paths.NSMd(name)); statErr == nil {
|
||||||
|
return fmt.Errorf("namespace %q already exists at %s", name, nsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"db", "jobs", "alloc"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(nsDir, sub), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create %s/%s: %w", nsDir, sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parents := []string{nsCreateParent}
|
||||||
|
if nsCreateParent == paths.DefaultNamespace() {
|
||||||
|
// Explicit _defaults listing is allowed (de-duped silently).
|
||||||
|
}
|
||||||
|
body := renderNSMd(name, parents, nsCreateInheritsEnv, nsCreateInheritsSecret)
|
||||||
|
if err := os.WriteFile(paths.NSMd(name), []byte(body), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write ns.md: %w", err)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"path": nsDir,
|
||||||
|
"parents": parents,
|
||||||
|
"ns_md": paths.NSMd(name),
|
||||||
|
"inherits_env": nsCreateInheritsEnv,
|
||||||
|
"inherits_secrets": nsCreateInheritsSecret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace created: %s (%s)\n", name, nsDir)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsDeleteCmd = &cobra.Command{
|
||||||
|
Use: "delete <name>",
|
||||||
|
Short: "Remove an empty namespace directory",
|
||||||
|
Long: `Remove a namespace directory. Refuses if jobs/ or alloc/
|
||||||
|
contain any files (non-empty namespace). The implicit root _defaults
|
||||||
|
cannot be deleted.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
if name == paths.DefaultNamespace() {
|
||||||
|
return fmt.Errorf("cannot delete the implicit root namespace %q", name)
|
||||||
|
}
|
||||||
|
nsDir := paths.NamespaceDir(name)
|
||||||
|
if _, err := os.Stat(nsDir); err != nil {
|
||||||
|
return fmt.Errorf("namespace %q not found: %w", name, err)
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"jobs", "alloc"} {
|
||||||
|
dir := filepath.Join(nsDir, sub)
|
||||||
|
if err := dirNonEmpty(dir); err != nil {
|
||||||
|
return fmt.Errorf("refusing to delete %q: %s is non-empty (%w); clear it first", name, sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(nsDir); err != nil {
|
||||||
|
return fmt.Errorf("delete %s: %w", nsDir, err)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]string{"name": name, "deleted": nsDir})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ Namespace deleted: %s (%s)\n", name, nsDir)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsInspectCmd = &cobra.Command{
|
||||||
|
Use: "inspect <name>",
|
||||||
|
Short: "Print the effective chain, merged env, and constraints",
|
||||||
|
Long: `Resolve a namespace's inheritance chain and print the merged env and unioned constraints (uses the resolver).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
root := paths.Root()
|
||||||
|
cfgs, err := ns.ParseNSMdDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load namespaces: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := cfgs[name]; !ok {
|
||||||
|
return fmt.Errorf("namespace %q not found under %s", name, root)
|
||||||
|
}
|
||||||
|
resolved, err := ns.Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve: %w", err)
|
||||||
|
}
|
||||||
|
r := resolved[name]
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("namespace %q resolved to nil", name)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]any{
|
||||||
|
"name": r.Name,
|
||||||
|
"chain": r.Chain,
|
||||||
|
"env": r.Env,
|
||||||
|
"constraints": r.Constraints,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Namespace: %s\n", r.Name)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Chain: %s\n", strings.Join(r.Chain, " -> "))
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Env:")
|
||||||
|
keys := sortedKeys(r.Env)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " %s = %s\n", k, r.Env[k])
|
||||||
|
}
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Constraints:")
|
||||||
|
if len(r.Constraints) == 0 {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), " (none)")
|
||||||
|
} else {
|
||||||
|
for _, c := range r.Constraints {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsValidateCmd = &cobra.Command{
|
||||||
|
Use: "validate <name>",
|
||||||
|
Short: "Run cycle + missing-parent + schema checks on a namespace",
|
||||||
|
Long: `Validate a namespace's inheritance chain and ns.md frontmatter.
|
||||||
|
Exits 0 if valid, 1 on error. Runs over ALL namespaces under ORCA_HOME
|
||||||
|
(parsing + resolving validates cycles and missing parents across the
|
||||||
|
set).`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name := args[0]
|
||||||
|
root := paths.Root()
|
||||||
|
cfgs, err := ns.ParseNSMdDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load namespaces: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := cfgs[name]; !ok {
|
||||||
|
return fmt.Errorf("namespace %q not found under %s", name, root)
|
||||||
|
}
|
||||||
|
resolved, err := ns.Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validate: %w", err)
|
||||||
|
}
|
||||||
|
r := resolved[name]
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("namespace %q resolved to nil", name)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return printJSON(map[string]any{
|
||||||
|
"name": r.Name,
|
||||||
|
"valid": true,
|
||||||
|
"chain": r.Chain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ %s valid\n chain: %s\n", name, strings.Join(r.Chain, " -> "))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderNSMd writes a minimal ns.md frontmatter for `orca ns create`.
|
||||||
|
func renderNSMd(name string, parents []string, inheritsEnv, inheritsSecrets bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("---\n")
|
||||||
|
b.WriteString("kind: Namespace\n")
|
||||||
|
b.WriteString("name: ")
|
||||||
|
b.WriteString(name)
|
||||||
|
b.WriteString("\n")
|
||||||
|
if len(parents) > 0 {
|
||||||
|
quoted := make([]string, len(parents))
|
||||||
|
for i, p := range parents {
|
||||||
|
quoted[i] = fmt.Sprintf("%q", p)
|
||||||
|
}
|
||||||
|
b.WriteString("parents: [")
|
||||||
|
b.WriteString(strings.Join(quoted, ", "))
|
||||||
|
b.WriteString("]\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "inherits_env: %t\n", inheritsEnv)
|
||||||
|
fmt.Fprintf(&b, "inherits_secrets: %t\n", inheritsSecrets)
|
||||||
|
b.WriteString("---\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirNonEmpty returns an error wrapping the offending entry if dir
|
||||||
|
// contains any entries.
|
||||||
|
func dirNonEmpty(dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
return fmt.Errorf("contains %s", e.Name())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
nsCreateCmd.Flags().StringVar(&nsCreateParent, "parent", "", "parent namespace (default _defaults; the implicit root is always appended last)")
|
||||||
|
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsEnv, "inherits-env", true, "inherit env from parents (default true)")
|
||||||
|
nsCreateCmd.Flags().BoolVar(&nsCreateInheritsSecret, "inherits-secrets", true, "inherit secrets from parents (default true)")
|
||||||
|
|
||||||
|
nsCmd.AddCommand(nsListCmd)
|
||||||
|
nsCmd.AddCommand(nsCreateCmd)
|
||||||
|
nsCmd.AddCommand(nsDeleteCmd)
|
||||||
|
nsCmd.AddCommand(nsInspectCmd)
|
||||||
|
nsCmd.AddCommand(nsValidateCmd)
|
||||||
|
rootCmd.AddCommand(nsCmd)
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetNSFlags zeroes the ns subcommand flag-bound vars so tests don't
|
||||||
|
// leak state.
|
||||||
|
func resetNSFlags() {
|
||||||
|
nsCreateParent = ""
|
||||||
|
nsCreateInheritsEnv = true
|
||||||
|
nsCreateInheritsSecret = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDefaultsNS(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
nsDir := filepath.Join(root, "_defaults")
|
||||||
|
if err := os.MkdirAll(nsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir _defaults: %v", err)
|
||||||
|
}
|
||||||
|
body := "---\nkind: Namespace\nname: _defaults\ninherits_env: true\ninherits_secrets: true\n---\n# defaults\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("write _defaults ns.md: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCustomNS(t *testing.T, root, name, parentsList string) {
|
||||||
|
t.Helper()
|
||||||
|
nsDir := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(nsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", name, err)
|
||||||
|
}
|
||||||
|
body := "---\nkind: Namespace\nname: " + name + "\n"
|
||||||
|
if parentsList != "" {
|
||||||
|
body += "parents: " + parentsList + "\n"
|
||||||
|
}
|
||||||
|
body += "inherits_env: true\ninherits_secrets: true\n---\n# " + name + "\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(nsDir, "ns.md"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s ns.md: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSListEmpty(t *testing.T) {
|
||||||
|
t.Setenv("ORCA_HOME", t.TempDir())
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "list"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns list: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSListWithNamespaces(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "list"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns list: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSCreateHappy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "create", "prod"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "prod", "ns.md")); err != nil {
|
||||||
|
t.Fatalf("ns.md not created: %v", err)
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"db", "jobs", "alloc"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "prod", sub)); err != nil {
|
||||||
|
t.Errorf("subdir %s not created: %v", sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSCreateWithParent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "base", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "create", "child", "--parent", "base"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns create: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "child", "ns.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read ns.md: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "parents: [") || !strings.Contains(string(data), "\"base\"") {
|
||||||
|
t.Errorf("ns.md missing parents: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSCreateExisting(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "create", "prod"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error creating existing namespace, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "already exists") {
|
||||||
|
t.Errorf("error = %q, want contains 'already exists'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSCreateDefaultsRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "create", "_defaults"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error creating _defaults, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSCreateClusterRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "create", "cluster"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error creating cluster, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSDeleteHappy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "prod")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("prod dir still exists after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSDeleteDefaultsRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "delete", "_defaults"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error deleting _defaults, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSDeleteNonEmpty(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
// put a job in jobs/
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "prod", "jobs"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir jobs: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prod", "jobs", "j1.md"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "delete", "prod"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error deleting non-empty namespace, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "refusing to delete") {
|
||||||
|
t.Errorf("error = %q, want contains 'refusing to delete'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSDeleteMissing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "delete", "ghost"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error deleting missing namespace, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSInspectHappy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "inspect", "prod"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns inspect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSInspectJSON(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetArgs([]string{"ns", "inspect", "prod", "--json"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns inspect --json: %v", err)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v\n%s", err, buf.String())
|
||||||
|
}
|
||||||
|
if result["name"] != "prod" {
|
||||||
|
t.Errorf("name = %v, want prod", result["name"])
|
||||||
|
}
|
||||||
|
chain, _ := result["chain"].([]any)
|
||||||
|
if len(chain) < 2 {
|
||||||
|
t.Errorf("chain too short: %v", chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSInspectMissingNamespace(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "inspect", "ghost"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing namespace, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSValidateHappy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "prod", "")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "validate", "prod"})
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("ns validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSValidateCycle(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
// a -> b, b -> a (cycle)
|
||||||
|
writeCustomNS(t, root, "a", "[\"b\"]")
|
||||||
|
writeCustomNS(t, root, "b", "[\"a\"]")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "validate", "a"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected cycle error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cycle") {
|
||||||
|
t.Errorf("error = %q, want contains 'cycle'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSValidateMissingParent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
writeCustomNS(t, root, "x", "[\"ghost\"]")
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "validate", "x"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing-parent error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Errorf("error = %q, want contains ghost + not found", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSValidateMissingNamespace(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", root)
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
writeDefaultsNS(t, root)
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "validate", "ghost"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing namespace, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderNSMd(t *testing.T) {
|
||||||
|
body := renderNSMd("foo", []string{"_defaults"}, true, false)
|
||||||
|
if !strings.Contains(body, "kind: Namespace") {
|
||||||
|
t.Errorf("missing kind: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "name: foo") {
|
||||||
|
t.Errorf("missing name: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "inherits_env: true") {
|
||||||
|
t.Errorf("missing inherits_env true: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "inherits_secrets: false") {
|
||||||
|
t.Errorf("missing inherits_secrets false: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSRootRegistered(t *testing.T) {
|
||||||
|
found := false
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "ns" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("ns command not registered on root")
|
||||||
|
}
|
||||||
|
// ensure subcommands present
|
||||||
|
sub := map[string]bool{}
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "ns" {
|
||||||
|
for _, sc := range c.Commands() {
|
||||||
|
sub[sc.Use] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"list", "create <name>", "delete <name>", "inspect <name>", "validate <name>"} {
|
||||||
|
if !sub[want] {
|
||||||
|
t.Errorf("missing ns subcommand %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNSListNoORCAHOME(t *testing.T) {
|
||||||
|
// ORCA_HOME points at a nonexistent dir; list should error.
|
||||||
|
t.Setenv("ORCA_HOME", filepath.Join(t.TempDir(), "nope"))
|
||||||
|
resetRootFlags(t)
|
||||||
|
resetNSFlags()
|
||||||
|
|
||||||
|
rootCmd.SetArgs([]string{"ns", "list"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing ORCA_HOME, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = paths.DefaultNamespace // keep paths import alive
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Package emitter defines the Layer-4 emitter interface (REQ-074,
|
||||||
|
// I-B-002): the bridge between the declarative *jobspec.WorkloadSpec
|
||||||
|
// and the server-side files. An Emitter renders a *WorkloadSpec into a
|
||||||
|
// slice of File artifacts that the SSH-push transport SCPs to peers.
|
||||||
|
//
|
||||||
|
// Emitters are registered per workload kind + runtime (e.g.
|
||||||
|
// "service:wasm", "job:process", "daemonset:wasm"). The Registry looks
|
||||||
|
// up the right emitter by "<kind>:<runtime>" and delegates. Unknown
|
||||||
|
// combinations return an error so the caller can fail fast before any
|
||||||
|
// file is written.
|
||||||
|
//
|
||||||
|
// P0c only ships the interface, the Registry, a stub SystemdEmitter
|
||||||
|
// (process runtime), and the File/Node value types. The full emitter
|
||||||
|
// implementations (systemd lifecycle hooks, Traefik, Syncthing,
|
||||||
|
// sockets) land in later phases (P02 Traefik, P04 lifecycle, P08
|
||||||
|
// sockets, P09 Syncthing, v0.10-P03 secrets).
|
||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File is a single rendered artifact destined for a peer. The SSH-push
|
||||||
|
// transport writes Content to Path atomically (write-to-tmp + rename)
|
||||||
|
// with the given Mode (an octal string like "0644").
|
||||||
|
type File struct {
|
||||||
|
Path string
|
||||||
|
Content string
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is the minimal peer description an emitter needs to render
|
||||||
|
// node-specific paths. It carries the hostname, the runtimes available
|
||||||
|
// on the node (so emitters can branch), and the node tags (used by
|
||||||
|
// DaemonSet matching and affinity in P05).
|
||||||
|
type Node struct {
|
||||||
|
Hostname string
|
||||||
|
Runtime []string
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emitter renders a *jobspec.WorkloadSpec for a given Node into a slice
|
||||||
|
// of File artifacts. Implementations are registered with a Registry
|
||||||
|
// keyed by "<kind>:<runtime>".
|
||||||
|
type Emitter interface {
|
||||||
|
Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry holds emitters keyed by "<kind>:<runtime>" (e.g.
|
||||||
|
// "service:process", "job:wasm"). The zero-value Registry is not
|
||||||
|
// usable; construct one with NewRegistry.
|
||||||
|
type Registry struct {
|
||||||
|
emitters map[string]Emitter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry returns an empty Registry ready for Register calls.
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{emitters: make(map[string]Emitter)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers an Emitter under the given key. The key is
|
||||||
|
// "<kind>:<runtime>" (e.g. "job:process"). Registering twice under the
|
||||||
|
// same key overwrites the prior registration (last-wins) to keep the
|
||||||
|
// surface simple; callers are responsible for not double-registering.
|
||||||
|
func (r *Registry) Register(key string, e Emitter) {
|
||||||
|
r.emitters[key] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render looks up the emitter for "<kind>:<runtime>" in the registry and
|
||||||
|
// delegates to it. The kind is lowercased so the canonical spec kinds
|
||||||
|
// (Job, Service, DaemonSet) map to the lowercase registry keys
|
||||||
|
// ("job:process", "service:wasm", "daemonset:process"). Returns an error
|
||||||
|
// if the spec is nil, the spec is missing its Kind, the runtime is
|
||||||
|
// missing, or no emitter is registered for the combination.
|
||||||
|
func (r *Registry) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, fmt.Errorf("emitter: spec is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.Kind) == "" {
|
||||||
|
return nil, fmt.Errorf("emitter: spec kind is empty")
|
||||||
|
}
|
||||||
|
if spec.Runtime == nil {
|
||||||
|
return nil, fmt.Errorf("emitter: spec runtime is nil")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(spec.Kind) + ":" + spec.Runtime.OneOf
|
||||||
|
e, ok := r.emitters[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("emitter: no emitter registered for %q (kind:runtime)", key)
|
||||||
|
}
|
||||||
|
return e.Render(spec, node)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockEmitter is a test-only Emitter that returns a fixed File slice
|
||||||
|
// (or an error) so the Registry tests do not depend on the
|
||||||
|
// SystemdEmitter. Implements Emitter via value receiver.
|
||||||
|
type mockEmitter struct {
|
||||||
|
files []File
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
out := make([]File, len(m.files))
|
||||||
|
copy(out, m.files)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_RegisterAndRender(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
want := []File{{Path: "/tmp/a", Content: "alpha", Mode: "0644"}}
|
||||||
|
r.Register("job:process", mockEmitter{files: want})
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "demo",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
|
||||||
|
got, err := r.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != want[0] {
|
||||||
|
t.Errorf("file = %+v, want %+v", got[0], want[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_UnknownKindRuntime(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "wasm"},
|
||||||
|
}
|
||||||
|
_, err := r.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown kind:runtime, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no emitter registered") {
|
||||||
|
t.Errorf("error = %q, want 'no emitter registered'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "service:wasm") {
|
||||||
|
t.Errorf("error = %q, want it to mention 'service:wasm'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_MultipleEmittersCorrectSelected(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
jobFiles := []File{{Path: "/tmp/job", Content: "job", Mode: "0644"}}
|
||||||
|
svcFiles := []File{{Path: "/tmp/svc", Content: "svc", Mode: "0644"}}
|
||||||
|
dsFiles := []File{{Path: "/tmp/ds", Content: "ds", Mode: "0644"}}
|
||||||
|
r.Register("job:process", mockEmitter{files: jobFiles})
|
||||||
|
r.Register("service:process", mockEmitter{files: svcFiles})
|
||||||
|
r.Register("daemonset:process", mockEmitter{files: dsFiles})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
kind string
|
||||||
|
runtime string
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{"Job", "process", "/tmp/job"},
|
||||||
|
{"Service", "process", "/tmp/svc"},
|
||||||
|
{"DaemonSet", "process", "/tmp/ds"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.kind+":"+tc.runtime, func(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: tc.kind,
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: tc.runtime, Command: "/bin/x"},
|
||||||
|
}
|
||||||
|
got, err := r.Render(spec, &Node{Hostname: "n"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Path != tc.wantPath {
|
||||||
|
t.Errorf("path = %q, want %q", got[0].Path, tc.wantPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_NilSpec(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
_, err := r.Render(nil, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "spec is nil") {
|
||||||
|
t.Errorf("error = %q, want 'spec is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_EmptyKind(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
spec := &jobspec.WorkloadSpec{Runtime: &jobspec.RuntimeBlock{OneOf: "process"}}
|
||||||
|
_, err := r.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty kind")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "kind is empty") {
|
||||||
|
t.Errorf("error = %q, want 'kind is empty'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_NilRuntime(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"}
|
||||||
|
_, err := r.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil runtime")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "runtime is nil") {
|
||||||
|
t.Errorf("error = %q, want 'runtime is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_EmitterErrorPropagates(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
r.Register("job:process", mockEmitter{err: wantErr})
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
}
|
||||||
|
_, err := r.Render(spec, &Node{})
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that mockEmitter and SystemdEmitter implement
|
||||||
|
// Emitter.
|
||||||
|
var (
|
||||||
|
_ Emitter = mockEmitter{}
|
||||||
|
_ Emitter = SystemdEmitter{}
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SocketEmitter renders the systemd directives that implement the
|
||||||
|
// R-007 socket-plumbing contract: workloads bind to
|
||||||
|
// /run/orca/alloc-<id>/port-<name>.sock unless overridden via
|
||||||
|
// service.bind = "127.0.0.1" (the only documented opt-in).
|
||||||
|
//
|
||||||
|
// The systemd side of the contract uses two directives:
|
||||||
|
//
|
||||||
|
// - RuntimeDirectory=orca/alloc-<alloc-id> — systemd creates
|
||||||
|
// /run/orca/alloc-<alloc-id>/ owned by the service user (orca:orca)
|
||||||
|
// with mode 0750. The directory is removed when the unit stops
|
||||||
|
// (RuntimeDirectory= semantics). P08 emits one RuntimeDirectory=
|
||||||
|
// line per port so each port's socket directory is created; the
|
||||||
|
// alloc-id placeholder is spec.Name (the real alloc-id is assigned
|
||||||
|
// by the scheduler at submit time — see allocIDFor).
|
||||||
|
//
|
||||||
|
// - ExecStartPre= — only when service.bind is "127.0.0.1" (the TCP
|
||||||
|
// opt-in). In that case the workload binds a TCP port directly
|
||||||
|
// (no socket), and the ExecStartPre is a placeholder that records
|
||||||
|
// the bind (the actual bind happens in the process; the directive
|
||||||
|
// is a no-op marker so operators can see the bind mode in the unit
|
||||||
|
// file). When service.bind is empty (the default), the workload
|
||||||
|
// binds the socket and no ExecStartPre is emitted for sockets.
|
||||||
|
//
|
||||||
|
// The socket path format is /run/orca/alloc-<alloc-id>/port-<port-name>.sock
|
||||||
|
// where alloc-id is a PLACEHOLDER (spec.Name) — the real alloc-id is
|
||||||
|
// assigned at submit time by the scheduler. The placeholder is
|
||||||
|
// documented in the rendered unit via a comment so operators reading
|
||||||
|
// the unit file understand the substitution.
|
||||||
|
//
|
||||||
|
// P08 is a PLAN/plumbing layer — the actual socket activation (socket
|
||||||
|
// unit files, systemd socket-activation passing the pre-bound socket
|
||||||
|
// fd to the process) lands in v0.10. P08 just renders the
|
||||||
|
// RuntimeDirectory= lines and the optional TCP-bind ExecStartPre so
|
||||||
|
// the directory exists at runtime.
|
||||||
|
type SocketEmitter struct{}
|
||||||
|
|
||||||
|
// runtimeDirectoryRoot is the systemd RuntimeDirectory path root.
|
||||||
|
// systemd joins this with the RuntimeDirectory= value to create
|
||||||
|
// /run/orca/alloc-<id>. The leading slash is implicit in systemd
|
||||||
|
// (RuntimeDirectory= is relative to /run).
|
||||||
|
const runtimeDirectoryRoot = "orca"
|
||||||
|
|
||||||
|
// SocketPath returns the R-007 socket path for a port on the given
|
||||||
|
// alloc-id. The alloc-id is the placeholder spec.Name when the real
|
||||||
|
// alloc-id is not yet known (the scheduler assigns the real alloc-id
|
||||||
|
// at submit time).
|
||||||
|
func SocketPath(allocID, portName string) string {
|
||||||
|
return fmt.Sprintf("/run/orca/alloc-%s/port-%s.sock", allocID, portName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSocketLines renders the systemd directives that implement
|
||||||
|
// the R-007 socket plumbing for the given spec. The lines are returned
|
||||||
|
// WITHOUT a trailing newline so the caller (the systemd emitter) can
|
||||||
|
// append them to the [Service] block with consistent formatting.
|
||||||
|
//
|
||||||
|
// The returned lines are:
|
||||||
|
//
|
||||||
|
// - one RuntimeDirectory= line per port (so each port's socket
|
||||||
|
// directory is created by systemd at unit start).
|
||||||
|
// - a comment documenting the alloc-id placeholder.
|
||||||
|
// - when service.bind is "127.0.0.1", an ExecStartPre= marker that
|
||||||
|
// records the TCP opt-in (the actual bind is in the process).
|
||||||
|
//
|
||||||
|
// Returns an empty slice when the spec has no ports (no socket
|
||||||
|
// plumbing needed — e.g. a Job or a port-less DaemonSet).
|
||||||
|
func (SocketEmitter) RenderSocketLines(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
if spec == nil || len(spec.Ports) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allocID := allocIDForSocket(spec)
|
||||||
|
var lines []string
|
||||||
|
// One RuntimeDirectory= per port. systemd dedupes identical
|
||||||
|
// values, but we emit one per port so the unit file is
|
||||||
|
// self-documenting (each port maps to a directory entry).
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
lines = append(lines, fmt.Sprintf("RuntimeDirectory=%s/alloc-%s", runtimeDirectoryRoot, allocID))
|
||||||
|
// Document the socket path this directory serves. systemd
|
||||||
|
// ignores comment lines (lines starting with '#').
|
||||||
|
lines = append(lines, fmt.Sprintf("# socket: %s", SocketPath(allocID, p.Name)))
|
||||||
|
}
|
||||||
|
// TCP opt-in: when service.bind is 127.0.0.1, the workload binds
|
||||||
|
// a TCP port directly instead of the socket. We emit an
|
||||||
|
// ExecStartPre marker so the bind mode is visible in the unit
|
||||||
|
// file. The actual bind is in the process; the marker is a
|
||||||
|
// no-op (echo to journald).
|
||||||
|
if spec.Service != nil && strings.TrimSpace(spec.Service.Bind) != "" {
|
||||||
|
if isTCPOptIn(spec.Service.Bind) {
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
lines = append(lines, fmt.Sprintf("ExecStartPre=/bin/echo orca: bind %s port %s (tcp, R-007 opt-in)", spec.Service.Bind, p.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocIDForSocket returns the alloc-id placeholder for the spec. The
|
||||||
|
// real alloc-id is assigned by the scheduler at submit time; P08 uses
|
||||||
|
// spec.Name as a deterministic placeholder so the rendered unit is
|
||||||
|
// stable across re-renders. This mirrors the Traefik emitter's
|
||||||
|
// allocIDFor (which uses the node hostname for the Traefik
|
||||||
|
// dynamic-config server URL); the systemd unit is per-alloc, so
|
||||||
|
// spec.Name is the right placeholder here.
|
||||||
|
func allocIDForSocket(spec *jobspec.WorkloadSpec) string {
|
||||||
|
if spec == nil || strings.TrimSpace(spec.Name) == "" {
|
||||||
|
return "<allocID>"
|
||||||
|
}
|
||||||
|
return spec.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTCPOptIn returns true when the bind value is the documented
|
||||||
|
// 127.0.0.1 TCP opt-in (R-007). Other valid IPs (::1, etc.) are also
|
||||||
|
// TCP opt-ins (any non-empty bind opts out of the socket default); we
|
||||||
|
// only emit the marker for 127.0.0.1 because that is the only
|
||||||
|
// documented opt-in per the PRD — other IPs are accepted by the
|
||||||
|
// schema validator but are operator-specific and we do not
|
||||||
|
// second-guess them.
|
||||||
|
func isTCPOptIn(bind string) bool {
|
||||||
|
return strings.TrimSpace(bind) == "127.0.0.1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NoPorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
if len(lines) != 0 {
|
||||||
|
t.Errorf("got %d lines, want 0 for no ports: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NilSpec(t *testing.T) {
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(nil)
|
||||||
|
if lines != nil {
|
||||||
|
t.Errorf("nil spec should return nil, got %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_SinglePort(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
// Expect: RuntimeDirectory + comment. No TCP bind (default socket).
|
||||||
|
wantRT := "RuntimeDirectory=orca/alloc-web"
|
||||||
|
if !contains(lines, wantRT) {
|
||||||
|
t.Errorf("lines %v missing %q", lines, wantRT)
|
||||||
|
}
|
||||||
|
wantSock := "# socket: /run/orca/alloc-web/port-http.sock"
|
||||||
|
if !contains(lines, wantSock) {
|
||||||
|
t.Errorf("lines %v missing %q", lines, wantSock)
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("socket bind should not emit ExecStartPre (no TCP opt-in): %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_MultiplePorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "api",
|
||||||
|
Ports: []jobspec.PortSpec{
|
||||||
|
{Name: "http", Port: 8080},
|
||||||
|
{Name: "grpc", Port: 9090},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
// Two RuntimeDirectory lines (one per port).
|
||||||
|
count := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
if l == "RuntimeDirectory=orca/alloc-api" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("RuntimeDirectory count = %d, want 2 (one per port)", count)
|
||||||
|
}
|
||||||
|
if !contains(lines, "# socket: /run/orca/alloc-api/port-http.sock") {
|
||||||
|
t.Errorf("missing http socket comment")
|
||||||
|
}
|
||||||
|
if !contains(lines, "# socket: /run/orca/alloc-api/port-grpc.sock") {
|
||||||
|
t.Errorf("missing grpc socket comment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_TCPBind127(t *testing.T) {
|
||||||
|
// service.bind = 127.0.0.1 → TCP opt-in → ExecStartPre marker per port.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
found := false
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=/bin/echo orca: bind 127.0.0.1 port http (tcp, R-007 opt-in)") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("missing TCP bind ExecStartPre marker; lines: %v", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_TCPBindIPv6(t *testing.T) {
|
||||||
|
// Non-127.0.0.1 bind is accepted by schema but not the documented
|
||||||
|
// opt-in; the marker is only emitted for 127.0.0.1. The
|
||||||
|
// RuntimeDirectory lines are still emitted (the directory exists
|
||||||
|
// regardless of bind mode — sockets or TCP).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "::1"},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("::1 bind should NOT emit TCP marker (only 127.0.0.1 is documented opt-in): %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory should still be emitted for ::1 bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_EmptyBindSocket(t *testing.T) {
|
||||||
|
// Empty bind → default socket → no TCP marker, but RuntimeDirectory emitted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: ""},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("empty bind should NOT emit TCP marker: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory missing for empty bind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_RenderSocketLines_NilService(t *testing.T) {
|
||||||
|
// No service block → default socket → no TCP marker, but RuntimeDirectory emitted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
lines := (SocketEmitter{}).RenderSocketLines(spec)
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "ExecStartPre=") {
|
||||||
|
t.Errorf("nil service should NOT emit TCP marker: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(lines, "RuntimeDirectory=orca/alloc-web") {
|
||||||
|
t.Errorf("RuntimeDirectory missing for nil service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_SocketPath(t *testing.T) {
|
||||||
|
got := SocketPath("alloc-123", "http")
|
||||||
|
want := "/run/orca/alloc-alloc-123/port-http.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SocketPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderNilSpec(t *testing.T) {
|
||||||
|
if got := allocIDForSocket(nil); got != "<allocID>" {
|
||||||
|
t.Errorf("allocIDForSocket(nil) = %q, want <allocID>", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderEmptyName(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Name: " "}
|
||||||
|
if got := allocIDForSocket(spec); got != "<allocID>" {
|
||||||
|
t.Errorf("allocIDForSocket(empty name) = %q, want <allocID>", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_AllocIDPlaceholderNamedSpec(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Name: "web"}
|
||||||
|
if got := allocIDForSocket(spec); got != "web" {
|
||||||
|
t.Errorf("allocIDForSocket(web) = %q, want web", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocketEmitter_SocketPathPlaceholder(t *testing.T) {
|
||||||
|
got := SocketPath("<allocID>", "grpc")
|
||||||
|
want := "/run/orca/alloc-<allocID>/port-grpc.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SocketPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_IntegratesSocketLines(t *testing.T) {
|
||||||
|
// End-to-end: the systemd unit for a Service with ports contains
|
||||||
|
// the RuntimeDirectory line emitted by the SocketEmitter.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "RuntimeDirectory=orca/alloc-web\n") {
|
||||||
|
t.Errorf("unit missing RuntimeDirectory line\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "# socket: /run/orca/alloc-web/port-http.sock\n") {
|
||||||
|
t.Errorf("unit missing socket path comment\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_IntegratesSocketLinesTCPBind(t *testing.T) {
|
||||||
|
// When service.bind = 127.0.0.1, the unit contains the ExecStartPre
|
||||||
|
// TCP-bind marker.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPre=/bin/echo orca: bind 127.0.0.1 port http (tcp, R-007 opt-in)\n") {
|
||||||
|
t.Errorf("unit missing TCP bind ExecStartPre marker\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_NoSocketLinesForPortlessSpec(t *testing.T) {
|
||||||
|
// A Job with no ports → no RuntimeDirectory line in the unit.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "RuntimeDirectory=") {
|
||||||
|
t.Errorf("portless spec should not emit RuntimeDirectory\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "# socket:") {
|
||||||
|
t.Errorf("portless spec should not emit socket comment\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains reports whether the slice contains the string s.
|
||||||
|
func contains(lines []string, s string) bool {
|
||||||
|
for _, l := range lines {
|
||||||
|
if l == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecyclePostStart(t *testing.T) {
|
||||||
|
// lifecycle.post_start → ExecStartPost (one line per command).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/usr/bin/sleep 1", "/usr/bin/curl localhost/healthz"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/usr/bin/sleep 1\n") {
|
||||||
|
t.Errorf("missing ExecStartPost for sleep 1\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/usr/bin/curl localhost/healthz\n") {
|
||||||
|
t.Errorf("missing ExecStartPost for curl\n%s", c)
|
||||||
|
}
|
||||||
|
// ExecStart must still be present.
|
||||||
|
if !strings.Contains(c, "ExecStart=/usr/local/bin/httpd -f\n") {
|
||||||
|
t.Errorf("missing ExecStart\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecyclePreStop(t *testing.T) {
|
||||||
|
// lifecycle.pre_stop → ExecStop (one line per command).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PreStop: []string{"/usr/local/bin/httpd -graceful", "/usr/bin/sleep 5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStop=/usr/local/bin/httpd -graceful\n") {
|
||||||
|
t.Errorf("missing ExecStop for graceful\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "ExecStop=/usr/bin/sleep 5\n") {
|
||||||
|
t.Errorf("missing ExecStop for sleep 5\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleBoth(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/httpd"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/bin/after-start"},
|
||||||
|
PreStop: []string{"/bin/before-stop"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
// ExecStartPost must appear before ExecStop (post_start runs after
|
||||||
|
// start; pre_stop runs before stop — the order in the unit file
|
||||||
|
// reflects the lifecycle order).
|
||||||
|
startIdx := strings.Index(c, "ExecStart=")
|
||||||
|
postIdx := strings.Index(c, "ExecStartPost=")
|
||||||
|
stopIdx := strings.Index(c, "ExecStop=")
|
||||||
|
if startIdx < 0 || postIdx < 0 || stopIdx < 0 {
|
||||||
|
t.Fatalf("missing one of ExecStart/ExecStartPost/ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
if !(startIdx < postIdx && postIdx < stopIdx) {
|
||||||
|
t.Errorf("expected order ExecStart < ExecStartPost < ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleNilOmitsDirectives(t *testing.T) {
|
||||||
|
// No lifecycle block → no ExecStartPost / ExecStop lines.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted when no lifecycle\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted when no lifecycle\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleEmptyListsOmitted(t *testing.T) {
|
||||||
|
// Lifecycle block present but empty lists → no ExecStartPost / ExecStop.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted for empty PostStart\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted for empty PreStop\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleOnlyPostStart(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PostStart: []string{"/bin/notify-up"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStartPost=/bin/notify-up\n") {
|
||||||
|
t.Errorf("missing ExecStartPost\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStop=") {
|
||||||
|
t.Errorf("ExecStop should be omitted when only PostStart set\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_LifecycleOnlyPreStop(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
Lifecycle: &jobspec.LifecycleBlock{
|
||||||
|
PreStop: []string{"/bin/notify-down"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "ExecStop=/bin/notify-down\n") {
|
||||||
|
t.Errorf("missing ExecStop\n%s", c)
|
||||||
|
}
|
||||||
|
if strings.Contains(c, "ExecStartPost=") {
|
||||||
|
t.Errorf("ExecStartPost should be omitted when only PreStop set\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemdEmitter_RenderJob(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/bin/rsync -a /src /dst"},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(files))
|
||||||
|
}
|
||||||
|
f := files[0]
|
||||||
|
wantPath := "/etc/systemd/system/orca-v1-backup.service"
|
||||||
|
if f.Path != wantPath {
|
||||||
|
t.Errorf("Path = %q, want %q", f.Path, wantPath)
|
||||||
|
}
|
||||||
|
wantContent := "[Service]\nExecStart=/usr/bin/rsync -a /src /dst\n"
|
||||||
|
if f.Content != wantContent {
|
||||||
|
t.Errorf("Content = %q, want %q", f.Content, wantContent)
|
||||||
|
}
|
||||||
|
if f.Mode != "0644" {
|
||||||
|
t.Errorf("Mode = %q, want 0644", f.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_RenderService(t *testing.T) {
|
||||||
|
// The full service emitter (Traefik route + health checks) lands in
|
||||||
|
// P02; here we only prove the systemd side renders for a Service
|
||||||
|
// kind with a process runtime.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(files))
|
||||||
|
}
|
||||||
|
if files[0].Path != "/etc/systemd/system/orca-v1-web.service" {
|
||||||
|
t.Errorf("Path = %q, want /etc/systemd/system/orca-v1-web.service", files[0].Path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(files[0].Content, "ExecStart=/usr/local/bin/httpd -f") {
|
||||||
|
t.Errorf("Content = %q, want it to contain the ExecStart line", files[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_EmptyCommandError(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: ""},
|
||||||
|
}
|
||||||
|
_, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty command, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "command is empty") {
|
||||||
|
t.Errorf("error = %q, want 'command is empty'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_WhitespaceCommandError(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: " "},
|
||||||
|
}
|
||||||
|
_, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for whitespace-only command, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "command is empty") {
|
||||||
|
t.Errorf("error = %q, want 'command is empty'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_NilSpec(t *testing.T) {
|
||||||
|
v := SystemdEmitter{}
|
||||||
|
if _, err := v.Render(nil, &Node{}); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_EmptyName(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: " ",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
|
||||||
|
}
|
||||||
|
_, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "name is empty") {
|
||||||
|
t.Errorf("error = %q, want 'name is empty'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_NilRuntime(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"}
|
||||||
|
_, err := SystemdEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil runtime")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "runtime block is nil") {
|
||||||
|
t.Errorf("error = %q, want 'runtime block is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdEmitter_UnitNamePrefix(t *testing.T) {
|
||||||
|
// The orca-v1- prefix is load-bearing for the dual-write window
|
||||||
|
// (REQ-090, I-C-006): the v0.8 daemon writes `orca-<job>.service`
|
||||||
|
// and the v0.9 SSH-push path writes `orca-v1-<spec.Name>.service`,
|
||||||
|
// so the two never collide. This test guards against accidental
|
||||||
|
// removal of the prefix.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "dual-write-safety",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
|
||||||
|
}
|
||||||
|
files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(files[0].Path, "/etc/systemd/system/orca-v1-") {
|
||||||
|
t.Errorf("Path = %q, want it to start with /etc/systemd/system/orca-v1- (REQ-090)", files[0].Path)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(files[0].Path, ".service") {
|
||||||
|
t.Errorf("Path = %q, want it to end with .service", files[0].Path)
|
||||||
|
}
|
||||||
|
// Explicitly assert the full expected unit name to lock the contract.
|
||||||
|
want := "/etc/systemd/system/orca-v1-dual-write-safety.service"
|
||||||
|
if files[0].Path != want {
|
||||||
|
t.Errorf("Path = %q, want %q", files[0].Path, want)
|
||||||
|
}
|
||||||
|
// Sanity: the prefix is exactly "orca-v1-", not "orca-v0" or "orca".
|
||||||
|
if unitNamePrefix != "orca-v1-" {
|
||||||
|
t.Errorf("unitNamePrefix = %q, want orca-v1-", unitNamePrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TraefikEmitter is the Layer-4 emitter for the Traefik dynamic-config
|
||||||
|
// file (REQ-077). It renders /etc/traefik/dynamic/orca-<spec.Name>.yaml
|
||||||
|
// — a single Traefik dynamic-config file describing the routers,
|
||||||
|
// services (servers = the R-007 socket paths), TLS config pointing at
|
||||||
|
// the step-ca root CA, and the service health check.
|
||||||
|
//
|
||||||
|
// Registered on the emitter.Registry under the service-kind keys:
|
||||||
|
//
|
||||||
|
// - service:process
|
||||||
|
// - service:podman
|
||||||
|
// - service:wasm
|
||||||
|
//
|
||||||
|
// RegisterTraefik wires all three; callers can also call Register
|
||||||
|
// directly with TraefikEmitter{} for a single runtime.
|
||||||
|
//
|
||||||
|
// Atomic reload (gate C-10): the Traefik dynamic-config file is written
|
||||||
|
// atomically via the SSH-push transport (sshpush.WriteFileIdempotent
|
||||||
|
// performs temp-file + fsync + rename, and WriteTraefikDynamic wraps
|
||||||
|
// it with an explicit tmp+mv so fsnotify sees a single rename event).
|
||||||
|
// Traefik watches the dynamic dir with fsnotify; the rename triggers a
|
||||||
|
// reload. On a malformed config Traefik logs an error and holds the
|
||||||
|
// last-good config (documented Traefik behavior; the C-10 test
|
||||||
|
// verifies the tmp+rename sequence so a half-written file is never
|
||||||
|
// observed by Traefik). Drain is rendered by setting the backend
|
||||||
|
// server's weight to 0 (or removing it) — see RenderDrain.
|
||||||
|
//
|
||||||
|
// The orca-v1- prefix is NOT applied to Traefik dynamic-config paths
|
||||||
|
// (the prefix is only for systemd unit names; the Traefik file is named
|
||||||
|
// orca-<spec.Name>.yaml and is the single source of truth for the
|
||||||
|
// service route — there is no dual-write window for Traefik configs).
|
||||||
|
type TraefikEmitter struct{}
|
||||||
|
|
||||||
|
// traefikDynamicDir is the canonical Traefik dynamic-config directory
|
||||||
|
// (R-006). The emitter writes one file per service at
|
||||||
|
// /etc/traefik/dynamic/orca-<spec.Name>.yaml.
|
||||||
|
const traefikDynamicDir = "/etc/traefik/dynamic"
|
||||||
|
|
||||||
|
// traefikRouterTLSCertResolver is the Traefik cert-resolver name that
|
||||||
|
// the orca step-ca integration configures on the Traefik static config
|
||||||
|
// (P10 / v0.10 wires the step-ca root into this resolver). The
|
||||||
|
// dynamic-config file references it by name.
|
||||||
|
const traefikRouterTLSCertResolver = "orca"
|
||||||
|
|
||||||
|
// defaultTrustDomain is the SPIFFE trust domain used in the rendered
|
||||||
|
// TLS stanza when the spec does not carry an explicit trust domain.
|
||||||
|
// The step-ca provisioner (P10) overrides this at render time via the
|
||||||
|
// node argument; for P02 the emitter renders the placeholder.
|
||||||
|
const defaultTrustDomain = "cluster.orca.local"
|
||||||
|
|
||||||
|
// Render renders the Traefik dynamic-config YAML for a Service
|
||||||
|
// workload. The output is a single File whose Path is
|
||||||
|
// /etc/traefik/dynamic/orca-<spec.Name>.yaml, Content is the rendered
|
||||||
|
// YAML, and Mode is 0644.
|
||||||
|
//
|
||||||
|
// Returns an error if the spec is nil, the name is empty, the spec has
|
||||||
|
// no ports (a Service with no ports has no backends to route to), or a
|
||||||
|
// service.bind value (when present) is not a valid IP address (R-007).
|
||||||
|
func (TraefikEmitter) Render(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, errors.New("emitter/traefik: spec is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
return nil, errors.New("emitter/traefik: spec name is empty")
|
||||||
|
}
|
||||||
|
if len(spec.Ports) == 0 {
|
||||||
|
return nil, errors.New("emitter/traefik: service has no ports (no backends to route to)")
|
||||||
|
}
|
||||||
|
if spec.Service != nil {
|
||||||
|
if b := strings.TrimSpace(spec.Service.Bind); b != "" && net.ParseIP(b) == nil {
|
||||||
|
return nil, fmt.Errorf("emitter/traefik: service.bind %q is not a valid IP (R-007)", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content, err := renderTraefikYAML(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
||||||
|
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDrain renders a Traefik dynamic-config that drains the service
|
||||||
|
// by setting every backend server's weight to 0 (I-B-005 drain). The
|
||||||
|
// path matches the live config so the atomic rename overwrites the
|
||||||
|
// routing config with the drained config (Traefik reloads and stops
|
||||||
|
// sending traffic). The caller writes the result via
|
||||||
|
// WriteTraefikDynamic for the C-10 atomicity protocol.
|
||||||
|
func (e TraefikEmitter) RenderDrain(spec *jobspec.WorkloadSpec, node *Node) ([]File, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, errors.New("emitter/traefik: spec is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
return nil, errors.New("emitter/traefik: spec name is empty")
|
||||||
|
}
|
||||||
|
if len(spec.Ports) == 0 {
|
||||||
|
return nil, errors.New("emitter/traefik: service has no ports (no backends to drain)")
|
||||||
|
}
|
||||||
|
content, err := renderTraefikYAMLDrain(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("%s/orca-%s.yaml", traefikDynamicDir, spec.Name)
|
||||||
|
return []File{{Path: path, Content: content, Mode: "0644"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTraefik registers the TraefikEmitter on the given Registry
|
||||||
|
// under the three service-kind runtime keys (service:process,
|
||||||
|
// service:podman, service:wasm). The emitter is the same instance for
|
||||||
|
// all three runtimes — the rendered Traefik config is runtime-agnostic
|
||||||
|
// (the backend server URL is the R-007 socket path, which the runtime
|
||||||
|
// layer binds regardless of process/wasm/podman).
|
||||||
|
func RegisterTraefik(reg *Registry) {
|
||||||
|
e := TraefikEmitter{}
|
||||||
|
reg.Register("service:process", e)
|
||||||
|
reg.Register("service:podman", e)
|
||||||
|
reg.Register("service:wasm", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTraefikYAML renders the Traefik dynamic-config YAML for the
|
||||||
|
// given spec + node. The shape (verified by the Traefik docs) is:
|
||||||
|
//
|
||||||
|
// http:
|
||||||
|
// routers:
|
||||||
|
// orca-<name>:
|
||||||
|
// rule: PathPrefix("/<name>")
|
||||||
|
// service: orca-<name>
|
||||||
|
// tls:
|
||||||
|
// certResolver: orca
|
||||||
|
// domains:
|
||||||
|
// - main: "<trust-domain>"
|
||||||
|
// services:
|
||||||
|
// orca-<name>:
|
||||||
|
// loadBalancer:
|
||||||
|
// servers:
|
||||||
|
// - url: "unix:///run/orca/alloc-<allocID>/port-<portName>.sock"
|
||||||
|
// healthCheck:
|
||||||
|
// path: /healthz
|
||||||
|
// interval: <interval>
|
||||||
|
// timeout: <timeout>
|
||||||
|
//
|
||||||
|
// The alloc-id placeholder is "<allocID>" pending the P08 socket
|
||||||
|
// layer; Traefik will reject the URL until a real alloc-id is
|
||||||
|
// substituted. For P02 the emitter renders the placeholder so the
|
||||||
|
// C-10 atomicity protocol is testable end-to-end; the socket layer
|
||||||
|
// (P08) replaces the placeholder with the live alloc-id.
|
||||||
|
func renderTraefikYAML(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
||||||
|
return renderTraefikYAMLWeighted(spec, node, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTraefikYAMLDrain renders the drained Traefik dynamic-config
|
||||||
|
// (every backend server has weight: 0). The shape mirrors the live
|
||||||
|
// config so the rename overwrites the live route with the drain.
|
||||||
|
func renderTraefikYAMLDrain(spec *jobspec.WorkloadSpec, node *Node) (string, error) {
|
||||||
|
return renderTraefikYAMLWeighted(spec, node, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTraefikYAMLWeighted renders the Traefik dynamic-config YAML.
|
||||||
|
// When drain is true, every server entry is emitted with `weight: 0`
|
||||||
|
// (I-B-005). When drain is false, no weight is emitted (Traefik
|
||||||
|
// defaults to 1 — equal weighting across servers).
|
||||||
|
func renderTraefikYAMLWeighted(spec *jobspec.WorkloadSpec, node *Node, drain bool) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
routerName := "orca-" + spec.Name
|
||||||
|
serviceName := "orca-" + spec.Name
|
||||||
|
rule := fmt.Sprintf("PathPrefix(\"/%s\")", spec.Name)
|
||||||
|
trustDomain := defaultTrustDomain
|
||||||
|
|
||||||
|
b.WriteString("http:\n")
|
||||||
|
b.WriteString(" routers:\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %s:\n", routerName))
|
||||||
|
b.WriteString(fmt.Sprintf(" rule: %s\n", rule))
|
||||||
|
b.WriteString(fmt.Sprintf(" service: %s\n", serviceName))
|
||||||
|
b.WriteString(" tls:\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" certResolver: %s\n", traefikRouterTLSCertResolver))
|
||||||
|
b.WriteString(" domains:\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" - main: %q\n", trustDomain))
|
||||||
|
b.WriteString(" services:\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %s:\n", serviceName))
|
||||||
|
b.WriteString(" loadBalancer:\n")
|
||||||
|
b.WriteString(" servers:\n")
|
||||||
|
allocID := allocIDFor(node)
|
||||||
|
for _, p := range spec.Ports {
|
||||||
|
sock := fmt.Sprintf("unix:///run/orca/alloc-%s/port-%s.sock", allocID, p.Name)
|
||||||
|
b.WriteString(" - url: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%q\n", sock))
|
||||||
|
if drain {
|
||||||
|
b.WriteString(" weight: 0\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.Health != nil {
|
||||||
|
b.WriteString(" healthCheck:\n")
|
||||||
|
path := "/healthz"
|
||||||
|
b.WriteString(fmt.Sprintf(" path: %s\n", path))
|
||||||
|
if spec.Health.Interval != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" interval: %s\n", spec.Health.Interval))
|
||||||
|
}
|
||||||
|
if spec.Health.Timeout != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(" timeout: %s\n", spec.Health.Timeout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocIDFor returns the alloc-id placeholder for the node. P08 will
|
||||||
|
// substitute the live alloc-id from the socket layer; for P02 we use a
|
||||||
|
// deterministic placeholder derived from the node hostname so the
|
||||||
|
// rendered config is stable across re-renders (the C-10 idempotency
|
||||||
|
// check depends on a stable hash). When the node is nil or has no
|
||||||
|
// hostname, the literal placeholder "<allocID>" is emitted.
|
||||||
|
func allocIDFor(node *Node) string {
|
||||||
|
if node == nil || strings.TrimSpace(node.Hostname) == "" {
|
||||||
|
return "<allocID>"
|
||||||
|
}
|
||||||
|
return node.Hostname
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AtomicWriter is the SSH-push transport surface that
|
||||||
|
// WriteTraefikDynamic uses to write the Traefik dynamic-config file
|
||||||
|
// atomically. It is the subset of *sshpush.Transport that the
|
||||||
|
// atomicity protocol depends on. Tests substitute a mock to assert
|
||||||
|
// the tmp+rename sequence (gate C-10) without a real SSH server.
|
||||||
|
//
|
||||||
|
// *sshpush.Transport satisfies this interface (the compile-time
|
||||||
|
// assertion lives in internal/sshpush to avoid an import cycle — the
|
||||||
|
// sshpush package imports emitter for fan-out, so this package cannot
|
||||||
|
// import sshpush).
|
||||||
|
type AtomicWriter interface {
|
||||||
|
// WriteFileIdempotent writes content to peer:path atomically with
|
||||||
|
// mode, returning written=true if the file was actually written
|
||||||
|
// (content hash differed). Used by WriteTraefikDynamic to write
|
||||||
|
// the .tmp sibling.
|
||||||
|
WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
|
||||||
|
// Exec runs a command on peer and returns its combined output.
|
||||||
|
// Used by WriteTraefikDynamic to perform the atomic `mv -f
|
||||||
|
// path.tmp path`.
|
||||||
|
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTraefikDynamic writes a Traefik dynamic-config file atomically
|
||||||
|
// (gate C-10: tmpfile + fsync + rename). The protocol is:
|
||||||
|
//
|
||||||
|
// 1. Write content to <path>.tmp via WriteFileIdempotent. The
|
||||||
|
// underlying sshpush transport writes the tmp file in the same
|
||||||
|
// directory as the target with mode-appended naming, fsyncs, and
|
||||||
|
// renames — but we add an extra hop here so the *Traefik* file is
|
||||||
|
// only ever observed at its final path after a single atomic
|
||||||
|
// rename event that Traefik's fsnotify watcher sees.
|
||||||
|
// 2. `mv -f <path>.tmp <path>` on the peer (atomic rename on POSIX).
|
||||||
|
// Traefik's fsnotify watcher picks up the rename → reload.
|
||||||
|
//
|
||||||
|
// On a malformed config Traefik logs an error and holds the
|
||||||
|
// last-good config (documented Traefik behavior; the C-10 test
|
||||||
|
// verifies the tmp+rename sequence so a half-written file is never
|
||||||
|
// observed by Traefik — the only window where Traefik can read the
|
||||||
|
// file is after the rename, which is atomic on POSIX).
|
||||||
|
//
|
||||||
|
// The mode is 0644 (Traefik reads the dynamic dir as root; the lead
|
||||||
|
// applier chmods after the rename).
|
||||||
|
func WriteTraefikDynamic(ctx context.Context, t AtomicWriter, peer string, path string, content []byte) error {
|
||||||
|
if t == nil {
|
||||||
|
return fmt.Errorf("traefik: atomic writer is nil")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("traefik: path is empty")
|
||||||
|
}
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
if _, err := t.WriteFileIdempotent(ctx, peer, tmpPath, content, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("traefik: write tmp %s: %w", tmpPath, err)
|
||||||
|
}
|
||||||
|
// Atomic rename on POSIX. `mv -f` overwrites an existing target
|
||||||
|
// without prompting. The rename is atomic; Traefik's fsnotify
|
||||||
|
// watcher observes a single IN_MOVED_TO event.
|
||||||
|
renameCmd := fmt.Sprintf("mv -f %s %s", shellQuoteLocal(tmpPath), shellQuoteLocal(path))
|
||||||
|
if _, err := t.Exec(ctx, peer, renameCmd); err != nil {
|
||||||
|
return fmt.Errorf("traefik: rename %s -> %s: %w", tmpPath, path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellQuoteLocal single-quotes a path for safe shell interpolation on
|
||||||
|
// the peer. It escapes embedded single-quotes via the standard '\”
|
||||||
|
// idiom (close the single-quoted string, escape the literal single
|
||||||
|
// quote, reopen the single-quoted string). This is a local
|
||||||
|
// re-implementation (the sshpush package has its own) so the emitter
|
||||||
|
// layer does not depend on the transport package's private helpers —
|
||||||
|
// the AtomicWriter interface keeps the boundary clean for testing.
|
||||||
|
func shellQuoteLocal(s string) string {
|
||||||
|
var b []byte
|
||||||
|
b = append(b, '\'')
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c == '\'' {
|
||||||
|
// close quote, escape the literal single-quote, reopen.
|
||||||
|
b = append(b, '\'', '\\', '\'', '\'')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
b = append(b, '\'')
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockAtomicWriter is a test-only AtomicWriter that records calls so
|
||||||
|
// the C-10 atomicity protocol (tmp + rename) can be asserted.
|
||||||
|
type mockAtomicWriter struct {
|
||||||
|
written []writeCall
|
||||||
|
execed []execCall
|
||||||
|
writeErr error
|
||||||
|
writeWrote bool
|
||||||
|
execErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeCall struct {
|
||||||
|
peer string
|
||||||
|
path string
|
||||||
|
mode os.FileMode
|
||||||
|
bytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type execCall struct {
|
||||||
|
peer string
|
||||||
|
cmd string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAtomicWriter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
|
||||||
|
m.written = append(m.written, writeCall{peer: peer, path: path, mode: mode, bytes: append([]byte(nil), content...)})
|
||||||
|
if m.writeErr != nil {
|
||||||
|
return false, m.writeErr
|
||||||
|
}
|
||||||
|
return m.writeWrote, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAtomicWriter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||||
|
m.execed = append(m.execed, execCall{peer: peer, cmd: cmd})
|
||||||
|
if m.execErr != nil {
|
||||||
|
return nil, m.execErr
|
||||||
|
}
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_TmpThenRename(t *testing.T) {
|
||||||
|
// Gate C-10: the Traefik dynamic-config write must be a tmp +
|
||||||
|
// rename sequence so Traefik's fsnotify watcher never observes a
|
||||||
|
// half-written file.
|
||||||
|
mock := &mockAtomicWriter{writeWrote: true}
|
||||||
|
path := "/etc/traefik/dynamic/orca-web.yaml"
|
||||||
|
peer := "node-1:22"
|
||||||
|
content := []byte("http:\n routers: {}\n")
|
||||||
|
|
||||||
|
if err := WriteTraefikDynamic(context.Background(), mock, peer, path, content); err != nil {
|
||||||
|
t.Fatalf("WriteTraefikDynamic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mock.written) != 1 {
|
||||||
|
t.Fatalf("WriteFileIdempotent calls = %d, want 1", len(mock.written))
|
||||||
|
}
|
||||||
|
w := mock.written[0]
|
||||||
|
if w.peer != peer {
|
||||||
|
t.Errorf("write peer = %q, want %q", w.peer, peer)
|
||||||
|
}
|
||||||
|
// The tmp path is the target path + ".tmp".
|
||||||
|
if w.path != path+".tmp" {
|
||||||
|
t.Errorf("write path = %q, want %q (.tmp suffix is the C-10 atomicity protocol)", w.path, path+".tmp")
|
||||||
|
}
|
||||||
|
if string(w.bytes) != string(content) {
|
||||||
|
t.Errorf("write content = %q, want %q", string(w.bytes), string(content))
|
||||||
|
}
|
||||||
|
if w.mode != 0o644 {
|
||||||
|
t.Errorf("write mode = %o, want 0644", w.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mock.execed) != 1 {
|
||||||
|
t.Fatalf("Exec calls = %d, want 1 (the rename)", len(mock.execed))
|
||||||
|
}
|
||||||
|
e := mock.execed[0]
|
||||||
|
if e.peer != peer {
|
||||||
|
t.Errorf("exec peer = %q, want %q", e.peer, peer)
|
||||||
|
}
|
||||||
|
// The rename command must `mv -f` the .tmp file to the final path.
|
||||||
|
if !strings.Contains(e.cmd, "mv -f") {
|
||||||
|
t.Errorf("exec cmd = %q, want it to contain 'mv -f' (atomic rename)", e.cmd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(e.cmd, path+".tmp") {
|
||||||
|
t.Errorf("exec cmd = %q, want it to contain the .tmp path as source", e.cmd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(e.cmd, path) {
|
||||||
|
t.Errorf("exec cmd = %q, want it to contain the final path as destination", e.cmd)
|
||||||
|
}
|
||||||
|
// Sanity: the source must come before the destination in the
|
||||||
|
// mv command.
|
||||||
|
srcIdx := strings.Index(e.cmd, path+".tmp")
|
||||||
|
dstIdx := strings.Index(e.cmd, "'"+path+"'")
|
||||||
|
if srcIdx < 0 || dstIdx < 0 || srcIdx > dstIdx {
|
||||||
|
t.Errorf("exec cmd %q: source .tmp must come before destination %s", e.cmd, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_WriteTmpError(t *testing.T) {
|
||||||
|
mock := &mockAtomicWriter{writeErr: errors.New("disk full")}
|
||||||
|
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from WriteFileIdempotent, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "write tmp") {
|
||||||
|
t.Errorf("error = %q, want 'write tmp'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "disk full") {
|
||||||
|
t.Errorf("error = %q, want underlying 'disk full'", err.Error())
|
||||||
|
}
|
||||||
|
if len(mock.execed) != 0 {
|
||||||
|
t.Errorf("on tmp write failure, no rename should happen; execed = %v", mock.execed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_RenameError(t *testing.T) {
|
||||||
|
mock := &mockAtomicWriter{writeWrote: true, execErr: errors.New("permission denied")}
|
||||||
|
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from rename, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "rename") {
|
||||||
|
t.Errorf("error = %q, want 'rename'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "permission denied") {
|
||||||
|
t.Errorf("error = %q, want underlying 'permission denied'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_NilWriter(t *testing.T) {
|
||||||
|
err := WriteTraefikDynamic(context.Background(), nil, "p", "/x", []byte("x"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil writer")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "nil") {
|
||||||
|
t.Errorf("error = %q, want 'nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_EmptyPath(t *testing.T) {
|
||||||
|
mock := &mockAtomicWriter{writeWrote: true}
|
||||||
|
err := WriteTraefikDynamic(context.Background(), mock, "p", "", []byte("x"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty path")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "path is empty") {
|
||||||
|
t.Errorf("error = %q, want 'path is empty'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTraefikDynamic_SkipWhenContentMatches(t *testing.T) {
|
||||||
|
// When the .tmp file already matches (writeWrote=false), the
|
||||||
|
// protocol still proceeds with the rename — the idempotency
|
||||||
|
// check is per-file, not per-protocol. The rename still happens
|
||||||
|
// so the final path reflects the (unchanged) content.
|
||||||
|
mock := &mockAtomicWriter{writeWrote: false}
|
||||||
|
err := WriteTraefikDynamic(context.Background(), mock, "p", "/etc/traefik/dynamic/orca-x.yaml", []byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteTraefikDynamic: %v", err)
|
||||||
|
}
|
||||||
|
if len(mock.execed) != 1 {
|
||||||
|
t.Errorf("rename should still happen on idempotent skip; execed = %v", mock.execed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuoteLocal(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"/etc/traefik/dynamic/orca-web.yaml", "'/etc/traefik/dynamic/orca-web.yaml'"},
|
||||||
|
{"", "''"},
|
||||||
|
{"/path with space/x", "'/path with space/x'"},
|
||||||
|
{"a'b", "'a'\\''b'"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.in, func(t *testing.T) {
|
||||||
|
got := shellQuoteLocal(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("shellQuoteLocal(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderBasic(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s", Timeout: "1s"},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(files))
|
||||||
|
}
|
||||||
|
f := files[0]
|
||||||
|
wantPath := "/etc/traefik/dynamic/orca-web.yaml"
|
||||||
|
if f.Path != wantPath {
|
||||||
|
t.Errorf("Path = %q, want %q", f.Path, wantPath)
|
||||||
|
}
|
||||||
|
if f.Mode != "0644" {
|
||||||
|
t.Errorf("Mode = %q, want 0644", f.Mode)
|
||||||
|
}
|
||||||
|
c := f.Content
|
||||||
|
if !strings.Contains(c, "http:") {
|
||||||
|
t.Errorf("content missing 'http:'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "routers:") {
|
||||||
|
t.Errorf("content missing 'routers:'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "orca-web:") {
|
||||||
|
t.Errorf("content missing 'orca-web:' router/service key\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, `rule: PathPrefix("/web")`) {
|
||||||
|
t.Errorf("content missing PathPrefix rule\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "services:") {
|
||||||
|
t.Errorf("content missing 'services:'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "loadBalancer:") {
|
||||||
|
t.Errorf("content missing 'loadBalancer:'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "unix:///run/orca/alloc-node-1/port-http.sock") {
|
||||||
|
t.Errorf("content missing socket server URL\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "certResolver: orca") {
|
||||||
|
t.Errorf("content missing 'certResolver: orca'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "domains:") {
|
||||||
|
t.Errorf("content missing TLS domains\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "healthCheck:") {
|
||||||
|
t.Errorf("content missing 'healthCheck:'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "interval: 5s") {
|
||||||
|
t.Errorf("content missing 'interval: 5s'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "timeout: 1s") {
|
||||||
|
t.Errorf("content missing 'timeout: 1s'\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderMultiplePorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "api",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Ports: []jobspec.PortSpec{
|
||||||
|
{Name: "http", Port: 8080},
|
||||||
|
{Name: "grpc", Port: 9090},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "n1"}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "port-http.sock") {
|
||||||
|
t.Errorf("missing http socket: %s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "port-grpc.sock") {
|
||||||
|
t.Errorf("missing grpc socket: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderDrain(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "n1"}
|
||||||
|
files, err := TraefikEmitter{}.RenderDrain(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderDrain: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(files))
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
if !strings.Contains(c, "weight: 0") {
|
||||||
|
t.Errorf("drain config missing 'weight: 0'\n%s", c)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c, "unix:///run/orca/alloc-n1/port-http.sock") {
|
||||||
|
t.Errorf("drain config missing socket URL\n%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderLiveHasNoWeightZero(t *testing.T) {
|
||||||
|
// Sanity: the live (non-drain) render must NOT emit `weight: 0`.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "n1"}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(files[0].Content, "weight: 0") {
|
||||||
|
t.Errorf("live config should not contain 'weight: 0'\n%s", files[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderNoHealthOmitsHealthCheck(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
node := &Node{Hostname: "n1"}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(files[0].Content, "healthCheck:") {
|
||||||
|
t.Errorf("config without Health should omit 'healthCheck:'\n%s", files[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_NilSpec(t *testing.T) {
|
||||||
|
_, err := TraefikEmitter{}.Render(nil, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_EmptyName(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: " ",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_NoPorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
}
|
||||||
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing ports")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no ports") {
|
||||||
|
t.Errorf("error = %q, want 'no ports'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_NoPortsDrain(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
}
|
||||||
|
_, err := TraefikEmitter{}.RenderDrain(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing ports on drain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_InvalidBind(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
||||||
|
}
|
||||||
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid service.bind")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "valid IP") {
|
||||||
|
t.Errorf("error = %q, want 'valid IP'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_ValidBindLoopback(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
_, err := TraefikEmitter{}.Render(spec, &Node{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("127.0.0.1 should be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_NilNodeAllocPlaceholder(t *testing.T) {
|
||||||
|
// With a nil node, the alloc-id placeholder is the literal
|
||||||
|
// "<allocID>" sentinel so the rendered config is still valid YAML
|
||||||
|
// (the P08 socket layer substitutes the real alloc-id).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(files[0].Content, "alloc-<allocID>") {
|
||||||
|
t.Errorf("nil node should render alloc-<allocID> placeholder\n%s", files[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_EmptyHostnameAllocPlaceholder(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: " "})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(files[0].Content, "alloc-<allocID>") {
|
||||||
|
t.Errorf("empty hostname should render alloc-<allocID> placeholder\n%s", files[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_PathNotOrcaV1Prefixed(t *testing.T) {
|
||||||
|
// REQ-090: the orca-v1- prefix is only for systemd units; Traefik
|
||||||
|
// dynamic-config paths are named orca-<spec.Name>.yaml (single
|
||||||
|
// source of truth — no dual-write window for Traefik configs).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(files[0].Path, "orca-v1-") {
|
||||||
|
t.Errorf("Path %q should NOT contain the orca-v1- prefix (systemd-only)", files[0].Path)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(files[0].Path, "/etc/traefik/dynamic/orca-") {
|
||||||
|
t.Errorf("Path %q should start with /etc/traefik/dynamic/orca-", files[0].Path)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(files[0].Path, ".yaml") {
|
||||||
|
t.Errorf("Path %q should end with .yaml", files[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraefikEmitter_RenderYAMLHasRoutersServicesTLS(t *testing.T) {
|
||||||
|
// Aggregate structural assertion: the rendered YAML has the four
|
||||||
|
// top-level Traefik concepts (routers, services, tls, servers).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
}
|
||||||
|
files, err := TraefikEmitter{}.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
c := files[0].Content
|
||||||
|
for _, want := range []string{"routers:", "services:", "tls:", "servers:", "url:"} {
|
||||||
|
if !strings.Contains(c, want) {
|
||||||
|
t.Errorf("rendered YAML missing %q\n%s", want, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterTraefik_AllServiceRuntimes(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
RegisterTraefik(r)
|
||||||
|
spec := func(runtime string) *jobspec.WorkloadSpec {
|
||||||
|
return &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: runtime},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, runtime := range []string{"process", "podman", "wasm"} {
|
||||||
|
t.Run(runtime, func(t *testing.T) {
|
||||||
|
files, err := r.Render(spec(runtime), &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render(service:%s): %v", runtime, err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("got %d files, want 1", len(files))
|
||||||
|
}
|
||||||
|
if !strings.Contains(files[0].Path, "/etc/traefik/dynamic/orca-web.yaml") {
|
||||||
|
t.Errorf("Path = %q", files[0].Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterTraefik_OverwritesExisting(t *testing.T) {
|
||||||
|
// RegisterTraefik should overwrite any prior registration (the
|
||||||
|
// Registry documents last-wins).
|
||||||
|
r := NewRegistry()
|
||||||
|
r.Register("service:process", mockEmitter{files: []File{{Path: "/old"}}})
|
||||||
|
RegisterTraefik(r)
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
files, err := r.Render(spec, &Node{Hostname: "n1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if files[0].Path == "/old" {
|
||||||
|
t.Errorf("RegisterTraefik did not overwrite the prior registration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that TraefikEmitter implements Emitter.
|
||||||
|
var _ Emitter = TraefikEmitter{}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdatePlan is the computed update sequence for a Service (P03). It is
|
||||||
|
// a PLAN, not an execution — the transactional execution lands in
|
||||||
|
// v0.10-P10. Each step describes a discrete action the executor takes:
|
||||||
|
// start a set of allocs (Action="start"), wait for them to become
|
||||||
|
// healthy (WaitForHealthy=true), or cutover from old to new
|
||||||
|
// (Action="cutover" for blue-green). The Allocs field carries
|
||||||
|
// placeholder alloc names of the form "<spec.Name>-<index>" where
|
||||||
|
// index is 1-based (the scheduler assigns the real alloc-id at submit
|
||||||
|
// time; P03 uses spec.Name as a placeholder per the socket layer
|
||||||
|
// contract — see SocketEmitter).
|
||||||
|
type UpdatePlan struct {
|
||||||
|
Steps []UpdateStep
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStep is a single step in an UpdatePlan. Action is one of
|
||||||
|
// "start", "wait", "cutover", "promote". Allocs is the list of
|
||||||
|
// placeholder alloc names the step applies to. WaitForHealthy is true
|
||||||
|
// when the executor must wait for the allocs in this step to pass
|
||||||
|
// their health check before proceeding to the next step (driven by
|
||||||
|
// min_healthy_time / healthy_deadline on the spec, which the executor
|
||||||
|
// — not the plan — enforces).
|
||||||
|
type UpdateStep struct {
|
||||||
|
Action string
|
||||||
|
Allocs []string
|
||||||
|
WaitForHealthy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxParallelFor returns the effective max_parallel for the spec,
|
||||||
|
// defaulting to 1 when unset (0) and clamping to count (the validator
|
||||||
|
// already rejects out-of-range values; this is a defensive clamp for
|
||||||
|
// direct callers that bypass the validator).
|
||||||
|
func maxParallelFor(spec *jobspec.WorkloadSpec) int {
|
||||||
|
if spec.Update == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Update.MaxParallel < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && spec.Update.MaxParallel > spec.Count {
|
||||||
|
return spec.Count
|
||||||
|
}
|
||||||
|
return spec.Update.MaxParallel
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocName returns the placeholder alloc name for index i (1-based).
|
||||||
|
// The real alloc-id is assigned by the scheduler at submit time; P03
|
||||||
|
// uses spec.Name as the placeholder per the socket-layer contract.
|
||||||
|
func allocName(spec *jobspec.WorkloadSpec, i int) string {
|
||||||
|
return fmt.Sprintf("%s-%d", spec.Name, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allAllocs returns the placeholder alloc names for the full count of
|
||||||
|
// the spec (1..count).
|
||||||
|
func allAllocs(spec *jobspec.WorkloadSpec) []string {
|
||||||
|
out := make([]string, 0, spec.Count)
|
||||||
|
for i := 1; i <= spec.Count; i++ {
|
||||||
|
out = append(out, allocName(spec, i))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// canaryCount returns the integer canary count for the spec. The
|
||||||
|
// canary field accepts an integer count or a percentage ("<n>%"). For
|
||||||
|
// a percentage, the count is ceil(count * n / 100) with a minimum of 1
|
||||||
|
// when n > 0 (a 10% canary of a 3-replica service is 1 alloc, not 0).
|
||||||
|
// When the canary field is empty, the default is 1 (a single canary
|
||||||
|
// alloc — the smallest meaningful canary).
|
||||||
|
func canaryCount(spec *jobspec.WorkloadSpec) int {
|
||||||
|
if spec.Update == nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
c := strings.TrimSpace(spec.Update.Canary)
|
||||||
|
if c == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(c, "%") {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(c, "%")))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
allocs := spec.Count * n / 100
|
||||||
|
if allocs < 1 {
|
||||||
|
allocs = 1
|
||||||
|
}
|
||||||
|
return allocs
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(c)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && n > spec.Count {
|
||||||
|
return spec.Count
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderUpdatePlan computes the rolling/canary/blue-green update
|
||||||
|
// sequence for a Service spec. Returns an *UpdatePlan describing the
|
||||||
|
// steps; the actual transactional execution lands in v0.10-P10.
|
||||||
|
//
|
||||||
|
// The three strategies:
|
||||||
|
//
|
||||||
|
// - rolling: allocs are started in batches of max_parallel. Each
|
||||||
|
// batch waits for healthy before the next batch starts. This is
|
||||||
|
// the simplest strategy and the default for stateless services.
|
||||||
|
//
|
||||||
|
// - canary: a single canary alloc (or N per the canary field) is
|
||||||
|
// started first and waits for healthy. After the canary is
|
||||||
|
// healthy, the plan emits a "promote" step (manual or auto per
|
||||||
|
// auto_promote); the remaining allocs are then started in
|
||||||
|
// max_parallel batches.
|
||||||
|
//
|
||||||
|
// - blue-green: all new allocs are started in parallel (a single
|
||||||
|
// "start" step with the full count). After they are healthy, a
|
||||||
|
// "cutover" step swaps traffic from the old allocs to the new
|
||||||
|
// ones. The old allocs are then stopped (the stop is implicit in
|
||||||
|
// the cutover step for the plan; v0.10-P10 makes it explicit).
|
||||||
|
//
|
||||||
|
// Returns an error if the spec is nil, the update block is nil, or
|
||||||
|
// the strategy is unknown (the validator should have caught these,
|
||||||
|
// but RenderUpdatePlan is defensive — emitters are called from
|
||||||
|
// render paths that may bypass the schema validator).
|
||||||
|
func RenderUpdatePlan(spec *jobspec.WorkloadSpec) (*UpdatePlan, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, fmt.Errorf("emitter/update: spec is nil")
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
return nil, fmt.Errorf("emitter/update: update block is nil")
|
||||||
|
}
|
||||||
|
if spec.Count < 1 {
|
||||||
|
return nil, fmt.Errorf("emitter/update: count must be ≥ 1, got %d", spec.Count)
|
||||||
|
}
|
||||||
|
switch spec.Update.Strategy {
|
||||||
|
case "rolling":
|
||||||
|
return renderRollingPlan(spec), nil
|
||||||
|
case "canary":
|
||||||
|
return renderCanaryPlan(spec), nil
|
||||||
|
case "blue-green":
|
||||||
|
return renderBlueGreenPlan(spec), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("emitter/update: unknown strategy %q (want rolling, canary, or blue-green)", spec.Update.Strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderRollingPlan emits the rolling-update plan: allocs in batches
|
||||||
|
// of max_parallel, each batch waiting for healthy before the next.
|
||||||
|
func renderRollingPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
batch := maxParallelFor(spec)
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
for i := 0; i < len(allocs); i += batch {
|
||||||
|
end := i + batch
|
||||||
|
if end > len(allocs) {
|
||||||
|
end = len(allocs)
|
||||||
|
}
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs[i:end],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCanaryPlan emits the canary-update plan: a canary batch first
|
||||||
|
// (size per the canary field, default 1), a "promote" step, then the
|
||||||
|
// remaining allocs in max_parallel batches.
|
||||||
|
func renderCanaryPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
canary := canaryCount(spec)
|
||||||
|
if canary > len(allocs) {
|
||||||
|
canary = len(allocs)
|
||||||
|
}
|
||||||
|
if canary < 1 {
|
||||||
|
canary = 1
|
||||||
|
}
|
||||||
|
// Step 1: start the canary alloc(s) and wait for healthy.
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs[:canary],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
// Step 2: promote (manual or auto per auto_promote).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "promote",
|
||||||
|
Allocs: allocs[:canary],
|
||||||
|
})
|
||||||
|
// Step 3+: remaining allocs in max_parallel batches.
|
||||||
|
batch := maxParallelFor(spec)
|
||||||
|
remaining := allocs[canary:]
|
||||||
|
for i := 0; i < len(remaining); i += batch {
|
||||||
|
end := i + batch
|
||||||
|
if end > len(remaining) {
|
||||||
|
end = len(remaining)
|
||||||
|
}
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: remaining[i:end],
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBlueGreenPlan emits the blue-green update plan: all new allocs
|
||||||
|
// start in parallel, wait for healthy, then cutover (swap traffic).
|
||||||
|
func renderBlueGreenPlan(spec *jobspec.WorkloadSpec) *UpdatePlan {
|
||||||
|
plan := &UpdatePlan{}
|
||||||
|
allocs := allAllocs(spec)
|
||||||
|
// Step 1: start ALL new allocs in parallel (blue-green does not
|
||||||
|
// batch — the new fleet stands up alongside the old).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "start",
|
||||||
|
Allocs: allocs,
|
||||||
|
WaitForHealthy: true,
|
||||||
|
})
|
||||||
|
// Step 2: cutover — swap traffic from old to new. The old allocs
|
||||||
|
// are stopped implicitly as part of the cutover (v0.10-P10 makes
|
||||||
|
// the stop explicit in the transactional plane).
|
||||||
|
plan.Steps = append(plan.Steps, UpdateStep{
|
||||||
|
Action: "cutover",
|
||||||
|
Allocs: allocs,
|
||||||
|
WaitForHealthy: false,
|
||||||
|
})
|
||||||
|
return plan
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package emitter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingBatches(t *testing.T) {
|
||||||
|
// count=4, max_parallel=2 → 2 batches of 2.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
||||||
|
}
|
||||||
|
for i, s := range plan.Steps {
|
||||||
|
if s.Action != "start" {
|
||||||
|
t.Errorf("step %d action = %q, want start", i, s.Action)
|
||||||
|
}
|
||||||
|
if !s.WaitForHealthy {
|
||||||
|
t.Errorf("step %d WaitForHealthy = false, want true", i)
|
||||||
|
}
|
||||||
|
if len(s.Allocs) != 2 {
|
||||||
|
t.Errorf("step %d allocs = %d, want 2", i, len(s.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
||||||
|
t.Errorf("step 0 allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Allocs[0] != "web-3" || plan.Steps[1].Allocs[1] != "web-4" {
|
||||||
|
t.Errorf("step 1 allocs = %v, want [web-3 web-4]", plan.Steps[1].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingUnevenBatches(t *testing.T) {
|
||||||
|
// count=5, max_parallel=2 → 3 batches: 2, 2, 1.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 5,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 3 {
|
||||||
|
t.Fatalf("got %d steps, want 3", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[2].Allocs) != 1 {
|
||||||
|
t.Errorf("step 2 allocs = %d, want 1 (remainder)", len(plan.Steps[2].Allocs))
|
||||||
|
}
|
||||||
|
if plan.Steps[2].Allocs[0] != "web-5" {
|
||||||
|
t.Errorf("step 2 allocs = %v, want [web-5]", plan.Steps[2].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_RollingMaxParallelUnset(t *testing.T) {
|
||||||
|
// max_parallel unset (0) → default 1 → 4 batches of 1.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 4 {
|
||||||
|
t.Fatalf("got %d steps, want 4 (one per alloc, batch=1)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
for _, s := range plan.Steps {
|
||||||
|
if len(s.Allocs) != 1 {
|
||||||
|
t.Errorf("allocs = %d, want 1", len(s.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryDefaultOne(t *testing.T) {
|
||||||
|
// canary unset → default 1 canary alloc, then 3 in batches of 2.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Step 0: canary (1 alloc). Step 1: promote. Steps 2..: remaining.
|
||||||
|
if plan.Steps[0].Action != "start" || len(plan.Steps[0].Allocs) != 1 {
|
||||||
|
t.Errorf("step 0 = %+v, want canary start with 1 alloc", plan.Steps[0])
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" {
|
||||||
|
t.Errorf("canary alloc = %q, want web-1", plan.Steps[0].Allocs[0])
|
||||||
|
}
|
||||||
|
if !plan.Steps[0].WaitForHealthy {
|
||||||
|
t.Error("canary step should wait for healthy")
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "promote" {
|
||||||
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
// Remaining: web-2, web-3, web-4 in batches of 2 → [web-2,web-3], [web-4].
|
||||||
|
if len(plan.Steps) != 4 {
|
||||||
|
t.Fatalf("got %d steps, want 4 (canary + promote + 2 batches)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[2].Allocs) != 2 || plan.Steps[2].Allocs[0] != "web-2" {
|
||||||
|
t.Errorf("step 2 = %v, want [web-2 web-3]", plan.Steps[2].Allocs)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[3].Allocs) != 1 || plan.Steps[3].Allocs[0] != "web-4" {
|
||||||
|
t.Errorf("step 3 = %v, want [web-4]", plan.Steps[3].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryPercent(t *testing.T) {
|
||||||
|
// count=10, canary=20% → 2 canary allocs, then 8 in batches of 3.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 10,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 3,
|
||||||
|
Canary: "20%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("canary step allocs = %d, want 2 (20%% of 10)", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
// Remaining 8 in batches of 3 → ceil(8/3)=3 batches.
|
||||||
|
// Steps: canary, promote, batch(3), batch(3), batch(2) = 5 steps.
|
||||||
|
if len(plan.Steps) != 5 {
|
||||||
|
t.Fatalf("got %d steps, want 5", len(plan.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryIntegerCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("canary step allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Allocs[0] != "web-1" || plan.Steps[0].Allocs[1] != "web-2" {
|
||||||
|
t.Errorf("canary allocs = %v, want [web-1 web-2]", plan.Steps[0].Allocs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CanaryFullCount(t *testing.T) {
|
||||||
|
// canary == count → no remaining allocs after canary.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Steps: canary start (3), promote. No remaining batches.
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2 (canary + promote, no remainder)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "promote" {
|
||||||
|
t.Errorf("step 1 action = %q, want promote", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_BlueGreen(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Step 0: start all 4 in parallel. Step 1: cutover.
|
||||||
|
if len(plan.Steps) != 2 {
|
||||||
|
t.Fatalf("got %d steps, want 2", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if plan.Steps[0].Action != "start" {
|
||||||
|
t.Errorf("step 0 action = %q, want start", plan.Steps[0].Action)
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 4 {
|
||||||
|
t.Errorf("step 0 allocs = %d, want 4 (all new in parallel)", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
if !plan.Steps[0].WaitForHealthy {
|
||||||
|
t.Error("blue-green start step should wait for healthy")
|
||||||
|
}
|
||||||
|
if plan.Steps[1].Action != "cutover" {
|
||||||
|
t.Errorf("step 1 action = %q, want cutover", plan.Steps[1].Action)
|
||||||
|
}
|
||||||
|
if plan.Steps[1].WaitForHealthy {
|
||||||
|
t.Error("cutover step should NOT wait for healthy (already healthy)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_BlueGreenAllocs(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "api",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"api-1", "api-2", "api-3"}
|
||||||
|
if len(plan.Steps[0].Allocs) != 3 {
|
||||||
|
t.Errorf("allocs = %v, want %v", plan.Steps[0].Allocs, want)
|
||||||
|
}
|
||||||
|
for i, a := range want {
|
||||||
|
if plan.Steps[0].Allocs[i] != a {
|
||||||
|
t.Errorf("alloc[%d] = %q, want %q", i, plan.Steps[0].Allocs[i], a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_NilSpec(t *testing.T) {
|
||||||
|
_, err := RenderUpdatePlan(nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "spec is nil") {
|
||||||
|
t.Errorf("error = %q, want 'spec is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_NilUpdate(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 1}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil update block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update block is nil") {
|
||||||
|
t.Errorf("error = %q, want 'update block is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_CountZero(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 0,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for count 0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "count must be") {
|
||||||
|
t.Errorf("error = %q, want 'count must be'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_UnknownStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := RenderUpdatePlan(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown strategy") {
|
||||||
|
t.Errorf("error = %q, want 'unknown strategy'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_AllStrategies(t *testing.T) {
|
||||||
|
for _, strat := range []string{"rolling", "canary", "blue-green"} {
|
||||||
|
t.Run(strat, func(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: strat,
|
||||||
|
MaxParallel: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan(%s): %v", strat, err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) == 0 {
|
||||||
|
t.Errorf("strategy %s produced 0 steps", strat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_PromoteStepCarriesCanaryAllocs(t *testing.T) {
|
||||||
|
// The promote step lists the canary allocs so the executor knows
|
||||||
|
// which allocs are being promoted from canary to stable.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
promote := plan.Steps[1]
|
||||||
|
if promote.Action != "promote" {
|
||||||
|
t.Fatalf("step 1 action = %q, want promote", promote.Action)
|
||||||
|
}
|
||||||
|
if len(promote.Allocs) != 2 {
|
||||||
|
t.Errorf("promote allocs = %d, want 2 (the canary allocs)", len(promote.Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderUpdatePlan_MaxParallelClampedToCount(t *testing.T) {
|
||||||
|
// max_parallel > count is clamped to count (defensive; validator
|
||||||
|
// rejects this but the emitter is defensive against direct
|
||||||
|
// callers).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 99,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan, err := RenderUpdatePlan(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RenderUpdatePlan: %v", err)
|
||||||
|
}
|
||||||
|
// Clamped to 2 → single batch of 2.
|
||||||
|
if len(plan.Steps) != 1 {
|
||||||
|
t.Errorf("got %d steps, want 1 (clamped)", len(plan.Steps))
|
||||||
|
}
|
||||||
|
if len(plan.Steps[0].Allocs) != 2 {
|
||||||
|
t.Errorf("step 0 allocs = %d, want 2", len(plan.Steps[0].Allocs))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package jobspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseFile reads a jobspec file from disk and dispatches on file
|
||||||
|
// extension (R-013, REQ-064):
|
||||||
|
//
|
||||||
|
// - .md → ParseMarkdown (canonical Markdown+frontmatter, R-014/R-015)
|
||||||
|
// - .yaml/.yml → ParseMarkdown with the whole file treated as
|
||||||
|
// frontmatter and Body = "" (pure YAML, no Markdown body)
|
||||||
|
// - .hcl → ParseHCL (legacy adapter; wraps the existing HCL parser
|
||||||
|
// and converts Spec{Job, Tasks} into *WorkloadSpec with Kind="Job",
|
||||||
|
// REQ-090 migration window)
|
||||||
|
//
|
||||||
|
// Unknown extensions return an error. The dispatcher preserves
|
||||||
|
// `orca job run old-spec.hcl` during the v0.9→v0.10 migration window
|
||||||
|
// (REQ-090).
|
||||||
|
func ParseFile(path string) (*WorkloadSpec, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read spec file: %w", err)
|
||||||
|
}
|
||||||
|
return Dispatch(data, filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHCLFile reads an HCL file and parses it via the legacy HCL parser,
|
||||||
|
// returning the legacy *Spec. It is a convenience wrapper retained for
|
||||||
|
// tests and direct HCL consumers that need the raw Spec{Job, Tasks}
|
||||||
|
// shape during the v0.9→v0.10 migration window (REQ-090).
|
||||||
|
//
|
||||||
|
// Deprecated: use ParseFile (dispatcher) for new code. HCL is legacy per
|
||||||
|
// R-013.
|
||||||
|
func ParseHCLFile(path string) (*Spec, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read spec file: %w", err)
|
||||||
|
}
|
||||||
|
return ParseHCLLegacy(data, filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch routes raw jobspec bytes on file extension to the
|
||||||
|
// appropriate parser. filename is used only for HCL (the HCL decoder
|
||||||
|
// needs a filename for error messages and syntax sniffing).
|
||||||
|
func Dispatch(data []byte, filename string) (*WorkloadSpec, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
switch ext {
|
||||||
|
case ".md":
|
||||||
|
return ParseMarkdown(data)
|
||||||
|
case ".yaml", ".yml":
|
||||||
|
// Pure YAML file: no Markdown body. Treat the whole file as
|
||||||
|
// the frontmatter block. Body is empty (R-015: no body to
|
||||||
|
// preserve).
|
||||||
|
spec, err := parseYAMLFile(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
case ".hcl":
|
||||||
|
return ParseHCL(data, filename)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("parse jobspec: unknown extension %q (want .md, .yaml, .yml, or .hcl)", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYAMLFile treats the whole file as a frontmatter block (no
|
||||||
|
// surrounding `---` delimiters, no Markdown body). This routes .yaml
|
||||||
|
// and .yml files through the same hand-rolled parser as .md.
|
||||||
|
func parseYAMLFile(data []byte) (*WorkloadSpec, error) {
|
||||||
|
block := string(data)
|
||||||
|
if strings.TrimSpace(block) == "" {
|
||||||
|
return nil, fmt.Errorf("parse yaml: empty file")
|
||||||
|
}
|
||||||
|
spec, err := parseFrontmatterBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spec.Body = ""
|
||||||
|
if err := validateWorkload(spec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHCL parses a legacy HCL jobspec and adapts it into a *WorkloadSpec
|
||||||
|
// (REQ-064 adapter, REQ-090 migration window). The existing HCL
|
||||||
|
// Spec{Job, Tasks} shape is converted to:
|
||||||
|
//
|
||||||
|
// Kind: "Job"
|
||||||
|
// Name: spec.Job.Name
|
||||||
|
// Runtime: {one_of: "process", command: tasks[0].Command}
|
||||||
|
//
|
||||||
|
// Body is empty (HCL has no Markdown body). The legacy Spec struct and
|
||||||
|
// ParseHCLLegacy are retained for direct HCL consumers that have not yet
|
||||||
|
// migrated.
|
||||||
|
//
|
||||||
|
// Deprecated: use the dispatcher (ParseFile/Dispatch). HCL is legacy
|
||||||
|
// per R-013; the HCL path is retained only for the v0.9→v0.10 migration
|
||||||
|
// window (REQ-090) and will be removed in v1.0.
|
||||||
|
func ParseHCL(data []byte, filename string) (*WorkloadSpec, error) {
|
||||||
|
spec, err := ParseHCLLegacy(data, filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ws := &WorkloadSpec{
|
||||||
|
SpecVersion: "",
|
||||||
|
Kind: "Job",
|
||||||
|
Name: spec.Job.Name,
|
||||||
|
Count: 1,
|
||||||
|
Body: "",
|
||||||
|
}
|
||||||
|
if len(spec.Tasks) > 0 {
|
||||||
|
ws.Runtime = &RuntimeBlock{
|
||||||
|
OneOf: "process",
|
||||||
|
Command: spec.Tasks[0].Command,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseHCLLegacy is the original HCL-only parser retained for direct
|
||||||
|
// HCL consumers (e.g. the cli/job.go toTaskSpecs path during the
|
||||||
|
// migration window). New code should call ParseHCL (which returns a
|
||||||
|
// *WorkloadSpec) or the dispatcher. Deprecated: HCL is legacy per
|
||||||
|
// R-013; see ParseHCL.
|
||||||
|
func ParseHCLLegacy(data []byte, filename string) (*Spec, error) {
|
||||||
|
var spec Spec
|
||||||
|
if err := hclDecode(filename, data, &spec); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode hcl: %w", err)
|
||||||
|
}
|
||||||
|
if spec.Job.Name == "" {
|
||||||
|
return nil, fmt.Errorf("spec missing job name")
|
||||||
|
}
|
||||||
|
if len(spec.Tasks) == 0 {
|
||||||
|
return nil, fmt.Errorf("spec must have at least one task")
|
||||||
|
}
|
||||||
|
for i, t := range spec.Tasks {
|
||||||
|
if t.Command == "" {
|
||||||
|
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &spec, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package jobspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDispatch_Markdown(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\nname: md-job\n---\nbody content\n"
|
||||||
|
ws, err := Dispatch([]byte(input), "spec.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch .md: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Job" {
|
||||||
|
t.Errorf("Kind = %q, want Job", ws.Kind)
|
||||||
|
}
|
||||||
|
if ws.Name != "md-job" {
|
||||||
|
t.Errorf("Name = %q, want md-job", ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Body != "body content\n" {
|
||||||
|
t.Errorf("Body = %q, want %q (R-015)", ws.Body, "body content\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_YAML(t *testing.T) {
|
||||||
|
input := "kind: Service\nname: yaml-svc\nports:\n - name: http\n port: 80\n"
|
||||||
|
ws, err := Dispatch([]byte(input), "spec.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch .yaml: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Service" {
|
||||||
|
t.Errorf("Kind = %q, want Service", ws.Kind)
|
||||||
|
}
|
||||||
|
if ws.Name != "yaml-svc" {
|
||||||
|
t.Errorf("Name = %q, want yaml-svc", ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Body != "" {
|
||||||
|
t.Errorf("Body = %q, want empty (YAML has no body)", ws.Body)
|
||||||
|
}
|
||||||
|
if len(ws.Ports) != 1 || ws.Ports[0].Name != "http" || ws.Ports[0].Port != 80 {
|
||||||
|
t.Errorf("Ports = %+v, want one http:80", ws.Ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_YML(t *testing.T) {
|
||||||
|
input := "kind: DaemonSet\nname: yml-ds\n"
|
||||||
|
ws, err := Dispatch([]byte(input), "spec.yml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch .yml: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "DaemonSet" {
|
||||||
|
t.Errorf("Kind = %q, want DaemonSet", ws.Kind)
|
||||||
|
}
|
||||||
|
if ws.Body != "" {
|
||||||
|
t.Errorf("Body = %q, want empty", ws.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_HCLAdapter(t *testing.T) {
|
||||||
|
hcl := `job "demo" {}
|
||||||
|
task "build" {
|
||||||
|
command = "/bin/echo"
|
||||||
|
args = ["hello"]
|
||||||
|
}
|
||||||
|
`
|
||||||
|
ws, err := Dispatch([]byte(hcl), "spec.hcl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dispatch .hcl: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Job" {
|
||||||
|
t.Errorf("Kind = %q, want Job (adapter always sets Job)", ws.Kind)
|
||||||
|
}
|
||||||
|
if ws.Name != "demo" {
|
||||||
|
t.Errorf("Name = %q, want demo (from spec.Job.Name)", ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Runtime == nil {
|
||||||
|
t.Fatal("Runtime is nil; adapter should populate from tasks[0]")
|
||||||
|
}
|
||||||
|
if ws.Runtime.OneOf != "process" {
|
||||||
|
t.Errorf("Runtime.OneOf = %q, want process", ws.Runtime.OneOf)
|
||||||
|
}
|
||||||
|
if ws.Runtime.Command != "/bin/echo" {
|
||||||
|
t.Errorf("Runtime.Command = %q, want /bin/echo (from tasks[0].Command)", ws.Runtime.Command)
|
||||||
|
}
|
||||||
|
if ws.Body != "" {
|
||||||
|
t.Errorf("Body = %q, want empty (HCL has no body)", ws.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_HCLAdapterNoTasks(t *testing.T) {
|
||||||
|
hcl := `job "x" {}`
|
||||||
|
_, err := Dispatch([]byte(hcl), "spec.hcl")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HCL with no tasks")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "at least one task") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'at least one task'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_UnknownExtension(t *testing.T) {
|
||||||
|
_, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec.json")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown extension, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown extension") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'unknown extension'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_NoExtension(t *testing.T) {
|
||||||
|
_, err := Dispatch([]byte("kind: Job\nname: x\n"), "spec")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for no extension, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_EmptyYAML(t *testing.T) {
|
||||||
|
_, err := Dispatch([]byte(""), "spec.yaml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_Markdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "spec.md")
|
||||||
|
content := "---\nkind: Job\nname: file-md\n---\nbody\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
ws, err := ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFile .md: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Job" || ws.Name != "file-md" {
|
||||||
|
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Body != "body\n" {
|
||||||
|
t.Errorf("Body = %q, want %q", ws.Body, "body\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_YAML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "spec.yaml")
|
||||||
|
content := "kind: Service\nname: file-yaml\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
ws, err := ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFile .yaml: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Service" || ws.Name != "file-yaml" {
|
||||||
|
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Body != "" {
|
||||||
|
t.Errorf("Body = %q, want empty", ws.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_HCL(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "spec.hcl")
|
||||||
|
content := `job "file-hcl" {}
|
||||||
|
task "t" { command = "/bin/true" }
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
ws, err := ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFile .hcl: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Job" || ws.Name != "file-hcl" {
|
||||||
|
t.Errorf("got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||||
|
}
|
||||||
|
if ws.Runtime == nil || ws.Runtime.Command != "/bin/true" {
|
||||||
|
t.Errorf("Runtime.Command = %v, want /bin/true", ws.Runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_MissingFile(t *testing.T) {
|
||||||
|
_, err := ParseFile(filepath.Join(t.TempDir(), "nope.md"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read spec file") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'read spec file'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFile_UnknownExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "spec.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("kind: Job\nname: x\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
_, err := ParseFile(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown extension, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown extension") {
|
||||||
|
t.Errorf("error = %q, want 'unknown extension'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHCL_LegacySpec(t *testing.T) {
|
||||||
|
hcl := `job "legacy" {}
|
||||||
|
task "t" { command = "/bin/echo" }
|
||||||
|
`
|
||||||
|
ws, err := ParseHCL([]byte(hcl), "spec.hcl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseHCL: %v", err)
|
||||||
|
}
|
||||||
|
if ws.Kind != "Job" || ws.Name != "legacy" {
|
||||||
|
t.Errorf("adapter got Kind=%q Name=%q", ws.Kind, ws.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,946 @@
|
|||||||
|
package jobspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkloadSpec is the unified canonical jobspec populated by both the
|
||||||
|
// Markdown frontmatter parser (canonical path, R-013/R-014) and the HCL
|
||||||
|
// legacy adapter (REQ-064, REQ-090). It is the single shape consumed by
|
||||||
|
// downstream phases (P0c schemas, P01 transport). The Markdown body
|
||||||
|
// after the closing `---` is preserved verbatim in Body (R-015
|
||||||
|
// byte-exact preservation is a load-bearing invariant enforced by the
|
||||||
|
// fuzz harness in markdown_fuzz_test.go).
|
||||||
|
type WorkloadSpec struct {
|
||||||
|
SpecVersion string
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
Runtime *RuntimeBlock
|
||||||
|
Count int
|
||||||
|
Ports []PortSpec
|
||||||
|
Env map[string]string
|
||||||
|
Secrets []string
|
||||||
|
Volumes []VolumeSpec
|
||||||
|
Body string
|
||||||
|
|
||||||
|
// Kind-specific blocks consumed by the P0c schema validators
|
||||||
|
// (internal/spec/schema). P02 populates Restart, Update, Service,
|
||||||
|
// Health, Constraints, Affinity, Lifecycle from the Markdown
|
||||||
|
// frontmatter (the rest are still populated by later phases).
|
||||||
|
|
||||||
|
// Restart is the restart policy block. Required for Service and
|
||||||
|
// DaemonSet; optional for Job (defaults to never/on-failure).
|
||||||
|
// P02 populates it from the `restart:` frontmatter block.
|
||||||
|
Restart *RestartBlock
|
||||||
|
|
||||||
|
// Schedule is the schedule block. For Job it carries an optional
|
||||||
|
// cron string; for DaemonSet it carries the placement mode
|
||||||
|
// (every-node/matching/mandatory). Populated by P05 (scheduler
|
||||||
|
// skeleton) and the DaemonSet phase.
|
||||||
|
Schedule *ScheduleBlock
|
||||||
|
|
||||||
|
// Update is the rolling/canary update stanza. Required for
|
||||||
|
// Service. P02 populates it from the `update:` frontmatter block;
|
||||||
|
// the rolling/canary semantics land in P03.
|
||||||
|
Update *UpdateBlock
|
||||||
|
|
||||||
|
// Service is the service block (Traefik route definition). For
|
||||||
|
// Service kind it is implied; Job and DaemonSet do not carry a
|
||||||
|
// Traefik route by default (D-175). P02 populates it from the
|
||||||
|
// `service:` frontmatter block.
|
||||||
|
Service *ServiceBlock
|
||||||
|
|
||||||
|
// Health is the health-check block. Required for Service (Traefik
|
||||||
|
// routing depends on it). P02 populates it from the `health:`
|
||||||
|
// frontmatter block (R-012).
|
||||||
|
Health *HealthBlock
|
||||||
|
|
||||||
|
// Constraints is the CEL expression list for placement. P02
|
||||||
|
// populates it from the `constraints:` frontmatter array; P05
|
||||||
|
// consumes it for the CLI-side scheduler (REQ-083).
|
||||||
|
Constraints []string
|
||||||
|
|
||||||
|
// Affinity is the affinity rule list for placement. P02 populates
|
||||||
|
// it from the `affinity:` frontmatter array; P05 consumes it.
|
||||||
|
Affinity []AffinityRule
|
||||||
|
|
||||||
|
// Lifecycle is the lifecycle hook block (pre_stop, post_start).
|
||||||
|
// P02 populates it from the `lifecycle:` frontmatter block; P04
|
||||||
|
// wires it into the systemd unit (ExecStop / ExecStartPost).
|
||||||
|
Lifecycle *LifecycleBlock
|
||||||
|
|
||||||
|
// Timeout is an optional execution timeout (duration string) for
|
||||||
|
// Job. Populated by P04.
|
||||||
|
Timeout string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeBlock is a minimal runtime abstraction surface populated by the
|
||||||
|
// Markdown parser. The full runtime abstraction lands in P07; for now
|
||||||
|
// only the one_of/image/command fields are parsed and stored (REQ-064).
|
||||||
|
type RuntimeBlock struct {
|
||||||
|
OneOf string
|
||||||
|
Image string
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartBlock is the restart policy block. Mode is one of never,
|
||||||
|
// on-failure, service (REQ-074 schema validators). P02 populates it from
|
||||||
|
// the frontmatter `restart:` block.
|
||||||
|
type RestartBlock struct {
|
||||||
|
Mode string
|
||||||
|
MaxRetries int
|
||||||
|
Delay string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleBlock is the scheduling block. For Job, Cron is an optional
|
||||||
|
// cron expression. For DaemonSet, Mode is one of every-node, matching,
|
||||||
|
// mandatory (REQ-074). Populated by P05 and the DaemonSet phase.
|
||||||
|
type ScheduleBlock struct {
|
||||||
|
Mode string
|
||||||
|
Cron string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBlock is the rolling/canary update stanza. Required for Service.
|
||||||
|
// P02 populates it from the frontmatter `update:` block; the
|
||||||
|
// rolling/canary/blue-green semantics land in P03.
|
||||||
|
type UpdateBlock struct {
|
||||||
|
Strategy string
|
||||||
|
MaxSurge int
|
||||||
|
MaxParallel int
|
||||||
|
MinHealthyTime string
|
||||||
|
HealthyDeadline string
|
||||||
|
Canary string
|
||||||
|
AutoPromote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceBlock is the Traefik route definition. For Service it is
|
||||||
|
// implied (Traefik route YES); Job and DaemonSet do not carry one by
|
||||||
|
// default (D-175). P02 populates it from the frontmatter `service:`
|
||||||
|
// block.
|
||||||
|
type ServiceBlock struct {
|
||||||
|
Host string
|
||||||
|
RouteID string
|
||||||
|
Name string
|
||||||
|
Port int
|
||||||
|
Bind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthBlock is the health-check block. P02 populates it from the
|
||||||
|
// frontmatter `health:` block (R-012). The Traefik emitter (REQ-077)
|
||||||
|
// renders it as the service's health-check stanza; ServiceValidator
|
||||||
|
// requires it for Traefik routing.
|
||||||
|
type HealthBlock struct {
|
||||||
|
CheckType string
|
||||||
|
Interval string
|
||||||
|
Timeout string
|
||||||
|
UnhealthyThreshold int
|
||||||
|
}
|
||||||
|
|
||||||
|
// AffinityRule is a single affinity entry: target CEL expression +
|
||||||
|
// integer weight. P02 populates it from the `affinity:` frontmatter
|
||||||
|
// array; P05 consumes it for the CLI-side scheduler (REQ-083).
|
||||||
|
type AffinityRule struct {
|
||||||
|
Target string
|
||||||
|
Weight int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleBlock is the lifecycle hook block. PreStop and PostStart
|
||||||
|
// are command lists run before stop / after start. P02 populates it
|
||||||
|
// from the `lifecycle:` frontmatter block; P04 wires it into the
|
||||||
|
// systemd unit (ExecStop / ExecStartPost).
|
||||||
|
type LifecycleBlock struct {
|
||||||
|
PreStop []string
|
||||||
|
PostStart []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortSpec is a minimal port binding entry. HostIP is optional.
|
||||||
|
type PortSpec struct {
|
||||||
|
Name string
|
||||||
|
HostPort int
|
||||||
|
Port int
|
||||||
|
Protocol string
|
||||||
|
HostIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeSpec is a minimal volume mount entry. Fields are stored raw
|
||||||
|
// pending the P0c schema work (REQ-074).
|
||||||
|
type VolumeSpec struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Source string
|
||||||
|
Target string
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// validKinds is the set of workload kinds accepted by the parser per
|
||||||
|
// R-012. Unknown kinds are rejected.
|
||||||
|
var validKinds = map[string]bool{
|
||||||
|
"Job": true,
|
||||||
|
"Service": true,
|
||||||
|
"DaemonSet": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMarkdown parses a Markdown jobspec with YAML frontmatter into a
|
||||||
|
// *WorkloadSpec (R-013 canonical format, R-014 frontmatter). The body
|
||||||
|
// after the closing `---` is preserved verbatim in result.Body
|
||||||
|
// (R-015 byte-exact, including trailing newlines, CRLF, and BOM in the
|
||||||
|
// body). The frontmatter parser is a minimal hand-rolled YAML-ish
|
||||||
|
// key:value reader — gopkg.in/yaml.v3 is intentionally not added (same
|
||||||
|
// approach as internal/config/markdown.go and internal/ns/parse.go).
|
||||||
|
//
|
||||||
|
// For .yaml/.yml files (no Markdown body), the dispatcher calls this
|
||||||
|
// with the whole file treated as frontmatter and Body left empty (see
|
||||||
|
// dispatch.go).
|
||||||
|
func ParseMarkdown(data []byte) (*WorkloadSpec, error) {
|
||||||
|
content := string(data)
|
||||||
|
block, body, ok := splitFrontmatter(content)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("parse markdown: missing frontmatter delimiters")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(block) == "" {
|
||||||
|
return nil, fmt.Errorf("parse markdown: empty frontmatter")
|
||||||
|
}
|
||||||
|
spec, err := parseFrontmatterBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spec.Body = body
|
||||||
|
if err := validateWorkload(spec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFrontmatter splits the file content into the YAML frontmatter
|
||||||
|
// block and the verbatim body that follows the closing `---`. A leading
|
||||||
|
// UTF-8 BOM is stripped from the frontmatter scan (R-015: BOM is not
|
||||||
|
// preserved in the frontmatter, but a BOM inside the body would be
|
||||||
|
// preserved because the body is verbatim). Returns (block, body, ok).
|
||||||
|
// ok is false when no opening `---` delimiter is present, or no closing
|
||||||
|
// `---` delimiter is found, or the block is empty after the opening
|
||||||
|
// delimiter (handled by caller).
|
||||||
|
func splitFrontmatter(content string) (block, body string, ok bool) {
|
||||||
|
// Strip a leading UTF-8 BOM if present (EF BB BF). Only the
|
||||||
|
// frontmatter scan is BOM-stripped; the body is byte-exact, so a BOM
|
||||||
|
// appearing inside the body is preserved verbatim.
|
||||||
|
stripped := content
|
||||||
|
if strings.HasPrefix(stripped, "\uFEFF") {
|
||||||
|
stripped = stripped[len("\uFEFF"):]
|
||||||
|
}
|
||||||
|
// Trim leading horizontal whitespace and newlines before the
|
||||||
|
// opening delimiter. We do NOT trim trailing — body must be exact.
|
||||||
|
trimmed := strings.TrimLeft(stripped, "\r\n\t ")
|
||||||
|
if !strings.HasPrefix(trimmed, "---") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// The opening delimiter must be on its own line: `---` optionally
|
||||||
|
// followed by a line terminator.
|
||||||
|
rest := trimmed[3:]
|
||||||
|
// The opening `---` must be followed by a newline or end-of-file
|
||||||
|
// (a `---foo` prefix is not a valid delimiter).
|
||||||
|
if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
rest = strings.TrimLeft(rest, "\r\n")
|
||||||
|
|
||||||
|
// Find the closing delimiter line. The closing `---` must be on its
|
||||||
|
// own line: preceded by a newline (or at the start of `rest`) and
|
||||||
|
// followed by a newline or end-of-file.
|
||||||
|
idx := findClosingDelimiter(rest)
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
block = rest[:idx]
|
||||||
|
// Body is everything after the closing `---` line. The closing
|
||||||
|
// delimiter line itself (including its trailing newline) is NOT
|
||||||
|
// part of the body. We compute the byte offset in the original
|
||||||
|
// `content` so the body is byte-exact (R-015).
|
||||||
|
afterClose := rest[idx:]
|
||||||
|
// afterClose starts with `---`. Strip the delimiter line.
|
||||||
|
delimLen := 3
|
||||||
|
// Account for an optional trailing `...` or spaces on the delimiter
|
||||||
|
// line — the delimiter is `---` followed by anything up to and
|
||||||
|
// including the line terminator. Body starts after the newline.
|
||||||
|
// Find the end of the delimiter line.
|
||||||
|
newlineIdx := strings.IndexAny(afterClose, "\r\n")
|
||||||
|
var bodyStart int
|
||||||
|
if newlineIdx < 0 {
|
||||||
|
// Closing `---` is the last line: body is empty.
|
||||||
|
bodyStart = len(afterClose)
|
||||||
|
} else {
|
||||||
|
// Consume the delimiter line and its line terminator(s).
|
||||||
|
bodyStart = newlineIdx
|
||||||
|
// Strip a single CRLF or LF.
|
||||||
|
if strings.HasPrefix(afterClose[bodyStart:], "\r\n") {
|
||||||
|
bodyStart += 2
|
||||||
|
} else {
|
||||||
|
bodyStart += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = afterClose[bodyStart:]
|
||||||
|
_ = delimLen
|
||||||
|
return block, body, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// findClosingDelimiter returns the byte index in `rest` where the
|
||||||
|
// closing `---` delimiter line begins, or -1 if none is found. The
|
||||||
|
// delimiter must be on its own line: either at the start of `rest` or
|
||||||
|
// preceded by a newline, and followed by a newline or end-of-file.
|
||||||
|
func findClosingDelimiter(rest string) int {
|
||||||
|
// Special case: closing delimiter at the very start (frontmatter
|
||||||
|
// block is empty). The opening `---` is immediately followed by the
|
||||||
|
// closing `---`. We require the opening to be its own line, so the
|
||||||
|
// closing at index 0 means the opening had no body — invalid (empty
|
||||||
|
// frontmatter handled by caller). We still report it; caller
|
||||||
|
// rejects empty block.
|
||||||
|
for i := 0; i < len(rest); i++ {
|
||||||
|
if rest[i] != '\n' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Candidate: line after this newline starts with `---`.
|
||||||
|
j := i + 1
|
||||||
|
if j+3 <= len(rest) && rest[j] == '-' && rest[j+1] == '-' && rest[j+2] == '-' {
|
||||||
|
// Must be followed by newline, CRLF, or end-of-file.
|
||||||
|
end := j + 3
|
||||||
|
if end == len(rest) {
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
if rest[end] == '\n' || rest[end] == '\r' {
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final candidate: closing delimiter at the very start of rest
|
||||||
|
// (immediately after the opening delimiter + its newline). This
|
||||||
|
// happens when frontmatter is empty: `---\n---\n`. We already trim
|
||||||
|
// leading newlines off `rest`, so if rest itself starts with `---`
|
||||||
|
// AND it's a closing delimiter (followed by newline/EOF), it is the
|
||||||
|
// empty-frontmatter case.
|
||||||
|
if strings.HasPrefix(rest, "---") {
|
||||||
|
end := 3
|
||||||
|
if end == len(rest) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if rest[end] == '\n' || rest[end] == '\r' {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatterBlock parses a minimal YAML-ish frontmatter block into
|
||||||
|
// a *WorkloadSpec (without Body, which is filled by the caller).
|
||||||
|
//
|
||||||
|
// Supported shapes:
|
||||||
|
//
|
||||||
|
// kind: Job
|
||||||
|
// name: my-job
|
||||||
|
// count: 3
|
||||||
|
// runtime:
|
||||||
|
// one_of: process
|
||||||
|
// image: docker.io/nginx:latest
|
||||||
|
// command: /bin/sh -c
|
||||||
|
// ports:
|
||||||
|
// - name: http
|
||||||
|
// port: 8080
|
||||||
|
// host_port: 80
|
||||||
|
// protocol: tcp
|
||||||
|
// env:
|
||||||
|
// FOO: bar
|
||||||
|
// BAR:
|
||||||
|
// from: secret:my-secret
|
||||||
|
// secrets:
|
||||||
|
// - db-password
|
||||||
|
// volumes:
|
||||||
|
// - name: data
|
||||||
|
// type: host
|
||||||
|
// source: /data
|
||||||
|
// target: /data
|
||||||
|
// read_only: true
|
||||||
|
//
|
||||||
|
// Comments (# ...) and blank lines are ignored. Quoted scalar values
|
||||||
|
// ("..." or '...') are unwrapped. No flow collections except the
|
||||||
|
// inline-array form for `secrets`. Multi-line block scalars (|, >) are
|
||||||
|
// not supported — by design, to avoid adding a YAML dependency for this
|
||||||
|
// small surface.
|
||||||
|
func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
|
||||||
|
spec := &WorkloadSpec{Count: 1}
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
|
||||||
|
type section int
|
||||||
|
const (
|
||||||
|
secNone section = iota
|
||||||
|
secRuntime
|
||||||
|
secPorts
|
||||||
|
secEnv
|
||||||
|
secSecrets
|
||||||
|
secVolumes
|
||||||
|
secRestart
|
||||||
|
secUpdate
|
||||||
|
secService
|
||||||
|
secHealth
|
||||||
|
secLifecycle
|
||||||
|
secAffinity
|
||||||
|
secConstraints
|
||||||
|
)
|
||||||
|
cur := secNone
|
||||||
|
var curPort *PortSpec
|
||||||
|
var curVol *VolumeSpec
|
||||||
|
var curAffinity *AffinityRule
|
||||||
|
var lifecycleCur string
|
||||||
|
|
||||||
|
flushPort := func() {
|
||||||
|
if curPort != nil {
|
||||||
|
spec.Ports = append(spec.Ports, *curPort)
|
||||||
|
curPort = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushVol := func() {
|
||||||
|
if curVol != nil {
|
||||||
|
spec.Volumes = append(spec.Volumes, *curVol)
|
||||||
|
curVol = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushAffinity := func() {
|
||||||
|
if curAffinity != nil {
|
||||||
|
spec.Affinity = append(spec.Affinity, *curAffinity)
|
||||||
|
curAffinity = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for lineNo, raw := range lines {
|
||||||
|
line := stripComment(raw)
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indent := countIndent(line)
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if indent == 0 {
|
||||||
|
// Flush any pending nested entry before switching sections.
|
||||||
|
flushPort()
|
||||||
|
flushVol()
|
||||||
|
flushAffinity()
|
||||||
|
cur = secNone
|
||||||
|
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("parse markdown: line %d: malformed key:value", lineNo+1)
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "orca-spec-version":
|
||||||
|
spec.SpecVersion = unquote(val)
|
||||||
|
case "kind":
|
||||||
|
spec.Kind = unquote(val)
|
||||||
|
case "name":
|
||||||
|
spec.Name = unquote(val)
|
||||||
|
case "count":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Count = n
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("parse markdown: line %d: count: %v", lineNo+1, err)
|
||||||
|
}
|
||||||
|
case "runtime":
|
||||||
|
spec.Runtime = &RuntimeBlock{}
|
||||||
|
if strings.TrimSpace(val) != "" {
|
||||||
|
// Inline value (unusual); ignore — runtime is a block.
|
||||||
|
}
|
||||||
|
cur = secRuntime
|
||||||
|
case "ports":
|
||||||
|
cur = secPorts
|
||||||
|
case "env":
|
||||||
|
spec.Env = map[string]string{}
|
||||||
|
cur = secEnv
|
||||||
|
case "secrets":
|
||||||
|
if strings.TrimSpace(val) != "" {
|
||||||
|
arr, err := parseStringArray(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse markdown: line %d: secrets: %w", lineNo+1, err)
|
||||||
|
}
|
||||||
|
spec.Secrets = append(spec.Secrets, arr...)
|
||||||
|
cur = secNone
|
||||||
|
} else {
|
||||||
|
cur = secSecrets
|
||||||
|
}
|
||||||
|
case "volumes":
|
||||||
|
cur = secVolumes
|
||||||
|
case "restart":
|
||||||
|
spec.Restart = &RestartBlock{}
|
||||||
|
cur = secRestart
|
||||||
|
case "update":
|
||||||
|
spec.Update = &UpdateBlock{}
|
||||||
|
cur = secUpdate
|
||||||
|
case "service":
|
||||||
|
spec.Service = &ServiceBlock{}
|
||||||
|
cur = secService
|
||||||
|
case "health":
|
||||||
|
spec.Health = &HealthBlock{}
|
||||||
|
cur = secHealth
|
||||||
|
case "lifecycle":
|
||||||
|
spec.Lifecycle = &LifecycleBlock{}
|
||||||
|
cur = secLifecycle
|
||||||
|
case "constraints":
|
||||||
|
if strings.TrimSpace(val) != "" {
|
||||||
|
arr, err := parseStringArray(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse markdown: line %d: constraints: %w", lineNo+1, err)
|
||||||
|
}
|
||||||
|
spec.Constraints = append(spec.Constraints, arr...)
|
||||||
|
cur = secNone
|
||||||
|
} else {
|
||||||
|
cur = secConstraints
|
||||||
|
}
|
||||||
|
case "affinity":
|
||||||
|
if strings.TrimSpace(val) != "" {
|
||||||
|
// Inline form not supported for affinity objects;
|
||||||
|
// require the block form. Ignore inline values.
|
||||||
|
cur = secNone
|
||||||
|
} else {
|
||||||
|
cur = secAffinity
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown top-level key are ignored (forward-compat).
|
||||||
|
cur = secNone
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indented line: a nested entry under the current section.
|
||||||
|
switch cur {
|
||||||
|
case secRuntime:
|
||||||
|
if spec.Runtime == nil {
|
||||||
|
spec.Runtime = &RuntimeBlock{}
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "one_of":
|
||||||
|
spec.Runtime.OneOf = unquote(val)
|
||||||
|
case "image":
|
||||||
|
spec.Runtime.Image = unquote(val)
|
||||||
|
case "command":
|
||||||
|
spec.Runtime.Command = unquote(val)
|
||||||
|
}
|
||||||
|
case secPorts:
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
flushPort()
|
||||||
|
p := PortSpec{}
|
||||||
|
curPort = &p
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if rest != "" {
|
||||||
|
applyPortKV(curPort, rest)
|
||||||
|
}
|
||||||
|
} else if curPort != nil {
|
||||||
|
applyPortKV(curPort, trimmed)
|
||||||
|
}
|
||||||
|
case secEnv:
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
// Nested mapping under env (e.g. `BAR:\n from: ...`).
|
||||||
|
// Store the raw string for now (REQ-064: store raw).
|
||||||
|
spec.Env[key] = ""
|
||||||
|
} else if strings.HasPrefix(val, "{") && strings.HasSuffix(val, "}") {
|
||||||
|
// Inline object form: `BAR: {from: "secret:..."}`.
|
||||||
|
// Store the raw object string for now.
|
||||||
|
spec.Env[key] = val
|
||||||
|
} else {
|
||||||
|
spec.Env[key] = unquote(val)
|
||||||
|
}
|
||||||
|
case secSecrets:
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if item != "" {
|
||||||
|
spec.Secrets = append(spec.Secrets, unquote(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case secVolumes:
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
flushVol()
|
||||||
|
v := VolumeSpec{}
|
||||||
|
curVol = &v
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if rest != "" {
|
||||||
|
applyVolumeKV(curVol, rest)
|
||||||
|
}
|
||||||
|
} else if curVol != nil {
|
||||||
|
applyVolumeKV(curVol, trimmed)
|
||||||
|
}
|
||||||
|
case secRestart:
|
||||||
|
if spec.Restart == nil {
|
||||||
|
spec.Restart = &RestartBlock{}
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "mode":
|
||||||
|
spec.Restart.Mode = unquote(val)
|
||||||
|
case "attempts", "max_retries":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Restart.MaxRetries = n
|
||||||
|
}
|
||||||
|
case "delay":
|
||||||
|
spec.Restart.Delay = unquote(val)
|
||||||
|
}
|
||||||
|
case secUpdate:
|
||||||
|
if spec.Update == nil {
|
||||||
|
spec.Update = &UpdateBlock{}
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "strategy":
|
||||||
|
spec.Update.Strategy = unquote(val)
|
||||||
|
case "max_parallel":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Update.MaxParallel = n
|
||||||
|
}
|
||||||
|
case "max_surge":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Update.MaxSurge = n
|
||||||
|
}
|
||||||
|
case "min_healthy_time":
|
||||||
|
spec.Update.MinHealthyTime = unquote(val)
|
||||||
|
case "healthy_deadline":
|
||||||
|
spec.Update.HealthyDeadline = unquote(val)
|
||||||
|
case "canary":
|
||||||
|
spec.Update.Canary = unquote(val)
|
||||||
|
case "auto_promote":
|
||||||
|
spec.Update.AutoPromote = parseBool(val)
|
||||||
|
}
|
||||||
|
case secService:
|
||||||
|
if spec.Service == nil {
|
||||||
|
spec.Service = &ServiceBlock{}
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
spec.Service.Name = unquote(val)
|
||||||
|
case "port":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Service.Port = n
|
||||||
|
}
|
||||||
|
case "bind":
|
||||||
|
spec.Service.Bind = unquote(val)
|
||||||
|
case "host":
|
||||||
|
spec.Service.Host = unquote(val)
|
||||||
|
case "route_id":
|
||||||
|
spec.Service.RouteID = unquote(val)
|
||||||
|
}
|
||||||
|
case secHealth:
|
||||||
|
if spec.Health == nil {
|
||||||
|
spec.Health = &HealthBlock{}
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "check_type":
|
||||||
|
spec.Health.CheckType = unquote(val)
|
||||||
|
case "interval":
|
||||||
|
spec.Health.Interval = unquote(val)
|
||||||
|
case "timeout":
|
||||||
|
spec.Health.Timeout = unquote(val)
|
||||||
|
case "unhealthy_threshold":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
spec.Health.UnhealthyThreshold = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case secLifecycle:
|
||||||
|
if spec.Lifecycle == nil {
|
||||||
|
spec.Lifecycle = &LifecycleBlock{}
|
||||||
|
}
|
||||||
|
// pre_stop / post_start are string arrays. The block form
|
||||||
|
// is:
|
||||||
|
// lifecycle:
|
||||||
|
// pre_stop:
|
||||||
|
// - cmd1
|
||||||
|
// - cmd2
|
||||||
|
// post_start:
|
||||||
|
// - cmd3
|
||||||
|
// We track which sub-list we are appending to via a local
|
||||||
|
// cursor that is reset on every top-level section change.
|
||||||
|
key, val, ok := splitKV(trimmed)
|
||||||
|
if !ok {
|
||||||
|
// Could be a list item under pre_stop/post_start.
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if item != "" && lifecycleCur != "" {
|
||||||
|
appendLifecycleCmd(spec.Lifecycle, lifecycleCur, unquote(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "pre_stop", "post_start":
|
||||||
|
lifecycleCur = key
|
||||||
|
if strings.TrimSpace(val) != "" {
|
||||||
|
// Inline list form: `pre_stop: [cmd1, cmd2]`.
|
||||||
|
arr, err := parseStringArray(val)
|
||||||
|
if err == nil {
|
||||||
|
for _, s := range arr {
|
||||||
|
appendLifecycleCmd(spec.Lifecycle, key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleCur = ""
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
lifecycleCur = ""
|
||||||
|
}
|
||||||
|
case secAffinity:
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
flushAffinity()
|
||||||
|
r := AffinityRule{}
|
||||||
|
curAffinity = &r
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if rest != "" {
|
||||||
|
applyAffinityKV(curAffinity, rest)
|
||||||
|
}
|
||||||
|
} else if curAffinity != nil {
|
||||||
|
applyAffinityKV(curAffinity, trimmed)
|
||||||
|
}
|
||||||
|
case secConstraints:
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||||
|
item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
|
||||||
|
if item != "" {
|
||||||
|
spec.Constraints = append(spec.Constraints, unquote(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPort()
|
||||||
|
flushVol()
|
||||||
|
flushAffinity()
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyPortKV applies a `key: value` pair to a PortSpec entry.
|
||||||
|
func applyPortKV(p *PortSpec, s string) {
|
||||||
|
key, val, ok := splitKV(s)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
p.Name = unquote(val)
|
||||||
|
case "host_port":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
p.HostPort = n
|
||||||
|
}
|
||||||
|
case "port":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
p.Port = n
|
||||||
|
}
|
||||||
|
case "protocol":
|
||||||
|
p.Protocol = unquote(val)
|
||||||
|
case "host_ip":
|
||||||
|
p.HostIP = unquote(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyVolumeKV applies a `key: value` pair to a VolumeSpec entry.
|
||||||
|
func applyVolumeKV(v *VolumeSpec, s string) {
|
||||||
|
key, val, ok := splitKV(s)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
v.Name = unquote(val)
|
||||||
|
case "type":
|
||||||
|
v.Type = unquote(val)
|
||||||
|
case "source":
|
||||||
|
v.Source = unquote(val)
|
||||||
|
case "target":
|
||||||
|
v.Target = unquote(val)
|
||||||
|
case "read_only":
|
||||||
|
switch strings.ToLower(strings.TrimSpace(unquote(val))) {
|
||||||
|
case "true", "yes", "on", "1":
|
||||||
|
v.ReadOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAffinityKV applies a `key: value` pair to an AffinityRule entry.
|
||||||
|
func applyAffinityKV(r *AffinityRule, s string) {
|
||||||
|
key, val, ok := splitKV(s)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "target":
|
||||||
|
r.Target = unquote(val)
|
||||||
|
case "weight":
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
|
||||||
|
r.Weight = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLifecycleCmd appends a command to the named lifecycle hook list
|
||||||
|
// (pre_stop or post_start) on the given LifecycleBlock.
|
||||||
|
func appendLifecycleCmd(lb *LifecycleBlock, name, cmd string) {
|
||||||
|
if lb == nil || cmd == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "pre_stop":
|
||||||
|
lb.PreStop = append(lb.PreStop, cmd)
|
||||||
|
case "post_start":
|
||||||
|
lb.PostStart = append(lb.PostStart, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBool parses a YAML-ish boolean value (true/yes/on/1 → true). The
|
||||||
|
// comparison is case-insensitive. Empty and unrecognized values return
|
||||||
|
// false (forward-compatible with future strict-mode validation).
|
||||||
|
func parseBool(s string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(unquote(s))) {
|
||||||
|
case "true", "yes", "on", "1":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWorkload enforces required fields and kind validity (R-012).
|
||||||
|
func validateWorkload(spec *WorkloadSpec) error {
|
||||||
|
if spec.Kind == "" {
|
||||||
|
return fmt.Errorf("parse markdown: missing kind")
|
||||||
|
}
|
||||||
|
if !validKinds[spec.Kind] {
|
||||||
|
return fmt.Errorf("parse markdown: kind %q is not one of Job, Service, DaemonSet", spec.Kind)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
return fmt.Errorf("parse markdown: missing name")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStringArray parses an inline YAML flow-array of scalars, e.g.
|
||||||
|
// `["a", "b"]` or `['a', 'b']` or `[a, b]`. Empty array `[]` returns nil.
|
||||||
|
func parseStringArray(val string) ([]string, error) {
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
if val == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(val, "[") || !strings.HasSuffix(val, "]") {
|
||||||
|
return nil, fmt.Errorf("expected [..] array, got %q", val)
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(val[1 : len(val)-1])
|
||||||
|
if inner == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := splitFlowItems(inner)
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, unquote(p))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFlowItems splits a comma-separated flow-array body, respecting
|
||||||
|
// single and double quotes.
|
||||||
|
func splitFlowItems(s string) []string {
|
||||||
|
var out []string
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
case ',':
|
||||||
|
if !inSingle && !inDouble {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s[start:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func countIndent(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r == ' ' || r == '\t' {
|
||||||
|
n++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKV(s string) (key, val string, ok bool) {
|
||||||
|
idx := strings.Index(s, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(s[:idx])
|
||||||
|
val = strings.TrimSpace(s[idx+1:])
|
||||||
|
if key == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return key, val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripComment(s string) string {
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
case '#':
|
||||||
|
if !inSingle && !inDouble {
|
||||||
|
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquote(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) >= 2 {
|
||||||
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package jobspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzParseMarkdownRoundTrip is the REQ-067 fuzz harness for R-015
|
||||||
|
// byte-exact body preservation. It generates random frontmatter + body
|
||||||
|
// combinations, runs ParseMarkdown, and asserts that the parsed Body
|
||||||
|
// equals the original body byte-for-byte whenever parsing succeeds.
|
||||||
|
// When parsing fails (bad frontmatter), the iteration passes — the
|
||||||
|
// parser is allowed to reject malformed input.
|
||||||
|
//
|
||||||
|
// The seed corpus (added via f.Add) covers adversarial fixtures: CRLF
|
||||||
|
// body, BOM prefix, no frontmatter, only-closing-separator, body with
|
||||||
|
// `---` inside a code fence, trailing whitespace, empty body. The seed
|
||||||
|
// corpus runs as regular tests under `go test` (CI); random input runs
|
||||||
|
// only under `go test -fuzz=FuzzParseMarkdownRoundTrip` in a dedicated
|
||||||
|
// process.
|
||||||
|
func FuzzParseMarkdownRoundTrip(f *testing.F) {
|
||||||
|
// Seed 1: valid frontmatter + simple body.
|
||||||
|
f.Add([]byte("---\nkind: Job\nname: seed1\n---\n# body\n"))
|
||||||
|
|
||||||
|
// Seed 2: CRLF body.
|
||||||
|
f.Add([]byte("---\r\nkind: Job\r\nname: seed2\r\n---\r\n# body\r\nCRLF\r\n"))
|
||||||
|
|
||||||
|
// Seed 3: BOM prefix.
|
||||||
|
f.Add([]byte("\uFEFF---\nkind: Job\nname: seed3\n---\nbody\n"))
|
||||||
|
|
||||||
|
// Seed 4: no frontmatter (just body) — should fail to parse.
|
||||||
|
f.Add([]byte("# just a body\nno frontmatter\n"))
|
||||||
|
|
||||||
|
// Seed 5: frontmatter with only the closing `---` (no opening).
|
||||||
|
f.Add([]byte("body\n---\nmore body\n"))
|
||||||
|
|
||||||
|
// Seed 6: body containing `---` in a code fence.
|
||||||
|
f.Add([]byte("---\nkind: Job\nname: seed6\n---\n```bash\necho '---'\n```\n"))
|
||||||
|
|
||||||
|
// Seed 7: body with trailing whitespace.
|
||||||
|
f.Add([]byte("---\nkind: Job\nname: seed7\n---\nbody with trailing spaces \n"))
|
||||||
|
|
||||||
|
// Seed 8: empty body.
|
||||||
|
f.Add([]byte("---\nkind: Job\nname: seed8\n---\n"))
|
||||||
|
|
||||||
|
// Seed 9: empty frontmatter (should fail).
|
||||||
|
f.Add([]byte("---\n---\nbody\n"))
|
||||||
|
|
||||||
|
// Seed 10: body with no trailing newline.
|
||||||
|
f.Add([]byte("---\nkind: Job\nname: seed10\n---\nno trailing newline"))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
// Reconstruct the body from the input so we can assert
|
||||||
|
// byte-exact round-trip. We do this by re-splitting the
|
||||||
|
// frontmatter using the same logic the parser uses, but only
|
||||||
|
// to extract the expected body. If the input has no valid
|
||||||
|
// frontmatter delimiter pair, ParseMarkdown will return an
|
||||||
|
// error and we pass the iteration.
|
||||||
|
expectedBody := extractExpectedBody(string(data))
|
||||||
|
|
||||||
|
spec, err := ParseMarkdown(data)
|
||||||
|
if err != nil {
|
||||||
|
// Parser rejected the input — acceptable for a fuzz
|
||||||
|
// iteration (the input may be malformed). Pass.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// R-015: body must be byte-exact.
|
||||||
|
if spec.Body != expectedBody {
|
||||||
|
t.Errorf("R-015 body round-trip mismatch:\n got = %q\nwant = %q", spec.Body, expectedBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractExpectedBody returns the body portion of a Markdown jobspec
|
||||||
|
// input using the same delimiter-splitting logic as splitFrontmatter,
|
||||||
|
// so the fuzz harness can assert byte-exact preservation independently
|
||||||
|
// of the parser's internal extraction. If the input has no valid
|
||||||
|
// frontmatter, the result is "" (and ParseMarkdown will error).
|
||||||
|
func extractExpectedBody(content string) string {
|
||||||
|
stripped := content
|
||||||
|
if strings.HasPrefix(stripped, "\uFEFF") {
|
||||||
|
stripped = stripped[len("\uFEFF"):]
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimLeft(stripped, "\r\n\t ")
|
||||||
|
if !strings.HasPrefix(trimmed, "---") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := trimmed[3:]
|
||||||
|
if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest = strings.TrimLeft(rest, "\r\n")
|
||||||
|
idx := findClosingDelimiter(rest)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
afterClose := rest[idx:]
|
||||||
|
newlineIdx := strings.IndexAny(afterClose, "\r\n")
|
||||||
|
if newlineIdx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
bodyStart := newlineIdx
|
||||||
|
if strings.HasPrefix(afterClose[bodyStart:], "\r\n") {
|
||||||
|
bodyStart += 2
|
||||||
|
} else {
|
||||||
|
bodyStart += 1
|
||||||
|
}
|
||||||
|
return afterClose[bodyStart:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
package jobspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMarkdown_FullFrontmatter(t *testing.T) {
|
||||||
|
body := "# Hello\n\nThis is the body.\n\nTrailing newline preserved.\n"
|
||||||
|
input := "---\n" +
|
||||||
|
"orca-spec-version: \"1\"\n" +
|
||||||
|
"kind: Job\n" +
|
||||||
|
"name: my-job\n" +
|
||||||
|
"count: 3\n" +
|
||||||
|
"---\n" +
|
||||||
|
body
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.SpecVersion != "1" {
|
||||||
|
t.Errorf("SpecVersion = %q, want %q", spec.SpecVersion, "1")
|
||||||
|
}
|
||||||
|
if spec.Kind != "Job" {
|
||||||
|
t.Errorf("Kind = %q, want %q", spec.Kind, "Job")
|
||||||
|
}
|
||||||
|
if spec.Name != "my-job" {
|
||||||
|
t.Errorf("Name = %q, want %q", spec.Name, "my-job")
|
||||||
|
}
|
||||||
|
if spec.Count != 3 {
|
||||||
|
t.Errorf("Count = %d, want 3", spec.Count)
|
||||||
|
}
|
||||||
|
if spec.Body != body {
|
||||||
|
t.Errorf("Body = %q, want %q (byte-exact, R-015)", spec.Body, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_BodyByteExactTrailingNewline(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"with_trailing_newline", "# Title\n\nbody\n"},
|
||||||
|
{"with_double_trailing_newline", "# Title\n\nbody\n\n"},
|
||||||
|
{"no_trailing_newline", "# Title\n\nbody"},
|
||||||
|
{"empty_body_with_newline", "\n"},
|
||||||
|
{"only_newlines", "\n\n\n"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\nname: x\n---\n" + tc.body
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Body != tc.body {
|
||||||
|
t.Errorf("Body byte-exact mismatch (R-015):\n got = %q\nwant = %q", spec.Body, tc.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_NoFrontmatter(t *testing.T) {
|
||||||
|
input := "# Just a body\n\nNo frontmatter here."
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing frontmatter, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "frontmatter") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'frontmatter'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_EmptyFrontmatter(t *testing.T) {
|
||||||
|
input := "---\n---\n\nbody"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty frontmatter, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "empty frontmatter") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'empty frontmatter'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_UnknownKind(t *testing.T) {
|
||||||
|
input := "---\nkind: CronJob\nname: x\n---\nbody\n"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown kind, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not one of") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'not one of'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_MissingName(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\n---\nbody\n"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing name, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing name") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'missing name'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_MissingKind(t *testing.T) {
|
||||||
|
input := "---\nname: x\n---\nbody\n"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing kind, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing kind") {
|
||||||
|
t.Errorf("error = %q, want it to contain 'missing kind'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_EachValidKind(t *testing.T) {
|
||||||
|
cases := []string{"Job", "Service", "DaemonSet"}
|
||||||
|
for _, kind := range cases {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
input := "---\nkind: " + kind + "\nname: x\n---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Kind != kind {
|
||||||
|
t.Errorf("Kind = %q, want %q", spec.Kind, kind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_EnvScalarAndObject(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Job\n" +
|
||||||
|
"name: x\n" +
|
||||||
|
"env:\n" +
|
||||||
|
" FOO: bar\n" +
|
||||||
|
" BAZ: \"qux\"\n" +
|
||||||
|
" SECRET_REF:\n" +
|
||||||
|
" from: \"secret:db-password\"\n" +
|
||||||
|
" INLINE: {from: \"secret:token\"}\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if got := spec.Env["FOO"]; got != "bar" {
|
||||||
|
t.Errorf("env[FOO] = %q, want %q", got, "bar")
|
||||||
|
}
|
||||||
|
if got := spec.Env["BAZ"]; got != "qux" {
|
||||||
|
t.Errorf("env[BAZ] = %q, want %q", got, "qux")
|
||||||
|
}
|
||||||
|
if got := spec.Env["INLINE"]; got != `{from: "secret:token"}` {
|
||||||
|
t.Errorf("env[INLINE] = %q, want the raw object string", got)
|
||||||
|
}
|
||||||
|
if _, ok := spec.Env["SECRET_REF"]; !ok {
|
||||||
|
t.Errorf("env[SECRET_REF] missing; nested from: stored as empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_PortsArray(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"ports:\n" +
|
||||||
|
" - name: http\n" +
|
||||||
|
" port: 8080\n" +
|
||||||
|
" host_port: 80\n" +
|
||||||
|
" protocol: tcp\n" +
|
||||||
|
" - name: https\n" +
|
||||||
|
" port: 8443\n" +
|
||||||
|
" host_port: 443\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Ports) != 2 {
|
||||||
|
t.Fatalf("Ports = %d, want 2", len(spec.Ports))
|
||||||
|
}
|
||||||
|
if spec.Ports[0].Name != "http" || spec.Ports[0].Port != 8080 || spec.Ports[0].HostPort != 80 || spec.Ports[0].Protocol != "tcp" {
|
||||||
|
t.Errorf("Ports[0] = %+v", spec.Ports[0])
|
||||||
|
}
|
||||||
|
if spec.Ports[1].Name != "https" || spec.Ports[1].Port != 8443 || spec.Ports[1].HostPort != 443 {
|
||||||
|
t.Errorf("Ports[1] = %+v", spec.Ports[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_VolumesArray(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Job\n" +
|
||||||
|
"name: x\n" +
|
||||||
|
"volumes:\n" +
|
||||||
|
" - name: data\n" +
|
||||||
|
" type: host\n" +
|
||||||
|
" source: /data\n" +
|
||||||
|
" target: /data\n" +
|
||||||
|
" read_only: true\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Volumes) != 1 {
|
||||||
|
t.Fatalf("Volumes = %d, want 1", len(spec.Volumes))
|
||||||
|
}
|
||||||
|
v := spec.Volumes[0]
|
||||||
|
if v.Name != "data" || v.Type != "host" || v.Source != "/data" || v.Target != "/data" || !v.ReadOnly {
|
||||||
|
t.Errorf("Volumes[0] = %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_RuntimeBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Job\n" +
|
||||||
|
"name: x\n" +
|
||||||
|
"runtime:\n" +
|
||||||
|
" one_of: process\n" +
|
||||||
|
" image: docker.io/nginx:latest\n" +
|
||||||
|
" command: /bin/sh -c 'echo hi'\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Runtime == nil {
|
||||||
|
t.Fatal("Runtime is nil")
|
||||||
|
}
|
||||||
|
if spec.Runtime.OneOf != "process" {
|
||||||
|
t.Errorf("Runtime.OneOf = %q, want %q", spec.Runtime.OneOf, "process")
|
||||||
|
}
|
||||||
|
if spec.Runtime.Image != "docker.io/nginx:latest" {
|
||||||
|
t.Errorf("Runtime.Image = %q, want %q", spec.Runtime.Image, "docker.io/nginx:latest")
|
||||||
|
}
|
||||||
|
if spec.Runtime.Command != "/bin/sh -c 'echo hi'" {
|
||||||
|
t.Errorf("Runtime.Command = %q, want %q", spec.Runtime.Command, "/bin/sh -c 'echo hi'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_SecretsInlineArray(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\nname: x\nsecrets: [\"db-password\", \"api-token\"]\n---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Secrets) != 2 {
|
||||||
|
t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
|
||||||
|
}
|
||||||
|
if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
|
||||||
|
t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_SecretsBlockArray(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Job\n" +
|
||||||
|
"name: x\n" +
|
||||||
|
"secrets:\n" +
|
||||||
|
" - db-password\n" +
|
||||||
|
" - api-token\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Secrets) != 2 {
|
||||||
|
t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
|
||||||
|
}
|
||||||
|
if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
|
||||||
|
t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_CRLFBodyPreserved(t *testing.T) {
|
||||||
|
body := "# Title\r\n\r\nCRLF body.\r\n"
|
||||||
|
input := "---\r\nkind: Job\r\nname: x\r\n---\r\n" + body
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Body != body {
|
||||||
|
t.Errorf("CRLF body not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_BOMStrippedFromFrontmatter(t *testing.T) {
|
||||||
|
body := "# body\n"
|
||||||
|
input := "\uFEFF" + "---\nkind: Job\nname: x\n---\n" + body
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Kind != "Job" {
|
||||||
|
t.Errorf("Kind = %q, want Job (BOM should be stripped from frontmatter scan)", spec.Kind)
|
||||||
|
}
|
||||||
|
if spec.Body != body {
|
||||||
|
t.Errorf("Body = %q, want %q", spec.Body, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_BodyWithCodeFenceContainingDashes(t *testing.T) {
|
||||||
|
body := "```bash\n" +
|
||||||
|
"echo '---'\n" +
|
||||||
|
"echo '--- end ---'\n" +
|
||||||
|
"```\n"
|
||||||
|
input := "---\nkind: Job\nname: x\n---\n" + body
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Body != body {
|
||||||
|
t.Errorf("Body with code-fence --- not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_OnlyClosingSeparator(t *testing.T) {
|
||||||
|
input := "no opening\n---\nbody\n"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for input with only closing separator, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_QuotedValues(t *testing.T) {
|
||||||
|
input := "---\nkind: \"Job\"\nname: 'my-job'\n---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Kind != "Job" {
|
||||||
|
t.Errorf("Kind = %q, want Job (double-quoted)", spec.Kind)
|
||||||
|
}
|
||||||
|
if spec.Name != "my-job" {
|
||||||
|
t.Errorf("Name = %q, want my-job (single-quoted)", spec.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_CountDefault(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\nname: x\n---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Count != 1 {
|
||||||
|
t.Errorf("Count default = %d, want 1", spec.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_UnknownKeyIgnored(t *testing.T) {
|
||||||
|
input := "---\nkind: Job\nname: x\nfuture_field: value\n---\nbody\n"
|
||||||
|
_, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown should ignore unknown keys: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_RestartBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"restart:\n" +
|
||||||
|
" mode: service\n" +
|
||||||
|
" attempts: 5\n" +
|
||||||
|
" delay: 3s\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Restart == nil {
|
||||||
|
t.Fatal("Restart is nil")
|
||||||
|
}
|
||||||
|
if spec.Restart.Mode != "service" {
|
||||||
|
t.Errorf("Restart.Mode = %q, want service", spec.Restart.Mode)
|
||||||
|
}
|
||||||
|
if spec.Restart.MaxRetries != 5 {
|
||||||
|
t.Errorf("Restart.MaxRetries = %d, want 5", spec.Restart.MaxRetries)
|
||||||
|
}
|
||||||
|
if spec.Restart.Delay != "3s" {
|
||||||
|
t.Errorf("Restart.Delay = %q, want 3s", spec.Restart.Delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_RestartBlockMaxRetriesAlias(t *testing.T) {
|
||||||
|
// max_retries is the canonical key; attempts is an accepted alias.
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"restart:\n" +
|
||||||
|
" mode: on-failure\n" +
|
||||||
|
" max_retries: 3\n" +
|
||||||
|
" delay: 1s\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Restart == nil || spec.Restart.MaxRetries != 3 {
|
||||||
|
t.Fatalf("Restart.MaxRetries = %d, want 3 (max_retries alias)", spec.Restart.MaxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_UpdateBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"update:\n" +
|
||||||
|
" strategy: canary\n" +
|
||||||
|
" max_parallel: 2\n" +
|
||||||
|
" min_healthy_time: 30s\n" +
|
||||||
|
" healthy_deadline: 5m\n" +
|
||||||
|
" canary: 10%\n" +
|
||||||
|
" auto_promote: true\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
t.Fatal("Update is nil")
|
||||||
|
}
|
||||||
|
if spec.Update.Strategy != "canary" {
|
||||||
|
t.Errorf("Update.Strategy = %q, want canary", spec.Update.Strategy)
|
||||||
|
}
|
||||||
|
if spec.Update.MaxParallel != 2 {
|
||||||
|
t.Errorf("Update.MaxParallel = %d, want 2", spec.Update.MaxParallel)
|
||||||
|
}
|
||||||
|
if spec.Update.MinHealthyTime != "30s" {
|
||||||
|
t.Errorf("Update.MinHealthyTime = %q, want 30s", spec.Update.MinHealthyTime)
|
||||||
|
}
|
||||||
|
if spec.Update.HealthyDeadline != "5m" {
|
||||||
|
t.Errorf("Update.HealthyDeadline = %q, want 5m", spec.Update.HealthyDeadline)
|
||||||
|
}
|
||||||
|
if spec.Update.Canary != "10%" {
|
||||||
|
t.Errorf("Update.Canary = %q, want 10%%", spec.Update.Canary)
|
||||||
|
}
|
||||||
|
if !spec.Update.AutoPromote {
|
||||||
|
t.Errorf("Update.AutoPromote = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_ServiceBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"service:\n" +
|
||||||
|
" name: web\n" +
|
||||||
|
" port: 8080\n" +
|
||||||
|
" bind: 127.0.0.1\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Service == nil {
|
||||||
|
t.Fatal("Service is nil")
|
||||||
|
}
|
||||||
|
if spec.Service.Name != "web" {
|
||||||
|
t.Errorf("Service.Name = %q, want web", spec.Service.Name)
|
||||||
|
}
|
||||||
|
if spec.Service.Port != 8080 {
|
||||||
|
t.Errorf("Service.Port = %d, want 8080", spec.Service.Port)
|
||||||
|
}
|
||||||
|
if spec.Service.Bind != "127.0.0.1" {
|
||||||
|
t.Errorf("Service.Bind = %q, want 127.0.0.1", spec.Service.Bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_HealthBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"health:\n" +
|
||||||
|
" check_type: http\n" +
|
||||||
|
" interval: 10s\n" +
|
||||||
|
" timeout: 2s\n" +
|
||||||
|
" unhealthy_threshold: 3\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Health == nil {
|
||||||
|
t.Fatal("Health is nil")
|
||||||
|
}
|
||||||
|
if spec.Health.CheckType != "http" {
|
||||||
|
t.Errorf("Health.CheckType = %q, want http", spec.Health.CheckType)
|
||||||
|
}
|
||||||
|
if spec.Health.Interval != "10s" {
|
||||||
|
t.Errorf("Health.Interval = %q, want 10s", spec.Health.Interval)
|
||||||
|
}
|
||||||
|
if spec.Health.Timeout != "2s" {
|
||||||
|
t.Errorf("Health.Timeout = %q, want 2s", spec.Health.Timeout)
|
||||||
|
}
|
||||||
|
if spec.Health.UnhealthyThreshold != 3 {
|
||||||
|
t.Errorf("Health.UnhealthyThreshold = %d, want 3", spec.Health.UnhealthyThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_ConstraintsInlineArray(t *testing.T) {
|
||||||
|
// Inline flow-array form: the parser does NOT unescape YAML
|
||||||
|
// escapes (consistent with the secrets inline parser). Use
|
||||||
|
// single-quoted scalars inside the flow array so the CEL strings
|
||||||
|
// are preserved verbatim.
|
||||||
|
input := "---\nkind: Service\nname: web\nconstraints: ['node.role == \"web\"', 'region == \"us\"']\n---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Constraints) != 2 {
|
||||||
|
t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
|
||||||
|
}
|
||||||
|
if spec.Constraints[0] != `node.role == "web"` {
|
||||||
|
t.Errorf("Constraints[0] = %q", spec.Constraints[0])
|
||||||
|
}
|
||||||
|
if spec.Constraints[1] != `region == "us"` {
|
||||||
|
t.Errorf("Constraints[1] = %q", spec.Constraints[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_ConstraintsBlockArray(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"constraints:\n" +
|
||||||
|
" - node.role == \"web\"\n" +
|
||||||
|
" - region == \"us\"\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Constraints) != 2 {
|
||||||
|
t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
|
||||||
|
}
|
||||||
|
if spec.Constraints[0] != `node.role == "web"` {
|
||||||
|
t.Errorf("Constraints[0] = %q", spec.Constraints[0])
|
||||||
|
}
|
||||||
|
if spec.Constraints[1] != `region == "us"` {
|
||||||
|
t.Errorf("Constraints[1] = %q", spec.Constraints[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_AffinityBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"affinity:\n" +
|
||||||
|
" - target: node.role == \"web\"\n" +
|
||||||
|
" weight: 100\n" +
|
||||||
|
" - target: region == \"us\"\n" +
|
||||||
|
" weight: 50\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if len(spec.Affinity) != 2 {
|
||||||
|
t.Fatalf("Affinity = %d, want 2", len(spec.Affinity))
|
||||||
|
}
|
||||||
|
if spec.Affinity[0].Target != `node.role == "web"` {
|
||||||
|
t.Errorf("Affinity[0].Target = %q", spec.Affinity[0].Target)
|
||||||
|
}
|
||||||
|
if spec.Affinity[0].Weight != 100 {
|
||||||
|
t.Errorf("Affinity[0].Weight = %d, want 100", spec.Affinity[0].Weight)
|
||||||
|
}
|
||||||
|
if spec.Affinity[1].Target != `region == "us"` {
|
||||||
|
t.Errorf("Affinity[1].Target = %q", spec.Affinity[1].Target)
|
||||||
|
}
|
||||||
|
if spec.Affinity[1].Weight != 50 {
|
||||||
|
t.Errorf("Affinity[1].Weight = %d, want 50", spec.Affinity[1].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_LifecycleBlock(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"lifecycle:\n" +
|
||||||
|
" pre_stop:\n" +
|
||||||
|
" - /bin/sh -c 'sleep 5'\n" +
|
||||||
|
" - /usr/local/bin/drain.sh\n" +
|
||||||
|
" post_start:\n" +
|
||||||
|
" - /usr/local/bin/warm-cache.sh\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Lifecycle == nil {
|
||||||
|
t.Fatal("Lifecycle is nil")
|
||||||
|
}
|
||||||
|
if len(spec.Lifecycle.PreStop) != 2 {
|
||||||
|
t.Fatalf("PreStop = %d, want 2", len(spec.Lifecycle.PreStop))
|
||||||
|
}
|
||||||
|
if spec.Lifecycle.PreStop[0] != "/bin/sh -c 'sleep 5'" {
|
||||||
|
t.Errorf("PreStop[0] = %q", spec.Lifecycle.PreStop[0])
|
||||||
|
}
|
||||||
|
if spec.Lifecycle.PreStop[1] != "/usr/local/bin/drain.sh" {
|
||||||
|
t.Errorf("PreStop[1] = %q", spec.Lifecycle.PreStop[1])
|
||||||
|
}
|
||||||
|
if len(spec.Lifecycle.PostStart) != 1 {
|
||||||
|
t.Fatalf("PostStart = %d, want 1", len(spec.Lifecycle.PostStart))
|
||||||
|
}
|
||||||
|
if spec.Lifecycle.PostStart[0] != "/usr/local/bin/warm-cache.sh" {
|
||||||
|
t.Errorf("PostStart[0] = %q", spec.Lifecycle.PostStart[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_LifecycleInlineArray(t *testing.T) {
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"lifecycle:\n" +
|
||||||
|
" pre_stop: [\"/bin/true\"]\n" +
|
||||||
|
" post_start: [\"/bin/warmup\", \"/bin/check\"]\n" +
|
||||||
|
"---\nbody\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Lifecycle == nil {
|
||||||
|
t.Fatal("Lifecycle is nil")
|
||||||
|
}
|
||||||
|
if len(spec.Lifecycle.PreStop) != 1 || spec.Lifecycle.PreStop[0] != "/bin/true" {
|
||||||
|
t.Errorf("PreStop = %v, want [/bin/true]", spec.Lifecycle.PreStop)
|
||||||
|
}
|
||||||
|
if len(spec.Lifecycle.PostStart) != 2 {
|
||||||
|
t.Fatalf("PostStart = %v, want 2 entries", spec.Lifecycle.PostStart)
|
||||||
|
}
|
||||||
|
if spec.Lifecycle.PostStart[0] != "/bin/warmup" || spec.Lifecycle.PostStart[1] != "/bin/check" {
|
||||||
|
t.Errorf("PostStart = %v, want [/bin/warmup /bin/check]", spec.Lifecycle.PostStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMarkdown_FullServiceSpec(t *testing.T) {
|
||||||
|
// A complete Service spec exercising every P02-parsed block together.
|
||||||
|
input := "---\n" +
|
||||||
|
"kind: Service\n" +
|
||||||
|
"name: web\n" +
|
||||||
|
"count: 3\n" +
|
||||||
|
"runtime:\n" +
|
||||||
|
" one_of: process\n" +
|
||||||
|
" command: /usr/bin/httpd\n" +
|
||||||
|
"ports:\n" +
|
||||||
|
" - name: http\n" +
|
||||||
|
" port: 8080\n" +
|
||||||
|
"restart:\n" +
|
||||||
|
" mode: service\n" +
|
||||||
|
" attempts: 5\n" +
|
||||||
|
" delay: 2s\n" +
|
||||||
|
"update:\n" +
|
||||||
|
" strategy: rolling\n" +
|
||||||
|
" max_parallel: 1\n" +
|
||||||
|
" auto_promote: false\n" +
|
||||||
|
"service:\n" +
|
||||||
|
" name: web\n" +
|
||||||
|
" port: 8080\n" +
|
||||||
|
"health:\n" +
|
||||||
|
" check_type: http\n" +
|
||||||
|
" interval: 5s\n" +
|
||||||
|
" timeout: 1s\n" +
|
||||||
|
" unhealthy_threshold: 2\n" +
|
||||||
|
"constraints:\n" +
|
||||||
|
" - node.role == \"web\"\n" +
|
||||||
|
"affinity:\n" +
|
||||||
|
" - target: zone == \"a\"\n" +
|
||||||
|
" weight: 80\n" +
|
||||||
|
"lifecycle:\n" +
|
||||||
|
" post_start:\n" +
|
||||||
|
" - /bin/ready.sh\n" +
|
||||||
|
"---\n# body\n"
|
||||||
|
spec, err := ParseMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMarkdown: %v", err)
|
||||||
|
}
|
||||||
|
if spec.Restart == nil || spec.Restart.Mode != "service" {
|
||||||
|
t.Errorf("Restart not parsed: %+v", spec.Restart)
|
||||||
|
}
|
||||||
|
if spec.Update == nil || spec.Update.Strategy != "rolling" {
|
||||||
|
t.Errorf("Update not parsed: %+v", spec.Update)
|
||||||
|
}
|
||||||
|
if spec.Service == nil || spec.Service.Port != 8080 {
|
||||||
|
t.Errorf("Service not parsed: %+v", spec.Service)
|
||||||
|
}
|
||||||
|
if spec.Health == nil || spec.Health.CheckType != "http" {
|
||||||
|
t.Errorf("Health not parsed: %+v", spec.Health)
|
||||||
|
}
|
||||||
|
if len(spec.Constraints) != 1 {
|
||||||
|
t.Errorf("Constraints = %v", spec.Constraints)
|
||||||
|
}
|
||||||
|
if len(spec.Affinity) != 1 || spec.Affinity[0].Weight != 80 {
|
||||||
|
t.Errorf("Affinity = %v", spec.Affinity)
|
||||||
|
}
|
||||||
|
if spec.Lifecycle == nil || len(spec.Lifecycle.PostStart) != 1 {
|
||||||
|
t.Errorf("Lifecycle not parsed: %+v", spec.Lifecycle)
|
||||||
|
}
|
||||||
|
if spec.Body != "# body\n" {
|
||||||
|
t.Errorf("Body = %q, want %q (R-015)", spec.Body, "# body\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-27
@@ -2,7 +2,6 @@ package jobspec
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/hcl/v2"
|
"github.com/hashicorp/hcl/v2"
|
||||||
@@ -10,16 +9,24 @@ import (
|
|||||||
"github.com/hashicorp/hcl/v2/hclsimple"
|
"github.com/hashicorp/hcl/v2/hclsimple"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Spec is the legacy HCL-only jobspec shape. It is retained for the
|
||||||
|
// v0.9→v0.10 migration window (REQ-090) and is populated by ParseHCLLegacy.
|
||||||
|
//
|
||||||
|
// Deprecated: HCL is legacy per R-013; new code should consume the
|
||||||
|
// unified *WorkloadSpec returned by ParseFile/Dispatch (see
|
||||||
|
// dispatch.go and markdown.go).
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
Job JobSpec `hcl:"job,block"`
|
Job JobSpec `hcl:"job,block"`
|
||||||
Tasks []TaskSpec `hcl:"task,block"`
|
Tasks []TaskSpec `hcl:"task,block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobSpec is the legacy HCL job block.
|
||||||
type JobSpec struct {
|
type JobSpec struct {
|
||||||
Name string `hcl:"name,label"`
|
Name string `hcl:"name,label"`
|
||||||
Type string `hcl:"type,optional"`
|
Type string `hcl:"type,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSpec is the legacy HCL task block.
|
||||||
type TaskSpec struct {
|
type TaskSpec struct {
|
||||||
Name string `hcl:"name,label"`
|
Name string `hcl:"name,label"`
|
||||||
Command string `hcl:"command"`
|
Command string `hcl:"command"`
|
||||||
@@ -27,34 +34,14 @@ type TaskSpec struct {
|
|||||||
Env []string `hcl:"env,optional"`
|
Env []string `hcl:"env,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseFile(path string) (*Spec, error) {
|
// hclDecode wraps hclsimple.Decode for testability.
|
||||||
data, err := os.ReadFile(path)
|
func hclDecode(filename string, data []byte, spec *Spec) error {
|
||||||
if err != nil {
|
return hclsimple.Decode(filename, data, nil, spec)
|
||||||
return nil, fmt.Errorf("read spec file: %w", err)
|
|
||||||
}
|
|
||||||
return Parse(data, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Parse(data []byte, filename string) (*Spec, error) {
|
|
||||||
var spec Spec
|
|
||||||
err := hclsimple.Decode(filename, data, nil, &spec)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decode hcl: %w", err)
|
|
||||||
}
|
|
||||||
if spec.Job.Name == "" {
|
|
||||||
return nil, fmt.Errorf("spec missing job name")
|
|
||||||
}
|
|
||||||
if len(spec.Tasks) == 0 {
|
|
||||||
return nil, fmt.Errorf("spec must have at least one task")
|
|
||||||
}
|
|
||||||
for i, t := range spec.Tasks {
|
|
||||||
if t.Command == "" {
|
|
||||||
return nil, fmt.Errorf("task[%d] (%s) missing command", i, t.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &spec, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate is the legacy HCL Spec validator retained for the migration
|
||||||
|
// window (REQ-090). New code should use validateWorkload on a
|
||||||
|
// *WorkloadSpec.
|
||||||
func (s *Spec) Validate() error {
|
func (s *Spec) Validate() error {
|
||||||
if strings.TrimSpace(s.Job.Name) == "" {
|
if strings.TrimSpace(s.Job.Name) == "" {
|
||||||
return fmt.Errorf("job name is required")
|
return fmt.Errorf("job name is required")
|
||||||
@@ -65,5 +52,17 @@ func (s *Spec) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse is the original HCL-only entry point retained for backward
|
||||||
|
// compatibility with direct HCL callers during the v0.9→v0.10 migration
|
||||||
|
// window (REQ-090). New code should call the dispatcher ParseFile (which
|
||||||
|
// returns *WorkloadSpec) or ParseHCL (which adapts HCL into
|
||||||
|
// *WorkloadSpec).
|
||||||
|
//
|
||||||
|
// Deprecated: use ParseFile (dispatcher) or ParseHCL (adapter). HCL is
|
||||||
|
// legacy per R-013.
|
||||||
|
func Parse(data []byte, filename string) (*Spec, error) {
|
||||||
|
return ParseHCLLegacy(data, filename)
|
||||||
|
}
|
||||||
|
|
||||||
var _ = hcl.Diagnostics{}
|
var _ = hcl.Diagnostics{}
|
||||||
var _ = gohcl.DecodeBody
|
var _ = gohcl.DecodeBody
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ func TestParse_GoldenFiles(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
path := filepath.Join("testdata", tc.file)
|
path := filepath.Join("testdata", tc.file)
|
||||||
spec, err := ParseFile(path)
|
spec, err := ParseHCLFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseFile(%s): %v", tc.file, err)
|
t.Fatalf("ParseHCLFile(%s): %v", tc.file, err)
|
||||||
}
|
}
|
||||||
if spec.Job.Name != tc.wantJob {
|
if spec.Job.Name != tc.wantJob {
|
||||||
t.Errorf("job name = %q, want %q", spec.Job.Name, tc.wantJob)
|
t.Errorf("job name = %q, want %q", spec.Job.Name, tc.wantJob)
|
||||||
@@ -254,9 +254,9 @@ func TestSpec_Validate(t *testing.T) {
|
|||||||
|
|
||||||
func TestSpec_Validate_RoundTripFromParse(t *testing.T) {
|
func TestSpec_Validate_RoundTripFromParse(t *testing.T) {
|
||||||
path := filepath.Join("testdata", "valid_single_task.hcl")
|
path := filepath.Join("testdata", "valid_single_task.hcl")
|
||||||
spec, err := ParseFile(path)
|
spec, err := ParseHCLFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseFile: %v", err)
|
t.Fatalf("ParseHCLFile: %v", err)
|
||||||
}
|
}
|
||||||
if err := spec.Validate(); err != nil {
|
if err := spec.Validate(); err != nil {
|
||||||
t.Errorf("Validate on parsed spec: %v", err)
|
t.Errorf("Validate on parsed spec: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package ns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseNSMd reads an ns.md file, extracts the YAML frontmatter, and
|
||||||
|
// parses it into a *NSConfig. The body after the closing `---` is
|
||||||
|
// discarded (namespace declarations do not require body preservation
|
||||||
|
// like jobspecs do under R-015; we keep the parser minimal and
|
||||||
|
// consistent with internal/config/markdown.go).
|
||||||
|
//
|
||||||
|
// Frontmatter keys (R-014):
|
||||||
|
//
|
||||||
|
// kind: Namespace (required; must be "Namespace")
|
||||||
|
// name: <ns-name> (required)
|
||||||
|
// parents: ["a", "b"] (optional; default empty)
|
||||||
|
// inherits_env: true (optional; default true)
|
||||||
|
// inherits_secrets: true (optional; default true)
|
||||||
|
// quota: {...} (optional; parsed but not surfaced here)
|
||||||
|
// acl: {...} (optional; parsed but not surfaced here)
|
||||||
|
//
|
||||||
|
// The parser is a minimal hand-rolled YAML-ish key:value reader (no
|
||||||
|
// new dependencies; gopkg.in/yaml.v3 is intentionally NOT added). It
|
||||||
|
// supports flat scalar keys and the inline flow-array form
|
||||||
|
// `["a", "b"]` for `parents`. Nested mappings (quota, acl) are
|
||||||
|
// recognized as keys but their contents are currently ignored — they
|
||||||
|
// are reserved for later phases.
|
||||||
|
func ParseNSMd(path string) (*NSConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
block, ok := extractFrontmatter(content)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("parse %s: missing frontmatter", path)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(block) == "" {
|
||||||
|
return nil, fmt.Errorf("parse %s: missing frontmatter", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := parseNSFrontmatter(block, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return nil, fmt.Errorf("parse %s: missing name", path)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFrontmatter returns the YAML block between the first pair of
|
||||||
|
// `---` delimiters and whether a frontmatter block was present.
|
||||||
|
func extractFrontmatter(content string) (string, bool) {
|
||||||
|
trimmed := strings.TrimLeft(content, "\r\n\t ")
|
||||||
|
if !strings.HasPrefix(trimmed, "---") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := trimmed[3:]
|
||||||
|
rest = strings.TrimLeft(rest, "\r\n")
|
||||||
|
idx := strings.Index(rest, "\n---")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest[:idx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNSFrontmatter parses a minimal YAML-ish frontmatter block into
|
||||||
|
// a *NSConfig. See ParseNSMd for the supported keys.
|
||||||
|
func parseNSFrontmatter(block, path string) (*NSConfig, error) {
|
||||||
|
cfg := &NSConfig{
|
||||||
|
InheritsEnv: true,
|
||||||
|
InheritsSecrets: true,
|
||||||
|
}
|
||||||
|
kind := ""
|
||||||
|
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
for lineNo, raw := range lines {
|
||||||
|
line := stripNSComment(raw)
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if countIndent(line) > 0 {
|
||||||
|
// Indented line under a nested mapping header (quota, acl).
|
||||||
|
// Recognized but ignored at this phase.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val, ok := splitKV(strings.TrimSpace(line))
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("parse %s: line %d: malformed key:value", path, lineNo+1)
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "kind":
|
||||||
|
kind = strings.TrimSpace(unquote(val))
|
||||||
|
case "name":
|
||||||
|
cfg.Name = strings.TrimSpace(unquote(val))
|
||||||
|
case "parents":
|
||||||
|
parents, err := parseStringArray(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: line %d: parents: %w", path, lineNo+1, err)
|
||||||
|
}
|
||||||
|
cfg.Parents = parents
|
||||||
|
case "inherits_env":
|
||||||
|
cfg.InheritsEnv = parseBool(val)
|
||||||
|
case "inherits_secrets":
|
||||||
|
cfg.InheritsSecrets = parseBool(val)
|
||||||
|
case "quota", "acl":
|
||||||
|
// Reserved nested-mapping keys; recognized, contents ignored.
|
||||||
|
default:
|
||||||
|
// Unknown keys are ignored (forward-compat with future
|
||||||
|
// frontmatter additions).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == "" {
|
||||||
|
return nil, fmt.Errorf("parse %s: missing kind", path)
|
||||||
|
}
|
||||||
|
if kind != "Namespace" {
|
||||||
|
return nil, fmt.Errorf("parse %s: kind %q is not %q", path, kind, "Namespace")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStringArray parses an inline YAML flow-array of scalars, e.g.
|
||||||
|
// `["a", "b"]` or `['a', 'b']` or `[a, b]`. Returns an error if the
|
||||||
|
// value is not a flow-array. Empty array `[]` returns nil.
|
||||||
|
func parseStringArray(val string) ([]string, error) {
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
if val == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(val, "[") || !strings.HasSuffix(val, "]") {
|
||||||
|
return nil, fmt.Errorf("expected [..] array, got %q", val)
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(val[1 : len(val)-1])
|
||||||
|
if inner == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := splitFlowItems(inner)
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, unquote(p))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFlowItems splits a comma-separated flow-array body, respecting
|
||||||
|
// single and double quotes.
|
||||||
|
func splitFlowItems(s string) []string {
|
||||||
|
var out []string
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
case ',':
|
||||||
|
if !inSingle && !inDouble {
|
||||||
|
out = append(out, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s[start:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBool parses a YAML-ish bool (true/false/yes/no), defaulting to
|
||||||
|
// true for empty (matches the inherits_* defaults).
|
||||||
|
func parseBool(val string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(unquote(val))) {
|
||||||
|
case "false", "no", "off", "0":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseNSMdDir walks `<root>/*/ns.md`, parses each, and returns the
|
||||||
|
// config map keyed by namespace name. The `cluster` directory is
|
||||||
|
// skipped (it is not a namespace). The `_defaults` namespace MUST
|
||||||
|
// exist; if missing, an error is returned.
|
||||||
|
func ParseNSMdDir(root string) (map[string]*NSConfig, error) {
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read namespace root %s: %w", root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := make(map[string]*NSConfig)
|
||||||
|
var found []string
|
||||||
|
for _, ent := range entries {
|
||||||
|
if !ent.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ent.Name() == "cluster" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nsMd := filepath.Join(root, ent.Name(), "ns.md")
|
||||||
|
info, err := os.Stat(nsMd)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg, err := ParseNSMd(nsMd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The directory name and the frontmatter `name` should match;
|
||||||
|
// we key by the frontmatter name (canonical) but also accept
|
||||||
|
// the directory name if frontmatter name is missing (the
|
||||||
|
// parser already errors on missing name, so this is defensive).
|
||||||
|
key := cfg.Name
|
||||||
|
if key == "" {
|
||||||
|
key = ent.Name()
|
||||||
|
}
|
||||||
|
if _, dup := configs[key]; dup {
|
||||||
|
return nil, fmt.Errorf("duplicate namespace %q (from %s)", key, nsMd)
|
||||||
|
}
|
||||||
|
configs[key] = cfg
|
||||||
|
found = append(found, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := configs[defaultsName]; !ok {
|
||||||
|
sort.Strings(found)
|
||||||
|
names := strings.Join(found, ", ")
|
||||||
|
if names == "" {
|
||||||
|
names = "(none)"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("namespace root %s: implicit root %q not found (found: %s)", root, defaultsName, names)
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countIndent(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r == ' ' || r == '\t' {
|
||||||
|
n++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKV(s string) (key, val string, ok bool) {
|
||||||
|
idx := strings.Index(s, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(s[:idx])
|
||||||
|
val = strings.TrimSpace(s[idx+1:])
|
||||||
|
if key == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return key, val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripNSComment(s string) string {
|
||||||
|
inSingle := false
|
||||||
|
inDouble := false
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
if !inDouble {
|
||||||
|
inSingle = !inSingle
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
if !inSingle {
|
||||||
|
inDouble = !inDouble
|
||||||
|
}
|
||||||
|
case '#':
|
||||||
|
if !inSingle && !inDouble {
|
||||||
|
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquote(s string) string {
|
||||||
|
if len(s) >= 2 {
|
||||||
|
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package ns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeNSMd(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validNSMd = `---
|
||||||
|
kind: Namespace
|
||||||
|
name: prod
|
||||||
|
parents: ["_defaults"]
|
||||||
|
inherits_env: true
|
||||||
|
inherits_secrets: true
|
||||||
|
quota:
|
||||||
|
cpu: 4
|
||||||
|
acl:
|
||||||
|
admin: ops
|
||||||
|
---
|
||||||
|
# Prod namespace
|
||||||
|
|
||||||
|
This body is ignored.
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseNSMdValid(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, validNSMd)
|
||||||
|
cfg, err := ParseNSMd(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMd: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Name != "prod" {
|
||||||
|
t.Errorf("name = %q, want prod", cfg.Name)
|
||||||
|
}
|
||||||
|
if !eqSlice(cfg.Parents, []string{"_defaults"}) {
|
||||||
|
t.Errorf("parents = %v, want [_defaults]", cfg.Parents)
|
||||||
|
}
|
||||||
|
if !cfg.InheritsEnv || !cfg.InheritsSecrets {
|
||||||
|
t.Errorf("inherits_env=%v inherits_secrets=%v, want both true", cfg.InheritsEnv, cfg.InheritsSecrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdMissingFrontmatter(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "# just a body, no frontmatter\n")
|
||||||
|
_, err := ParseNSMd(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing frontmatter error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing frontmatter") {
|
||||||
|
t.Errorf("error = %q, want contains 'missing frontmatter'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdEmptyFrontmatter(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\n---\nbody\n")
|
||||||
|
_, err := ParseNSMd(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty frontmatter, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdWrongKind(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nkind: Job\nname: x\n---\n")
|
||||||
|
_, err := ParseNSMd(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected wrong-kind error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not \"Namespace\"") {
|
||||||
|
t.Errorf("error = %q, want contains 'is not \"Namespace\"'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdMissingKind(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nname: x\n---\n")
|
||||||
|
_, err := ParseNSMd(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing kind error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing kind") {
|
||||||
|
t.Errorf("error = %q, want contains 'missing kind'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdMissingName(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nkind: Namespace\n---\n")
|
||||||
|
_, err := ParseNSMd(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing name error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing name") {
|
||||||
|
t.Errorf("error = %q, want contains 'missing name'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdParentsUnquoted(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: [a, b]\n---\n")
|
||||||
|
cfg, err := ParseNSMd(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMd: %v", err)
|
||||||
|
}
|
||||||
|
if !eqSlice(cfg.Parents, []string{"a", "b"}) {
|
||||||
|
t.Errorf("parents = %v, want [a b]", cfg.Parents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdParentsEmpty(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nkind: Namespace\nname: x\nparents: []\n---\n")
|
||||||
|
cfg, err := ParseNSMd(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMd: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Parents) != 0 {
|
||||||
|
t.Errorf("parents = %v, want empty", cfg.Parents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdInheritsFalse(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "ns.md")
|
||||||
|
writeNSMd(t, path, "---\nkind: Namespace\nname: x\ninherits_env: false\ninherits_secrets: no\n---\n")
|
||||||
|
cfg, err := ParseNSMd(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMd: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.InheritsEnv {
|
||||||
|
t.Errorf("inherits_env should be false")
|
||||||
|
}
|
||||||
|
if cfg.InheritsSecrets {
|
||||||
|
t.Errorf("inherits_secrets should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdMissingFile(t *testing.T) {
|
||||||
|
_, err := ParseNSMd(filepath.Join(t.TempDir(), "nope.md"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdDirHappy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
|
||||||
|
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
|
||||||
|
|
||||||
|
cfgs, err := ParseNSMdDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMdDir: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := cfgs["_defaults"]; !ok {
|
||||||
|
t.Errorf("missing _defaults in %v", cfgs)
|
||||||
|
}
|
||||||
|
if _, ok := cfgs["prod"]; !ok {
|
||||||
|
t.Errorf("missing prod in %v", cfgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdDirMissingDefaults(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeNSMd(t, filepath.Join(root, "prod", "ns.md"), validNSMd)
|
||||||
|
_, err := ParseNSMdDir(root)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing _defaults error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "_defaults") {
|
||||||
|
t.Errorf("error = %q, want contains _defaults", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdDirSkipsCluster(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeNSMd(t, filepath.Join(root, "_defaults", "ns.md"), "---\nkind: Namespace\nname: _defaults\n---\n")
|
||||||
|
// cluster/ contains a ns.md-shaped file but must be skipped.
|
||||||
|
writeNSMd(t, filepath.Join(root, "cluster", "ns.md"), "---\nkind: Namespace\nname: cluster\n---\n")
|
||||||
|
|
||||||
|
cfgs, err := ParseNSMdDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNSMdDir: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := cfgs["cluster"]; ok {
|
||||||
|
t.Errorf("cluster should be skipped, present in %v", cfgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdDirNoFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
_, err := ParseNSMdDir(root)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing _defaults error on empty dir, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNSMdDirNotADir(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
// Create a file with the same name as the expected root dir.
|
||||||
|
root := filepath.Join(tmp, "notadir")
|
||||||
|
writeNSMd(t, root, "x")
|
||||||
|
_, err := ParseNSMdDir(root)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-dir root")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Package ns implements the namespace inheritance resolver (REQ-082)
|
||||||
|
// and the ns.md frontmatter parser used by `orca ns` CLI subcommands.
|
||||||
|
//
|
||||||
|
// The resolver is a PURE function (no I/O): it takes a map of parsed
|
||||||
|
// namespace configs keyed by name and returns a map of resolved
|
||||||
|
// namespaces with merged env and unioned constraints. The inheritance
|
||||||
|
// model is:
|
||||||
|
//
|
||||||
|
// - Each namespace declares zero or more parents in `ns.md`
|
||||||
|
// frontmatter (`parents: ["ns1", "ns2"]`).
|
||||||
|
// - The implicit root namespace `_defaults` (R-002 D-159) always
|
||||||
|
// exists and has no parents; it is ALWAYS appended as the last
|
||||||
|
// element of the chain (D-185).
|
||||||
|
// - Opting out of `_defaults` is impossible (D-187): even with
|
||||||
|
// `parents: []`, `_defaults` still appears at the end of the chain.
|
||||||
|
// - Merge semantics: child overrides parent for scalars (env keys);
|
||||||
|
// arrays union (child constraints add to parent constraints, with
|
||||||
|
// duplicates removed, order: most-specific first).
|
||||||
|
// - The chain order is most-specific first, `_defaults` last.
|
||||||
|
// - `_defaults` may be listed explicitly in `parents`; the explicit
|
||||||
|
// listing is de-duped silently (still appears once, at the end).
|
||||||
|
// - Misordering (`parents: ["_defaults", "x"]`) is rejected: an
|
||||||
|
// explicit `_defaults` entry must be the only entry (or omitted).
|
||||||
|
// - Cycle detection uses DFS with a visited set; a cycle returns an
|
||||||
|
// error with the cycle path.
|
||||||
|
// - Missing parents return "parent X not found".
|
||||||
|
package ns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultsName = "_defaults"
|
||||||
|
|
||||||
|
// NSConfig is a parsed namespace declaration from ns.md frontmatter.
|
||||||
|
// The resolver consumes this; the parser populates it.
|
||||||
|
type NSConfig struct {
|
||||||
|
Name string
|
||||||
|
Parents []string
|
||||||
|
Env map[string]string
|
||||||
|
Constraints []string
|
||||||
|
InheritsEnv bool
|
||||||
|
InheritsSecrets bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedNS is the output of the resolver: the namespace with its
|
||||||
|
// fully-merged env and unioned constraints, plus the ordered
|
||||||
|
// inheritance chain (most-specific first, `_defaults` last).
|
||||||
|
type ResolvedNS struct {
|
||||||
|
Name string
|
||||||
|
Chain []string
|
||||||
|
Env map[string]string
|
||||||
|
Constraints []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve walks the parent chain for each namespace, merges env (child
|
||||||
|
// wins scalars), unions constraints (child adds to parent, de-duped),
|
||||||
|
// and detects cycles. It is PURE (no I/O). The empty-configs case
|
||||||
|
// returns an empty map and no error.
|
||||||
|
//
|
||||||
|
// The `_defaults` namespace is ALWAYS the last element of every chain
|
||||||
|
// (D-185); opting out is impossible (D-187). An explicit `_defaults`
|
||||||
|
// entry in `parents` is de-duped silently. Misordering (e.g.
|
||||||
|
// `parents: ["_defaults", "x"]`) is rejected.
|
||||||
|
func Resolve(configs map[string]*NSConfig) (map[string]*ResolvedNS, error) {
|
||||||
|
if len(configs) == 0 {
|
||||||
|
return map[string]*ResolvedNS{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each config's parents reference exists and the
|
||||||
|
// _defaults entry (if explicit) is the only entry.
|
||||||
|
for name, cfg := range configs {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("namespace %q has nil config", name)
|
||||||
|
}
|
||||||
|
for _, p := range cfg.Parents {
|
||||||
|
if p == defaultsName {
|
||||||
|
// Explicit _defaults must be the only parent.
|
||||||
|
if len(cfg.Parents) != 1 {
|
||||||
|
return nil, fmt.Errorf("namespace %q: %s must be the only parent if listed explicitly (misordering rejected)", name, defaultsName)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := configs[p]; !ok {
|
||||||
|
return nil, fmt.Errorf("namespace %q: parent %q not found", name, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `_defaults` must be present in the configs map (the parser
|
||||||
|
// enforces this for ParseNSMdDir; Resolve trusts its input but
|
||||||
|
// still requires _defaults to exist for chain assembly).
|
||||||
|
if _, ok := configs[defaultsName]; !ok {
|
||||||
|
return nil, fmt.Errorf("namespace %q not found (implicit root must be present)", defaultsName)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make(map[string]*ResolvedNS, len(configs))
|
||||||
|
// Resolve in deterministic order for stable error reporting.
|
||||||
|
names := make([]string, 0, len(configs))
|
||||||
|
for n := range configs {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
r, err := resolveOne(configs, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved[name] = r
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOne resolves a single namespace. The chain is built by walking
|
||||||
|
// parents depth-first in POST-order (least-specific first), then
|
||||||
|
// reversing so the returned chain is most-specific first with
|
||||||
|
// `_defaults` last (D-185). Cycle detection uses a visiting set.
|
||||||
|
func resolveOne(configs map[string]*NSConfig, name string) (*ResolvedNS, error) {
|
||||||
|
post, err := buildChain(configs, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// post is least-specific first; reverse to most-specific first.
|
||||||
|
reverseStrings(post)
|
||||||
|
chain := post
|
||||||
|
|
||||||
|
// Env: child (most-specific) wins. Walk least-specific to
|
||||||
|
// most-specific (end -> beginning) so later writes override.
|
||||||
|
env := make(map[string]string)
|
||||||
|
for i := len(chain) - 1; i >= 0; i-- {
|
||||||
|
c := configs[chain[i]]
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range c.Env {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraints: union, child (most-specific) first. Walk the chain
|
||||||
|
// front-to-back (most-specific first) and append unseen items.
|
||||||
|
constraintsSeen := make(map[string]bool)
|
||||||
|
var constraints []string
|
||||||
|
for _, ns := range chain {
|
||||||
|
c := configs[ns]
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, con := range c.Constraints {
|
||||||
|
if !constraintsSeen[con] {
|
||||||
|
constraintsSeen[con] = true
|
||||||
|
constraints = append(constraints, con)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ResolvedNS{
|
||||||
|
Name: name,
|
||||||
|
Chain: chain,
|
||||||
|
Env: env,
|
||||||
|
Constraints: constraints,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChain walks parents depth-first and returns the chain in
|
||||||
|
// POST-order (least-specific first, `_defaults` first). The caller
|
||||||
|
// reverses to get most-specific first. Cycle detection uses the
|
||||||
|
// visiting set: a node currently being walked indicates a back-edge.
|
||||||
|
func buildChain(configs map[string]*NSConfig, name string) ([]string, error) {
|
||||||
|
var post []string
|
||||||
|
seen := make(map[string]bool) // final chain membership (de-dup)
|
||||||
|
visiting := make(map[string]bool)
|
||||||
|
if err := dfsChain(configs, name, &post, seen, visiting); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// `_defaults` is the implicit root: it must be the FIRST element
|
||||||
|
// in post-order (so it ends up LAST after reversal). If it was not
|
||||||
|
// reached via parents (no explicit listing and no chain leads to
|
||||||
|
// it), prepend it.
|
||||||
|
if !seen[defaultsName] {
|
||||||
|
post = append([]string{defaultsName}, post...)
|
||||||
|
seen[defaultsName] = true
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dfsChain appends each node AFTER its parents (post-order), producing
|
||||||
|
// least-specific first. Cycle detection uses the visiting set.
|
||||||
|
func dfsChain(configs map[string]*NSConfig, name string, post *[]string, seen, visiting map[string]bool) error {
|
||||||
|
if visiting[name] {
|
||||||
|
return fmt.Errorf("cycle detected: %s", cyclePath(visiting, configs, name))
|
||||||
|
}
|
||||||
|
if seen[name] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visiting[name] = true
|
||||||
|
cfg := configs[name]
|
||||||
|
if cfg != nil {
|
||||||
|
for _, p := range cfg.Parents {
|
||||||
|
if err := dfsChain(configs, p, post, seen, visiting); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(visiting, name)
|
||||||
|
seen[name] = true
|
||||||
|
*post = append(*post, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cyclePath reconstructs a readable cycle path from the visiting set.
|
||||||
|
// Since visiting is a set (not ordered), we reconstruct by re-walking
|
||||||
|
// parents from the offending node until we revisit it.
|
||||||
|
func cyclePath(visiting map[string]bool, configs map[string]*NSConfig, start string) string {
|
||||||
|
// Walk parents from start, collecting names until we hit start
|
||||||
|
// again or run out.
|
||||||
|
var path []string
|
||||||
|
cur := start
|
||||||
|
for i := 0; i < len(visiting)+1; i++ {
|
||||||
|
path = append(path, cur)
|
||||||
|
cfg := configs[cur]
|
||||||
|
if cfg == nil || len(cfg.Parents) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next := cfg.Parents[0]
|
||||||
|
if next == start {
|
||||||
|
path = append(path, next)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
return joinArrows(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinArrows(parts []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += " -> "
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseStrings(s []string) {
|
||||||
|
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
s[i], s[j] = s[j], s[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package ns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveEmptyConfigs(t *testing.T) {
|
||||||
|
out, err := Resolve(map[string]*NSConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve empty: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Fatalf("Resolve empty: want empty map, got %d entries", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSingleNoParents(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Env: map[string]string{"A": "1"}},
|
||||||
|
"x": {Name: "x", Env: map[string]string{"B": "2"}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
r := out["x"]
|
||||||
|
if r == nil {
|
||||||
|
t.Fatal("missing resolved x")
|
||||||
|
}
|
||||||
|
if !eqSlice(r.Chain, []string{"x", defaultsName}) {
|
||||||
|
t.Errorf("chain = %v, want [x _defaults]", r.Chain)
|
||||||
|
}
|
||||||
|
if r.Env["A"] != "1" || r.Env["B"] != "2" {
|
||||||
|
t.Errorf("env = %v, want A=1 B=2", r.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChildOverridesParentScalar(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Env: map[string]string{"K": "parent"}},
|
||||||
|
"child": {Name: "child", Parents: []string{defaultsName}, Env: map[string]string{"K": "child"}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got := out["child"].Env["K"]; got != "child" {
|
||||||
|
t.Errorf("child K = %q, want %q (child overrides parent)", got, "child")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveArraysUnion(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Constraints: []string{"a", "b"}},
|
||||||
|
"x": {Name: "x", Parents: []string{defaultsName}, Constraints: []string{"c", "a"}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
c := out["x"].Constraints
|
||||||
|
// Union de-duped; most-specific (x) first.
|
||||||
|
if !eqSlice(c, []string{"c", "a", "b"}) {
|
||||||
|
t.Errorf("constraints = %v, want [c a b]", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDefaultsImplicitLast(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName},
|
||||||
|
"mid": {Name: "mid", Parents: []string{defaultsName}},
|
||||||
|
"top": {Name: "top", Parents: []string{"mid"}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
if !eqSlice(out["top"].Chain, []string{"top", "mid", defaultsName}) {
|
||||||
|
t.Errorf("top chain = %v, want [top mid _defaults]", out["top"].Chain)
|
||||||
|
}
|
||||||
|
if !eqSlice(out["mid"].Chain, []string{"mid", defaultsName}) {
|
||||||
|
t.Errorf("mid chain = %v, want [mid _defaults]", out["mid"].Chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDefaultsDedupExplicit(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Env: map[string]string{"D": "1"}},
|
||||||
|
"x": {Name: "x", Parents: []string{defaultsName}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
// _defaults appears exactly once.
|
||||||
|
count := 0
|
||||||
|
for _, c := range out["x"].Chain {
|
||||||
|
if c == defaultsName {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("_defaults appears %d times in chain %v, want 1", count, out["x"].Chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMisorderingRejected(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName},
|
||||||
|
"x": {Name: "x"},
|
||||||
|
"y": {Name: "y", Parents: []string{defaultsName, "x"}},
|
||||||
|
}
|
||||||
|
_, err := Resolve(cfgs)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected misordering error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "must be the only parent") {
|
||||||
|
t.Errorf("error = %q, want misordering message", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOptOutImpossible(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Env: map[string]string{"ROOT": "1"}},
|
||||||
|
"x": {Name: "x", Parents: nil},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
r := out["x"]
|
||||||
|
last := r.Chain[len(r.Chain)-1]
|
||||||
|
if last != defaultsName {
|
||||||
|
t.Errorf("last chain element = %q, want %q (opt-out impossible)", last, defaultsName)
|
||||||
|
}
|
||||||
|
if r.Env["ROOT"] != "1" {
|
||||||
|
t.Errorf("env should inherit from _defaults: ROOT=%q", r.Env["ROOT"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCycleDetection(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName},
|
||||||
|
"a": {Name: "a", Parents: []string{"b"}},
|
||||||
|
"b": {Name: "b", Parents: []string{"a"}},
|
||||||
|
}
|
||||||
|
_, err := Resolve(cfgs)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected cycle error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cycle") {
|
||||||
|
t.Errorf("error = %q, want cycle message", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMissingParent(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName},
|
||||||
|
"a": {Name: "a", Parents: []string{"ghost"}},
|
||||||
|
}
|
||||||
|
_, err := Resolve(cfgs)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing-parent error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Errorf("error = %q, want contains 'ghost' and 'not found'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMissingDefaults(t *testing.T) {
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
"x": {Name: "x"},
|
||||||
|
}
|
||||||
|
_, err := Resolve(cfgs)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing _defaults error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), defaultsName) {
|
||||||
|
t.Errorf("error = %q, want contains %q", err.Error(), defaultsName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveChainOrderWithDiamond(t *testing.T) {
|
||||||
|
// Diamond: top -> {left, right} -> base; base -> _defaults.
|
||||||
|
cfgs := map[string]*NSConfig{
|
||||||
|
defaultsName: {Name: defaultsName, Env: map[string]string{"R": "r"}},
|
||||||
|
"base": {Name: "base", Parents: []string{defaultsName}, Env: map[string]string{"B": "b"}},
|
||||||
|
"left": {Name: "left", Parents: []string{"base"}, Env: map[string]string{"L": "l"}},
|
||||||
|
"right": {Name: "right", Parents: []string{"base"}, Env: map[string]string{"L": "r"}},
|
||||||
|
"top": {Name: "top", Parents: []string{"left", "right"}, Env: map[string]string{"T": "t"}},
|
||||||
|
}
|
||||||
|
out, err := Resolve(cfgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
r := out["top"]
|
||||||
|
if r == nil {
|
||||||
|
t.Fatal("missing top")
|
||||||
|
}
|
||||||
|
// top first, _defaults last.
|
||||||
|
if r.Chain[0] != "top" || r.Chain[len(r.Chain)-1] != defaultsName {
|
||||||
|
t.Errorf("chain = %v, want top first and _defaults last", r.Chain)
|
||||||
|
}
|
||||||
|
// base appears exactly once (diamond de-duped).
|
||||||
|
count := 0
|
||||||
|
for _, c := range r.Chain {
|
||||||
|
if c == "base" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("base appears %d times in %v, want 1", count, r.Chain)
|
||||||
|
}
|
||||||
|
// top inherits R from _defaults.
|
||||||
|
if r.Env["R"] != "r" {
|
||||||
|
t.Errorf("top should inherit R=r, got %q", r.Env["R"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eqSlice(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -25,3 +25,11 @@ func Flock(path string) (release func(), err error) {
|
|||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tryFlockEx(fd int) error {
|
||||||
|
return syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseFlock(fd int) error {
|
||||||
|
return syscall.Flock(fd, syscall.LOCK_UN)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFlock_acquireAndRelease(t *testing.T) {
|
func TestFlock_acquireAndRelease(t *testing.T) {
|
||||||
@@ -45,17 +46,37 @@ func TestFlock_concurrentBlocks(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Flock: %v", err)
|
t.Fatalf("first Flock: %v", err)
|
||||||
}
|
}
|
||||||
defer r1()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
// Give the blocking goroutine a chance to start and block.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify the second lock is blocked by checking it hasn't acquired after a short window.
|
||||||
|
// Use a non-blocking attempt: open the file and try LOCK_EX|LOCK_NB.
|
||||||
|
blocked := make(chan bool, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, err := Flock(path)
|
f, err := os.OpenFile(path, os.O_RDWR, 0o600)
|
||||||
done <- err
|
if err != nil {
|
||||||
|
blocked <- false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
// LOCK_NB = non-blocking; returns EWOULDBLOCK if locked.
|
||||||
|
if err := tryFlockEx(int(f.Fd())); err != nil {
|
||||||
|
blocked <- true // got EWOULDBLOCK = the lock is held by r1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
releaseFlock(int(f.Fd()))
|
||||||
|
blocked <- false // acquired = r1 didn't hold the lock (bug)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case b := <-blocked:
|
||||||
t.Fatal("second Flock should block while first holds the lock")
|
if !b {
|
||||||
default:
|
t.Fatal("second lock acquired while first holds it — lock not working")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("non-blocking try-lock timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r1()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Package schema provides kind-specific validators for the unified
|
||||||
|
// *jobspec.WorkloadSpec introduced in P0b (REQ-064). Each workload kind
|
||||||
|
// (Job, Service, DaemonSet per R-012) has different required fields;
|
||||||
|
// this package exposes a Validator interface and a ValidatorFor
|
||||||
|
// dispatcher so the emitter layer (REQ-074) and the lint engine
|
||||||
|
// (REQ-084) can reject invalid specs before rendering.
|
||||||
|
//
|
||||||
|
// The validators operate purely on the *WorkloadSpec shape; they do no
|
||||||
|
// I/O. Required-field violations return a structured error listing
|
||||||
|
// every problem found (missing required fields, invalid combinations).
|
||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validator validates a *jobspec.WorkloadSpec against a kind-specific
|
||||||
|
// schema. Implementations are pure (no I/O) and return a clear error
|
||||||
|
// listing every violation found.
|
||||||
|
type Validator interface {
|
||||||
|
Validate(spec *jobspec.WorkloadSpec) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobValidator validates the Job workload kind (R-012).
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - no service block required (Job has no Traefik route by default D-175)
|
||||||
|
// - restart optional (defaults to never/on-failure when omitted)
|
||||||
|
// - schedule optional (cron string)
|
||||||
|
// - timeout optional
|
||||||
|
// - ports optional
|
||||||
|
// - count must be 1 (or unset → 1); count > 1 is an error for Job
|
||||||
|
// (use a Service for replicas)
|
||||||
|
// - no Traefik route (a ServiceBlock is rejected)
|
||||||
|
type JobValidator struct{}
|
||||||
|
|
||||||
|
// ServiceValidator validates the Service workload kind (R-012).
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - ports required (at least one)
|
||||||
|
// - count ≥ 1
|
||||||
|
// - restart required (mode must be service)
|
||||||
|
// - update required (strategy must be rolling/canary/blue-green)
|
||||||
|
// - runtime required
|
||||||
|
// - health block required (Traefik routing depends on health checks)
|
||||||
|
// - service block, if present, must have a valid bind (127.0.0.1
|
||||||
|
// opt-in per R-007; default is socket — empty bind is OK)
|
||||||
|
// - service block implied (Traefik route YES)
|
||||||
|
type ServiceValidator struct{}
|
||||||
|
|
||||||
|
// DaemonSetValidator validates the DaemonSet workload kind (R-012).
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - schedule block with mode (every-node/matching/mandatory) required
|
||||||
|
// - no ports (no Traefik route by default D-175)
|
||||||
|
// - no count (implicit = nodes matching condition)
|
||||||
|
// - restart required
|
||||||
|
type DaemonSetValidator struct{}
|
||||||
|
|
||||||
|
// ValidatorFor returns the Validator for the given workload kind, or an
|
||||||
|
// error for an unknown kind. kind must be one of Job, Service,
|
||||||
|
// DaemonSet (R-012).
|
||||||
|
func ValidatorFor(kind string) (Validator, error) {
|
||||||
|
switch kind {
|
||||||
|
case "Job":
|
||||||
|
return JobValidator{}, nil
|
||||||
|
case "Service":
|
||||||
|
return ServiceValidator{}, nil
|
||||||
|
case "DaemonSet":
|
||||||
|
return DaemonSetValidator{}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("schema: unknown kind %q (want one of Job, Service, DaemonSet)", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates a Job spec. See JobValidator for the rules.
|
||||||
|
func (JobValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
|
if spec == nil {
|
||||||
|
return errors.New("schema/Job: spec is nil")
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
errs = append(errs, "name is required")
|
||||||
|
}
|
||||||
|
if spec.Count != 0 && spec.Count != 1 {
|
||||||
|
errs = append(errs, fmt.Sprintf("count must be 1 (or unset) for Job, got %d (use Service for replicas)", spec.Count))
|
||||||
|
}
|
||||||
|
if spec.Service != nil {
|
||||||
|
errs = append(errs, "service block (Traefik route) is not allowed for Job (D-175)")
|
||||||
|
}
|
||||||
|
return composeErrors("schema/Job", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates a Service spec. See ServiceValidator for the rules.
|
||||||
|
func (ServiceValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
|
if spec == nil {
|
||||||
|
return errors.New("schema/Service: spec is nil")
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
errs = append(errs, "name is required")
|
||||||
|
}
|
||||||
|
if len(spec.Ports) == 0 {
|
||||||
|
errs = append(errs, "ports required (at least one)")
|
||||||
|
}
|
||||||
|
if spec.Count < 1 {
|
||||||
|
errs = append(errs, fmt.Sprintf("count must be ≥ 1 for Service, got %d", spec.Count))
|
||||||
|
}
|
||||||
|
if spec.Restart == nil {
|
||||||
|
errs = append(errs, "restart block required for Service")
|
||||||
|
} else {
|
||||||
|
switch spec.Restart.Mode {
|
||||||
|
case "service", "on-failure", "never":
|
||||||
|
// Valid per R-012 (default for Service is "service",
|
||||||
|
// but the validator accepts the full enum; the
|
||||||
|
// Service-specific "must be service" rule is enforced
|
||||||
|
// below for the default case where mode is empty).
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "restart mode required for Service (one of service, on-failure, never; default is service)")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("restart mode %q invalid (want one of service, on-failure, never)", spec.Restart.Mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
errs = append(errs, "update block required for Service")
|
||||||
|
} else {
|
||||||
|
switch spec.Update.Strategy {
|
||||||
|
case "rolling", "canary", "blue-green":
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "update strategy required for Service (one of rolling, canary, blue-green)")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", spec.Update.Strategy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.Runtime == nil {
|
||||||
|
errs = append(errs, "runtime block required for Service")
|
||||||
|
}
|
||||||
|
if spec.Health == nil {
|
||||||
|
errs = append(errs, "health block required for Service (Traefik routing requires health checks)")
|
||||||
|
}
|
||||||
|
if spec.Service != nil {
|
||||||
|
if err := validateServiceBind(spec.Service.Bind); err != nil {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return composeErrors("schema/Service", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateServiceBind validates the service.bind field (R-007). Empty
|
||||||
|
// is OK (default = socket). When set, it must be a valid IPv4/IPv6
|
||||||
|
// address (the only opt-in to bind on a non-loopback address); the
|
||||||
|
// loopback 127.0.0.1 is the documented opt-in. Anything that is not
|
||||||
|
// parseable as an IP address is rejected.
|
||||||
|
func validateServiceBind(bind string) error {
|
||||||
|
if strings.TrimSpace(bind) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if net.ParseIP(bind) == nil {
|
||||||
|
return fmt.Errorf("service.bind %q is not a valid IP address (R-007: 127.0.0.1 opt-in; default is socket)", bind)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates a DaemonSet spec. See DaemonSetValidator for the rules.
|
||||||
|
func (DaemonSetValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
|
if spec == nil {
|
||||||
|
return errors.New("schema/DaemonSet: spec is nil")
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
if strings.TrimSpace(spec.Name) == "" {
|
||||||
|
errs = append(errs, "name is required")
|
||||||
|
}
|
||||||
|
if spec.Schedule == nil {
|
||||||
|
errs = append(errs, "schedule block required for DaemonSet")
|
||||||
|
} else {
|
||||||
|
switch spec.Schedule.Mode {
|
||||||
|
case "every-node", "matching", "mandatory":
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "schedule mode required for DaemonSet (one of every-node, matching, mandatory)")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("schedule mode %q invalid (want one of every-node, matching, mandatory)", spec.Schedule.Mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(spec.Ports) > 0 {
|
||||||
|
errs = append(errs, "ports not allowed for DaemonSet (no Traefik route by default D-175)")
|
||||||
|
}
|
||||||
|
if spec.Count != 0 {
|
||||||
|
errs = append(errs, fmt.Sprintf("count not allowed for DaemonSet (implicit = nodes matching condition), got %d", spec.Count))
|
||||||
|
}
|
||||||
|
if spec.Restart == nil {
|
||||||
|
errs = append(errs, "restart block required for DaemonSet")
|
||||||
|
}
|
||||||
|
return composeErrors("schema/DaemonSet", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeErrors joins the per-field errors into a single error prefixed
|
||||||
|
// by the validator name. Returns nil when there are no errors so the
|
||||||
|
// caller can return the result directly.
|
||||||
|
func composeErrors(name string, errs []string) error {
|
||||||
|
if len(errs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %s", name, strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobValidator_ValidMinimal(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "backup", Count: 1}
|
||||||
|
v := JobValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_ValidWithSchedule(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "backup",
|
||||||
|
Count: 1,
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Cron: "0 2 * * *"},
|
||||||
|
Timeout: "1h",
|
||||||
|
}
|
||||||
|
v := JobValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_ValidUnsetCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "one-shot"}
|
||||||
|
v := JobValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("unset count should default-accept, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_MissingName(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Count: 1}
|
||||||
|
err := JobValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing name, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "name is required") {
|
||||||
|
t.Errorf("error = %q, want 'name is required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_CountGreaterThanOne(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "batch", Count: 3}
|
||||||
|
err := JobValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for count > 1, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "count must be 1") {
|
||||||
|
t.Errorf("error = %q, want 'count must be 1'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_ServiceBlockRejected(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Job",
|
||||||
|
Name: "x",
|
||||||
|
Count: 1,
|
||||||
|
Service: &jobspec.ServiceBlock{Host: "x.example"},
|
||||||
|
}
|
||||||
|
err := JobValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for service block on Job, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "service block") {
|
||||||
|
t.Errorf("error = %q, want 'service block'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobValidator_NilSpec(t *testing.T) {
|
||||||
|
v := JobValidator{}
|
||||||
|
if err := v.Validate(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_ValidFull(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/http"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling", MaxSurge: 1},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http", Interval: "5s"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MissingPorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing ports, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ports required") {
|
||||||
|
t.Errorf("error = %q, want 'ports required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_CountZero(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 0,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for count 0, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "count must be") {
|
||||||
|
t.Errorf("error = %q, want 'count must be'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MissingRestart(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing restart, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "restart block required") {
|
||||||
|
t.Errorf("error = %q, want 'restart block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_WrongRestartMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid restart mode, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "restart mode") {
|
||||||
|
t.Errorf("error = %q, want 'restart mode'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_AcceptedRestartModes(t *testing.T) {
|
||||||
|
// R-012: restart.mode accepts service / on-failure / never for
|
||||||
|
// Service; the default per R-012 is "service" but the validator
|
||||||
|
// accepts the full enum (a Service that wants on-failure is
|
||||||
|
// unusual but not invalid — only "always" and unknown modes are
|
||||||
|
// rejected).
|
||||||
|
for _, mode := range []string{"service", "on-failure", "never"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: mode},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Errorf("mode %q should be accepted, got: %v", mode, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MissingUpdate(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing update, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update block required") {
|
||||||
|
t.Errorf("error = %q, want 'update block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MissingRuntime(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing runtime, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "runtime block required") {
|
||||||
|
t.Errorf("error = %q, want 'runtime block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_NilSpec(t *testing.T) {
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MissingHealth(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing health block, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "health block required") {
|
||||||
|
t.Errorf("error = %q, want 'health block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_InvalidRestartMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid restart mode, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "restart mode") || !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Errorf("error = %q, want 'restart mode ... invalid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_EmptyRestartMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: ""},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty restart mode, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "restart mode required") {
|
||||||
|
t.Errorf("error = %q, want 'restart mode required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_InvalidUpdateStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid update strategy, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update strategy") || !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Errorf("error = %q, want 'update strategy ... invalid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_EmptyUpdateStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: ""},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty update strategy, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update strategy required") {
|
||||||
|
t.Errorf("error = %q, want 'update strategy required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_AcceptedUpdateStrategies(t *testing.T) {
|
||||||
|
for _, strat := range []string{"rolling", "canary", "blue-green"} {
|
||||||
|
t.Run(strat, func(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: strat},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Errorf("strategy %q should be accepted, got: %v", strat, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_InvalidServiceBind(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid service.bind, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "service.bind") || !strings.Contains(err.Error(), "valid IP") {
|
||||||
|
t.Errorf("error = %q, want 'service.bind ... valid IP'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_ValidServiceBindLoopback(t *testing.T) {
|
||||||
|
// R-007: 127.0.0.1 is the documented opt-in for a non-socket bind.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "127.0.0.1"},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("127.0.0.1 should be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_ValidServiceBindIPv6(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "::1"},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("::1 should be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_EmptyServiceBindOK(t *testing.T) {
|
||||||
|
// R-007: empty bind = default = socket; valid.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 1,
|
||||||
|
Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "service"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "rolling"},
|
||||||
|
Health: &jobspec.HealthBlock{CheckType: "http"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 8080}},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: ""},
|
||||||
|
}
|
||||||
|
v := ServiceValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("empty bind should default to socket (valid), got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceValidator_MultipleErrors(t *testing.T) {
|
||||||
|
// Multiple violations should all surface in the composed error.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "",
|
||||||
|
Count: 0,
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "always"},
|
||||||
|
Update: &jobspec.UpdateBlock{Strategy: "recreate"},
|
||||||
|
Service: &jobspec.ServiceBlock{Bind: "not-an-ip"},
|
||||||
|
}
|
||||||
|
err := ServiceValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"name is required",
|
||||||
|
"ports required",
|
||||||
|
"count must be",
|
||||||
|
"restart mode",
|
||||||
|
"update strategy",
|
||||||
|
"runtime block required",
|
||||||
|
"health block required",
|
||||||
|
"service.bind",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q missing %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_Valid(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "log-shipper",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
v := DaemonSetValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_ValidMatchingMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "matching"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
v := DaemonSetValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("matching mode should be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_ValidMandatoryMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "mandatory"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
v := DaemonSetValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("mandatory mode should be accepted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_MissingScheduleMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: ""},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing schedule mode, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "schedule mode required") {
|
||||||
|
t.Errorf("error = %q, want 'schedule mode required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_MissingScheduleBlock(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing schedule block, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "schedule block required") {
|
||||||
|
t.Errorf("error = %q, want 'schedule block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_InvalidScheduleMode(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "always"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid schedule mode, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "schedule mode") {
|
||||||
|
t.Errorf("error = %q, want 'schedule mode'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_HasPorts(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
Ports: []jobspec.PortSpec{{Name: "http", Port: 80}},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for ports on DaemonSet, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ports not allowed") {
|
||||||
|
t.Errorf("error = %q, want 'ports not allowed'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_HasCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Count: 3,
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
||||||
|
Restart: &jobspec.RestartBlock{Mode: "on-failure"},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for count on DaemonSet, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "count not allowed") {
|
||||||
|
t.Errorf("error = %q, want 'count not allowed'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_MissingRestart(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "DaemonSet",
|
||||||
|
Name: "x",
|
||||||
|
Schedule: &jobspec.ScheduleBlock{Mode: "every-node"},
|
||||||
|
}
|
||||||
|
err := DaemonSetValidator{}.Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing restart on DaemonSet, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "restart block required") {
|
||||||
|
t.Errorf("error = %q, want 'restart block required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemonSetValidator_NilSpec(t *testing.T) {
|
||||||
|
v := DaemonSetValidator{}
|
||||||
|
if err := v.Validate(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorFor_EachKind(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
kind string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Job", "schema.JobValidator"},
|
||||||
|
{"Service", "schema.ServiceValidator"},
|
||||||
|
{"DaemonSet", "schema.DaemonSetValidator"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.kind, func(t *testing.T) {
|
||||||
|
v, err := ValidatorFor(tc.kind)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidatorFor(%q): %v", tc.kind, err)
|
||||||
|
}
|
||||||
|
got := fmtType(v)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("ValidatorFor(%q) type = %q, want %q", tc.kind, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatorFor_UnknownKind(t *testing.T) {
|
||||||
|
_, err := ValidatorFor("CronJob")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown kind, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown kind") {
|
||||||
|
t.Errorf("error = %q, want 'unknown kind'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtType returns a readable type name for a validator. Uses fmt.Sprintf
|
||||||
|
// with %T rather than reflection to keep the test surface minimal.
|
||||||
|
func fmtType(v Validator) string {
|
||||||
|
switch v.(type) {
|
||||||
|
case JobValidator:
|
||||||
|
return "schema.JobValidator"
|
||||||
|
case ServiceValidator:
|
||||||
|
return "schema.ServiceValidator"
|
||||||
|
case DaemonSetValidator:
|
||||||
|
return "schema.DaemonSetValidator"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure composeErrors returns nil for empty input (covers the
|
||||||
|
// short-circuit branch that the validators rely on).
|
||||||
|
func TestComposeErrors_Empty(t *testing.T) {
|
||||||
|
if err := composeErrors("schema/X", nil); err != nil {
|
||||||
|
t.Errorf("composeErrors(nil) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if err := composeErrors("schema/X", []string{}); err != nil {
|
||||||
|
t.Errorf("composeErrors([]) = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the error type returned by composeErrors is a non-nil error
|
||||||
|
// when violations are present (guards against accidental nil-return).
|
||||||
|
func TestComposeErrors_NonEmpty(t *testing.T) {
|
||||||
|
err := composeErrors("schema/X", []string{"a", "b"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-nil error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "a") || !strings.Contains(err.Error(), "b") {
|
||||||
|
t.Errorf("error = %q, want both 'a' and 'b'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that the validators implement the interface.
|
||||||
|
var (
|
||||||
|
_ Validator = JobValidator{}
|
||||||
|
_ Validator = ServiceValidator{}
|
||||||
|
_ Validator = DaemonSetValidator{}
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateValidator validates the rolling/canary/blue-green update stanza
|
||||||
|
// (P03). The schema validator (ServiceValidator) already enforces that
|
||||||
|
// the strategy is one of rolling/canary/blue-green and that the update
|
||||||
|
// block is present for a Service. UpdateValidator adds the
|
||||||
|
// field-level validation:
|
||||||
|
//
|
||||||
|
// - max_parallel: integer in [1, count] (defaults to 1 when unset)
|
||||||
|
// - min_healthy_time: a valid time.Duration when set (time.ParseDuration)
|
||||||
|
// - healthy_deadline: a valid time.Duration when set (time.ParseDuration)
|
||||||
|
// - canary: an integer count in [0, count] OR a percentage string of
|
||||||
|
// the form "<n>%" where n is in [0, 100] (the parser already accepts
|
||||||
|
// both shapes; the validator accepts them too). Only meaningful
|
||||||
|
// for the canary strategy; ignored (but still validated for shape)
|
||||||
|
// for rolling/blue-green.
|
||||||
|
// - auto_promote: boolean (no validation beyond the parser's
|
||||||
|
// true/false parse; the field is always populated)
|
||||||
|
//
|
||||||
|
// The validator is pure (no I/O). Violations return a clear error
|
||||||
|
// listing every problem found, mirroring the per-field style of
|
||||||
|
// ServiceValidator.
|
||||||
|
type UpdateValidator struct{}
|
||||||
|
|
||||||
|
// Validate validates the UpdateBlock on the given spec. The spec must
|
||||||
|
// be non-nil and carry a Count (services have count ≥ 1 per
|
||||||
|
// ServiceValidator). When spec.Update is nil the validator returns an
|
||||||
|
// error (the update block is required for Service; this validator
|
||||||
|
// assumes the caller has already established the spec is a Service).
|
||||||
|
func (UpdateValidator) Validate(spec *jobspec.WorkloadSpec) error {
|
||||||
|
if spec == nil {
|
||||||
|
return fmt.Errorf("schema/Update: spec is nil")
|
||||||
|
}
|
||||||
|
if spec.Update == nil {
|
||||||
|
return fmt.Errorf("schema/Update: update block is nil")
|
||||||
|
}
|
||||||
|
var errs []string
|
||||||
|
u := spec.Update
|
||||||
|
|
||||||
|
switch u.Strategy {
|
||||||
|
case "rolling", "canary", "blue-green":
|
||||||
|
case "":
|
||||||
|
errs = append(errs, "update strategy required (one of rolling, canary, blue-green)")
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Sprintf("update strategy %q invalid (want one of rolling, canary, blue-green)", u.Strategy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// max_parallel defaults to 1 when unset (0); validate the range
|
||||||
|
// only when the user has set it explicitly.
|
||||||
|
if u.MaxParallel != 0 {
|
||||||
|
if u.MaxParallel < 1 {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.max_parallel must be ≥ 1, got %d", u.MaxParallel))
|
||||||
|
}
|
||||||
|
if spec.Count > 0 && u.MaxParallel > spec.Count {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.max_parallel %d exceeds count %d (must be 1..count)", u.MaxParallel, spec.Count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.MinHealthyTime != "" {
|
||||||
|
if _, err := time.ParseDuration(u.MinHealthyTime); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.min_healthy_time %q is not a valid duration: %v", u.MinHealthyTime, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.HealthyDeadline != "" {
|
||||||
|
if _, err := time.ParseDuration(u.HealthyDeadline); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("update.healthy_deadline %q is not a valid duration: %v", u.HealthyDeadline, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canary accepts an integer count (0..count) or a percentage
|
||||||
|
// ("<n>%" with n in 0..100). The field is only meaningful for the
|
||||||
|
// canary strategy but we validate the shape regardless so a typo
|
||||||
|
// in a rolling/blue-green stanza still surfaces.
|
||||||
|
if u.Canary != "" {
|
||||||
|
if err := validateCanary(u.Canary, spec.Count); err != nil {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto_promote is a bool; no extra validation beyond the parser.
|
||||||
|
|
||||||
|
return composeErrors("schema/Update", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCanary validates the canary field shape: either an integer
|
||||||
|
// count (0..count) or a percentage string "<n>%" (n in 0..100). count
|
||||||
|
// is the spec.Count; when count is 0 (e.g. a DaemonSet or unset), the
|
||||||
|
// integer-count upper bound is not enforced (only the percentage
|
||||||
|
// bound is enforced, since percentage does not depend on count).
|
||||||
|
func validateCanary(canary string, count int) error {
|
||||||
|
c := strings.TrimSpace(canary)
|
||||||
|
if c == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(c, "%") {
|
||||||
|
nStr := strings.TrimSuffix(c, "%")
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(nStr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update.canary %q is not a valid percentage (want \"<n>%%\")", canary)
|
||||||
|
}
|
||||||
|
if n < 0 || n > 100 {
|
||||||
|
return fmt.Errorf("update.canary percentage %d out of range (want 0..100)", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(c)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update.canary %q is not a valid count or percentage (want integer or \"<n>%%\")", canary)
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return fmt.Errorf("update.canary count %d must be ≥ 0", n)
|
||||||
|
}
|
||||||
|
if count > 0 && n > count {
|
||||||
|
return fmt.Errorf("update.canary count %d exceeds count %d (must be 0..count)", n, count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package schema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/jobspec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidRolling(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 2,
|
||||||
|
MinHealthyTime: "30s",
|
||||||
|
HealthyDeadline: "5m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
v := UpdateValidator{}
|
||||||
|
if err := v.Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanary(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
MaxParallel: 2,
|
||||||
|
MinHealthyTime: "30s",
|
||||||
|
HealthyDeadline: "5m",
|
||||||
|
Canary: "10%",
|
||||||
|
AutoPromote: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidBlueGreen(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "blue-green",
|
||||||
|
MinHealthyTime: "1m",
|
||||||
|
HealthyDeadline: "10m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryIntegerCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("integer canary count 1 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryZeroPercent(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "0%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("0%% canary should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_ValidCanaryHundredPercent(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "100%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("100%% canary should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_EmptyDurationsOK(t *testing.T) {
|
||||||
|
// Empty min_healthy_time / healthy_deadline should be accepted
|
||||||
|
// (they are optional; defaults are applied by the executor).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("empty durations should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_NilSpec(t *testing.T) {
|
||||||
|
if err := (UpdateValidator{}).Validate(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_NilUpdateBlock(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{Kind: "Service", Name: "web", Count: 2}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil update block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "update block is nil") {
|
||||||
|
t.Errorf("error = %q, want 'update block is nil'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "strategy") || !strings.Contains(err.Error(), "invalid") {
|
||||||
|
t.Errorf("error = %q, want 'strategy ... invalid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_EmptyStrategy(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty strategy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "strategy required") {
|
||||||
|
t.Errorf("error = %q, want 'strategy required'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelZero(t *testing.T) {
|
||||||
|
// max_parallel=0 means "unset" → default 1; accepted.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("max_parallel=0 (unset) should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative max_parallel")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "max_parallel") {
|
||||||
|
t.Errorf("error = %q, want 'max_parallel'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelExceedsCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for max_parallel > count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds count") {
|
||||||
|
t.Errorf("error = %q, want 'exceeds count'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MaxParallelEqualsCount(t *testing.T) {
|
||||||
|
// max_parallel == count is the upper bound; valid.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 3,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MaxParallel: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("max_parallel==count should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidMinHealthyTime(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
MinHealthyTime: "not-a-duration",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid min_healthy_time")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "min_healthy_time") {
|
||||||
|
t.Errorf("error = %q, want 'min_healthy_time'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_InvalidHealthyDeadline(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "rolling",
|
||||||
|
HealthyDeadline: "nope",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid healthy_deadline")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "healthy_deadline") {
|
||||||
|
t.Errorf("error = %q, want 'healthy_deadline'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative canary count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "canary") {
|
||||||
|
t.Errorf("error = %q, want 'canary'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryExceedsCount(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "5",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canary > count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exceeds count") {
|
||||||
|
t.Errorf("error = %q, want 'exceeds count'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentOver100(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "150%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for canary > 100%")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "out of range") {
|
||||||
|
t.Errorf("error = %q, want 'out of range'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentNegative(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "-10%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for negative canary percent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "out of range") {
|
||||||
|
t.Errorf("error = %q, want 'out of range'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryNotANumber(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "abc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric canary")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid") {
|
||||||
|
t.Errorf("error = %q, want 'not a valid'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentNotANumber(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "xx%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric canary percent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid percentage") {
|
||||||
|
t.Errorf("error = %q, want 'not a valid percentage'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryCountZeroOK(t *testing.T) {
|
||||||
|
// canary=0 is the lower bound; valid.
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 4,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("canary=0 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_CanaryPercentWithoutCountOK(t *testing.T) {
|
||||||
|
// A percentage canary does not depend on count; valid even when
|
||||||
|
// count is 0 (e.g. DaemonSet-shaped spec bypassing the Service
|
||||||
|
// validator — defensive).
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 0,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "canary",
|
||||||
|
Canary: "25%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := (UpdateValidator{}).Validate(spec); err != nil {
|
||||||
|
t.Fatalf("percentage canary with count=0 should be valid, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateValidator_MultipleErrors(t *testing.T) {
|
||||||
|
spec := &jobspec.WorkloadSpec{
|
||||||
|
Kind: "Service",
|
||||||
|
Name: "web",
|
||||||
|
Count: 2,
|
||||||
|
Update: &jobspec.UpdateBlock{
|
||||||
|
Strategy: "recreate",
|
||||||
|
MaxParallel: 99,
|
||||||
|
MinHealthyTime: "nope",
|
||||||
|
HealthyDeadline: "also-nope",
|
||||||
|
Canary: "200%",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := (UpdateValidator{}).Validate(spec)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"strategy",
|
||||||
|
"max_parallel",
|
||||||
|
"min_healthy_time",
|
||||||
|
"healthy_deadline",
|
||||||
|
"canary",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q missing %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time assertion that UpdateValidator implements Validator.
|
||||||
|
var _ Validator = UpdateValidator{}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Package sshpush_test contains compile-time assertions that *Transport
|
||||||
|
// satisfies the emitter.AtomicWriter interface (the Traefik C-10
|
||||||
|
// atomicity protocol — internal/emitter/traefik_atomic.go). The
|
||||||
|
// assertion lives here (not in internal/emitter) to avoid an import
|
||||||
|
// cycle: internal/emitter is imported by this package (fanout.go), so
|
||||||
|
// internal/emitter cannot import this package.
|
||||||
|
package sshpush_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/sshpush"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time assertion: *sshpush.Transport satisfies
|
||||||
|
// emitter.AtomicWriter. WriteTraefikDynamic relies on this so the
|
||||||
|
// Traefik dynamic-config file is written atomically (gate C-10).
|
||||||
|
var _ emitter.AtomicWriter = (*sshpush.Transport)(nil)
|
||||||
|
|
||||||
|
func TestTransportSatisfiesAtomicWriter(t *testing.T) {
|
||||||
|
// A trivial runtime check that the type conversion is valid; the
|
||||||
|
// compile-time assertion above is the real test, but this gives
|
||||||
|
// `go test` a function to run.
|
||||||
|
tr := sshpush.NewTransport("/nonexistent", "/nonexistent")
|
||||||
|
var w emitter.AtomicWriter = tr
|
||||||
|
_ = w
|
||||||
|
_ = context.Background()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Package sshpush implements the v0.9 SSH-push transport layer (REQ-073,
|
||||||
|
// R-001): the CLI on the operator host SSHes to each peer to render files,
|
||||||
|
// apply configs, and run commands. It replaces the v0.8
|
||||||
|
// internal/transport mTLS HTTP layer.
|
||||||
|
//
|
||||||
|
// The Transport reuses one *ssh.Client per peer across multiple
|
||||||
|
// operations within a single CLI invocation (I-B-001), retries transient
|
||||||
|
// failures with exponential backoff (100ms ×2, cap 5s, max 5 attempts —
|
||||||
|
// reimplemented from the v0.8 transport/retry.go pattern, since
|
||||||
|
// internal/transport is deprecated and not imported), applies per-call
|
||||||
|
// timeouts (10s exec, 30s SCP per I-B-001), and fans out to many peers
|
||||||
|
// with bounded concurrency (default 8, errgroup + semaphore).
|
||||||
|
//
|
||||||
|
// Idempotency is content-addressed (C-18): WriteFile / WriteFileIdempotent
|
||||||
|
// compare the remote file's SHA-256 to the local content and skip the
|
||||||
|
// write on match — the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key.
|
||||||
|
//
|
||||||
|
// Host-key verification reuses proxmox.TOFUHostKeyCallback (D-035), which
|
||||||
|
// reads/writes the known_hosts file (certpaths.KnownHostsPath during the
|
||||||
|
// v0.9 dual-write window; the move to paths.KnownHostsPath happens in
|
||||||
|
// v0.10-P14). The known_hosts file is flock-protected inside the TOFU
|
||||||
|
// callback, so the Transport does NOT re-lock.
|
||||||
|
package sshpush
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultFanoutConcurrency is the default bounded-concurrency limit for
|
||||||
|
// fan-out operations (I-B-001). The Transport.ExecAll and WriteAll
|
||||||
|
// methods use this when the caller does not override it.
|
||||||
|
const DefaultFanoutConcurrency = 8
|
||||||
|
|
||||||
|
// ExecAll runs cmd on all peers in parallel with bounded concurrency
|
||||||
|
// (default 8, I-B-001). Returns per-peer output and per-peer errors. A
|
||||||
|
// nil entry in the errors map means that peer succeeded; the output map
|
||||||
|
// contains that peer's stdout. The returned error is non-nil only if
|
||||||
|
// the fan-out itself failed (e.g., context cancelled before any peer
|
||||||
|
// ran); per-peer failures are in the errors map.
|
||||||
|
func (t *Transport) ExecAll(ctx context.Context, peers []string, cmd string) (map[string][]byte, map[string]error) {
|
||||||
|
return t.ExecAllWithConcurrency(ctx, peers, cmd, DefaultFanoutConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecAllWithConcurrency is ExecAll with an explicit concurrency limit.
|
||||||
|
// A limit <= 0 uses DefaultFanoutConcurrency.
|
||||||
|
func (t *Transport) ExecAllWithConcurrency(ctx context.Context, peers []string, cmd string, concurrency int) (map[string][]byte, map[string]error) {
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = DefaultFanoutConcurrency
|
||||||
|
}
|
||||||
|
out := make(map[string][]byte, len(peers))
|
||||||
|
errs := make(map[string]error, len(peers))
|
||||||
|
var mu sync.Mutex
|
||||||
|
g, gctx := errgroup.WithContext(ctx)
|
||||||
|
g.SetLimit(concurrency)
|
||||||
|
for _, p := range peers {
|
||||||
|
peer := p
|
||||||
|
g.Go(func() error {
|
||||||
|
o, err := t.Exec(gctx, peer, cmd)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs[peer] = err
|
||||||
|
return nil // per-peer error; do not cancel the group
|
||||||
|
}
|
||||||
|
out[peer] = o
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = g.Wait()
|
||||||
|
return out, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAll writes the given files to each peer in parallel with bounded
|
||||||
|
// concurrency (default 8, I-B-001). The files map is keyed by peer; each
|
||||||
|
// peer's files are written sequentially (to preserve order and avoid
|
||||||
|
// intra-peer races on shared paths). Returns per-peer errors; a peer
|
||||||
|
// missing from the map or with a nil entry succeeded. The returned
|
||||||
|
// error is non-nil only if the fan-out itself failed (context cancelled).
|
||||||
|
func (t *Transport) WriteAll(ctx context.Context, peers []string, files map[string][]emitter.File) map[string]error {
|
||||||
|
return t.WriteAllWithConcurrency(ctx, peers, files, DefaultFanoutConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAllWithConcurrency is WriteAll with an explicit concurrency limit.
|
||||||
|
// A limit <= 0 uses DefaultFanoutConcurrency.
|
||||||
|
func (t *Transport) WriteAllWithConcurrency(ctx context.Context, peers []string, files map[string][]emitter.File, concurrency int) map[string]error {
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = DefaultFanoutConcurrency
|
||||||
|
}
|
||||||
|
errs := make(map[string]error, len(peers))
|
||||||
|
var mu sync.Mutex
|
||||||
|
g, gctx := errgroup.WithContext(ctx)
|
||||||
|
g.SetLimit(concurrency)
|
||||||
|
for _, p := range peers {
|
||||||
|
peer := p
|
||||||
|
peerFiles := files[peer]
|
||||||
|
g.Go(func() error {
|
||||||
|
for _, f := range peerFiles {
|
||||||
|
mode := parseMode(f.Mode)
|
||||||
|
if _, err := t.WriteFileIdempotent(gctx, peer, f.Path, []byte(f.Content), mode); err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
errs[peer] = fmt.Errorf("sshpush: write %s on %s: %w", f.Path, peer, err)
|
||||||
|
mu.Unlock()
|
||||||
|
return nil // per-peer error; do not cancel the group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = g.Wait()
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMode parses an octal mode string like "0644" into an os.FileMode.
|
||||||
|
// Returns 0644 on parse failure (a safe default for non-executable
|
||||||
|
// config files).
|
||||||
|
func parseMode(s string) os.FileMode {
|
||||||
|
var m uint32
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '7' {
|
||||||
|
return 0o644
|
||||||
|
}
|
||||||
|
m = m<<3 | uint32(r-'0')
|
||||||
|
}
|
||||||
|
if m == 0 {
|
||||||
|
return 0o644
|
||||||
|
}
|
||||||
|
return os.FileMode(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/emitter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- ExecAll tests ---
|
||||||
|
|
||||||
|
func TestExecAll_AllSucceed(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
// Use one server for all peers (same addr).
|
||||||
|
addr := srv.addr()
|
||||||
|
peers := []string{addr, addr, addr}
|
||||||
|
out, errs := tr.ExecAll(context.Background(), peers, "echo hello")
|
||||||
|
for _, p := range peers {
|
||||||
|
if e, ok := errs[p]; ok && e != nil {
|
||||||
|
t.Errorf("peer %s: %v", p, e)
|
||||||
|
}
|
||||||
|
if string(out[p]) != "hello\n" {
|
||||||
|
t.Errorf("out[%s] = %q, want hello\\n", p, out[p])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecAll_OneFails(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
// Peer "bad" returns a permanent error via a mock session.
|
||||||
|
goodPeers := []string{addr}
|
||||||
|
badPeer := "127.0.0.1:1" // unreachable -> transient dial error, retried, fails
|
||||||
|
peers := append(goodPeers, badPeer)
|
||||||
|
out, errs := tr.ExecAll(context.Background(), peers, "echo hello")
|
||||||
|
if string(out[addr]) != "hello\n" {
|
||||||
|
t.Errorf("good peer out = %q, want hello\\n", out[addr])
|
||||||
|
}
|
||||||
|
if errs[badPeer] == nil {
|
||||||
|
t.Error("bad peer should have an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecAll_WithConcurrency(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
peers := []string{addr, addr, addr, addr}
|
||||||
|
out, errs := tr.ExecAllWithConcurrency(context.Background(), peers, "echo hello", 2)
|
||||||
|
for _, p := range peers {
|
||||||
|
if e := errs[p]; e != nil {
|
||||||
|
t.Errorf("peer %s: %v", p, e)
|
||||||
|
}
|
||||||
|
if string(out[p]) != "hello\n" {
|
||||||
|
t.Errorf("out[%s] = %q", p, out[p])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecAll_EmptyPeers(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
out, errs := tr.ExecAll(context.Background(), nil, "echo hello")
|
||||||
|
if len(out) != 0 || len(errs) != 0 {
|
||||||
|
t.Errorf("empty peers: out=%v errs=%v", out, errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecAll_ContextCancelled(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
out, errs := tr.ExecAll(ctx, []string{addr, addr}, "echo hello")
|
||||||
|
// With a cancelled context, all peers should fail.
|
||||||
|
for _, p := range []string{addr, addr} {
|
||||||
|
if errs[p] == nil && string(out[p]) == "" {
|
||||||
|
// acceptable: either error or no output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WriteAll tests ---
|
||||||
|
|
||||||
|
func TestWriteAll_AllSucceed(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
files := map[string][]emitter.File{
|
||||||
|
addr: {
|
||||||
|
{Path: "/w/a", Content: "alpha\n", Mode: "0644"},
|
||||||
|
{Path: "/w/b", Content: "beta\n", Mode: "0644"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
errs := tr.WriteAll(context.Background(), []string{addr}, files)
|
||||||
|
for p, e := range errs {
|
||||||
|
if e != nil {
|
||||||
|
t.Errorf("peer %s: %v", p, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srv.mu.Lock()
|
||||||
|
if srv.files["/w/a"] != "alpha\n" {
|
||||||
|
t.Errorf("file a = %q", srv.files["/w/a"])
|
||||||
|
}
|
||||||
|
if srv.files["/w/b"] != "beta\n" {
|
||||||
|
t.Errorf("file b = %q", srv.files["/w/b"])
|
||||||
|
}
|
||||||
|
srv.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAll_OnePeerFails(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
bad := "127.0.0.1:1"
|
||||||
|
files := map[string][]emitter.File{
|
||||||
|
addr: {{Path: "/ok/f", Content: "ok\n", Mode: "0644"}},
|
||||||
|
bad: {{Path: "/fail/f", Content: "fail\n", Mode: "0644"}},
|
||||||
|
}
|
||||||
|
errs := tr.WriteAll(context.Background(), []string{addr, bad}, files)
|
||||||
|
if errs[addr] != nil {
|
||||||
|
t.Errorf("good peer should not have error, got %v", errs[addr])
|
||||||
|
}
|
||||||
|
if errs[bad] == nil {
|
||||||
|
t.Error("bad peer should have error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAll_EmptyPeers(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
errs := tr.WriteAll(context.Background(), nil, nil)
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Errorf("empty peers: errs=%v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAll_WithConcurrency(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
files := map[string][]emitter.File{
|
||||||
|
addr: {{Path: "/c/f", Content: "c\n", Mode: "0644"}},
|
||||||
|
}
|
||||||
|
errs := tr.WriteAllWithConcurrency(context.Background(), []string{addr}, files, 4)
|
||||||
|
for _, e := range errs {
|
||||||
|
if e != nil {
|
||||||
|
t.Errorf("peer err: %v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteAll_Idempotent(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
srv.mu.Lock()
|
||||||
|
srv.files["/i/f"] = "same\n"
|
||||||
|
srv.mu.Unlock()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
files := map[string][]emitter.File{
|
||||||
|
addr: {{Path: "/i/f", Content: "same\n", Mode: "0644"}},
|
||||||
|
}
|
||||||
|
// Capture writes to verify idempotent skip.
|
||||||
|
var writes int64
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
return &writeCountingSession{srv: srv, writes: &writes}, nil
|
||||||
|
})
|
||||||
|
// Pre-populate pool.
|
||||||
|
client, err := tr.dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(addr, client)
|
||||||
|
errs := tr.WriteAll(context.Background(), []string{addr}, files)
|
||||||
|
if errs[addr] != nil {
|
||||||
|
t.Errorf("WriteAll err: %v", errs[addr])
|
||||||
|
}
|
||||||
|
// sha256sum returns a hash that matches -> no write command.
|
||||||
|
srv.mu.Lock()
|
||||||
|
content := srv.files["/i/f"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if content != "same\n" {
|
||||||
|
t.Errorf("content changed to %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCountingSession counts how many write commands (mkdir + cat >)
|
||||||
|
// are issued; returns the server's file content for sha256sum.
|
||||||
|
type writeCountingSession struct {
|
||||||
|
srv *fakeSSHServer
|
||||||
|
writes *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *writeCountingSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||||
|
c := trim(cmd)
|
||||||
|
if startsWith(c, "sha256sum ") {
|
||||||
|
path := unquote(trimPrefix(c, "sha256sum "))
|
||||||
|
path = trimSuffix(path, " 2>/dev/null")
|
||||||
|
w.srv.mu.Lock()
|
||||||
|
content, ok := w.srv.files[path]
|
||||||
|
w.srv.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
sum := sha256HexStr([]byte(content))
|
||||||
|
return []byte(sum + " " + path + "\n"), nil
|
||||||
|
}
|
||||||
|
if startsWith(c, "mkdir -p ") && contains(c, "cat >") {
|
||||||
|
atomic.AddInt64(w.writes, 1)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (w *writeCountingSession) Close() error { return nil }
|
||||||
|
|
||||||
|
// Local string helpers to avoid importing strings in a way that
|
||||||
|
// conflicts with the test's existing imports.
|
||||||
|
func trim(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func startsWith(s, prefix string) bool { return len(s) >= len(prefix) && s[:len(prefix)] == prefix }
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||||
|
}
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
func trimPrefix(s, prefix string) string {
|
||||||
|
if startsWith(s, prefix) {
|
||||||
|
return s[len(prefix):]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func trimSuffix(s, suffix string) string {
|
||||||
|
if len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||||
|
return s[:len(s)-len(suffix)]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultFanoutConcurrency(t *testing.T) {
|
||||||
|
if DefaultFanoutConcurrency != 8 {
|
||||||
|
t.Errorf("DefaultFanoutConcurrency = %d, want 8", DefaultFanoutConcurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMode_Fanout(t *testing.T) {
|
||||||
|
// parseMode is in fanout.go; sanity-check here too.
|
||||||
|
for _, tc := range []struct{ in, want string }{
|
||||||
|
{"0644", "644"},
|
||||||
|
{"0755", "755"},
|
||||||
|
{"bad", "644"},
|
||||||
|
} {
|
||||||
|
got := fmt.Sprintf("%o", parseMode(tc.in))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("parseMode(%q) = %s, want %s", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = errors.New
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteFileIdempotent writes content to peer:path atomically (write-to-tmp
|
||||||
|
// + mv, REQ-074) with mode, but only if the remote file's SHA-256 differs
|
||||||
|
// from the local content's SHA-256 (C-18 content-addressed idempotency —
|
||||||
|
// the SSH-push equivalent of the v0.8 X-Orca-Idempotency-Key).
|
||||||
|
//
|
||||||
|
// Returns written=true if the file was written, written=false if the
|
||||||
|
// content already matched (skip). The default per-SCP timeout is
|
||||||
|
// SCPTimeout (I-B-001).
|
||||||
|
//
|
||||||
|
// Atomicity: the content is written to a temp file in the same directory
|
||||||
|
// as the target, then `mv`'d into place. The temp file is mode-appended
|
||||||
|
// (e.g. `/etc/orca/foo.conf.orca-tmp-<rand>`) so the rename is atomic on
|
||||||
|
// POSIX filesystems.
|
||||||
|
func (t *Transport) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
|
||||||
|
localHash := sha256Hex(content)
|
||||||
|
remoteHash, err := t.remoteSHA256(ctx, peer, path)
|
||||||
|
if err == nil && remoteHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := t.writeFile(ctx, peer, path, content, mode); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile writes content to peer:path atomically (write-to-tmp + mv).
|
||||||
|
// It writes the content via a single SSH exec (cat heredoc + chmod + mv),
|
||||||
|
// keeping the transfer in one round-trip. The temp file lives next to the
|
||||||
|
// target so the rename is atomic.
|
||||||
|
func (t *Transport) writeFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
||||||
|
dir, base := splitDir(path)
|
||||||
|
tmpName := fmt.Sprintf(".orca-tmp-%s", randomToken(8))
|
||||||
|
tmpPath := base + "/" + tmpName
|
||||||
|
if dir == "" {
|
||||||
|
tmpPath = tmpName
|
||||||
|
}
|
||||||
|
// Build the remote command: mkdir -p <dir> && cat > <tmp> <<'EOF'
|
||||||
|
// ... EOF && chmod <mode> <tmp> && mv <tmp> <path>. The heredoc
|
||||||
|
// delimiter is chosen to not appear in the content (we use a fixed
|
||||||
|
// marker; content with the marker would break, but the marker is
|
||||||
|
// sufficiently unusual).
|
||||||
|
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||||
|
modeStr := fmt.Sprintf("%04o", uint32(mode.Perm()))
|
||||||
|
cmd := fmt.Sprintf(
|
||||||
|
"mkdir -p %s && cat > %s <<'%s'\n%s\n%s\nchmod %s %s && mv -f %s %s",
|
||||||
|
shellQuote(base),
|
||||||
|
shellQuote(tmpPath),
|
||||||
|
eof,
|
||||||
|
string(content),
|
||||||
|
eof,
|
||||||
|
modeStr,
|
||||||
|
shellQuote(tmpPath),
|
||||||
|
shellQuote(tmpPath),
|
||||||
|
shellQuote(path),
|
||||||
|
)
|
||||||
|
execCtx, cancel := context.WithTimeout(ctx, SCPTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := t.execWithRetry(execCtx, peer, cmd, true); err != nil {
|
||||||
|
return fmt.Errorf("sshpush: write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256Hex returns the lowercase hex SHA-256 digest of b.
|
||||||
|
func sha256Hex(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitDir returns the directory and the directory itself (for mkdir).
|
||||||
|
// For "/etc/orca/foo.conf" it returns ("/etc/orca", "/etc/orca"). For
|
||||||
|
// "foo.conf" it returns ("", ".").
|
||||||
|
func splitDir(path string) (dir, base string) {
|
||||||
|
idx := strings.LastIndex(path, "/")
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "."
|
||||||
|
}
|
||||||
|
return path[:idx], path[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomToken returns a random hex token of the given byte length. Used
|
||||||
|
// for temp-file naming to avoid collisions under parallel fan-out.
|
||||||
|
func randomToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte(rand.Intn(256))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSha256Hex(t *testing.T) {
|
||||||
|
got := sha256Hex([]byte("hello"))
|
||||||
|
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("sha256Hex = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitDir(t *testing.T) {
|
||||||
|
dir, base := splitDir("/etc/orca/foo.conf")
|
||||||
|
if dir != "/etc/orca" || base != "/etc/orca" {
|
||||||
|
t.Errorf("splitDir(/etc/orca/foo.conf) = (%q,%q), want (/etc/orca,/etc/orca)", dir, base)
|
||||||
|
}
|
||||||
|
dir, base = splitDir("foo.conf")
|
||||||
|
if dir != "" || base != "." {
|
||||||
|
t.Errorf("splitDir(foo.conf) = (%q,%q), want (\"\",.)", dir, base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomToken(t *testing.T) {
|
||||||
|
a := randomToken(8)
|
||||||
|
b := randomToken(8)
|
||||||
|
if a == b {
|
||||||
|
t.Error("randomToken returned same value twice")
|
||||||
|
}
|
||||||
|
if len(a) != 16 { // 8 bytes hex = 16 chars
|
||||||
|
t.Errorf("randomToken(8) len = %d, want 16", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WriteFileIdempotent tests via the fake SSH server ---
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_WritesWhenFileMissing(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
content := []byte("first content\n")
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/a.conf", content, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
t.Error("written=false, want true (file was missing)")
|
||||||
|
}
|
||||||
|
srv.mu.Lock()
|
||||||
|
got := srv.files["/etc/orca/a.conf"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if got != string(content) {
|
||||||
|
t.Errorf("remote file = %q, want %q", got, string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_SkipsWhenContentMatches(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
content := []byte("same content\n")
|
||||||
|
srv.mu.Lock()
|
||||||
|
srv.files["/etc/orca/b.conf"] = string(content)
|
||||||
|
srv.mu.Unlock()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/b.conf", content, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if written {
|
||||||
|
t.Error("written=true, want false (content matched)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_WritesWhenContentDiffers(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
srv.mu.Lock()
|
||||||
|
srv.files["/etc/orca/c.conf"] = "old content\n"
|
||||||
|
srv.mu.Unlock()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
newContent := []byte("new content\n")
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/etc/orca/c.conf", newContent, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
t.Error("written=false, want true (content differed)")
|
||||||
|
}
|
||||||
|
srv.mu.Lock()
|
||||||
|
got := srv.files["/etc/orca/c.conf"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if got != string(newContent) {
|
||||||
|
t.Errorf("remote file = %q, want %q", got, string(newContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFile_DelegatesToIdempotent(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
content := []byte("delegated\n")
|
||||||
|
if err := tr.WriteFile(context.Background(), srv.addr(), "/etc/orca/d.conf", content, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
srv.mu.Lock()
|
||||||
|
got := srv.files["/etc/orca/d.conf"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if got != string(content) {
|
||||||
|
t.Errorf("remote file = %q, want %q", got, string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pure-logic idempotency tests via mock session (no SSH server) ---
|
||||||
|
|
||||||
|
// mockHashSession returns the hash of the file matching the sha256sum
|
||||||
|
// command's path argument; for write commands (mkdir + cat >), it
|
||||||
|
// records the write. This lets us test the idempotency decision logic
|
||||||
|
// without a real SSH server.
|
||||||
|
type mockHashSession struct {
|
||||||
|
files map[string]string
|
||||||
|
out []byte
|
||||||
|
err error
|
||||||
|
cmd string
|
||||||
|
writeHook func(cmd string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockHashSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||||
|
m.cmd = cmd
|
||||||
|
c := strings.TrimSpace(cmd)
|
||||||
|
if strings.HasPrefix(c, "sha256sum ") {
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(c, "sha256sum "))
|
||||||
|
rest = strings.TrimSuffix(rest, " 2>/dev/null")
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
path := unquote(rest)
|
||||||
|
content, ok := m.files[path]
|
||||||
|
if !ok {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(content))
|
||||||
|
return []byte(hex.EncodeToString(sum[:]) + " " + path + "\n"), nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(c, "mkdir -p ") && strings.Contains(c, "cat >") {
|
||||||
|
if m.writeHook != nil {
|
||||||
|
m.writeHook(c)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return m.out, m.err
|
||||||
|
}
|
||||||
|
func (m *mockHashSession) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_MockSkip(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
files := map[string]string{"/x/f": "match"}
|
||||||
|
var writes int
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
return &mockHashSession{
|
||||||
|
files: files,
|
||||||
|
writeHook: func(string) { writes++ },
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("match"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if written {
|
||||||
|
t.Error("written=true, want false (hash matched)")
|
||||||
|
}
|
||||||
|
if writes != 0 {
|
||||||
|
t.Errorf("writes = %d, want 0 (no write on hash match)", writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_MockWrite(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
files := map[string]string{"/x/f": "old"}
|
||||||
|
var writes int
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
return &mockHashSession{
|
||||||
|
files: files,
|
||||||
|
writeHook: func(string) { writes++ },
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("new"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
t.Error("written=false, want true (hash differed)")
|
||||||
|
}
|
||||||
|
if writes != 1 {
|
||||||
|
t.Errorf("writes = %d, want 1", writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_MockMissingFile(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
files := map[string]string{}
|
||||||
|
var writes int
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
return &mockHashSession{
|
||||||
|
files: files,
|
||||||
|
writeHook: func(string) { writes++ },
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
written, err := tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/new", []byte("fresh"), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriteFileIdempotent: %v", err)
|
||||||
|
}
|
||||||
|
if !written {
|
||||||
|
t.Error("written=false, want true (file missing)")
|
||||||
|
}
|
||||||
|
if writes != 1 {
|
||||||
|
t.Errorf("writes = %d, want 1", writes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteSHA256_ParsesDigest(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
srv.mu.Lock()
|
||||||
|
srv.files["/x/h"] = "abc"
|
||||||
|
srv.mu.Unlock()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
got, err := tr.remoteSHA256(context.Background(), srv.addr(), "/x/h")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remoteSHA256: %v", err)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("%x", sha256.Sum256([]byte("abc")))
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("remoteSHA256 = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteSHA256_MissingFile(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
got, err := t_remoteSHA256_noErr(t, tr, srv.addr(), "/missing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remoteSHA256: %v", err)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("remoteSHA256 = %q, want empty for missing file", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func t_remoteSHA256_noErr(t *testing.T, tr *Transport, peer, path string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
return tr.remoteSHA256(context.Background(), peer, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFile_ModeApplied(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
if err := tr.WriteFile(context.Background(), srv.addr(), "/m/f", []byte("mode"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
srv.mu.Lock()
|
||||||
|
got := srv.files["/m/f"]
|
||||||
|
srv.mu.Unlock()
|
||||||
|
if got != "mode" {
|
||||||
|
t.Errorf("content = %q, want mode", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteFileIdempotent_ExecError(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
return &mockSession{err: fmt.Errorf("%w: boom", ErrPermanent)}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
_, err = tr.WriteFileIdempotent(context.Background(), srv.addr(), "/x/f", []byte("z"), 0o644)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMode(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
want os.FileMode
|
||||||
|
}{
|
||||||
|
{"0644", 0o644},
|
||||||
|
{"0755", 0o755},
|
||||||
|
{"0600", 0o600},
|
||||||
|
{"bad", 0o644},
|
||||||
|
{"", 0o644},
|
||||||
|
{"0", 0o644},
|
||||||
|
} {
|
||||||
|
got := parseMode(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("parseMode(%q) = %o, want %o", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = errors.New
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default timeouts and retry parameters (REQ-073, I-B-001).
|
||||||
|
const (
|
||||||
|
// ExecTimeout is the default per-exec timeout for a single SSH
|
||||||
|
// command (I-B-001).
|
||||||
|
ExecTimeout = 10 * time.Second
|
||||||
|
// SCPTimeout is the default per-SCP timeout for a single file
|
||||||
|
// transfer (I-B-001).
|
||||||
|
SCPTimeout = 30 * time.Second
|
||||||
|
// DialTimeout is the default SSH dial timeout.
|
||||||
|
DialTimeout = 15 * time.Second
|
||||||
|
// RetryInitial is the first backoff interval (v0.8 transport/retry.go).
|
||||||
|
RetryInitial = 100 * time.Millisecond
|
||||||
|
// RetryMax is the cap on backoff between attempts.
|
||||||
|
RetryMax = 5 * time.Second
|
||||||
|
// RetryMaxAttempts is the total attempt count (including the first).
|
||||||
|
RetryMaxAttempts = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors. ErrTransient marks a transient failure worth
|
||||||
|
// retrying; ErrPermanent marks a non-retryable failure (auth, host-key
|
||||||
|
// mismatch, validation). These mirror the v0.8 transport sentinels
|
||||||
|
// (reimplemented here since internal/transport is not imported).
|
||||||
|
var (
|
||||||
|
ErrTransient = errors.New("sshpush: transient error")
|
||||||
|
ErrPermanent = errors.New("sshpush: permanent error")
|
||||||
|
ErrNotConnected = errors.New("sshpush: not connected")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport is the SSH-push transport (REQ-073). It reuses one
|
||||||
|
// *ssh.Client per peer across multiple operations within a single CLI
|
||||||
|
// invocation (I-B-001). The zero value is NOT usable; construct one with
|
||||||
|
// NewTransport.
|
||||||
|
type Transport struct {
|
||||||
|
// pool caches *ssh.Client per peer address ("host:port").
|
||||||
|
pool sync.Map
|
||||||
|
// keyPath is the SSH private key path (Ed25519, D-037).
|
||||||
|
keyPath string
|
||||||
|
// knownHostsPath is the v0.9 known_hosts path (paths.KnownHostsPath()
|
||||||
|
// = ClusterDir()/known_hosts). It is stored for the v0.10-P14 migration
|
||||||
|
// when proxmox.TOFUHostKeyCallback will accept a path parameter; today
|
||||||
|
// the callback reads certpaths.KnownHostsPath() (the v0.8 flat layout)
|
||||||
|
// directly, so this field is not yet read by dial(). Tests set
|
||||||
|
// $ORCA_HOME so certpaths.KnownHostsPath() resolves under the temp dir.
|
||||||
|
knownHostsPath string
|
||||||
|
// user is the remote SSH user (default "orca", D-037).
|
||||||
|
user string
|
||||||
|
// signer is the parsed SSH private key signer, set lazily on first
|
||||||
|
// dial.
|
||||||
|
signer ssh.Signer
|
||||||
|
signErr error
|
||||||
|
// signerOnce guards signer initialization.
|
||||||
|
signerOnce sync.Once
|
||||||
|
|
||||||
|
// dialer is the SSH dialer. Tests override it to inject a mock
|
||||||
|
// server. The default uses ssh.DialContext via the context-aware
|
||||||
|
// wrapper.
|
||||||
|
dialer sshDialer
|
||||||
|
|
||||||
|
// sessionFactory returns a new session for a given client. Tests
|
||||||
|
// override it to inject mock sessions without a real *ssh.Client.
|
||||||
|
// When nil, the default (*ssh.Client).NewSession is used.
|
||||||
|
sessionFactory func(*ssh.Client) (sshSession, error)
|
||||||
|
|
||||||
|
// mu guards the closed flag (pool iteration is sync.Map.Range).
|
||||||
|
closed bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshSession is the minimal *ssh.Session surface the transport uses.
|
||||||
|
// It lets tests substitute a mock without a real SSH server.
|
||||||
|
type sshSession interface {
|
||||||
|
CombinedOutput(cmd string) ([]byte, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshDialer is the SSH dialer interface (mirrors proxmox.sshDialerType).
|
||||||
|
// The default uses ssh.Dial; tests inject mocks that return a fake
|
||||||
|
// *ssh.Client or an error.
|
||||||
|
type sshDialer interface {
|
||||||
|
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSSHDialer wraps ssh.Dial with a context-aware connect timeout.
|
||||||
|
type defaultSSHDialer struct{}
|
||||||
|
|
||||||
|
func (defaultSSHDialer) DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
|
||||||
|
d := net.Dialer{Timeout: config.Timeout}
|
||||||
|
if d.Timeout == 0 {
|
||||||
|
d.Timeout = DialTimeout
|
||||||
|
}
|
||||||
|
conn, err := d.DialContext(ctx, network, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ssh.NewClient(sshConn, chans, reqs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransport returns a Transport configured with the given SSH
|
||||||
|
// private key path and known_hosts path. The known_hosts path is the v0.9
|
||||||
|
// location (paths.KnownHostsPath); it is stored for the v0.10-P14
|
||||||
|
// migration when the TOFU callback will accept a path parameter. Today
|
||||||
|
// dial() delegates host-key verification to proxmox.TOFUHostKeyCallback,
|
||||||
|
// which reads certpaths.KnownHostsPath() (the v0.8 flat layout under
|
||||||
|
// $ORCA_HOME) directly — so callers must ensure $ORCA_HOME points at the
|
||||||
|
// cluster root (the CLI sets this up). The remote user defaults to
|
||||||
|
// "orca" (D-037); override with SetUser. The dialer defaults to the
|
||||||
|
// real ssh.Dial-based dialer; tests call SetDialer to inject a mock.
|
||||||
|
func NewTransport(keyPath, knownHostsPath string) *Transport {
|
||||||
|
return &Transport{
|
||||||
|
keyPath: keyPath,
|
||||||
|
knownHostsPath: knownHostsPath,
|
||||||
|
user: "orca",
|
||||||
|
dialer: defaultSSHDialer{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUser overrides the remote SSH user (default "orca").
|
||||||
|
func (t *Transport) SetUser(user string) {
|
||||||
|
if user != "" {
|
||||||
|
t.user = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDialer overrides the SSH dialer (for tests).
|
||||||
|
func (t *Transport) SetDialer(d sshDialer) {
|
||||||
|
if d != nil {
|
||||||
|
t.dialer = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionFactory overrides the session factory (for tests). The
|
||||||
|
// factory is called per-exec/write/read to obtain a fresh session; it
|
||||||
|
// must close the session when the test mock is done, or the transport
|
||||||
|
// will call Close on the returned session.
|
||||||
|
func (t *Transport) SetSessionFactory(f func(*ssh.Client) (sshSession, error)) {
|
||||||
|
t.sessionFactory = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial returns the cached *ssh.Client for peer, dialing and caching on
|
||||||
|
// first use (I-B-001 connection pooling). Returns an error if the dial
|
||||||
|
// fails or the transport is closed.
|
||||||
|
func (t *Transport) dial(peer string) (*ssh.Client, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.closed {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil, ErrPermanent
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c, ok := t.pool.Load(peer); ok {
|
||||||
|
return c.(*ssh.Client), nil
|
||||||
|
}
|
||||||
|
// Lazily parse the private key signer (once across all dials).
|
||||||
|
t.signerOnce.Do(func() {
|
||||||
|
keyBytes, err := os.ReadFile(t.keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.signErr = fmt.Errorf("sshpush: read key %s: %w", t.keyPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := ssh.ParsePrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.signErr = fmt.Errorf("sshpush: parse key: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.signer = s
|
||||||
|
})
|
||||||
|
if t.signErr != nil {
|
||||||
|
return nil, t.signErr
|
||||||
|
}
|
||||||
|
// Host-key verification reuses the v0.8 TOFU wrapper (D-035). The
|
||||||
|
// known_hosts file is flock-protected inside the callback on
|
||||||
|
// first-connect capture, so we do NOT re-lock here.
|
||||||
|
cb, err := proxmox.TOFUHostKeyCallback(peer, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sshpush: host-key callback: %w", err)
|
||||||
|
}
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: t.user,
|
||||||
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(t.signer)},
|
||||||
|
HostKeyCallback: cb,
|
||||||
|
Timeout: DialTimeout,
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), DialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
client, err := t.dialer.DialContext(ctx, "tcp", peer, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyDialErr(err)
|
||||||
|
}
|
||||||
|
// Race: two goroutines dialing the same peer concurrently both
|
||||||
|
// create a client. Last-wins; the loser is closed. This is rare
|
||||||
|
// (dial is rare and the pool hit short-circuits) and harmless.
|
||||||
|
if existing, loaded := t.pool.LoadOrStore(peer, client); loaded {
|
||||||
|
_ = client.Close()
|
||||||
|
return existing.(*ssh.Client), nil
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec runs cmd on peer over SSH and returns its combined output. The
|
||||||
|
// default per-exec timeout is ExecTimeout (I-B-001); override by
|
||||||
|
// passing a context with a shorter deadline. Transient failures are
|
||||||
|
// retried with exponential backoff (100ms ×2, cap 5s, max 5 attempts —
|
||||||
|
// the v0.8 transport/retry.go pattern, reimplemented here).
|
||||||
|
func (t *Transport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||||
|
return t.execWithRetry(ctx, peer, cmd, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execWithRetry runs the exec with retry. exec is treated as
|
||||||
|
// idempotent (read-only) for retry purposes; the idempotency helpers
|
||||||
|
// (WriteFileIdempotent) handle writes.
|
||||||
|
func (t *Transport) execWithRetry(ctx context.Context, peer string, cmd string, idempotent bool) ([]byte, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= RetryMaxAttempts; attempt++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := t.execOnce(ctx, peer, cmd)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrPermanent) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
if attempt == RetryMaxAttempts {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !isTransient(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wait := backoff(RetryInitial, RetryMax, attempt)
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// execOnce runs the command a single time against peer.
|
||||||
|
func (t *Transport) execOnce(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||||
|
client, err := t.dial(peer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, classifyDialErr(err)
|
||||||
|
}
|
||||||
|
sess, err := t.newSession(client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sshpush: new session: %w", err)
|
||||||
|
}
|
||||||
|
defer sess.Close()
|
||||||
|
type result struct {
|
||||||
|
out []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
out, err := sess.CombinedOutput(cmd)
|
||||||
|
ch <- result{out, err}
|
||||||
|
}()
|
||||||
|
timeout := ExecTimeout
|
||||||
|
if dl, ok := ctx.Deadline(); ok {
|
||||||
|
if remaining := time.Until(dl); remaining > 0 && remaining < timeout {
|
||||||
|
timeout = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(timeout):
|
||||||
|
return nil, fmt.Errorf("sshpush: exec timeout after %s: %w", timeout, ErrTransient)
|
||||||
|
case r := <-ch:
|
||||||
|
if r.err != nil {
|
||||||
|
return r.out, classifyExecErr(r.err)
|
||||||
|
}
|
||||||
|
return r.out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSession returns a session for client, using the override factory
|
||||||
|
// when set (tests), otherwise the real *ssh.Client.NewSession.
|
||||||
|
func (t *Transport) newSession(client *ssh.Client) (sshSession, error) {
|
||||||
|
if t.sessionFactory != nil {
|
||||||
|
return t.sessionFactory(client)
|
||||||
|
}
|
||||||
|
s, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &realSession{Session: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// realSession wraps *ssh.Session to satisfy the sshSession interface.
|
||||||
|
type realSession struct {
|
||||||
|
*ssh.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *realSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||||
|
return r.Session.CombinedOutput(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile SCPs content to peer:path atomically (write-to-tmp + mv,
|
||||||
|
// REQ-074). The default per-SCP timeout is SCPTimeout (I-B-001).
|
||||||
|
// Idempotency: if the file already exists with the same SHA-256, the
|
||||||
|
// write is skipped (C-18). Use WriteFileIdempotent for the explicit
|
||||||
|
// written/skipped result.
|
||||||
|
func (t *Transport) WriteFile(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) error {
|
||||||
|
_, err := t.WriteFileIdempotent(ctx, peer, path, content, mode)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile reads the file at peer:path via SSH cat.
|
||||||
|
func (t *Transport) ReadFile(ctx context.Context, peer string, path string) ([]byte, error) {
|
||||||
|
cmd := fmt.Sprintf("cat %s", shellQuote(path))
|
||||||
|
out, err := t.Exec(ctx, peer, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all pooled SSH clients (REQ-073). Safe to call
|
||||||
|
// multiple times; subsequent calls are no-ops.
|
||||||
|
func (t *Transport) Close() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.closed {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.closed = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
var firstErr error
|
||||||
|
t.pool.Range(func(key, value any) bool {
|
||||||
|
if c, ok := value.(*ssh.Client); ok {
|
||||||
|
if err := c.Close(); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.pool.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoff returns the wait duration for the n-th attempt (1-indexed).
|
||||||
|
// Formula: min(Initial * 2^(n-1), Max), with up to 25% jitter (matches
|
||||||
|
// v0.8 transport/retry.go).
|
||||||
|
func backoff(initial, max time.Duration, n int) time.Duration {
|
||||||
|
d := initial
|
||||||
|
for i := 1; i < n; i++ {
|
||||||
|
d *= 2
|
||||||
|
if d > max {
|
||||||
|
d = max
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
jitter := time.Duration(rand.Int63n(int64(d) / 2))
|
||||||
|
d = d - d/4 + jitter
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTransient reports whether err looks like a transient failure worth
|
||||||
|
// retrying (mirrors v0.8 transport.IsTransient, reimplemented here).
|
||||||
|
func isTransient(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrTransient) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrPermanent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
for _, sub := range []string{
|
||||||
|
"connection refused", "i/o timeout", "EOF",
|
||||||
|
"no such host", "connection reset", "timeout",
|
||||||
|
"deadline exceeded", "temporarily unavailable",
|
||||||
|
} {
|
||||||
|
if strings.Contains(s, sub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyDialErr converts a raw ssh.Dial error into a transport error
|
||||||
|
// (transient vs permanent). Auth failures and host-key mismatches are
|
||||||
|
// permanent; everything else is transient.
|
||||||
|
func classifyDialErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
if strings.Contains(s, "unable to authenticate") || strings.Contains(s, "handshake failed") {
|
||||||
|
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "host key") && strings.Contains(s, "mismatch") {
|
||||||
|
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "knownhosts") {
|
||||||
|
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyExecErr converts a raw session exec error into a transport
|
||||||
|
// error. Non-zero exit codes are NOT transient (the command ran; the
|
||||||
|
// failure is logical, not network). Session-creation failures and
|
||||||
|
// network-level errors are transient.
|
||||||
|
func classifyExecErr(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var exitErr *ssh.ExitError
|
||||||
|
if errors.As(err, &exitErr) {
|
||||||
|
return fmt.Errorf("%w: exit %d", ErrPermanent, exitErr.ExitStatus())
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
for _, sub := range []string{"EOF", "session closed", "channel closed"} {
|
||||||
|
if strings.Contains(s, sub) {
|
||||||
|
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", ErrPermanent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellQuote single-quotes a path for safe shell interpolation. It
|
||||||
|
// escapes embedded single-quotes via the standard '\” idiom.
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteSHA256 returns the SHA-256 of the file at peer:path via SSH
|
||||||
|
// `sha256sum`, or ("", error) if the file is missing or the command
|
||||||
|
// fails. The returned hash is the hex digest (lowercase, no filename).
|
||||||
|
func (t *Transport) remoteSHA256(ctx context.Context, peer string, path string) (string, error) {
|
||||||
|
cmd := fmt.Sprintf("sha256sum %s 2>/dev/null", shellQuote(path))
|
||||||
|
out, err := t.execWithRetry(ctx, peer, cmd, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out = bytes.TrimSpace(out)
|
||||||
|
if len(out) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(out))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return fields[0], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package sshpush
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- fakeSSHServer: a minimal in-process SSH server for hermetic tests.
|
||||||
|
|
||||||
|
type fakeSSHServer struct {
|
||||||
|
listener net.Listener
|
||||||
|
config *ssh.ServerConfig
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
files map[string]string
|
||||||
|
hostKey ssh.Signer
|
||||||
|
cmdCount int64
|
||||||
|
execDelay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSSHServer(t *testing.T) *fakeSSHServer {
|
||||||
|
t.Helper()
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ed25519 gen: %v", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.NewSignerFromKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ssh signer: %v", err)
|
||||||
|
}
|
||||||
|
config := &ssh.ServerConfig{
|
||||||
|
NoClientAuth: true,
|
||||||
|
}
|
||||||
|
config.AddHostKey(signer)
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
srv := &fakeSSHServer{
|
||||||
|
listener: ln,
|
||||||
|
config: config,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
files: make(map[string]string),
|
||||||
|
hostKey: signer,
|
||||||
|
}
|
||||||
|
go srv.serve()
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() }
|
||||||
|
func (s *fakeSSHServer) hostPublicKey() ssh.PublicKey { return s.hostKey.PublicKey() }
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) close() {
|
||||||
|
_ = s.listener.Close()
|
||||||
|
<-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) setExecDelay(d time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.execDelay = d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) serve() {
|
||||||
|
for {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
close(s.done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go s.handle(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) handle(netConn net.Conn) {
|
||||||
|
defer netConn.Close()
|
||||||
|
_, chans, reqs, err := ssh.NewServerConn(netConn, s.config)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
for newChan := range chans {
|
||||||
|
if newChan.ChannelType() != "session" {
|
||||||
|
newChan.Reject(ssh.UnknownChannelType, "only session")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go s.handleSession(newChan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSSHServer) handleSession(newChan ssh.NewChannel) {
|
||||||
|
ch, reqs, err := newChan.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
for req := range reqs {
|
||||||
|
if req.Type != "exec" {
|
||||||
|
req.Reply(false, nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var execReq struct{ Command string }
|
||||||
|
if err := ssh.Unmarshal(req.Payload, &execReq); err != nil {
|
||||||
|
req.Reply(false, nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Reply(true, nil)
|
||||||
|
atomic.AddInt64(&s.cmdCount, 1)
|
||||||
|
s.mu.Lock()
|
||||||
|
delay := s.execDelay
|
||||||
|
s.mu.Unlock()
|
||||||
|
if delay > 0 {
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
out, code := s.runCommand(execReq.Command)
|
||||||
|
_, _ = ch.Write(out)
|
||||||
|
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{uint32(code)}))
|
||||||
|
_ = ch.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand implements the minimal command surface the transport uses:
|
||||||
|
// echo (for exec tests), sha256sum (for idempotency), cat (read), and the
|
||||||
|
// heredoc-based write (cat > tmp <<EOF ... EOF && chmod ... && mv ...).
|
||||||
|
func (s *fakeSSHServer) runCommand(cmd string) ([]byte, int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
trimmed := strings.TrimSpace(cmd)
|
||||||
|
switch {
|
||||||
|
case trimmed == "echo hello":
|
||||||
|
return []byte("hello\n"), 0
|
||||||
|
case strings.HasPrefix(trimmed, "sha256sum "):
|
||||||
|
// Format: sha256sum '/path' 2>/dev/null
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "sha256sum "))
|
||||||
|
rest = strings.TrimSuffix(rest, " 2>/dev/null")
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
path := unquote(rest)
|
||||||
|
content, ok := s.files[path]
|
||||||
|
if !ok {
|
||||||
|
// `2>/dev/null` swallows the error; sha256sum exits 1 but
|
||||||
|
// stderr is suppressed. The transport treats empty output as
|
||||||
|
// "file missing" (no hash), so return ("", 0).
|
||||||
|
return []byte(""), 0
|
||||||
|
}
|
||||||
|
sum := sha256HexStr([]byte(content))
|
||||||
|
return []byte(sum + " " + path + "\n"), 0
|
||||||
|
case strings.HasPrefix(trimmed, "cat '"):
|
||||||
|
path := unquote(strings.TrimPrefix(trimmed, "cat "))
|
||||||
|
content, ok := s.files[path]
|
||||||
|
if !ok {
|
||||||
|
return []byte("cat: " + path + ": No such file or directory\n"), 1
|
||||||
|
}
|
||||||
|
return []byte(content), 0
|
||||||
|
case strings.HasPrefix(trimmed, "mkdir -p ") && strings.Contains(trimmed, "cat >"):
|
||||||
|
return s.handleWrite(trimmed)
|
||||||
|
default:
|
||||||
|
return []byte("sh: command not found\n"), 127
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWrite parses the heredoc write command produced by writeFile.
|
||||||
|
// Command format:
|
||||||
|
//
|
||||||
|
// mkdir -p '<dir>' && cat > '<tmp>' <<'ORCA_PUSH_EOF_a1b2c3'
|
||||||
|
// <content>
|
||||||
|
// ORCA_PUSH_EOF_a1b2c3
|
||||||
|
// chmod <mode> '<tmp>' && mv -f '<tmp>' '<path>'
|
||||||
|
func (s *fakeSSHServer) handleWrite(cmd string) ([]byte, int) {
|
||||||
|
const eof = "ORCA_PUSH_EOF_a1b2c3"
|
||||||
|
// Find the opening heredoc line: ... <<'EOF'\n
|
||||||
|
openerIdx := strings.Index(cmd, "<<'"+eof+"'")
|
||||||
|
if openerIdx < 0 {
|
||||||
|
return []byte("sh: no heredoc opener\n"), 1
|
||||||
|
}
|
||||||
|
// Body starts after the opener line's newline.
|
||||||
|
rest := cmd[openerIdx+len("<<'"+eof+"'"):]
|
||||||
|
nl := strings.Index(rest, "\n")
|
||||||
|
if nl < 0 {
|
||||||
|
return []byte("sh: no body start\n"), 1
|
||||||
|
}
|
||||||
|
body := rest[nl+1:]
|
||||||
|
// Body ends at the closing EOF marker on its own line.
|
||||||
|
closeIdx := strings.Index(body, "\n"+eof+"\n")
|
||||||
|
if closeIdx < 0 {
|
||||||
|
// Maybe EOF is at the end without trailing newline.
|
||||||
|
closeIdx = strings.Index(body, "\n"+eof)
|
||||||
|
if closeIdx < 0 {
|
||||||
|
return []byte("sh: no heredoc close\n"), 1
|
||||||
|
}
|
||||||
|
body = body[:closeIdx]
|
||||||
|
} else {
|
||||||
|
body = body[:closeIdx]
|
||||||
|
}
|
||||||
|
// Find the mv target: last quoted arg of "mv -f 'tmp' 'path'".
|
||||||
|
mvIdx := strings.LastIndex(cmd, "mv -f ")
|
||||||
|
if mvIdx < 0 {
|
||||||
|
return []byte("sh: no mv\n"), 1
|
||||||
|
}
|
||||||
|
tail := cmd[mvIdx+len("mv -f "):]
|
||||||
|
parts := splitQuoted(tail)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return []byte("sh: bad mv args\n"), 1
|
||||||
|
}
|
||||||
|
target := parts[1]
|
||||||
|
s.files[target] = body
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitQuoted splits a string of the form "'a' 'b'" into ["a","b"].
|
||||||
|
func splitQuoted(s string) []string {
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
in := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\'' {
|
||||||
|
if in {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
in = !in
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if in {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquote(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256HexStr is a test-local copy of the sha256Hex helper.
|
||||||
|
func sha256HexStr(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return fmt.Sprintf("%x", sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- test helpers for transport setup ---
|
||||||
|
|
||||||
|
// setupORCAHome creates a temp ORCA_HOME with an empty known_hosts (at
|
||||||
|
// the v0.8 flat location $ORCA_HOME/known_hosts, which is where
|
||||||
|
// proxmox.TOFUHostKeyCallback reads via certpaths.KnownHostsPath()) and a
|
||||||
|
// generated Ed25519 SSH key, returns the key path.
|
||||||
|
func setupORCAHome(t *testing.T) (keyPath string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
// certpaths.KnownHostsPath() = paths.Root()/known_hosts = $ORCA_HOME/known_hosts.
|
||||||
|
knownHosts := filepath.Join(dir, "known_hosts")
|
||||||
|
if err := os.WriteFile(knownHosts, []byte{}, 0o600); err != nil {
|
||||||
|
t.Fatalf("create known_hosts: %v", err)
|
||||||
|
}
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ed25519 gen: %v", err)
|
||||||
|
}
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal key: %v", err)
|
||||||
|
}
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||||
|
keyPath = filepath.Join(dir, "orca_ssh_key")
|
||||||
|
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
|
||||||
|
t.Fatalf("write key: %v", err)
|
||||||
|
}
|
||||||
|
return keyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// realTransport returns a Transport wired to use the real SSH dialer
|
||||||
|
// against a fake SSH server, with the server's host key pre-populated in
|
||||||
|
// known_hosts (so the TOFU callback matches on first dial — no first-
|
||||||
|
// connect write race in tests).
|
||||||
|
func realTransport(t *testing.T, srv *fakeSSHServer) *Transport {
|
||||||
|
t.Helper()
|
||||||
|
keyPath := setupORCAHome(t)
|
||||||
|
tr := NewTransport(keyPath, "")
|
||||||
|
tr.SetUser("root")
|
||||||
|
addr := srv.addr()
|
||||||
|
line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, srv.hostPublicKey())
|
||||||
|
home := os.Getenv("ORCA_HOME")
|
||||||
|
kh := filepath.Join(home, "known_hosts")
|
||||||
|
if err := os.WriteFile(kh, []byte(line+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("pre-pop known_hosts: %v", err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mock dialer + mock session for pure-logic tests (no real SSH) ---
|
||||||
|
|
||||||
|
type mockDialer struct {
|
||||||
|
client *ssh.Client
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDialer) DialContext(ctx context.Context, network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
|
||||||
|
m.calls++
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
return m.client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockSession struct {
|
||||||
|
out []byte
|
||||||
|
err error
|
||||||
|
cmd string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockSession) CombinedOutput(cmd string) ([]byte, error) {
|
||||||
|
m.cmd = cmd
|
||||||
|
return m.out, m.err
|
||||||
|
}
|
||||||
|
func (m *mockSession) Close() error { return nil }
|
||||||
|
|
||||||
|
// --- tests ---
|
||||||
|
|
||||||
|
func TestNewTransport_Defaults(t *testing.T) {
|
||||||
|
tr := NewTransport("/tmp/key", "/tmp/kh")
|
||||||
|
if tr.keyPath != "/tmp/key" {
|
||||||
|
t.Errorf("keyPath = %q", tr.keyPath)
|
||||||
|
}
|
||||||
|
if tr.user != "orca" {
|
||||||
|
t.Errorf("default user = %q, want orca", tr.user)
|
||||||
|
}
|
||||||
|
if tr.dialer == nil {
|
||||||
|
t.Error("dialer is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUser(t *testing.T) {
|
||||||
|
tr := NewTransport("/tmp/key", "/tmp/kh")
|
||||||
|
tr.SetUser("root")
|
||||||
|
if tr.user != "root" {
|
||||||
|
t.Errorf("user = %q, want root", tr.user)
|
||||||
|
}
|
||||||
|
tr.SetUser("")
|
||||||
|
if tr.user != "root" {
|
||||||
|
t.Errorf("user = %q, want root", tr.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackoff(t *testing.T) {
|
||||||
|
// Without jitter, attempt 1 -> 100ms, 2 -> 200ms, ... up to 5s cap.
|
||||||
|
// The jittered result is in [d/4, 3d/4) where d is the capped base,
|
||||||
|
// so for high attempts the result can reach 3*d/4 < 1.5*d. We bound
|
||||||
|
// the upper end at 2x the cap to allow jitter headroom.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
attempt int
|
||||||
|
max time.Duration
|
||||||
|
}{
|
||||||
|
{1, 200 * time.Millisecond},
|
||||||
|
{2, 400 * time.Millisecond},
|
||||||
|
{6, 2 * RetryMax},
|
||||||
|
{10, 2 * RetryMax},
|
||||||
|
} {
|
||||||
|
got := backoff(RetryInitial, RetryMax, tc.attempt)
|
||||||
|
if got < 0 || got > tc.max {
|
||||||
|
t.Errorf("backoff(%d) = %s, want in [0, %s]", tc.attempt, got, tc.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTransient(t *testing.T) {
|
||||||
|
if isTransient(nil) {
|
||||||
|
t.Error("nil should not be transient")
|
||||||
|
}
|
||||||
|
if !isTransient(ErrTransient) {
|
||||||
|
t.Error("ErrTransient should be transient")
|
||||||
|
}
|
||||||
|
if isTransient(ErrPermanent) {
|
||||||
|
t.Error("ErrPermanent should not be transient")
|
||||||
|
}
|
||||||
|
if !isTransient(errors.New("connection refused")) {
|
||||||
|
t.Error("connection refused should be transient")
|
||||||
|
}
|
||||||
|
if !isTransient(errors.New("i/o timeout")) {
|
||||||
|
t.Error("i/o timeout should be transient")
|
||||||
|
}
|
||||||
|
if isTransient(errors.New("some other error")) {
|
||||||
|
t.Error("unknown error should not be transient")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDialErr(t *testing.T) {
|
||||||
|
if got := classifyDialErr(nil); got != nil {
|
||||||
|
t.Errorf("nil -> nil, got %v", got)
|
||||||
|
}
|
||||||
|
perm := classifyDialErr(errors.New("ssh: unable to authenticate"))
|
||||||
|
if !errors.Is(perm, ErrPermanent) {
|
||||||
|
t.Errorf("auth failure should be permanent, got %v", perm)
|
||||||
|
}
|
||||||
|
perm2 := classifyDialErr(errors.New("host key mismatch"))
|
||||||
|
if !errors.Is(perm2, ErrPermanent) {
|
||||||
|
t.Errorf("host key mismatch should be permanent, got %v", perm2)
|
||||||
|
}
|
||||||
|
trans := classifyDialErr(errors.New("connection refused"))
|
||||||
|
if !errors.Is(trans, ErrTransient) {
|
||||||
|
t.Errorf("connection refused should be transient, got %v", trans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyExecErr(t *testing.T) {
|
||||||
|
exitErr := ssh.ExitError{}
|
||||||
|
perm := classifyExecErr(&exitErr)
|
||||||
|
if !errors.Is(perm, ErrPermanent) {
|
||||||
|
t.Errorf("ExitError should be permanent, got %v", perm)
|
||||||
|
}
|
||||||
|
trans := classifyExecErr(errors.New("session closed"))
|
||||||
|
if !errors.Is(trans, ErrTransient) {
|
||||||
|
t.Errorf("session closed should be transient, got %v", trans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
got := shellQuote("/etc/orca/foo.conf")
|
||||||
|
if got != "'/etc/orca/foo.conf'" {
|
||||||
|
t.Errorf("shellQuote = %q", got)
|
||||||
|
}
|
||||||
|
got = shellQuote("it's a path")
|
||||||
|
if got != "'it'\\''s a path'" {
|
||||||
|
t.Errorf("shellQuote with quote = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ExecSuccess_RealSSH(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
out, err := tr.Exec(context.Background(), srv.addr(), "echo hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exec: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(out)) != "hello" {
|
||||||
|
t.Errorf("out = %q, want hello", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ExecRetry_TransientFailure(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
var calls int32
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
n := atomic.AddInt32(&calls, 1)
|
||||||
|
if n < 3 {
|
||||||
|
return &mockSession{err: errors.New("EOF")}, nil
|
||||||
|
}
|
||||||
|
return &mockSession{out: []byte("ok\n")}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
out, err := tr.Exec(context.Background(), srv.addr(), "echo ok")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exec: %v (calls=%d)", err, atomic.LoadInt32(&calls))
|
||||||
|
}
|
||||||
|
if string(out) != "ok\n" {
|
||||||
|
t.Errorf("out = %q, want ok\\n", out)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got < 3 {
|
||||||
|
t.Errorf("calls = %d, want >= 3 (retried)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ExecPermanentError_NoRetry(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
var calls int32
|
||||||
|
tr.SetSessionFactory(func(c *ssh.Client) (sshSession, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
return &mockSession{err: &ssh.ExitError{}}, nil
|
||||||
|
})
|
||||||
|
client, err := tr.dial(srv.addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
tr.pool.Store(srv.addr(), client)
|
||||||
|
_, err = tr.Exec(context.Background(), srv.addr(), "exit 1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrPermanent) {
|
||||||
|
t.Errorf("err should be permanent, got %v", err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Errorf("calls = %d, want 1 (no retry on permanent)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ExecTimeout(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
srv.setExecDelay(500 * time.Millisecond)
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err := tr.Exec(ctx, srv.addr(), "echo hello")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected timeout error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, ErrTransient) {
|
||||||
|
t.Errorf("err should be timeout/transient, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ConnectionPoolReuse(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
addr := srv.addr()
|
||||||
|
c1, err := tr.dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first dial: %v", err)
|
||||||
|
}
|
||||||
|
c2, err := tr.dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second dial: %v", err)
|
||||||
|
}
|
||||||
|
if c1 != c2 {
|
||||||
|
t.Error("pool did not reuse client for same peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_CloseClosesAllClients(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
if _, err := tr.dial(srv.addr()); err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
tr.pool.Range(func(_, _ any) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("pool has %d entries, want 1", count)
|
||||||
|
}
|
||||||
|
if err := tr.Close(); err != nil {
|
||||||
|
t.Errorf("Close: %v", err)
|
||||||
|
}
|
||||||
|
_, err := tr.dial(srv.addr())
|
||||||
|
if !errors.Is(err, ErrPermanent) {
|
||||||
|
t.Errorf("dial after Close should be ErrPermanent, got %v", err)
|
||||||
|
}
|
||||||
|
if err := tr.Close(); err != nil {
|
||||||
|
t.Errorf("second Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_ReadFile_RealSSH(t *testing.T) {
|
||||||
|
srv := newFakeSSHServer(t)
|
||||||
|
defer srv.close()
|
||||||
|
srv.mu.Lock()
|
||||||
|
srv.files["/etc/orca/test.conf"] = "content-line\n"
|
||||||
|
srv.mu.Unlock()
|
||||||
|
tr := realTransport(t, srv)
|
||||||
|
defer tr.Close()
|
||||||
|
out, err := tr.ReadFile(context.Background(), srv.addr(), "/etc/orca/test.conf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if string(out) != "content-line\n" {
|
||||||
|
t.Errorf("out = %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_DialKeyParseFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||||
|
t.Fatalf("kh: %v", err)
|
||||||
|
}
|
||||||
|
keyPath := filepath.Join(dir, "bad_key")
|
||||||
|
if err := os.WriteFile(keyPath, []byte("not a key"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write key: %v", err)
|
||||||
|
}
|
||||||
|
tr := NewTransport(keyPath, "")
|
||||||
|
tr.SetUser("root")
|
||||||
|
_, err := tr.dial("127.0.0.1:1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected parse error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parse key") {
|
||||||
|
t.Errorf("err should mention parse key, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_DialKeyMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||||
|
t.Fatalf("kh: %v", err)
|
||||||
|
}
|
||||||
|
tr := NewTransport(filepath.Join(dir, "missing_key"), "")
|
||||||
|
tr.SetUser("root")
|
||||||
|
_, err := tr.dial("127.0.0.1:1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected read error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "read key") {
|
||||||
|
t.Errorf("err should mention read key, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransport_DialMockFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("ORCA_HOME", dir)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||||
|
t.Fatalf("kh: %v", err)
|
||||||
|
}
|
||||||
|
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
der, _ := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||||
|
keyPath := filepath.Join(dir, "orca_ssh_key")
|
||||||
|
_ = os.WriteFile(keyPath, pemBytes, 0o600)
|
||||||
|
tr := NewTransport(keyPath, "")
|
||||||
|
tr.SetUser("root")
|
||||||
|
tr.SetDialer(&mockDialer{err: errors.New("connection refused")})
|
||||||
|
_, err := tr.dial("127.0.0.1:1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected dial error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrTransient) {
|
||||||
|
t.Errorf("connection refused should be transient, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user