diff --git a/internal/cli/cert.go b/internal/cli/cert.go index 92dec8a..f732b92 100644 --- a/internal/cli/cert.go +++ b/internal/cli/cert.go @@ -60,10 +60,6 @@ Deprecated: v0.9 re-architecture replaces the internal CA with step-ca (D-101/REQ-076). The ` + "`orca cert`" + ` command tree is retained for the dual-write window and scheduled for deletion in v0.10. See .ciagent/PRD_v0.9.md.`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - warnDeprecated("orca cert is deprecated in v0.9: step-ca (D-101) now handles CA; orca cert will be removed in v0.10 — see .ciagent/PRD_v0.9.md") - return nil - }, } certCmd.AddCommand(newCAInitCmd(log)) @@ -81,6 +77,7 @@ func newCAInitCmd(log *slog.Logger) *cobra.Command { Short: "Initialize a local orca CA (ca.crt + ca.key) under ~/.orca", Long: "Generates a new RSA CA cert and writes it to ~/.orca/ca.crt (0644) and ~/.orca/ca.key (0600) per REQ-033.", RunE: func(cmd *cobra.Command, args []string) error { + warnDeprecated("orca cert ca-init is deprecated: use step-ca (R-006); the internal CA is replaced by step-ca (D-101) — see .ciagent/PRD_v0.9.md") dir := CADir() if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) @@ -114,6 +111,7 @@ func newGenCmd(log *slog.Logger) *cobra.Command { Short: "Generate a server cert (CSR + sign) under ~/.orca", Long: "Builds a CSR with the requested SANs, signs it with the local CA, and writes server.crt + server.key.", RunE: func(cmd *cobra.Command, args []string) error { + warnDeprecated("orca cert gen is deprecated: use step-ca + orca node join for cert generation (D-101) — see .ciagent/PRD_v0.9.md") dir := CADir() if cn == "" { cn = "orca-server" @@ -185,6 +183,7 @@ func newRenewCmd(log *slog.Logger) *cobra.Command { Short: "Rotate the server cert (hot-swapped by the daemon; REQ-034)", Long: "Re-runs `cert gen` and overwrites server.crt / server.key in place. The daemon's GetCertificate callback picks up the new cert on the next handshake — no restart required.", RunE: func(cmd *cobra.Command, args []string) error { + warnDeprecated("orca cert renew is deprecated: use step-ca for cert rotation (D-101) — see .ciagent/PRD_v0.9.md") dir := CADir() if cn == "" { cn = "orca-server" diff --git a/internal/cli/daemon_test.go b/internal/cli/daemon_test.go index fc65539..9ea1a61 100644 --- a/internal/cli/daemon_test.go +++ b/internal/cli/daemon_test.go @@ -133,8 +133,8 @@ func TestNoDeprecationWarningsFlagRegistered(t *testing.T) { } } -// TestCertEmitsDeprecationWarning verifies REQ-068: `orca cert` -// subcommands emit a deprecation banner. +// TestCertEmitsDeprecationWarning verifies REQ-068: deprecated +// `orca cert ca-init` subcommand emits a deprecation banner. func TestCertEmitsDeprecationWarning(t *testing.T) { _, cleanup := initTestEnv(t) defer cleanup() @@ -146,12 +146,12 @@ func TestCertEmitsDeprecationWarning(t *testing.T) { var out bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&out) - rootCmd.SetArgs([]string{"cert", "fingerprint", "--which", "ca"}) + rootCmd.SetArgs([]string{"cert", "ca-init", "--cn", "test-ca"}) _ = rootCmd.Execute() logged := buf.String() - if !strings.Contains(logged, "orca cert is deprecated in v0.9") { - t.Errorf("expected cert deprecation warning, got:\n%s", logged) + if !strings.Contains(logged, "orca cert ca-init is deprecated") { + t.Errorf("expected cert ca-init deprecation warning, got:\n%s", logged) } if !strings.Contains(logged, "step-ca") { t.Errorf("deprecation warning should mention step-ca, got:\n%s", logged) @@ -172,10 +172,10 @@ func TestCertDeprecationWarningSuppressed(t *testing.T) { var out bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&out) - rootCmd.SetArgs([]string{"cert", "fingerprint", "--which", "ca"}) + rootCmd.SetArgs([]string{"cert", "ca-init", "--cn", "test-ca"}) _ = rootCmd.Execute() - if strings.Contains(buf.String(), "orca cert is deprecated") { + if strings.Contains(buf.String(), "orca cert ca-init is deprecated") { t.Errorf("--no-deprecation-warnings should suppress cert warning, got:\n%s", buf.String()) } } diff --git a/internal/cli/deprecation_test.go b/internal/cli/deprecation_test.go new file mode 100644 index 0000000..941ad6a --- /dev/null +++ b/internal/cli/deprecation_test.go @@ -0,0 +1,177 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// runWithSlog executes the given args against rootCmd, capturing the +// slog output (where warnDeprecated writes). It returns the captured +// slog buffer and the command stdout buffer. +func runWithSlog(t *testing.T, args []string, suppressWarnings bool) (slogOut, stdOut string, err error) { + t.Helper() + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + + buf, restore := captureSlog(t) + defer restore() + + if suppressWarnings { + _ = rootCmd.PersistentFlags().Set("no-deprecation-warnings", "true") + } + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs(args) + err = rootCmd.Execute() + return buf.String(), out.String(), err +} + +func TestDeprecationDaemonEmitsWarning(t *testing.T) { + slogOut, _ := runDaemonHermetic(t, false) + if !strings.Contains(slogOut, "orca daemon is deprecated in v0.9") { + t.Errorf("expected daemon deprecation warning, got:\n%s", slogOut) + } +} + +func TestDeprecationCertCAInitEmitsWarning(t *testing.T) { + slogOut, _, err := runWithSlog(t, []string{"cert", "ca-init", "--cn", "dep-test"}, false) + if err != nil { + t.Fatalf("cert ca-init: %v", err) + } + if !strings.Contains(slogOut, "orca cert ca-init is deprecated") { + t.Errorf("expected cert ca-init deprecation warning, got:\n%s", slogOut) + } + if !strings.Contains(slogOut, "step-ca") { + t.Errorf("deprecation warning should mention step-ca, got:\n%s", slogOut) + } +} + +func TestDeprecationCertGenEmitsWarning(t *testing.T) { + // gen requires a CA; we only assert the warning fires (before the + // error path). + slogOut, _, _ := runWithSlog(t, []string{"cert", "gen", "--cn", "dep-gen"}, false) + if !strings.Contains(slogOut, "orca cert gen is deprecated") { + t.Errorf("expected cert gen deprecation warning, got:\n%s", slogOut) + } +} + +func TestDeprecationCertRenewEmitsWarning(t *testing.T) { + // renew requires a CA; we only assert the warning fires (before the + // error path). + slogOut, _, _ := runWithSlog(t, []string{"cert", "renew"}, false) + if !strings.Contains(slogOut, "orca cert renew is deprecated") { + t.Errorf("expected cert renew deprecation warning, got:\n%s", slogOut) + } +} + +func TestDeprecationCertShowNoWarning(t *testing.T) { + slogOut, _, err := runWithSlog(t, []string{"cert", "show"}, false) + // show may fail if no cert exists; we only assert no deprecation. + _ = err + if strings.Contains(slogOut, "deprecated") { + t.Errorf("cert show must NOT emit deprecation warning, got:\n%s", slogOut) + } +} + +func TestDeprecationCertFingerprintNoWarning(t *testing.T) { + // Need a CA first so fingerprint has something to read. + _, cleanup := initTestEnv(t) + defer cleanup() + resetRootFlags(t) + var b bytes.Buffer + rootCmd.SetOut(&b) + rootCmd.SetErr(&b) + rootCmd.SetArgs([]string{"cert", "ca-init", "--cn", "fp-test"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("cert ca-init: %v", err) + } + + slogBuf, restore := captureSlog(t) + defer restore() + + resetRootFlags(t) + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"cert", "fingerprint", "--which", "ca"}) + _ = rootCmd.Execute() + + if strings.Contains(slogBuf.String(), "deprecated") { + t.Errorf("cert fingerprint must NOT emit deprecation warning, got:\n%s", slogBuf.String()) + } +} + +func TestDeprecationJobRunHCLEmitsWarning(t *testing.T) { + dir := t.TempDir() + specPath := filepath.Join(dir, "old-spec.hcl") + if err := os.WriteFile(specPath, []byte(`job "true" {} +task "t" { + command = "/bin/true" +} +`), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + slogOut, _, err := runWithSlog(t, []string{"job", "run", specPath}, false) + if err != nil { + t.Fatalf("job run: %v", err) + } + if !strings.Contains(slogOut, ".hcl jobspec is legacy") { + t.Errorf("expected .hcl deprecation warning, got:\n%s", slogOut) + } + if !strings.Contains(slogOut, "R-013") { + t.Errorf("deprecation warning should reference R-013, got:\n%s", slogOut) + } +} + +func TestDeprecationJobRunMDNoWarning(t *testing.T) { + dir := t.TempDir() + specPath := filepath.Join(dir, "spec.md") + if err := os.WriteFile(specPath, []byte("---\nkind: Workload\nname: md-job\n---\n"), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + slogOut, _, _ := runWithSlog(t, []string{"job", "run", specPath}, false) + if strings.Contains(slogOut, ".hcl jobspec is legacy") { + t.Errorf(".md jobspec must NOT emit .hcl deprecation warning, got:\n%s", slogOut) + } +} + +func TestDeprecationWarningsSuppressedByFlag(t *testing.T) { + // daemon + slogOut, _ := runDaemonHermetic(t, true) + if strings.Contains(slogOut, "deprecated in v0.9") { + t.Errorf("--no-deprecation-warnings should suppress daemon warning, got:\n%s", slogOut) + } + + // cert ca-init + slogOut2, _, err := runWithSlog(t, []string{"cert", "ca-init", "--cn", "sup-test"}, true) + if err != nil { + t.Fatalf("cert ca-init: %v", err) + } + if strings.Contains(slogOut2, "deprecated") { + t.Errorf("--no-deprecation-warnings should suppress cert warning, got:\n%s", slogOut2) + } + + // job run .hcl + dir := t.TempDir() + specPath := filepath.Join(dir, "old-spec.hcl") + if err := os.WriteFile(specPath, []byte(`job "true" {} +task "t" { + command = "/bin/true" +} +`), 0o644); err != nil { + t.Fatalf("write spec: %v", err) + } + slogOut3, _, err := runWithSlog(t, []string{"job", "run", specPath}, true) + if err != nil { + t.Fatalf("job run: %v", err) + } + if strings.Contains(slogOut3, "deprecated") { + t.Errorf("--no-deprecation-warnings should suppress .hcl warning, got:\n%s", slogOut3) + } +} diff --git a/internal/cli/job.go b/internal/cli/job.go index 355f873..8e07fe6 100644 --- a/internal/cli/job.go +++ b/internal/cli/job.go @@ -49,6 +49,9 @@ var jobRunCmd = &cobra.Command{ Long: "Submit a job spec, execute its tasks, and persist the result. Use --target to pin to a specific node (overrides bin-packing); --idempotency-key for cross-node dispatch dedupe.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if strings.HasSuffix(args[0], ".hcl") { + warnDeprecated("orca job run is deprecated: .hcl jobspec is legacy (R-013); convert to .md format (REQ-064) — see .ciagent/PRD_v0.9.md") + } spec, err := jobspec.ParseFile(args[0]) if err != nil { return err diff --git a/internal/cli/ns.go b/internal/cli/ns.go index 316c74e..4855a9f 100644 --- a/internal/cli/ns.go +++ b/internal/cli/ns.go @@ -6,6 +6,8 @@ // orca ns delete — remove an empty namespace dir // orca ns inspect — print effective chain + merged env // orca ns validate — cycle + missing-parent + schema checks +// orca ns inherit — set the parent namespace (R-002) +// orca ns set-constraint = — set a constraint // // All subcommands honor $ORCA_HOME via internal/paths. The inheritance // resolver (internal/ns) is a pure function shared by inspect + validate. @@ -291,6 +293,144 @@ set).`, }, } +var ( + nsInheritParent string +) + +var nsInheritCmd = &cobra.Command{ + Use: "inherit ", + Short: "Set the parent namespace for inheritance (R-002)", + Long: `Set the parent namespace for a namespace. Updates ns.md +frontmatter (parents) and validates the new chain has no cycles +(child cannot inherit from itself transitively). The implicit root +_defaults is always appended last (D-185).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if name == paths.DefaultNamespace() { + return fmt.Errorf("cannot set parent on the implicit root namespace %q", name) + } + if nsInheritParent == "" { + return fmt.Errorf("--parent is required") + } + if nsInheritParent == name { + return fmt.Errorf("namespace %q cannot inherit from itself", name) + } + nsMd := paths.NSMd(name) + cfg, nsBody, err := ns.ParseNSMdWithBody(nsMd) + if err != nil { + return fmt.Errorf("parse %s: %w", nsMd, err) + } + cfg.Parents = []string{nsInheritParent} + + root := paths.Root() + cfgs, err := ns.ParseNSMdDir(root) + if err != nil { + return fmt.Errorf("load namespaces: %w", err) + } + cfgs[name] = cfg + if _, err := ns.Resolve(cfgs); err != nil { + return fmt.Errorf("cycle check: %w", err) + } + + body := renderNSMdFull(cfg, nsBody) + if err := os.WriteFile(nsMd, []byte(body), 0o644); err != nil { + return fmt.Errorf("write %s: %w", nsMd, err) + } + if jsonOutput { + return printJSON(map[string]any{ + "name": name, + "parents": cfg.Parents, + "ns_md": nsMd, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "\u2713 Namespace %s now inherits from %s\n", name, nsInheritParent) + return nil + }, +} + +var nsSetConstraintCmd = &cobra.Command{ + Use: "set-constraint =", + Short: "Set a constraint on a namespace (stored in ns.md frontmatter)", + Long: `Set a constraint on a namespace. Constraints are key=value +strings (e.g. max-allocs=10) stored in ns.md frontmatter and unioned +across the inheritance chain by the resolver.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + kv := args[1] + if name == paths.DefaultNamespace() { + return fmt.Errorf("cannot set a constraint on the implicit root namespace %q with set-constraint; edit ns.md directly", name) + } + idx := strings.Index(kv, "=") + if idx <= 0 || idx == len(kv)-1 { + return fmt.Errorf("constraint must be =, got %q", kv) + } + constraint := kv + nsMd := paths.NSMd(name) + cfg, nsBody, err := ns.ParseNSMdWithBody(nsMd) + if err != nil { + return fmt.Errorf("parse %s: %w", nsMd, err) + } + for _, c := range cfg.Constraints { + if c == constraint { + return fmt.Errorf("constraint %q already set on namespace %q", constraint, name) + } + } + cfg.Constraints = append(cfg.Constraints, constraint) + + body := renderNSMdFull(cfg, nsBody) + if err := os.WriteFile(nsMd, []byte(body), 0o644); err != nil { + return fmt.Errorf("write %s: %w", nsMd, err) + } + if jsonOutput { + return printJSON(map[string]any{ + "name": name, + "constraints": cfg.Constraints, + "ns_md": nsMd, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "\u2713 Constraint set on %s: %s\n", name, constraint) + return nil + }, +} + +// renderNSMdFull renders a complete ns.md from a parsed *ns.NSConfig +// plus an optional body (the markdown after the frontmatter). Used by +// the ns inherit / set-constraint editors to rewrite frontmatter while +// preserving the body. +func renderNSMdFull(cfg *ns.NSConfig, body string) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString("kind: Namespace\n") + b.WriteString("name: ") + b.WriteString(cfg.Name) + b.WriteString("\n") + if len(cfg.Parents) > 0 { + quoted := make([]string, len(cfg.Parents)) + for i, p := range cfg.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", cfg.InheritsEnv) + fmt.Fprintf(&b, "inherits_secrets: %t\n", cfg.InheritsSecrets) + if len(cfg.Constraints) > 0 { + quoted := make([]string, len(cfg.Constraints)) + for i, c := range cfg.Constraints { + quoted[i] = fmt.Sprintf("%q", c) + } + b.WriteString("constraints: [") + b.WriteString(strings.Join(quoted, ", ")) + b.WriteString("]\n") + } + b.WriteString("---\n") + b.WriteString(body) + return b.String() +} + // 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 @@ -344,10 +484,14 @@ func init() { 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)") + nsInheritCmd.Flags().StringVar(&nsInheritParent, "parent", "", "parent namespace to inherit from (required)") + nsCmd.AddCommand(nsListCmd) nsCmd.AddCommand(nsCreateCmd) nsCmd.AddCommand(nsDeleteCmd) nsCmd.AddCommand(nsInspectCmd) nsCmd.AddCommand(nsValidateCmd) + nsCmd.AddCommand(nsInheritCmd) + nsCmd.AddCommand(nsSetConstraintCmd) rootCmd.AddCommand(nsCmd) } diff --git a/internal/cli/ns_test.go b/internal/cli/ns_test.go index 73e82e8..0ba2940 100644 --- a/internal/cli/ns_test.go +++ b/internal/cli/ns_test.go @@ -17,6 +17,7 @@ func resetNSFlags() { nsCreateParent = "" nsCreateInheritsEnv = true nsCreateInheritsSecret = true + nsInheritParent = "" } func writeDefaultsNS(t *testing.T, root string) { @@ -384,7 +385,7 @@ func TestNSRootRegistered(t *testing.T) { } } } - for _, want := range []string{"list", "create ", "delete ", "inspect ", "validate "} { + for _, want := range []string{"list", "create ", "delete ", "inspect ", "validate ", "inherit ", "set-constraint ="} { if !sub[want] { t.Errorf("missing ns subcommand %q", want) } @@ -405,3 +406,192 @@ func TestNSListNoORCAHOME(t *testing.T) { } var _ = paths.DefaultNamespace // keep paths import alive + +func TestNSInheritSetsParent(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", "inherit", "prod", "--parent", "_defaults"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns inherit: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, "prod", "ns.md")) + if err != nil { + t.Fatalf("read ns.md: %v", err) + } + if !strings.Contains(string(data), "parents:") || !strings.Contains(string(data), "_defaults") { + t.Errorf("ns.md missing parents: %s", string(data)) + } +} + +func TestNSInheritCycleRefused(t *testing.T) { + root := t.TempDir() + t.Setenv("ORCA_HOME", root) + resetRootFlags(t) + resetNSFlags() + writeDefaultsNS(t, root) + // a -> b already; now set a's parent to b, then try b -> a. + writeCustomNS(t, root, "a", `["b"]`) + writeCustomNS(t, root, "b", "") + + resetNSFlags() + rootCmd.SetArgs([]string{"ns", "inherit", "b", "--parent", "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 TestNSInheritSelfRefused(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", "inherit", "prod", "--parent", "prod"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected self-inherit error, got nil") + } +} + +func TestNSInheritDefaultsRefused(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", "inherit", "_defaults", "--parent", "prod"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error setting parent on _defaults, got nil") + } +} + +func TestNSSetConstraint(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", "set-constraint", "prod", "max-allocs=10"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns set-constraint: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, "prod", "ns.md")) + if err != nil { + t.Fatalf("read ns.md: %v", err) + } + if !strings.Contains(string(data), "constraints:") || !strings.Contains(string(data), "max-allocs=10") { + t.Errorf("ns.md missing constraints: %s", string(data)) + } +} + +func TestNSSetConstraintValidatePasses(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", "set-constraint", "prod", "max-allocs=10"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns set-constraint: %v", err) + } + resetNSFlags() + rootCmd.SetArgs([]string{"ns", "validate", "prod"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns validate after set-constraint: %v", err) + } +} + +func TestNSSetConstraintInspectShowsConstraint(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", "set-constraint", "prod", "max-allocs=10"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns set-constraint: %v", err) + } + + resetNSFlags() + 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()) + } + cons, _ := result["constraints"].([]any) + found := false + for _, c := range cons { + if c == "max-allocs=10" { + found = true + } + } + if !found { + t.Errorf("constraint max-allocs=10 not in inspect output: %v", cons) + } +} + +func TestNSSetConstraintInvalidFormat(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", "set-constraint", "prod", "noequals"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for malformed constraint, got nil") + } + if !strings.Contains(err.Error(), "=") { + t.Errorf("error = %q, want contains '='", err.Error()) + } +} + +func TestNSSetConstraintDuplicate(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", "set-constraint", "prod", "max-allocs=10"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("ns set-constraint first: %v", err) + } + resetNSFlags() + rootCmd.SetArgs([]string{"ns", "set-constraint", "prod", "max-allocs=10"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected duplicate error, got nil") + } + if !strings.Contains(err.Error(), "already set") { + t.Errorf("error = %q, want contains 'already set'", err.Error()) + } +} diff --git a/internal/ns/parse.go b/internal/ns/parse.go index 68504e6..e4d95e7 100644 --- a/internal/ns/parse.go +++ b/internal/ns/parse.go @@ -21,6 +21,7 @@ import ( // parents: ["a", "b"] (optional; default empty) // inherits_env: true (optional; default true) // inherits_secrets: true (optional; default true) +// constraints: ["k=v"] (optional; parsed into cfg.Constraints) // quota: {...} (optional; parsed but not surfaced here) // acl: {...} (optional; parsed but not surfaced here) // @@ -55,6 +56,55 @@ func ParseNSMd(path string) (*NSConfig, error) { return cfg, nil } +// ParseNSMdWithBody is like ParseNSMd but also returns the markdown +// body (the content after the closing `---` delimiter). Used by the +// ns inherit / set-constraint editors that rewrite frontmatter while +// preserving the body. +func ParseNSMdWithBody(path string) (*NSConfig, string, 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) + } + body := bodyAfterFrontmatter(content) + return cfg, body, nil +} + +// bodyAfterFrontmatter returns the content after the closing frontmatter +// delimiter of an ns.md file. If no frontmatter is present, the whole +// input is returned unchanged. +func bodyAfterFrontmatter(content string) string { + trimmed := strings.TrimLeft(content, "\r\n\t ") + if !strings.HasPrefix(trimmed, "---") { + return content + } + rest := trimmed[3:] + rest = strings.TrimLeft(rest, "\r\n") + idx := strings.Index(rest, "\n---") + if idx < 0 { + return content + } + after := rest[idx+4:] + after = strings.TrimLeft(after, "\r\n") + return after +} + // extractFrontmatter returns the YAML block between the first pair of // `---` delimiters and whether a frontmatter block was present. func extractFrontmatter(content string) (string, bool) { @@ -110,6 +160,12 @@ func parseNSFrontmatter(block, path string) (*NSConfig, error) { cfg.InheritsEnv = parseBool(val) case "inherits_secrets": cfg.InheritsSecrets = parseBool(val) + case "constraints": + cons, err := parseStringArray(val) + if err != nil { + return nil, fmt.Errorf("parse %s: line %d: constraints: %w", path, lineNo+1, err) + } + cfg.Constraints = cons case "quota", "acl": // Reserved nested-mapping keys; recognized, contents ignored. default: