Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,17 +320,22 @@ these are the corners where drift would start if it starts anywhere.
declaration). Think "typed status for a controller," not "Terraform
outputs."

- [ ] **K7 — Expose the scheduling DAG / plan-preview.** Today only a *rooted*
composition graph is exposed (`GetChildrenGraph` → UI DagView: a resource's
`owns`/`ref` edges outward). The **operation-scheduling DAG** the dispatcher
builds from `$ref` edges — the actual apply *order* and *why* — is internal
and never surfaced. And `DryRunApply` previews one resource's diff, not a
whole submission's ordering. Add a batch **plan-preview**: given a set of
manifests (or a repo dir), return the dependency DAG + the topological
apply order (+ per-resource dry-run diff), so an operator can see "what
will happen, in what order, and what waits on what" before applying —
including for a GitOps sync. Pairs with I1 (plan-on-PR: the same preview,
rendered on a PR).
- [~] **K7 — Expose the scheduling DAG / plan-preview.** New **`openctl plan
-f <files|dirs>`** (`internal/plan` + `internal/cli/plan.go`): loads a set
of manifests (`.cue`/`.yaml`/`.yml`, dirs walked recursively), derives the
cross-resource `$ref` dependency graph (via the same `refs.Collect`
primitive the dispatcher uses, keyed on the full apiVersion|kind|name
triple), topologically sorts it (Kahn) into **waves** — resources in a
wave have no inter-dependency and can apply concurrently — prints each
resource's direct deps (`← Kind/Name`), flags `$ref` targets **not** in the
set (must already exist), and errors on a dependency cycle (naming the
stuck resources). Offline: no controller connection. Validated by planning
the shipped `examples/homelab/` set (11 resources → 2 waves). Unit-tested
(chain/diamond/external/cycle/duplicate/full-triple-key + the batch
loader + output). **Remaining:** per-resource dry-run **diff** (`--diff`)
is server-only — it needs the controller's `DryRunApply` (drift vs stored
last-applied + live provider Get) — and a batch RPC to expose the DAG to
the UI. Pairs with I1 (plan-on-PR: render this preview on a PR).

The UI/controller feature build-out is essentially complete (all UI
phases + arch Phase 8 shipped). The next round is driven by the
Expand Down
146 changes: 146 additions & 0 deletions internal/cli/plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package cli

import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"

"github.com/spf13/cobra"

"github.com/openctl/openctl/internal/manifest"
"github.com/openctl/openctl/internal/plan"
"github.com/openctl/openctl/pkg/protocol"
)

func newPlanCommand() *cobra.Command {
var files []string

cmd := &cobra.Command{
Use: "plan -f <file|dir> [-f ...]",
Short: "Preview the apply order and $ref dependency graph for a set of manifests",
Long: "Load a set of manifests (files or directories, .cue/.yaml/.yml) and print the\n" +
"cross-resource dependency graph derived from their $ref markers: the\n" +
"topological apply order as waves (resources in the same wave have no\n" +
"inter-dependency and can apply concurrently), what each resource waits on, and\n" +
"any $ref targets that are NOT in the set (they must already exist).\n\n" +
"This is offline — it needs no controller connection. (Per-resource dry-run\n" +
"diffs need a running controller and are not included here.)",
RunE: func(cmd *cobra.Command, args []string) error {
paths := append([]string{}, files...)
paths = append(paths, args...) // also accept bare path args
if len(paths) == 0 {
return fmt.Errorf("no manifests: pass -f <file|dir> (or bare path args)")
}
resources, err := loadManifestBatch(paths)
if err != nil {
return err
}
if len(resources) == 0 {
return fmt.Errorf("no manifests found in %s", strings.Join(paths, ", "))
}
p, err := plan.Build(resources)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
printPlan(cmd.OutOrStdout(), p)
return nil
},
}
cmd.Flags().StringArrayVarP(&files, "file", "f", nil, "Manifest file or directory (repeatable)")
return cmd
}

// loadManifestBatch loads every manifest under the given paths (files or
// directories), dispatching by extension. Directories are walked recursively
// for .cue/.yaml/.yml files, in sorted order for deterministic output.
func loadManifestBatch(paths []string) ([]*protocol.Resource, error) {
var files []string
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
return nil, fmt.Errorf("read %s: %w", p, err)
}
if !info.IsDir() {
files = append(files, p)
continue
}
err = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && isManifestFile(path) {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", p, err)
}
}
sort.Strings(files)

var out []*protocol.Resource
for _, f := range files {
rs, err := loadManifestFile(f)
if err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
out = append(out, rs...)
}
return out, nil
}

func isManifestFile(path string) bool {
switch filepath.Ext(path) {
case ".cue", ".yaml", ".yml":
return true
}
return false
}

