// 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 — create a namespace dir + ns.md // orca ns delete — remove an empty namespace dir // orca ns inspect — print effective chain + merged env // orca ns validate — 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 ", 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 ", 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 ", 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 ", 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) }