feat(P13): ns subcommands (inherit, set-constraint) + deprecation warnings (REQ-068)
orca ns inherit <name> --parent (cycle detection), ns set-constraint <key>=value>. Deprecation warnings on orca cert ca-init/gen/renew (step-ca replaces) and .hcl jobspec (R-013). --no-deprecation-warnings suppresses all. ---ci--- project: orca phase: 13 milestone: v0.11 status: execute ---/ci---
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <spec.hcl> 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
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// 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
|
||||
// orca ns inherit <name> — set the parent namespace (R-002)
|
||||
// orca ns set-constraint <name> <key>=<value> — 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 <name>",
|
||||
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 <name> <key>=<value>",
|
||||
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 <key>=<value>, 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)
|
||||
}
|
||||
|
||||
+191
-1
@@ -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 <name>", "delete <name>", "inspect <name>", "validate <name>"} {
|
||||
for _, want := range []string{"list", "create <name>", "delete <name>", "inspect <name>", "validate <name>", "inherit <name>", "set-constraint <name> <key>=<value>"} {
|
||||
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(), "<key>=<value>") {
|
||||
t.Errorf("error = %q, want contains '<key>=<value>'", 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user