func loadManifestFile(path string) ([]*protocol.Resource, error) {
switch filepath.Ext(path) {
case ".cue":
return manifest.LoadCUE(path)
case ".yaml", ".yml":
return manifest.LoadMultiple(path)
default:
return nil, fmt.Errorf("unsupported manifest extension (want .cue/.yaml/.yml)")
}
}

func printPlan(w io.Writer, p *plan.Plan) {
fmt.Fprintf(w, "Plan: %d resource(s), %d wave(s)\n\n", p.Count(), len(p.Waves))
fmt.Fprintln(w, "Apply order:")

external := map[string][]string{} // node display -> external refs
for i, wave := range p.Waves {
fmt.Fprintf(w, " wave %d:\n", i+1)
for _, n := range wave {
line := " " + n.Display()
if len(n.Deps) > 0 {
line += " ← " + strings.Join(n.Deps, ", ")
}
fmt.Fprintln(w, line)
if len(n.External) > 0 {
external[n.Display()] = n.External
}
}
}

if len(external) > 0 {
fmt.Fprintln(w, "\nExternal references (must already exist — not in this set):")
names := make([]string, 0, len(external))
for k := range external {
names = append(names, k)
}
sort.Strings(names)
for _, k := range names {
fmt.Fprintf(w, " %s → %s\n", k, strings.Join(external[k], ", "))
}
}
}
88 changes: 88 additions & 0 deletions internal/cli/plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cli

import (
"bytes"
"os"
"path/filepath"
"testing"

"github.com/openctl/openctl/internal/plan"
)

func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

// TestLoadManifestBatch_DirAndMultiDoc loads a directory recursively, splitting
// multi-doc YAML and dispatching by extension.
func TestLoadManifestBatch_DirAndMultiDoc(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "a.yaml"),
"apiVersion: v1\nkind: A\nmetadata:\n name: a\n---\napiVersion: v1\nkind: B\nmetadata:\n name: b\n")
writeFile(t, filepath.Join(dir, "sub", "c.yml"),
"apiVersion: v1\nkind: C\nmetadata:\n name: c\n")
// A non-manifest file must be ignored by the dir walk.
writeFile(t, filepath.Join(dir, "notes.txt"), "ignore me")

rs, err := loadManifestBatch([]string{dir})
if err != nil {
t.Fatalf("loadManifestBatch: %v", err)
}
if len(rs) != 3 {
t.Fatalf("loaded %d resources, want 3 (A,B,C)", len(rs))
}
// The loaded set feeds Build without error.
if _, err := plan.Build(rs); err != nil {
t.Fatalf("Build: %v", err)
}
}

func TestLoadManifestBatch_MissingPath(t *testing.T) {
if _, err := loadManifestBatch([]string{filepath.Join(t.TempDir(), "nope.yaml")}); err == nil {
t.Fatal("want an error for a missing path")
}
}

func TestLoadManifestFile_UnsupportedExt(t *testing.T) {
f := filepath.Join(t.TempDir(), "x.json")
writeFile(t, f, "{}")
if _, err := loadManifestFile(f); err == nil {
t.Fatal("want an error for an unsupported extension")
}
}

// TestPrintPlan_Output checks the rendered plan lists waves, dependencies, and
// external references.
func TestPrintPlan_Output(t *testing.T) {
writeDir := t.TempDir()
// B $refs A (in set) and an external Cluster/home (not in set).
writeFile(t, filepath.Join(writeDir, "m.yaml"),
"apiVersion: v1\nkind: A\nmetadata:\n name: a\n"+
"---\n"+
"apiVersion: v1\nkind: B\nmetadata:\n name: b\nspec:\n"+
" dep:\n $ref:\n apiVersion: v1\n kind: A\n name: a\n field: status.x\n"+
" ext:\n $ref:\n apiVersion: k3s.openctl.io/v1\n kind: Cluster\n name: home\n field: status.x\n")

rs, err := loadManifestBatch([]string{writeDir})
if err != nil {
t.Fatal(err)
}
p, err := plan.Build(rs)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
printPlan(&buf, p)
out := buf.String()
for _, want := range []string{"wave 1:", "A/a", "wave 2:", "B/b ← A/a", "External references", "Cluster/home"} {
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Errorf("plan output missing %q:\n%s", want, out)
}
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func init() {
rootCmd.AddCommand(newConfigCommand())
rootCmd.AddCommand(newApplyCommand())
rootCmd.AddCommand(newValidateCommand())
rootCmd.AddCommand(newPlanCommand())
rootCmd.AddCommand(newExplainCommand())
rootCmd.AddCommand(newVersionCommand())
rootCmd.AddCommand(newStateCommand())
Expand Down
Loading
Loading