diff --git a/pkg/cli/report/v1alpha1/runtime_common.go b/pkg/cli/report/v1alpha1/runtime_common.go index d9473ffab..c8ba3acf9 100644 --- a/pkg/cli/report/v1alpha1/runtime_common.go +++ b/pkg/cli/report/v1alpha1/runtime_common.go @@ -262,7 +262,7 @@ type RuntimeSourceReference struct { type runtimeContent[T any] interface { Content[T] runtimeReportKind() string - RuntimeEffectiveContent | RuntimeHistoryContent + RuntimeEffectiveContent | RuntimeHistoryContent | RuntimeTreeContent } // RuntimeEnvelope carries the fields shared by runtime reports. It is kept @@ -347,7 +347,15 @@ func runtimeSourceLess(a, b RuntimeSourceReference) bool { // Table returns the human-readable view of the canonical typed content. func (e RuntimeEnvelope[T]) Table() report.Table { - return e.Content.Table() + canonical := e.Canonical() + if content, ok := any(canonical.Content).(runtimeWarningTable); ok { + return content.tableWithWarnings(canonical.Warnings) + } + return canonical.Content.Table() +} + +type runtimeWarningTable interface { + tableWithWarnings([]RuntimeWarning) report.Table } // RuntimeObjectReference is an allowlisted runtime object identity. diff --git a/pkg/cli/report/v1alpha1/runtime_tree.go b/pkg/cli/report/v1alpha1/runtime_tree.go new file mode 100644 index 000000000..ec1faacb9 --- /dev/null +++ b/pkg/cli/report/v1alpha1/runtime_tree.go @@ -0,0 +1,537 @@ +package v1alpha1 + +import ( + "cmp" + "sort" + "strconv" + "strings" + + "sigs.k8s.io/ome/pkg/cli/report" +) + +const RuntimeTreeReportKind = "RuntimeTreeReport" + +// RuntimeTreeSnapshotCompleteness describes whether every requested list was +// observed without a bounded-page cutoff or source failure. +type RuntimeTreeSnapshotCompleteness string + +const ( + RuntimeTreeSnapshotComplete RuntimeTreeSnapshotCompleteness = "Complete" + RuntimeTreeSnapshotPartial RuntimeTreeSnapshotCompleteness = "Partial" +) + +// RuntimeTreeCollectionKind identifies one collection contributing to the +// tree snapshot. +type RuntimeTreeCollectionKind string + +const ( + RuntimeTreeCollectionClusterServingRuntime RuntimeTreeCollectionKind = "ClusterServingRuntime" + RuntimeTreeCollectionServingRuntime RuntimeTreeCollectionKind = "ServingRuntime" + RuntimeTreeCollectionInferenceService RuntimeTreeCollectionKind = "InferenceService" +) + +// RuntimeTreeCollectionStatus describes the outcome of one bounded list. +type RuntimeTreeCollectionStatus string + +const ( + RuntimeTreeCollectionStatusComplete RuntimeTreeCollectionStatus = "Complete" + RuntimeTreeCollectionStatusTruncated RuntimeTreeCollectionStatus = "Truncated" + RuntimeTreeCollectionStatusUnavailable RuntimeTreeCollectionStatus = "Unavailable" +) + +// RuntimeTreeCollection reports bounded collection evidence for one kind. +type RuntimeTreeCollection struct { + Kind RuntimeTreeCollectionKind `json:"kind"` + Status RuntimeTreeCollectionStatus `json:"status"` + ObservedPages int `json:"observedPages"` + ObservedItems int `json:"observedItems"` +} + +// RuntimeTreeSnapshot reports the completeness of the inputs used to build a +// tree. Collections is additive so later dependency collectors can describe +// their own bounded reads without changing this schema. +type RuntimeTreeSnapshot struct { + Completeness RuntimeTreeSnapshotCompleteness `json:"completeness"` + Collections []RuntimeTreeCollection `json:"collections"` +} + +// RuntimeTreeIdentity is the full kind/scope/name identity of one runtime. +type RuntimeTreeIdentity struct { + Kind RuntimeKind `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` +} + +// RuntimeTreeRuntime retains the declared and resolved inheritance edge for a +// runtime represented in the tree. +type RuntimeTreeRuntime struct { + Identity RuntimeTreeIdentity `json:"identity"` + ParentName string `json:"parentName,omitempty"` + ResolvedParent *RuntimeTreeIdentity `json:"resolvedParent,omitempty"` +} + +// RuntimeTreeDependentKind identifies a supported non-runtime leaf. +type RuntimeTreeDependentKind string + +const ( + RuntimeTreeDependentInferenceService RuntimeTreeDependentKind = "InferenceService" +) + +// RuntimeTreeDependent is an allowlisted identity-only dependency leaf. +type RuntimeTreeDependent struct { + Kind RuntimeTreeDependentKind `json:"kind"` + Namespace string `json:"namespace"` + Name string `json:"name"` + UID string `json:"uid,omitempty"` +} + +// RuntimeTreeIssueCode classifies an inheritance topology problem. +type RuntimeTreeIssueCode string + +const ( + RuntimeTreeIssueParentMissing RuntimeTreeIssueCode = "ParentMissing" + RuntimeTreeIssueCycleDetected RuntimeTreeIssueCode = "CycleDetected" + RuntimeTreeIssueMaxDepthExceeded RuntimeTreeIssueCode = "MaxDepthExceeded" +) + +// RuntimeTreeIssue preserves bounded graph diagnostics. Path retains the +// graph's subject-first order toward its ancestors. +type RuntimeTreeIssue struct { + Code RuntimeTreeIssueCode `json:"code"` + Subject RuntimeTreeIdentity `json:"subject"` + ParentName string `json:"parentName,omitempty"` + Path []RuntimeTreeIdentity `json:"path"` +} + +// RuntimeTreeResolutionMode identifies the lookup policy used for an entire +// controller inheritance walk. +type RuntimeTreeResolutionMode string + +const ( + RuntimeTreeResolutionModeCluster RuntimeTreeResolutionMode = "Cluster" + RuntimeTreeResolutionModeNamespaced RuntimeTreeResolutionMode = "Namespaced" +) + +// RuntimeTreeResolutionContext identifies one fixed controller lookup scope. +type RuntimeTreeResolutionContext struct { + Mode RuntimeTreeResolutionMode `json:"mode"` + Namespace string `json:"namespace,omitempty"` +} + +// RuntimeTreePath is one exact controller inheritance walk. Runtimes remain +// ordered from the observed root or error boundary to Head. Dependents belong +// only to Head; Issue belongs only to this walk. +type RuntimeTreePath struct { + Head RuntimeTreeIdentity `json:"head"` + Runtimes []RuntimeTreeRuntime `json:"runtimes"` + Dependents []RuntimeTreeDependent `json:"dependents"` + Issue *RuntimeTreeIssue `json:"issue,omitempty"` +} + +// RuntimeTreeContext groups exact head paths resolved under one fixed lookup +// context. ResolutionCompleteness describes only the runtime collections used +// by the controller lookup; dependent-list completeness remains in Snapshot. +type RuntimeTreeContext struct { + Context RuntimeTreeResolutionContext `json:"context"` + ResolutionCompleteness RuntimeTreeSnapshotCompleteness `json:"resolutionCompleteness"` + Paths []RuntimeTreePath `json:"paths"` +} + +// RuntimeTreeContent is the typed body shared by terminal and machine output. +type RuntimeTreeContent struct { + Target RuntimeTreeIdentity `json:"target"` + Snapshot RuntimeTreeSnapshot `json:"snapshot"` + Contexts []RuntimeTreeContext `json:"contexts"` +} + +// NewRuntimeTreeReport creates a canonical runtime inheritance tree report. +func NewRuntimeTreeReport( + metadata Metadata, + content RuntimeTreeContent, + clock Clock, +) RuntimeEnvelope[RuntimeTreeContent] { + return newRuntimeEnvelope(metadata, content, clock) +} + +func (RuntimeTreeContent) runtimeReportKind() string { + return RuntimeTreeReportKind +} + +// Canonical returns a deeply copied and deterministically ordered report. It +// sorts contexts and heads without changing the order inside an exact path. +func (c RuntimeTreeContent) Canonical() RuntimeTreeContent { + result := c + result.Snapshot.Collections = append([]RuntimeTreeCollection{}, c.Snapshot.Collections...) + sort.Slice(result.Snapshot.Collections, func(i, j int) bool { + return compareRuntimeTreeCollections(result.Snapshot.Collections[i], result.Snapshot.Collections[j]) < 0 + }) + result.Contexts = make([]RuntimeTreeContext, len(c.Contexts)) + for i := range c.Contexts { + result.Contexts[i] = c.Contexts[i].canonical(c.Target) + } + sort.Slice(result.Contexts, func(i, j int) bool { + return compareRuntimeTreeContexts(result.Contexts[i], result.Contexts[j]) < 0 + }) + return result +} + +func (c RuntimeTreeContext) canonical(target RuntimeTreeIdentity) RuntimeTreeContext { + result := c + result.Paths = make([]RuntimeTreePath, len(c.Paths)) + for i := range c.Paths { + result.Paths[i] = c.Paths[i].canonical() + } + sort.Slice(result.Paths, func(i, j int) bool { + leftSelected := result.Paths[i].Head == target + rightSelected := result.Paths[j].Head == target + if leftSelected != rightSelected { + return leftSelected + } + return compareRuntimeTreePaths(result.Paths[i], result.Paths[j]) < 0 + }) + return result +} + +func (p RuntimeTreePath) canonical() RuntimeTreePath { + result := p + result.Runtimes = make([]RuntimeTreeRuntime, len(p.Runtimes)) + for i := range p.Runtimes { + result.Runtimes[i] = copyRuntimeTreeRuntime(p.Runtimes[i]) + } + result.Dependents = append([]RuntimeTreeDependent{}, p.Dependents...) + sort.Slice(result.Dependents, func(i, j int) bool { + return compareRuntimeTreeDependents(result.Dependents[i], result.Dependents[j]) < 0 + }) + if p.Issue != nil { + issue := *p.Issue + issue.Path = append([]RuntimeTreeIdentity{}, p.Issue.Path...) + result.Issue = &issue + } + return result +} + +func copyRuntimeTreeRuntime(runtime RuntimeTreeRuntime) RuntimeTreeRuntime { + result := runtime + if runtime.ResolvedParent != nil { + parent := *runtime.ResolvedParent + result.ResolvedParent = &parent + } + return result +} + +// Table returns a one-column view suitable for constrained terminals. Every +// context and head is a separate section so distinct controller walks are +// never visually merged. +func (c RuntimeTreeContent) Table() report.Table { + return c.tableWithWarnings(nil) +} + +func (c RuntimeTreeContent) tableWithWarnings(warnings []RuntimeWarning) report.Table { + canonical := c.Canonical() + rows := [][]string{{"Target: " + formatRuntimeTreeIdentity(canonical.Target)}} + for _, context := range canonical.Contexts { + rows = append(rows, []string{ + "Context: " + formatRuntimeTreeContext(context.Context) + + " (resolution: " + string(context.ResolutionCompleteness) + ")", + }) + for _, path := range context.Paths { + rows = append(rows, []string{"Head: " + formatRuntimeTreeIdentityInContext(path.Head, context.Context)}) + for i, runtime := range path.Runtimes { + prefix := "" + if i > 0 { + prefix = strings.Repeat(" ", i-1) + "`-- " + } + rows = append(rows, []string{ + prefix + formatRuntimeTreeIdentityInContext(runtime.Identity, context.Context) + + selectedSuffix(runtime.Identity == canonical.Target), + }) + } + dependentPrefix := strings.Repeat(" ", max(0, len(path.Runtimes)-1)) + for i, dependent := range path.Dependents { + branch := "|-- " + if i == len(path.Dependents)-1 { + branch = "`-- " + } + rows = append(rows, []string{ + dependentPrefix + branch + formatRuntimeTreeDependentInContext(dependent, context.Context), + }) + } + if path.Issue != nil { + rows = append(rows, []string{formatRuntimeTreeIssue(*path.Issue, context.Context)}) + if len(path.Issue.Path) > 0 { + rows = append(rows, []string{formatRuntimeTreeIssuePath(path.Issue.Path, context.Context)}) + } + } + } + } + rows = append(rows, []string{"Snapshot: " + string(canonical.Snapshot.Completeness)}) + for _, collection := range canonical.Snapshot.Collections { + rows = append(rows, []string{formatRuntimeTreeCollection(collection)}) + } + for _, warning := range warnings { + rows = append(rows, []string{"Warning: " + string(warning.Code)}) + } + return report.Table{Headers: []string{"RUNTIME TREE"}, Rows: rows} +} + +func selectedSuffix(selected bool) string { + if selected { + return " [selected]" + } + return "" +} + +func formatRuntimeTreeContext(context RuntimeTreeResolutionContext) string { + if context.Namespace == "" { + return string(context.Mode) + } + return strings.Join([]string{string(context.Mode), context.Namespace}, "/") +} + +func formatRuntimeTreeIdentity(identity RuntimeTreeIdentity) string { + parts := []string{string(identity.Kind)} + if identity.Namespace != "" { + parts = append(parts, identity.Namespace) + } + parts = append(parts, identity.Name) + return strings.Join(parts, "/") +} + +func formatRuntimeTreeIdentityInContext( + identity RuntimeTreeIdentity, + context RuntimeTreeResolutionContext, +) string { + if context.Mode == RuntimeTreeResolutionModeNamespaced && + identity.Kind == RuntimeKindServingRuntime && identity.Namespace == context.Namespace { + return strings.Join([]string{string(identity.Kind), identity.Name}, "/") + } + return formatRuntimeTreeIdentity(identity) +} + +func formatRuntimeTreeDependentInContext( + dependent RuntimeTreeDependent, + context RuntimeTreeResolutionContext, +) string { + if context.Mode == RuntimeTreeResolutionModeNamespaced && dependent.Namespace == context.Namespace { + return strings.Join([]string{string(dependent.Kind), dependent.Name}, "/") + } + return strings.Join([]string{string(dependent.Kind), dependent.Namespace, dependent.Name}, "/") +} + +func formatRuntimeTreeIssue(issue RuntimeTreeIssue, context RuntimeTreeResolutionContext) string { + result := "Issue: " + string(issue.Code) + + " subject=" + formatRuntimeTreeIdentityInContext(issue.Subject, context) + if issue.ParentName != "" { + result += " parent=" + issue.ParentName + } + return result +} + +func formatRuntimeTreeIssuePath( + path []RuntimeTreeIdentity, + context RuntimeTreeResolutionContext, +) string { + parts := make([]string, len(path)) + for i := range path { + parts[i] = formatRuntimeTreeIdentityInContext(path[i], context) + } + return "Issue path: " + strings.Join(parts, " -> ") +} + +func formatRuntimeTreeCollection(collection RuntimeTreeCollection) string { + return "Collection: " + string(collection.Kind) + + " status=" + string(collection.Status) + + " pages=" + strconv.Itoa(collection.ObservedPages) + + " items=" + strconv.Itoa(collection.ObservedItems) +} + +func compareRuntimeTreeCollections(a, b RuntimeTreeCollection) int { + if result := cmp.Compare(runtimeTreeCollectionRank(a.Kind), runtimeTreeCollectionRank(b.Kind)); result != 0 { + return result + } + for _, result := range []int{ + cmp.Compare(a.Kind, b.Kind), + cmp.Compare(a.Status, b.Status), + cmp.Compare(a.ObservedPages, b.ObservedPages), + cmp.Compare(a.ObservedItems, b.ObservedItems), + } { + if result != 0 { + return result + } + } + return 0 +} + +func runtimeTreeCollectionRank(kind RuntimeTreeCollectionKind) int { + switch kind { + case RuntimeTreeCollectionClusterServingRuntime: + return 0 + case RuntimeTreeCollectionServingRuntime: + return 1 + case RuntimeTreeCollectionInferenceService: + return 2 + default: + return 3 + } +} + +func compareRuntimeTreeIdentities(a, b RuntimeTreeIdentity) int { + for _, result := range []int{ + cmp.Compare(runtimeTreeKindRank(a.Kind), runtimeTreeKindRank(b.Kind)), + cmp.Compare(a.Kind, b.Kind), + cmp.Compare(a.Namespace, b.Namespace), + cmp.Compare(a.Name, b.Name), + } { + if result != 0 { + return result + } + } + return 0 +} + +func runtimeTreeKindRank(kind RuntimeKind) int { + switch kind { + case RuntimeKindClusterServingRuntime: + return 0 + case RuntimeKindServingRuntime: + return 1 + default: + return 2 + } +} + +func compareRuntimeTreeDependents(a, b RuntimeTreeDependent) int { + for _, result := range []int{ + cmp.Compare(a.Kind, b.Kind), + cmp.Compare(a.Namespace, b.Namespace), + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.UID, b.UID), + } { + if result != 0 { + return result + } + } + return 0 +} + +func compareRuntimeTreeContexts(a, b RuntimeTreeContext) int { + for _, result := range []int{ + cmp.Compare(runtimeTreeResolutionModeRank(a.Context.Mode), runtimeTreeResolutionModeRank(b.Context.Mode)), + cmp.Compare(a.Context.Mode, b.Context.Mode), + cmp.Compare(a.Context.Namespace, b.Context.Namespace), + cmp.Compare(a.ResolutionCompleteness, b.ResolutionCompleteness), + compareRuntimeTreePathSlices(a.Paths, b.Paths), + } { + if result != 0 { + return result + } + } + return 0 +} + +func runtimeTreeResolutionModeRank(mode RuntimeTreeResolutionMode) int { + switch mode { + case RuntimeTreeResolutionModeCluster: + return 0 + case RuntimeTreeResolutionModeNamespaced: + return 1 + default: + return 2 + } +} + +func compareRuntimeTreePaths(a, b RuntimeTreePath) int { + for _, result := range []int{ + compareRuntimeTreeIdentities(a.Head, b.Head), + compareRuntimeTreeRuntimeSlices(a.Runtimes, b.Runtimes), + compareRuntimeTreeDependentSlices(a.Dependents, b.Dependents), + compareRuntimeTreeIssuePointers(a.Issue, b.Issue), + } { + if result != 0 { + return result + } + } + return 0 +} + +func compareRuntimeTreePathSlices(a, b []RuntimeTreePath) int { + for i := 0; i < len(a) && i < len(b); i++ { + if result := compareRuntimeTreePaths(a[i], b[i]); result != 0 { + return result + } + } + return cmp.Compare(len(a), len(b)) +} + +func compareRuntimeTreeRuntimeSlices(a, b []RuntimeTreeRuntime) int { + for i := 0; i < len(a) && i < len(b); i++ { + for _, result := range []int{ + compareRuntimeTreeIdentities(a[i].Identity, b[i].Identity), + cmp.Compare(a[i].ParentName, b[i].ParentName), + compareRuntimeTreeIdentityPointers(a[i].ResolvedParent, b[i].ResolvedParent), + } { + if result != 0 { + return result + } + } + } + return cmp.Compare(len(a), len(b)) +} + +func compareRuntimeTreeIdentityPointers(a, b *RuntimeTreeIdentity) int { + if a == nil { + if b == nil { + return 0 + } + return -1 + } + if b == nil { + return 1 + } + return compareRuntimeTreeIdentities(*a, *b) +} + +func compareRuntimeTreeDependentSlices(a, b []RuntimeTreeDependent) int { + for i := 0; i < len(a) && i < len(b); i++ { + if result := compareRuntimeTreeDependents(a[i], b[i]); result != 0 { + return result + } + } + return cmp.Compare(len(a), len(b)) +} + +func compareRuntimeTreeIssues(a, b RuntimeTreeIssue) int { + for _, result := range []int{ + cmp.Compare(a.Code, b.Code), + compareRuntimeTreeIdentities(a.Subject, b.Subject), + cmp.Compare(a.ParentName, b.ParentName), + compareRuntimeTreeIdentitySlices(a.Path, b.Path), + } { + if result != 0 { + return result + } + } + return 0 +} + +func compareRuntimeTreeIssuePointers(a, b *RuntimeTreeIssue) int { + if a == nil { + if b == nil { + return 0 + } + return -1 + } + if b == nil { + return 1 + } + return compareRuntimeTreeIssues(*a, *b) +} + +func compareRuntimeTreeIdentitySlices(a, b []RuntimeTreeIdentity) int { + for i := 0; i < len(a) && i < len(b); i++ { + if result := compareRuntimeTreeIdentities(a[i], b[i]); result != 0 { + return result + } + } + return cmp.Compare(len(a), len(b)) +} diff --git a/pkg/cli/report/v1alpha1/runtime_tree_test.go b/pkg/cli/report/v1alpha1/runtime_tree_test.go new file mode 100644 index 000000000..b166dd03b --- /dev/null +++ b/pkg/cli/report/v1alpha1/runtime_tree_test.go @@ -0,0 +1,458 @@ +package v1alpha1_test + +import ( + "bytes" + "encoding/json" + "io" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sigs.k8s.io/ome/pkg/cli/report" + "sigs.k8s.io/ome/pkg/cli/report/v1alpha1" +) + +func TestRuntimeTreeTablePreservesContextAndHeadPaths(t *testing.T) { + target := treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "root") + content := v1alpha1.RuntimeTreeContent{ + Target: target, + Snapshot: v1alpha1.RuntimeTreeSnapshot{ + Completeness: v1alpha1.RuntimeTreeSnapshotPartial, + Collections: []v1alpha1.RuntimeTreeCollection{ + {Kind: v1alpha1.RuntimeTreeCollectionServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusTruncated, ObservedPages: 1, ObservedItems: 2}, + {Kind: v1alpha1.RuntimeTreeCollectionInferenceService, Status: v1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 3}, + {Kind: v1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 2}, + }, + }, + Contexts: []v1alpha1.RuntimeTreeContext{ + { + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-b"}, + ResolutionCompleteness: v1alpha1.RuntimeTreeSnapshotPartial, + Paths: []v1alpha1.RuntimeTreePath{{ + Head: treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-b", "orphan"), + Runtimes: []v1alpha1.RuntimeTreeRuntime{ + treeRuntime(v1alpha1.RuntimeKindClusterServingRuntime, "", "root", "", nil), + treeRuntime(v1alpha1.RuntimeKindServingRuntime, "team-b", "orphan", "root", treeIdentityPointer(v1alpha1.RuntimeKindClusterServingRuntime, "", "root")), + }, + Dependents: []v1alpha1.RuntimeTreeDependent{}, + Issue: &v1alpha1.RuntimeTreeIssue{ + Code: v1alpha1.RuntimeTreeIssueParentMissing, + Subject: treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-b", "orphan"), + ParentName: "missing", + Path: []v1alpha1.RuntimeTreeIdentity{ + treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-b", "orphan"), + treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "root"), + }, + }, + }}, + }, + { + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeCluster}, + ResolutionCompleteness: v1alpha1.RuntimeTreeSnapshotComplete, + Paths: []v1alpha1.RuntimeTreePath{ + { + Head: treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "worker"), + Runtimes: []v1alpha1.RuntimeTreeRuntime{treeRuntime(v1alpha1.RuntimeKindClusterServingRuntime, "", "root", "", nil), treeRuntime(v1alpha1.RuntimeKindClusterServingRuntime, "", "worker", "root", treeIdentityPointer(v1alpha1.RuntimeKindClusterServingRuntime, "", "root"))}, + Dependents: []v1alpha1.RuntimeTreeDependent{{Kind: v1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "worker-user"}}, + }, + { + Head: target, + Runtimes: []v1alpha1.RuntimeTreeRuntime{treeRuntime(v1alpha1.RuntimeKindClusterServingRuntime, "", "root", "", nil)}, + Dependents: []v1alpha1.RuntimeTreeDependent{{Kind: v1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "direct"}}, + }, + }, + }, + { + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-a"}, + ResolutionCompleteness: v1alpha1.RuntimeTreeSnapshotPartial, + Paths: []v1alpha1.RuntimeTreePath{{ + Head: treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-a", "local"), + Runtimes: []v1alpha1.RuntimeTreeRuntime{ + treeRuntime(v1alpha1.RuntimeKindClusterServingRuntime, "", "root", "", nil), + treeRuntime(v1alpha1.RuntimeKindServingRuntime, "team-a", "local", "root", treeIdentityPointer(v1alpha1.RuntimeKindClusterServingRuntime, "", "root")), + }, + Dependents: []v1alpha1.RuntimeTreeDependent{{Kind: v1alpha1.RuntimeTreeDependentInferenceService, Namespace: "team-a", Name: "chat"}}, + }}, + }, + }, + } + reportValue := v1alpha1.NewRuntimeTreeReport(v1alpha1.Metadata{Name: "root"}, content, treeClock()) + reportValue.Warnings = []v1alpha1.RuntimeWarning{{Code: v1alpha1.WarningTruncated}, {Code: v1alpha1.WarningPartialData}} + + var output bytes.Buffer + require.NoError(t, report.Write(&output, report.FormatTable, reportValue)) + assert.Equal(t, "RUNTIME TREE\n"+ + "Target: ClusterServingRuntime/root\n"+ + "Context: Cluster (resolution: Complete)\n"+ + "Head: ClusterServingRuntime/root\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- InferenceService/ops/direct\n"+ + "Head: ClusterServingRuntime/worker\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ClusterServingRuntime/worker\n"+ + " `-- InferenceService/ops/worker-user\n"+ + "Context: Namespaced/team-a (resolution: Partial)\n"+ + "Head: ServingRuntime/local\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/local\n"+ + " `-- InferenceService/chat\n"+ + "Context: Namespaced/team-b (resolution: Partial)\n"+ + "Head: ServingRuntime/orphan\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/orphan\n"+ + "Issue: ParentMissing subject=ServingRuntime/orphan parent=missing\n"+ + "Issue path: ServingRuntime/orphan -> ClusterServingRuntime/root\n"+ + "Snapshot: Partial\n"+ + "Collection: ClusterServingRuntime status=Complete pages=1 items=2\n"+ + "Collection: ServingRuntime status=Truncated pages=1 items=2\n"+ + "Collection: InferenceService status=Complete pages=1 items=3\n"+ + "Warning: PartialData\n"+ + "Warning: Truncated\n", + output.String()) +} + +func TestRuntimeTreeTableShowsCycleClosingEdge(t *testing.T) { + target := treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-a", "a") + parent := treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-a", "b") + reportValue := v1alpha1.NewRuntimeTreeReport( + v1alpha1.Metadata{Namespace: "team-a", Name: "a"}, + v1alpha1.RuntimeTreeContent{ + Target: target, + Snapshot: v1alpha1.RuntimeTreeSnapshot{ + Completeness: v1alpha1.RuntimeTreeSnapshotComplete, + Collections: []v1alpha1.RuntimeTreeCollection{ + {Kind: v1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1}, + {Kind: v1alpha1.RuntimeTreeCollectionServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 2}, + {Kind: v1alpha1.RuntimeTreeCollectionInferenceService, Status: v1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1}, + }, + }, + Contexts: []v1alpha1.RuntimeTreeContext{{ + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-a"}, + ResolutionCompleteness: v1alpha1.RuntimeTreeSnapshotComplete, + Paths: []v1alpha1.RuntimeTreePath{{ + Head: target, + Runtimes: []v1alpha1.RuntimeTreeRuntime{ + {Identity: parent, ParentName: "a", ResolvedParent: &target}, + {Identity: target, ParentName: "b", ResolvedParent: &parent}, + }, + Dependents: []v1alpha1.RuntimeTreeDependent{}, + Issue: &v1alpha1.RuntimeTreeIssue{ + Code: v1alpha1.RuntimeTreeIssueCycleDetected, Subject: target, ParentName: "a", + Path: []v1alpha1.RuntimeTreeIdentity{target, parent, target}, + }, + }}, + }}, + }, + treeClock(), + ) + + var output bytes.Buffer + require.NoError(t, report.Write(&output, report.FormatTable, reportValue)) + assert.Equal(t, "RUNTIME TREE\n"+ + "Target: ServingRuntime/team-a/a\n"+ + "Context: Namespaced/team-a (resolution: Complete)\n"+ + "Head: ServingRuntime/a\n"+ + "ServingRuntime/b\n"+ + "`-- ServingRuntime/a [selected]\n"+ + "Issue: CycleDetected subject=ServingRuntime/a parent=a\n"+ + "Issue path: ServingRuntime/a -> ServingRuntime/b -> ServingRuntime/a\n"+ + "Snapshot: Complete\n"+ + "Collection: ClusterServingRuntime status=Complete pages=1 items=0\n"+ + "Collection: ServingRuntime status=Complete pages=1 items=2\n"+ + "Collection: InferenceService status=Complete pages=1 items=0\n", + output.String()) +} + +func TestRuntimeTreeCanonicalIsDeterministicImmutableAndNonNil(t *testing.T) { + target := treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "root") + parent := treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "root") + content := v1alpha1.RuntimeTreeContent{ + Target: target, + Snapshot: v1alpha1.RuntimeTreeSnapshot{Collections: []v1alpha1.RuntimeTreeCollection{ + {Kind: v1alpha1.RuntimeTreeCollectionServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusComplete}, + {Kind: v1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusComplete}, + }}, + Contexts: []v1alpha1.RuntimeTreeContext{ + { + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "z-team"}, + Paths: []v1alpha1.RuntimeTreePath{{ + Head: treeIdentity(v1alpha1.RuntimeKindServingRuntime, "z-team", "z"), + Runtimes: []v1alpha1.RuntimeTreeRuntime{{ + Identity: treeIdentity(v1alpha1.RuntimeKindServingRuntime, "z-team", "z"), ResolvedParent: &parent, + }}, + Issue: &v1alpha1.RuntimeTreeIssue{Code: v1alpha1.RuntimeTreeIssueCycleDetected, Path: []v1alpha1.RuntimeTreeIdentity{target}}, + }}, + }, + { + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeCluster}, + Paths: []v1alpha1.RuntimeTreePath{ + {Head: treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "z"), Dependents: []v1alpha1.RuntimeTreeDependent{{Kind: v1alpha1.RuntimeTreeDependentInferenceService, Namespace: "z", Name: "z"}}}, + {Head: target, Dependents: []v1alpha1.RuntimeTreeDependent{{Kind: v1alpha1.RuntimeTreeDependentInferenceService, Namespace: "a", Name: "a"}}}, + }, + }, + }, + } + before := cloneTreeContent(t, content) + + first := content.Canonical() + second := content.Canonical() + + assert.Equal(t, first, second) + assert.Equal(t, before, content, "canonicalization mutated caller-owned content") + require.NotNil(t, first.Snapshot.Collections) + require.NotNil(t, first.Contexts) + require.NotNil(t, first.Contexts[0].Paths) + require.NotNil(t, first.Contexts[0].Paths[0].Runtimes) + require.NotNil(t, first.Contexts[0].Paths[0].Dependents) + assert.Equal(t, v1alpha1.RuntimeTreeResolutionModeCluster, first.Contexts[0].Context.Mode) + assert.Equal(t, target, first.Contexts[0].Paths[0].Head, "selected target head sorts first") + assert.Equal(t, "a", first.Contexts[0].Paths[0].Dependents[0].Namespace) + assert.Equal(t, v1alpha1.RuntimeTreeCollectionClusterServingRuntime, first.Snapshot.Collections[0].Kind) + + first.Contexts[1].Paths[0].Runtimes[0].ResolvedParent.Name = "changed" + first.Contexts[1].Paths[0].Issue.Path[0].Name = "changed" + assert.Equal(t, before, content, "canonical result aliases caller-owned pointers or slices") +} + +func TestRuntimeTreeMachineFormatsAreStableAndKeepPerPathEvidence(t *testing.T) { + target := treeIdentity(v1alpha1.RuntimeKindServingRuntime, "team-a", "leaf") + reportValue := v1alpha1.NewRuntimeTreeReport( + v1alpha1.Metadata{Namespace: "team-a", Name: "leaf"}, + v1alpha1.RuntimeTreeContent{ + Target: target, + Snapshot: v1alpha1.RuntimeTreeSnapshot{ + Completeness: v1alpha1.RuntimeTreeSnapshotPartial, + Collections: []v1alpha1.RuntimeTreeCollection{{Kind: v1alpha1.RuntimeTreeCollectionServingRuntime, Status: v1alpha1.RuntimeTreeCollectionStatusUnavailable}}, + }, + Contexts: []v1alpha1.RuntimeTreeContext{{ + Context: v1alpha1.RuntimeTreeResolutionContext{Mode: v1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-a"}, + ResolutionCompleteness: v1alpha1.RuntimeTreeSnapshotPartial, + Paths: []v1alpha1.RuntimeTreePath{{ + Head: target, + Runtimes: []v1alpha1.RuntimeTreeRuntime{{Identity: target, ParentName: "missing"}}, + Dependents: []v1alpha1.RuntimeTreeDependent{}, + Issue: &v1alpha1.RuntimeTreeIssue{ + Code: v1alpha1.RuntimeTreeIssueParentMissing, Subject: target, ParentName: "missing", + Path: []v1alpha1.RuntimeTreeIdentity{target}, + }, + }}, + }}, + }, + treeClock(), + ) + reportValue.Warnings = []v1alpha1.RuntimeWarning{{Code: v1alpha1.WarningSourceUnavailable}, {Code: v1alpha1.WarningPartialData}} + + tests := []struct { + format report.Format + want string + }{ + {format: report.FormatJSON, want: `{ + "apiVersion": "cli.ome.io/v1alpha1", + "kind": "RuntimeTreeReport", + "metadata": { + "namespace": "team-a", + "name": "leaf" + }, + "collectedAt": "2026-09-07T18:30:00Z", + "sources": [], + "content": { + "target": { + "kind": "ServingRuntime", + "namespace": "team-a", + "name": "leaf" + }, + "snapshot": { + "completeness": "Partial", + "collections": [ + { + "kind": "ServingRuntime", + "status": "Unavailable", + "observedPages": 0, + "observedItems": 0 + } + ] + }, + "contexts": [ + { + "context": { + "mode": "Namespaced", + "namespace": "team-a" + }, + "resolutionCompleteness": "Partial", + "paths": [ + { + "head": { + "kind": "ServingRuntime", + "namespace": "team-a", + "name": "leaf" + }, + "runtimes": [ + { + "identity": { + "kind": "ServingRuntime", + "namespace": "team-a", + "name": "leaf" + }, + "parentName": "missing" + } + ], + "dependents": [], + "issue": { + "code": "ParentMissing", + "subject": { + "kind": "ServingRuntime", + "namespace": "team-a", + "name": "leaf" + }, + "parentName": "missing", + "path": [ + { + "kind": "ServingRuntime", + "namespace": "team-a", + "name": "leaf" + } + ] + } + } + ] + } + ] + }, + "warnings": [ + { + "code": "PartialData" + }, + { + "code": "SourceUnavailable" + } + ] +} +`}, + {format: report.FormatYAML, want: `apiVersion: cli.ome.io/v1alpha1 +collectedAt: "2026-09-07T18:30:00Z" +content: + contexts: + - context: + mode: Namespaced + namespace: team-a + paths: + - dependents: [] + head: + kind: ServingRuntime + name: leaf + namespace: team-a + issue: + code: ParentMissing + parentName: missing + path: + - kind: ServingRuntime + name: leaf + namespace: team-a + subject: + kind: ServingRuntime + name: leaf + namespace: team-a + runtimes: + - identity: + kind: ServingRuntime + name: leaf + namespace: team-a + parentName: missing + resolutionCompleteness: Partial + snapshot: + collections: + - kind: ServingRuntime + observedItems: 0 + observedPages: 0 + status: Unavailable + completeness: Partial + target: + kind: ServingRuntime + name: leaf + namespace: team-a +kind: RuntimeTreeReport +metadata: + name: leaf + namespace: team-a +sources: [] +warnings: +- code: PartialData +- code: SourceUnavailable +`}, + } + + for _, test := range tests { + t.Run(string(test.format), func(t *testing.T) { + first := renderTreeReport(t, reportValue, test.format) + second := renderTreeReport(t, reportValue, test.format) + assert.Equal(t, test.want, first) + assert.Equal(t, first, second) + }) + } +} + +func TestRuntimeTreeWriteReturnsShortWritesForEveryFormat(t *testing.T) { + target := treeIdentity(v1alpha1.RuntimeKindClusterServingRuntime, "", "root") + reportValue := v1alpha1.NewRuntimeTreeReport( + v1alpha1.Metadata{Name: "root"}, + v1alpha1.RuntimeTreeContent{Target: target, Contexts: []v1alpha1.RuntimeTreeContext{}}, + treeClock(), + ) + + for _, format := range []report.Format{report.FormatTable, report.FormatJSON, report.FormatYAML} { + t.Run(string(format), func(t *testing.T) { + err := report.Write(treeShortWriter{}, format, reportValue) + require.Error(t, err) + assert.ErrorIs(t, err, io.ErrShortWrite) + }) + } +} + +func TestRuntimeTreeSchemaIsStrictlyAllowlisted(t *testing.T) { + assertRuntimeReportSchema(t, reflect.TypeOf(v1alpha1.RuntimeEnvelope[v1alpha1.RuntimeTreeContent]{}), map[reflect.Type]bool{}) +} + +func treeIdentity(kind v1alpha1.RuntimeKind, namespace, name string) v1alpha1.RuntimeTreeIdentity { + return v1alpha1.RuntimeTreeIdentity{Kind: kind, Namespace: namespace, Name: name} +} + +func treeIdentityPointer(kind v1alpha1.RuntimeKind, namespace, name string) *v1alpha1.RuntimeTreeIdentity { + identity := treeIdentity(kind, namespace, name) + return &identity +} + +func treeRuntime(kind v1alpha1.RuntimeKind, namespace, name, parentName string, resolvedParent *v1alpha1.RuntimeTreeIdentity) v1alpha1.RuntimeTreeRuntime { + return v1alpha1.RuntimeTreeRuntime{Identity: treeIdentity(kind, namespace, name), ParentName: parentName, ResolvedParent: resolvedParent} +} + +func treeClock() fixedClock { + return fixedClock{now: time.Date(2026, time.September, 7, 18, 30, 0, 0, time.UTC)} +} + +func cloneTreeContent(t *testing.T, content v1alpha1.RuntimeTreeContent) v1alpha1.RuntimeTreeContent { + t.Helper() + data, err := json.Marshal(content) + require.NoError(t, err) + var result v1alpha1.RuntimeTreeContent + require.NoError(t, json.Unmarshal(data, &result)) + return result +} + +func renderTreeReport(t *testing.T, reportValue v1alpha1.RuntimeEnvelope[v1alpha1.RuntimeTreeContent], format report.Format) string { + t.Helper() + var output bytes.Buffer + require.NoError(t, report.Write(&output, format, reportValue)) + return output.String() +} + +type treeShortWriter struct{} + +func (treeShortWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + return len(data) - 1, nil +} diff --git a/pkg/cli/runtimetreeprojection/project.go b/pkg/cli/runtimetreeprojection/project.go new file mode 100644 index 000000000..731517461 --- /dev/null +++ b/pkg/cli/runtimetreeprojection/project.go @@ -0,0 +1,752 @@ +// Package runtimetreeprojection projects an already-resolved runtime graph +// into the versioned kubectl-ome runtime-tree report. It performs no cluster +// reads. +package runtimetreeprojection + +import ( + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/util/validation" + + reportv1alpha1 "sigs.k8s.io/ome/pkg/cli/report/v1alpha1" + "sigs.k8s.io/ome/pkg/cli/runtimegraph" + "sigs.k8s.io/ome/pkg/constants" +) + +var ( + // ErrInvalidProjection indicates graph evidence that cannot be represented + // safely by the runtime-tree contract. + ErrInvalidProjection = errors.New("runtime tree projection is invalid") + // ErrInvalidSnapshot indicates contradictory or malformed collection + // completeness evidence. + ErrInvalidSnapshot = errors.New("runtime tree snapshot is invalid") + // ErrInvalidDependent indicates an incomplete or unsupported dependency + // leaf identity. + ErrInvalidDependent = errors.New("runtime tree dependent is invalid") + // ErrDependentRuntimeNotVisible indicates a leaf whose runtime is absent as + // an exact direct head. An ancestor occurrence is intentionally insufficient. + ErrDependentRuntimeNotVisible = errors.New("runtime tree dependent runtime is not a visible head") +) + +// CollectionObservation is bounded pagination evidence for one collected +// object kind. +type CollectionObservation struct { + Kind reportv1alpha1.RuntimeTreeCollectionKind + Status reportv1alpha1.RuntimeTreeCollectionStatus + ObservedPages int + ObservedItems int +} + +// SnapshotObservation describes the graph and dependency reads used by a +// caller. Project derives all completeness and warnings from these bounded +// statuses; callers cannot provide contradictory summary fields. +type SnapshotObservation struct { + Collections []CollectionObservation +} + +// DependentLeaf attaches one normalized, identity-only object to the exact +// runtime it references. Kind is additive; v1alpha1 currently admits only an +// InferenceService leaf. +type DependentLeaf struct { + Runtime runtimegraph.Identity + Kind reportv1alpha1.RuntimeTreeDependentKind + Namespace string + Name string + UID string +} + +// Input contains already-collected evidence. Project never performs I/O or +// mutates these values. +type Input struct { + Projection runtimegraph.Projection + Snapshot SnapshotObservation + Dependents []DependentLeaf +} + +// Project builds a canonical runtime-tree report from already-resolved graph +// evidence. +func Project( + input Input, + clock reportv1alpha1.Clock, +) (reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent], error) { + snapshot, statuses, warnings, err := projectSnapshot(input.Snapshot) + if err != nil { + return reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent]{}, err + } + target, err := projectIdentity(input.Projection.Target) + if err != nil { + return reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent]{}, err + } + contexts, heads, err := projectContexts(input.Projection, statuses) + if err != nil { + return reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent]{}, err + } + if err := attachDependents(contexts, heads, input.Dependents); err != nil { + return reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent]{}, err + } + if err := validateVisibleCounts(snapshot, contexts); err != nil { + return reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent]{}, err + } + reportValue := reportv1alpha1.NewRuntimeTreeReport( + reportv1alpha1.Metadata{ + Namespace: input.Projection.Target.Namespace, + Name: input.Projection.Target.Name, + }, + reportv1alpha1.RuntimeTreeContent{Target: target, Snapshot: snapshot, Contexts: contexts}, + clock, + ) + reportValue.Warnings = warnings + return reportValue.Canonical(), nil +} + +func projectSnapshot( + snapshot SnapshotObservation, +) ( + reportv1alpha1.RuntimeTreeSnapshot, + map[reportv1alpha1.RuntimeTreeCollectionKind]reportv1alpha1.RuntimeTreeCollectionStatus, + []reportv1alpha1.RuntimeWarning, + error, +) { + collections := make([]reportv1alpha1.RuntimeTreeCollection, 0, len(snapshot.Collections)) + seenKinds := make(map[reportv1alpha1.RuntimeTreeCollectionKind]struct{}, len(snapshot.Collections)) + statuses := make(map[reportv1alpha1.RuntimeTreeCollectionKind]reportv1alpha1.RuntimeTreeCollectionStatus, len(snapshot.Collections)) + truncated := false + unavailable := false + for _, collection := range snapshot.Collections { + if !validCollectionKind(collection.Kind) || !validCollectionStatus(collection.Status) || + collection.ObservedPages < 0 || collection.ObservedItems < 0 { + return reportv1alpha1.RuntimeTreeSnapshot{}, nil, nil, fmt.Errorf( + "%w: malformed %q collection", ErrInvalidSnapshot, collection.Kind, + ) + } + if collection.ObservedPages == 0 && + (collection.ObservedItems > 0 || collection.Status != reportv1alpha1.RuntimeTreeCollectionStatusUnavailable) { + return reportv1alpha1.RuntimeTreeSnapshot{}, nil, nil, fmt.Errorf( + "%w: %q collection has impossible page evidence", ErrInvalidSnapshot, collection.Kind, + ) + } + if _, duplicate := seenKinds[collection.Kind]; duplicate { + return reportv1alpha1.RuntimeTreeSnapshot{}, nil, nil, fmt.Errorf( + "%w: duplicate %q collection", ErrInvalidSnapshot, collection.Kind, + ) + } + seenKinds[collection.Kind] = struct{}{} + statuses[collection.Kind] = collection.Status + truncated = truncated || collection.Status == reportv1alpha1.RuntimeTreeCollectionStatusTruncated + unavailable = unavailable || collection.Status == reportv1alpha1.RuntimeTreeCollectionStatusUnavailable + collections = append(collections, reportv1alpha1.RuntimeTreeCollection{ + Kind: collection.Kind, Status: collection.Status, + ObservedPages: collection.ObservedPages, ObservedItems: collection.ObservedItems, + }) + } + for _, kind := range requiredCollectionKinds() { + if _, observed := seenKinds[kind]; !observed { + return reportv1alpha1.RuntimeTreeSnapshot{}, nil, nil, fmt.Errorf( + "%w: missing %q collection", ErrInvalidSnapshot, kind, + ) + } + } + + completeness := reportv1alpha1.RuntimeTreeSnapshotComplete + warnings := []reportv1alpha1.RuntimeWarning{} + if truncated || unavailable { + completeness = reportv1alpha1.RuntimeTreeSnapshotPartial + warnings = append(warnings, reportv1alpha1.RuntimeWarning{Code: reportv1alpha1.WarningPartialData}) + } + if unavailable { + warnings = append(warnings, reportv1alpha1.RuntimeWarning{Code: reportv1alpha1.WarningSourceUnavailable}) + } + if truncated { + warnings = append(warnings, reportv1alpha1.RuntimeWarning{Code: reportv1alpha1.WarningTruncated}) + } + return reportv1alpha1.RuntimeTreeSnapshot{ + Completeness: completeness, Collections: collections, + }, statuses, warnings, nil +} + +func requiredCollectionKinds() []reportv1alpha1.RuntimeTreeCollectionKind { + return []reportv1alpha1.RuntimeTreeCollectionKind{ + reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, + reportv1alpha1.RuntimeTreeCollectionServingRuntime, + reportv1alpha1.RuntimeTreeCollectionInferenceService, + } +} + +func validCollectionKind(kind reportv1alpha1.RuntimeTreeCollectionKind) bool { + switch kind { + case reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, + reportv1alpha1.RuntimeTreeCollectionServingRuntime, + reportv1alpha1.RuntimeTreeCollectionInferenceService: + return true + default: + return false + } +} + +func validCollectionStatus(status reportv1alpha1.RuntimeTreeCollectionStatus) bool { + switch status { + case reportv1alpha1.RuntimeTreeCollectionStatusComplete, + reportv1alpha1.RuntimeTreeCollectionStatusTruncated, + reportv1alpha1.RuntimeTreeCollectionStatusUnavailable: + return true + default: + return false + } +} + +func projectRuntime(value runtimegraph.Runtime) (reportv1alpha1.RuntimeTreeRuntime, error) { + identity, err := projectIdentity(value.Identity) + if err != nil { + return reportv1alpha1.RuntimeTreeRuntime{}, err + } + result := reportv1alpha1.RuntimeTreeRuntime{Identity: identity, ParentName: value.ParentName} + if value.ResolvedParent != nil { + parent, err := projectIdentity(*value.ResolvedParent) + if err != nil { + return reportv1alpha1.RuntimeTreeRuntime{}, err + } + result.ResolvedParent = &parent + } + return result, nil +} + +func projectIdentity(identity runtimegraph.Identity) (reportv1alpha1.RuntimeTreeIdentity, error) { + if len(validation.IsDNS1123Subdomain(identity.Name)) != 0 { + return reportv1alpha1.RuntimeTreeIdentity{}, fmt.Errorf( + "%w: runtime name %q is invalid", ErrInvalidProjection, identity.Name, + ) + } + result := reportv1alpha1.RuntimeTreeIdentity{Namespace: identity.Namespace, Name: identity.Name} + switch identity.Kind { + case runtimegraph.KindClusterServingRuntime: + if identity.Namespace != "" { + return reportv1alpha1.RuntimeTreeIdentity{}, fmt.Errorf( + "%w: ClusterServingRuntime cannot have a namespace", ErrInvalidProjection, + ) + } + result.Kind = reportv1alpha1.RuntimeKindClusterServingRuntime + case runtimegraph.KindServingRuntime: + if len(validation.IsDNS1123Label(identity.Namespace)) != 0 { + return reportv1alpha1.RuntimeTreeIdentity{}, fmt.Errorf( + "%w: ServingRuntime namespace %q is invalid", ErrInvalidProjection, identity.Namespace, + ) + } + result.Kind = reportv1alpha1.RuntimeKindServingRuntime + default: + return reportv1alpha1.RuntimeTreeIdentity{}, fmt.Errorf( + "%w: unsupported runtime kind %q", ErrInvalidProjection, identity.Kind, + ) + } + return result, nil +} + +type headLocation struct { + context int + path int +} + +type resolutionKey struct { + context runtimegraph.ResolutionContext + runtime runtimegraph.Identity +} + +type parentResolution struct { + found bool + parent runtimegraph.Identity +} + +type projectionConsistency struct { + declaredParents map[runtimegraph.Identity]string + resolutions map[resolutionKey]parentResolution +} + +func newProjectionConsistency() *projectionConsistency { + return &projectionConsistency{ + declaredParents: map[runtimegraph.Identity]string{}, + resolutions: map[resolutionKey]parentResolution{}, + } +} + +func (c *projectionConsistency) observePath( + path runtimegraph.ResolutionPath, + context runtimegraph.ResolutionContext, +) error { + for i := range path.Runtimes { + runtime := path.Runtimes[i] + if previous, observed := c.declaredParents[runtime.Identity]; observed && previous != runtime.ParentName { + return fmt.Errorf( + "%w: runtime %s has inconsistent declared parents", ErrInvalidProjection, runtime.Identity.Name, + ) + } + c.declaredParents[runtime.Identity] = runtime.ParentName + if runtime.ParentName == "" { + continue + } + + resolution, definitive := definitiveParentResolution(path, i) + if !definitive { + continue + } + key := resolutionKey{context: context, runtime: runtime.Identity} + if previous, observed := c.resolutions[key]; observed && previous != resolution { + return fmt.Errorf( + "%w: runtime %s has inconsistent parent resolution", ErrInvalidProjection, runtime.Identity.Name, + ) + } + c.resolutions[key] = resolution + } + return nil +} + +func definitiveParentResolution(path runtimegraph.ResolutionPath, runtimeIndex int) (parentResolution, bool) { + runtime := path.Runtimes[runtimeIndex] + if runtime.ResolvedParent != nil { + return parentResolution{found: true, parent: *runtime.ResolvedParent}, true + } + if runtimeIndex == 0 && path.Issue != nil { + switch path.Issue.Code { + case runtimegraph.IssueParentMissing: + return parentResolution{}, true + case runtimegraph.IssueMaxDepthExceeded: + return parentResolution{}, false + } + } + return parentResolution{}, false +} + +func projectContexts( + projection runtimegraph.Projection, + statuses map[reportv1alpha1.RuntimeTreeCollectionKind]reportv1alpha1.RuntimeTreeCollectionStatus, +) ([]reportv1alpha1.RuntimeTreeContext, map[runtimegraph.Identity]headLocation, error) { + if len(projection.Contexts) == 0 { + return nil, nil, fmt.Errorf("%w: at least one context is required", ErrInvalidProjection) + } + result := make([]reportv1alpha1.RuntimeTreeContext, 0, len(projection.Contexts)) + heads := make(map[runtimegraph.Identity]headLocation) + seenContexts := make(map[runtimegraph.ResolutionContext]struct{}, len(projection.Contexts)) + consistency := newProjectionConsistency() + directTargetHeads := 0 + for _, sourceContext := range projection.Contexts { + if _, duplicate := seenContexts[sourceContext.Context]; duplicate { + return nil, nil, fmt.Errorf("%w: duplicate resolution context", ErrInvalidProjection) + } + seenContexts[sourceContext.Context] = struct{}{} + context, err := projectResolutionContext(sourceContext.Context) + if err != nil { + return nil, nil, err + } + if len(sourceContext.Paths) == 0 { + return nil, nil, fmt.Errorf("%w: context has no paths", ErrInvalidProjection) + } + projectedContext := reportv1alpha1.RuntimeTreeContext{ + Context: context, ResolutionCompleteness: contextCompleteness(context.Mode, statuses), + Paths: []reportv1alpha1.RuntimeTreePath{}, + } + for _, sourcePath := range sourceContext.Paths { + if _, duplicate := heads[sourcePath.Subject]; duplicate { + return nil, nil, fmt.Errorf("%w: duplicate head %s", ErrInvalidProjection, sourcePath.Subject.Name) + } + path, err := projectPath(sourcePath, sourceContext.Context, projection.Target) + if err != nil { + return nil, nil, err + } + if err := consistency.observePath(sourcePath, sourceContext.Context); err != nil { + return nil, nil, err + } + if sourcePath.Subject == projection.Target { + directTargetHeads++ + } + heads[sourcePath.Subject] = headLocation{context: len(result), path: len(projectedContext.Paths)} + projectedContext.Paths = append(projectedContext.Paths, path) + } + result = append(result, projectedContext) + } + if directTargetHeads != 1 { + return nil, nil, fmt.Errorf("%w: target must have exactly one direct head path", ErrInvalidProjection) + } + return result, heads, nil +} + +func projectResolutionContext( + context runtimegraph.ResolutionContext, +) (reportv1alpha1.RuntimeTreeResolutionContext, error) { + switch context.Mode { + case runtimegraph.ResolutionModeCluster: + if context.Namespace != "" { + return reportv1alpha1.RuntimeTreeResolutionContext{}, fmt.Errorf( + "%w: cluster context cannot have a namespace", ErrInvalidProjection, + ) + } + return reportv1alpha1.RuntimeTreeResolutionContext{ + Mode: reportv1alpha1.RuntimeTreeResolutionModeCluster, + }, nil + case runtimegraph.ResolutionModeNamespaced: + if len(validation.IsDNS1123Label(context.Namespace)) != 0 { + return reportv1alpha1.RuntimeTreeResolutionContext{}, fmt.Errorf( + "%w: namespaced context namespace %q is invalid", ErrInvalidProjection, context.Namespace, + ) + } + return reportv1alpha1.RuntimeTreeResolutionContext{ + Mode: reportv1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: context.Namespace, + }, nil + default: + return reportv1alpha1.RuntimeTreeResolutionContext{}, fmt.Errorf( + "%w: unsupported resolution mode %q", ErrInvalidProjection, context.Mode, + ) + } +} + +func contextCompleteness( + mode reportv1alpha1.RuntimeTreeResolutionMode, + statuses map[reportv1alpha1.RuntimeTreeCollectionKind]reportv1alpha1.RuntimeTreeCollectionStatus, +) reportv1alpha1.RuntimeTreeSnapshotCompleteness { + required := []reportv1alpha1.RuntimeTreeCollectionKind{ + reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, + } + if mode == reportv1alpha1.RuntimeTreeResolutionModeNamespaced { + required = append(required, reportv1alpha1.RuntimeTreeCollectionServingRuntime) + } + for _, kind := range required { + if statuses[kind] != reportv1alpha1.RuntimeTreeCollectionStatusComplete { + return reportv1alpha1.RuntimeTreeSnapshotPartial + } + } + return reportv1alpha1.RuntimeTreeSnapshotComplete +} + +func projectPath( + path runtimegraph.ResolutionPath, + context runtimegraph.ResolutionContext, + target runtimegraph.Identity, +) (reportv1alpha1.RuntimeTreePath, error) { + if err := validatePath(path, context, target); err != nil { + return reportv1alpha1.RuntimeTreePath{}, err + } + head, err := projectIdentity(path.Subject) + if err != nil { + return reportv1alpha1.RuntimeTreePath{}, err + } + result := reportv1alpha1.RuntimeTreePath{ + Head: head, Runtimes: make([]reportv1alpha1.RuntimeTreeRuntime, len(path.Runtimes)), + Dependents: []reportv1alpha1.RuntimeTreeDependent{}, + } + for i := range path.Runtimes { + result.Runtimes[i], err = projectRuntime(path.Runtimes[i]) + if err != nil { + return reportv1alpha1.RuntimeTreePath{}, err + } + } + if path.Issue != nil { + issue, err := projectIssue(*path.Issue) + if err != nil { + return reportv1alpha1.RuntimeTreePath{}, err + } + result.Issue = &issue + } + return result, nil +} + +func validatePath( + path runtimegraph.ResolutionPath, + context runtimegraph.ResolutionContext, + target runtimegraph.Identity, +) error { + if len(path.Runtimes) == 0 || path.Subject != path.Runtimes[len(path.Runtimes)-1].Identity { + return fmt.Errorf("%w: head and final runtime must match", ErrInvalidProjection) + } + if len(path.Runtimes) > constants.RuntimeInheritMaxDepth { + return fmt.Errorf("%w: path exceeds the controller depth bound", ErrInvalidProjection) + } + if err := validateHeadContext(path.Subject, context); err != nil { + return err + } + seenNames := make(map[string]struct{}, len(path.Runtimes)) + targetOccurrences := 0 + for i := range path.Runtimes { + runtime := path.Runtimes[i] + if err := validateRuntimeInContext(runtime, context); err != nil { + return err + } + if _, duplicate := seenNames[runtime.Identity.Name]; duplicate { + return fmt.Errorf("%w: a path repeats runtime name %q", ErrInvalidProjection, runtime.Identity.Name) + } + seenNames[runtime.Identity.Name] = struct{}{} + if runtime.Identity == target { + targetOccurrences++ + } + if i > 0 { + expected := path.Runtimes[i-1].Identity + if runtime.ResolvedParent == nil || *runtime.ResolvedParent != expected || runtime.ParentName != expected.Name { + return fmt.Errorf("%w: structural parent disagrees for %s", ErrInvalidProjection, runtime.Identity.Name) + } + } + } + if targetOccurrences != 1 { + return fmt.Errorf("%w: every path must visit the target exactly once", ErrInvalidProjection) + } + return validateBoundary(path) +} + +func validateHeadContext(identity runtimegraph.Identity, context runtimegraph.ResolutionContext) error { + if context.Mode == runtimegraph.ResolutionModeCluster { + if identity.Kind != runtimegraph.KindClusterServingRuntime || identity.Namespace != "" { + return fmt.Errorf("%w: cluster context head must be a ClusterServingRuntime", ErrInvalidProjection) + } + return nil + } + if context.Mode == runtimegraph.ResolutionModeNamespaced && + identity.Kind == runtimegraph.KindServingRuntime && identity.Namespace == context.Namespace { + return nil + } + return fmt.Errorf("%w: namespaced context head must be a ServingRuntime in that namespace", ErrInvalidProjection) +} + +func validateRuntimeInContext(runtime runtimegraph.Runtime, context runtimegraph.ResolutionContext) error { + if _, err := projectIdentity(runtime.Identity); err != nil { + return err + } + if !identityAllowedInContext(runtime.Identity, context) { + return fmt.Errorf("%w: runtime is incompatible with its resolution context", ErrInvalidProjection) + } + if runtime.ParentName != "" && len(validation.IsDNS1123Subdomain(runtime.ParentName)) != 0 { + return fmt.Errorf("%w: parent name %q is invalid", ErrInvalidProjection, runtime.ParentName) + } + if runtime.ResolvedParent != nil { + if _, err := projectIdentity(*runtime.ResolvedParent); err != nil { + return err + } + if !identityAllowedInContext(*runtime.ResolvedParent, context) || + runtime.ParentName == "" || runtime.ParentName != runtime.ResolvedParent.Name { + return fmt.Errorf("%w: declared and resolved parent disagree for %s", ErrInvalidProjection, runtime.Identity.Name) + } + } + return nil +} + +func identityAllowedInContext(identity runtimegraph.Identity, context runtimegraph.ResolutionContext) bool { + switch context.Mode { + case runtimegraph.ResolutionModeCluster: + return identity.Kind == runtimegraph.KindClusterServingRuntime && identity.Namespace == "" + case runtimegraph.ResolutionModeNamespaced: + return identity.Kind == runtimegraph.KindClusterServingRuntime && identity.Namespace == "" || + identity.Kind == runtimegraph.KindServingRuntime && identity.Namespace == context.Namespace + default: + return false + } +} + +func validateBoundary(path runtimegraph.ResolutionPath) error { + boundary := path.Runtimes[0] + if path.Issue == nil { + if boundary.ParentName != "" || boundary.ResolvedParent != nil { + return fmt.Errorf("%w: unresolved boundary has no issue", ErrInvalidProjection) + } + return nil + } + issue := path.Issue + if issue.Subject != path.Subject || issue.ParentName == "" || issue.ParentName != boundary.ParentName { + return fmt.Errorf("%w: issue does not describe its head boundary", ErrInvalidProjection) + } + childFirst := reverseRuntimeIdentities(path.Runtimes) + for _, identity := range issue.Path { + if !identityAllowedInContext(identity, resolutionContextForPath(path)) { + return fmt.Errorf("%w: issue path identity is incompatible with context", ErrInvalidProjection) + } + if _, err := projectIdentity(identity); err != nil { + return err + } + } + switch issue.Code { + case runtimegraph.IssueParentMissing: + if len(path.Runtimes) >= constants.RuntimeInheritMaxDepth || boundary.ResolvedParent != nil || + !equalIdentitySlices(issue.Path, childFirst) { + return fmt.Errorf("%w: malformed missing-parent boundary", ErrInvalidProjection) + } + case runtimegraph.IssueMaxDepthExceeded: + if len(path.Runtimes) != constants.RuntimeInheritMaxDepth || boundary.ResolvedParent != nil || + !equalIdentitySlices(issue.Path, childFirst) { + return fmt.Errorf("%w: malformed max-depth boundary", ErrInvalidProjection) + } + case runtimegraph.IssueCycleDetected: + if len(path.Runtimes) >= constants.RuntimeInheritMaxDepth || boundary.ResolvedParent == nil || + len(issue.Path) != len(childFirst)+1 || + !equalIdentitySlices(issue.Path[:len(childFirst)], childFirst) || + issue.Path[len(issue.Path)-1] != *boundary.ResolvedParent || + !containsIdentity(childFirst, *boundary.ResolvedParent) { + return fmt.Errorf("%w: malformed cycle boundary", ErrInvalidProjection) + } + default: + return fmt.Errorf("%w: unsupported issue code %q", ErrInvalidProjection, issue.Code) + } + return nil +} + +// resolutionContextForPath returns the only context compatible with the +// path's head. validatePath already checked the original context against it. +func resolutionContextForPath(path runtimegraph.ResolutionPath) runtimegraph.ResolutionContext { + if path.Subject.Kind == runtimegraph.KindServingRuntime { + return runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeNamespaced, Namespace: path.Subject.Namespace} + } + return runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeCluster} +} + +func reverseRuntimeIdentities(runtimes []runtimegraph.Runtime) []runtimegraph.Identity { + result := make([]runtimegraph.Identity, len(runtimes)) + for i := range runtimes { + result[len(runtimes)-1-i] = runtimes[i].Identity + } + return result +} + +func equalIdentitySlices(left, right []runtimegraph.Identity) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func containsIdentity(values []runtimegraph.Identity, candidate runtimegraph.Identity) bool { + for _, value := range values { + if value == candidate { + return true + } + } + return false +} + +func projectIssue(issue runtimegraph.Issue) (reportv1alpha1.RuntimeTreeIssue, error) { + result := reportv1alpha1.RuntimeTreeIssue{ParentName: issue.ParentName} + switch issue.Code { + case runtimegraph.IssueParentMissing: + result.Code = reportv1alpha1.RuntimeTreeIssueParentMissing + case runtimegraph.IssueCycleDetected: + result.Code = reportv1alpha1.RuntimeTreeIssueCycleDetected + case runtimegraph.IssueMaxDepthExceeded: + result.Code = reportv1alpha1.RuntimeTreeIssueMaxDepthExceeded + default: + return reportv1alpha1.RuntimeTreeIssue{}, fmt.Errorf( + "%w: unsupported issue code %q", ErrInvalidProjection, issue.Code, + ) + } + var err error + result.Subject, err = projectIdentity(issue.Subject) + if err != nil { + return reportv1alpha1.RuntimeTreeIssue{}, err + } + result.Path = make([]reportv1alpha1.RuntimeTreeIdentity, len(issue.Path)) + for i := range issue.Path { + result.Path[i], err = projectIdentity(issue.Path[i]) + if err != nil { + return reportv1alpha1.RuntimeTreeIssue{}, err + } + } + return result, nil +} + +type dependentIdentity struct { + kind reportv1alpha1.RuntimeTreeDependentKind + namespace string + name string +} + +func attachDependents( + contexts []reportv1alpha1.RuntimeTreeContext, + heads map[runtimegraph.Identity]headLocation, + values []DependentLeaf, +) error { + seen := make(map[dependentIdentity]DependentLeaf, len(values)) + for _, value := range values { + if err := validateDependent(value); err != nil { + return err + } + key := dependentIdentity{kind: value.Kind, namespace: value.Namespace, name: value.Name} + if previous, duplicate := seen[key]; duplicate { + if previous == value { + return fmt.Errorf("%w: duplicate dependent %s/%s", ErrInvalidDependent, value.Namespace, value.Name) + } + return fmt.Errorf("%w: ambiguous dependent %s/%s", ErrInvalidDependent, value.Namespace, value.Name) + } + seen[key] = value + location, visible := heads[value.Runtime] + if !visible { + return fmt.Errorf("%w: %s", ErrDependentRuntimeNotVisible, value.Runtime.Name) + } + contexts[location.context].Paths[location.path].Dependents = append( + contexts[location.context].Paths[location.path].Dependents, + reportv1alpha1.RuntimeTreeDependent{ + Kind: value.Kind, Namespace: value.Namespace, Name: value.Name, UID: value.UID, + }, + ) + } + return nil +} + +func validateDependent(value DependentLeaf) error { + if value.Kind != reportv1alpha1.RuntimeTreeDependentInferenceService || + len(validation.IsDNS1123Label(value.Namespace)) != 0 || + len(validation.IsDNS1123Subdomain(value.Name)) != 0 { + return fmt.Errorf("%w: incomplete or unsupported leaf identity", ErrInvalidDependent) + } + switch value.Runtime.Kind { + case runtimegraph.KindClusterServingRuntime: + if value.Runtime.Namespace != "" || len(validation.IsDNS1123Subdomain(value.Runtime.Name)) != 0 { + return fmt.Errorf("%w: malformed ClusterServingRuntime identity", ErrInvalidDependent) + } + case runtimegraph.KindServingRuntime: + if len(validation.IsDNS1123Label(value.Runtime.Namespace)) != 0 || + len(validation.IsDNS1123Subdomain(value.Runtime.Name)) != 0 || + value.Namespace != value.Runtime.Namespace { + return fmt.Errorf("%w: malformed ServingRuntime identity", ErrInvalidDependent) + } + default: + return fmt.Errorf("%w: unsupported runtime kind %q", ErrInvalidDependent, value.Runtime.Kind) + } + return nil +} + +func validateVisibleCounts( + snapshot reportv1alpha1.RuntimeTreeSnapshot, + contexts []reportv1alpha1.RuntimeTreeContext, +) error { + visibleRuntimes := map[reportv1alpha1.RuntimeTreeIdentity]struct{}{} + visibleDependents := map[dependentIdentity]struct{}{} + for _, context := range contexts { + for _, path := range context.Paths { + for _, runtime := range path.Runtimes { + visibleRuntimes[runtime.Identity] = struct{}{} + } + for _, dependent := range path.Dependents { + visibleDependents[dependentIdentity{ + kind: dependent.Kind, namespace: dependent.Namespace, name: dependent.Name, + }] = struct{}{} + } + } + } + + minimums := map[reportv1alpha1.RuntimeTreeCollectionKind]int{ + reportv1alpha1.RuntimeTreeCollectionInferenceService: len(visibleDependents), + } + for identity := range visibleRuntimes { + switch identity.Kind { + case reportv1alpha1.RuntimeKindClusterServingRuntime: + minimums[reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime]++ + case reportv1alpha1.RuntimeKindServingRuntime: + minimums[reportv1alpha1.RuntimeTreeCollectionServingRuntime]++ + } + } + for _, collection := range snapshot.Collections { + if collection.ObservedItems < minimums[collection.Kind] { + return fmt.Errorf( + "%w: %q observed item count is below visible report objects", + ErrInvalidSnapshot, collection.Kind, + ) + } + } + return nil +} diff --git a/pkg/cli/runtimetreeprojection/project_test.go b/pkg/cli/runtimetreeprojection/project_test.go new file mode 100644 index 000000000..b500412d4 --- /dev/null +++ b/pkg/cli/runtimetreeprojection/project_test.go @@ -0,0 +1,822 @@ +package runtimetreeprojection_test + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + omev1beta1 "sigs.k8s.io/ome/pkg/apis/ome/v1beta1" + "sigs.k8s.io/ome/pkg/cli/report" + reportv1alpha1 "sigs.k8s.io/ome/pkg/cli/report/v1alpha1" + "sigs.k8s.io/ome/pkg/cli/runtimegraph" + "sigs.k8s.io/ome/pkg/cli/runtimetreeprojection" + "sigs.k8s.io/ome/pkg/constants" +) + +func TestProjectPreservesThreeContextsAndAttachesOnlyToExactHeads(t *testing.T) { + projection := graphProjection(t, threeContextSnapshot(), runtimegraph.Target{ + Kind: runtimegraph.KindClusterServingRuntime, Name: "root", + }) + input := runtimetreeprojection.Input{ + Projection: projection, + Snapshot: completeSnapshotObservation(2, 2, 4), + Dependents: []runtimetreeprojection.DependentLeaf{ + {Runtime: clusterIdentity("root"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "direct"}, + {Runtime: clusterIdentity("cluster-child"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "cluster-user"}, + {Runtime: namespacedIdentity("team-a", "local-a"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "team-a", Name: "chat-a"}, + {Runtime: namespacedIdentity("team-b", "local-b"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "team-b", Name: "chat-b"}, + }, + } + + got, err := runtimetreeprojection.Project(input, fixedProjectionClock()) + require.NoError(t, err) + require.Len(t, got.Content.Contexts, 3) + assert.Equal(t, []reportv1alpha1.RuntimeTreeResolutionContext{ + {Mode: reportv1alpha1.RuntimeTreeResolutionModeCluster}, + {Mode: reportv1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-a"}, + {Mode: reportv1alpha1.RuntimeTreeResolutionModeNamespaced, Namespace: "team-b"}, + }, reportContexts(got.Content)) + assert.Equal(t, []string{"root", "cluster-child"}, reportHeads(got.Content.Contexts[0])) + assert.Equal(t, []string{"local-a"}, reportHeads(got.Content.Contexts[1])) + assert.Equal(t, []string{"local-b"}, reportHeads(got.Content.Contexts[2])) + assert.Equal(t, 1, countDependent(got.Content, "ops", "direct"), + "a direct ClusterServingRuntime user must not be repeated on ancestor occurrences") + assert.Equal(t, 1, countDependent(got.Content, "team-a", "chat-a")) + + var output bytes.Buffer + require.NoError(t, report.Write(&output, report.FormatTable, got)) + assert.Equal(t, "RUNTIME TREE\n"+ + "Target: ClusterServingRuntime/root\n"+ + "Context: Cluster (resolution: Complete)\n"+ + "Head: ClusterServingRuntime/root\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- InferenceService/ops/direct\n"+ + "Head: ClusterServingRuntime/cluster-child\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ClusterServingRuntime/cluster-child\n"+ + " `-- InferenceService/ops/cluster-user\n"+ + "Context: Namespaced/team-a (resolution: Complete)\n"+ + "Head: ServingRuntime/local-a\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/local-a\n"+ + " `-- InferenceService/chat-a\n"+ + "Context: Namespaced/team-b (resolution: Complete)\n"+ + "Head: ServingRuntime/local-b\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/local-b\n"+ + " `-- InferenceService/chat-b\n"+ + "Snapshot: Complete\n"+ + "Collection: ClusterServingRuntime status=Complete pages=1 items=2\n"+ + "Collection: ServingRuntime status=Complete pages=1 items=2\n"+ + "Collection: InferenceService status=Complete pages=1 items=4\n", + output.String()) +} + +func TestProjectKeepsSameContextMaxDepthPathsSeparate(t *testing.T) { + projection := graphProjection(t, runtimegraph.Snapshot{ClusterServingRuntimes: []omev1beta1.ClusterServingRuntime{ + clusterRuntime("level-1", ""), + clusterRuntime("level-2", "level-1"), + clusterRuntime("target", "level-2"), + clusterRuntime("level-4", "target"), + clusterRuntime("level-5", "level-4"), + clusterRuntime("level-6", "level-5"), + }}, runtimegraph.Target{Kind: runtimegraph.KindClusterServingRuntime, Name: "target"}) + + got, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(6, 0, 0), + }, fixedProjectionClock()) + require.NoError(t, err) + require.Len(t, got.Content.Contexts, 1) + paths := got.Content.Contexts[0].Paths + require.Len(t, paths, 4) + assert.Equal(t, []string{"target", "level-4", "level-5", "level-6"}, reportHeads(got.Content.Contexts[0])) + assert.Equal(t, []string{"level-1", "level-2", "target"}, runtimeNames(paths[0])) + assert.Equal(t, []string{"level-1", "level-2", "target", "level-4"}, runtimeNames(paths[1])) + assert.Equal(t, []string{"level-1", "level-2", "target", "level-4", "level-5"}, runtimeNames(paths[2])) + assert.Equal(t, []string{"level-2", "target", "level-4", "level-5", "level-6"}, runtimeNames(paths[3])) + require.NotNil(t, paths[3].Issue) + assert.Equal(t, reportv1alpha1.RuntimeTreeIssueMaxDepthExceeded, paths[3].Issue.Code) + assert.Equal(t, "level-1", paths[3].Issue.ParentName) + for i := 0; i < 3; i++ { + assert.Nil(t, paths[i].Issue) + } + + var output bytes.Buffer + require.NoError(t, report.Write(&output, report.FormatTable, got)) + assert.Equal(t, "RUNTIME TREE\n"+ + "Target: ClusterServingRuntime/target\n"+ + "Context: Cluster (resolution: Complete)\n"+ + "Head: ClusterServingRuntime/target\n"+ + "ClusterServingRuntime/level-1\n"+ + "`-- ClusterServingRuntime/level-2\n"+ + " `-- ClusterServingRuntime/target [selected]\n"+ + "Head: ClusterServingRuntime/level-4\n"+ + "ClusterServingRuntime/level-1\n"+ + "`-- ClusterServingRuntime/level-2\n"+ + " `-- ClusterServingRuntime/target [selected]\n"+ + " `-- ClusterServingRuntime/level-4\n"+ + "Head: ClusterServingRuntime/level-5\n"+ + "ClusterServingRuntime/level-1\n"+ + "`-- ClusterServingRuntime/level-2\n"+ + " `-- ClusterServingRuntime/target [selected]\n"+ + " `-- ClusterServingRuntime/level-4\n"+ + " `-- ClusterServingRuntime/level-5\n"+ + "Head: ClusterServingRuntime/level-6\n"+ + "ClusterServingRuntime/level-2\n"+ + "`-- ClusterServingRuntime/target [selected]\n"+ + " `-- ClusterServingRuntime/level-4\n"+ + " `-- ClusterServingRuntime/level-5\n"+ + " `-- ClusterServingRuntime/level-6\n"+ + "Issue: MaxDepthExceeded subject=ClusterServingRuntime/level-6 parent=level-1\n"+ + "Issue path: ClusterServingRuntime/level-6 -> ClusterServingRuntime/level-5 -> ClusterServingRuntime/level-4 -> ClusterServingRuntime/target -> ClusterServingRuntime/level-2\n"+ + "Snapshot: Complete\n"+ + "Collection: ClusterServingRuntime status=Complete pages=1 items=6\n"+ + "Collection: ServingRuntime status=Complete pages=1 items=0\n"+ + "Collection: InferenceService status=Complete pages=1 items=0\n", + output.String()) +} + +func TestProjectDoesNotInventShadowedNamespacedDescendants(t *testing.T) { + projection := graphProjection(t, runtimegraph.Snapshot{ + ClusterServingRuntimes: []omev1beta1.ClusterServingRuntime{ + clusterRuntime("root", ""), clusterRuntime("cluster-child", "root"), + }, + ServingRuntimes: []omev1beta1.ServingRuntime{ + namespacedRuntime("team-a", "root", ""), + namespacedRuntime("team-a", "local-child", "root"), + }, + }, runtimegraph.Target{Kind: runtimegraph.KindClusterServingRuntime, Name: "root"}) + + got, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(2, 2, 0), + }, fixedProjectionClock()) + require.NoError(t, err) + require.Len(t, got.Content.Contexts, 1) + assert.Equal(t, reportv1alpha1.RuntimeTreeResolutionModeCluster, got.Content.Contexts[0].Context.Mode) + assert.Equal(t, []string{"root", "cluster-child"}, reportHeads(got.Content.Contexts[0])) +} + +func TestProjectDerivesSnapshotContextCompletenessAndWarnings(t *testing.T) { + projection := graphProjection(t, threeContextSnapshot(), runtimegraph.Target{ + Kind: runtimegraph.KindClusterServingRuntime, Name: "root", + }) + + got, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, + Snapshot: runtimetreeprojection.SnapshotObservation{Collections: []runtimetreeprojection.CollectionObservation{ + {Kind: reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 2}, + {Kind: reportv1alpha1.RuntimeTreeCollectionServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusUnavailable, ObservedPages: 1, ObservedItems: 2}, + {Kind: reportv1alpha1.RuntimeTreeCollectionInferenceService, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 0}, + }}, + }, fixedProjectionClock()) + require.NoError(t, err) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Snapshot.Completeness) + assert.Equal(t, []reportv1alpha1.RuntimeWarning{ + {Code: reportv1alpha1.WarningPartialData}, + {Code: reportv1alpha1.WarningSourceUnavailable}, + }, got.Warnings) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotComplete, got.Content.Contexts[0].ResolutionCompleteness, + "cluster resolution does not consume the ServingRuntime collection") + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Contexts[1].ResolutionCompleteness) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Contexts[2].ResolutionCompleteness) + var output bytes.Buffer + require.NoError(t, report.Write(&output, report.FormatTable, got)) + assert.Equal(t, "RUNTIME TREE\n"+ + "Target: ClusterServingRuntime/root\n"+ + "Context: Cluster (resolution: Complete)\n"+ + "Head: ClusterServingRuntime/root\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "Head: ClusterServingRuntime/cluster-child\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ClusterServingRuntime/cluster-child\n"+ + "Context: Namespaced/team-a (resolution: Partial)\n"+ + "Head: ServingRuntime/local-a\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/local-a\n"+ + "Context: Namespaced/team-b (resolution: Partial)\n"+ + "Head: ServingRuntime/local-b\n"+ + "ClusterServingRuntime/root [selected]\n"+ + "`-- ServingRuntime/local-b\n"+ + "Snapshot: Partial\n"+ + "Collection: ClusterServingRuntime status=Complete pages=1 items=2\n"+ + "Collection: ServingRuntime status=Unavailable pages=1 items=2\n"+ + "Collection: InferenceService status=Complete pages=1 items=0\n"+ + "Warning: PartialData\n"+ + "Warning: SourceUnavailable\n", + output.String()) + + got, err = runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, + Snapshot: runtimetreeprojection.SnapshotObservation{Collections: []runtimetreeprojection.CollectionObservation{ + {Kind: reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: 2}, + {Kind: reportv1alpha1.RuntimeTreeCollectionServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusTruncated, ObservedPages: 1, ObservedItems: 2}, + {Kind: reportv1alpha1.RuntimeTreeCollectionInferenceService, Status: reportv1alpha1.RuntimeTreeCollectionStatusUnavailable}, + }}, + }, fixedProjectionClock()) + require.NoError(t, err) + assert.Equal(t, []reportv1alpha1.RuntimeWarning{ + {Code: reportv1alpha1.WarningPartialData}, + {Code: reportv1alpha1.WarningSourceUnavailable}, + {Code: reportv1alpha1.WarningTruncated}, + }, got.Warnings) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotComplete, got.Content.Contexts[0].ResolutionCompleteness, + "InferenceService availability does not change cluster runtime resolution") + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Contexts[1].ResolutionCompleteness) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Contexts[2].ResolutionCompleteness) +} + +func TestProjectMapsEachGraphIssueOntoItsPath(t *testing.T) { + tests := []struct { + name string + snapshot runtimegraph.Snapshot + target runtimegraph.Target + code reportv1alpha1.RuntimeTreeIssueCode + count int + }{ + { + name: "missing parent", + snapshot: runtimegraph.Snapshot{ServingRuntimes: []omev1beta1.ServingRuntime{ + namespacedRuntime("team-a", "orphan", "missing"), + }}, + target: runtimegraph.Target{Kind: runtimegraph.KindServingRuntime, Namespace: "team-a", Name: "orphan"}, + code: reportv1alpha1.RuntimeTreeIssueParentMissing, + count: 1, + }, + { + name: "cycle", + snapshot: runtimegraph.Snapshot{ServingRuntimes: []omev1beta1.ServingRuntime{ + namespacedRuntime("team-a", "a", "b"), namespacedRuntime("team-a", "b", "a"), + }}, + target: runtimegraph.Target{Kind: runtimegraph.KindServingRuntime, Namespace: "team-a", Name: "a"}, + code: reportv1alpha1.RuntimeTreeIssueCycleDetected, + count: 2, + }, + { + name: "max depth", + snapshot: runtimegraph.Snapshot{ClusterServingRuntimes: []omev1beta1.ClusterServingRuntime{ + clusterRuntime("one", ""), clusterRuntime("two", "one"), clusterRuntime("three", "two"), + clusterRuntime("four", "three"), clusterRuntime("five", "four"), clusterRuntime("six", "five"), + }}, + target: runtimegraph.Target{Kind: runtimegraph.KindClusterServingRuntime, Name: "six"}, + code: reportv1alpha1.RuntimeTreeIssueMaxDepthExceeded, + count: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: graphProjection(t, test.snapshot, test.target), + Snapshot: completeSnapshotObservation( + len(test.snapshot.ClusterServingRuntimes), len(test.snapshot.ServingRuntimes), 0, + ), + }, fixedProjectionClock()) + require.NoError(t, err) + issues := collectPathIssues(got.Content) + require.Len(t, issues, test.count) + for _, issue := range issues { + assert.Equal(t, test.code, issue.Code) + } + }) + } +} + +func TestProjectRejectsInvalidDuplicateAmbiguousAndNonHeadDependents(t *testing.T) { + projection := graphProjection(t, runtimegraph.Snapshot{ClusterServingRuntimes: []omev1beta1.ClusterServingRuntime{ + clusterRuntime("ancestor", ""), clusterRuntime("target", "ancestor"), clusterRuntime("head", "target"), + }}, runtimegraph.Target{Kind: runtimegraph.KindClusterServingRuntime, Name: "target"}) + valid := runtimetreeprojection.DependentLeaf{ + Runtime: clusterIdentity("head"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, + Namespace: "ops", Name: "chat", UID: "uid-1", + } + tests := []struct { + name string + dependents []runtimetreeprojection.DependentLeaf + want error + }{ + {name: "ancestor occurrence is not a head", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: clusterIdentity("ancestor"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, + Namespace: "ops", Name: "chat", + }}, want: runtimetreeprojection.ErrDependentRuntimeNotVisible}, + {name: "missing head", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: clusterIdentity("absent"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, + Namespace: "ops", Name: "chat", + }}, want: runtimetreeprojection.ErrDependentRuntimeNotVisible}, + {name: "duplicate", dependents: []runtimetreeprojection.DependentLeaf{valid, valid}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "ambiguous identity", dependents: []runtimetreeprojection.DependentLeaf{valid, { + Runtime: clusterIdentity("target"), Kind: valid.Kind, Namespace: valid.Namespace, Name: valid.Name, UID: "uid-2", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "missing namespace", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: clusterIdentity("head"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Name: "chat", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "future dependent kind", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: clusterIdentity("head"), Kind: reportv1alpha1.RuntimeTreeDependentKind("Pod"), Namespace: "ops", Name: "chat", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "future runtime kind", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: runtimegraph.Identity{Kind: runtimegraph.Kind("FutureRuntime"), Name: "head"}, + Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "chat", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "cluster runtime namespace", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: runtimegraph.Identity{Kind: runtimegraph.KindClusterServingRuntime, Namespace: "team-a", Name: "head"}, + Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "chat", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "ServingRuntime cross-namespace user", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: namespacedIdentity("team-a", "head"), + Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "team-b", Name: "chat", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + {name: "terminal control in name", dependents: []runtimetreeprojection.DependentLeaf{{ + Runtime: clusterIdentity("head"), + Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "bad\nname", + }}, want: runtimetreeprojection.ErrInvalidDependent}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(3, 0, 2), Dependents: test.dependents, + }, fixedProjectionClock()) + assert.ErrorIs(t, err, test.want) + }) + } +} + +func TestProjectRejectsMalformedSnapshotAndFutureCollectionEnums(t *testing.T) { + projection := validDirectProjection() + tests := []struct { + name string + collections []runtimetreeprojection.CollectionObservation + }{ + {name: "missing kind", collections: completeCollections(1, 0, 0)[:2]}, + {name: "duplicate kind", collections: append(completeCollections(1, 0, 0), completeCollections(1, 0, 0)[0])}, + {name: "negative pages", collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { value.ObservedPages = -1 })}, + {name: "future kind", collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { + value.Kind = reportv1alpha1.RuntimeTreeCollectionKind("Future") + })}, + {name: "future status", collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { + value.Status = reportv1alpha1.RuntimeTreeCollectionStatus("Future") + })}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, + Snapshot: runtimetreeprojection.SnapshotObservation{Collections: test.collections}, + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidSnapshot) + }) + } +} + +func TestProjectRejectsCollectionPageAndVisibleCountContradictions(t *testing.T) { + clusterProjection := validDirectProjection() + namespacedProjection := graphProjection(t, runtimegraph.Snapshot{ServingRuntimes: []omev1beta1.ServingRuntime{ + namespacedRuntime("team-a", "target", ""), + }}, runtimegraph.Target{Kind: runtimegraph.KindServingRuntime, Namespace: "team-a", Name: "target"}) + directDependent := runtimetreeprojection.DependentLeaf{ + Runtime: clusterIdentity("root"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, + Namespace: "ops", Name: "chat", + } + tests := []struct { + name string + projection runtimegraph.Projection + collections []runtimetreeprojection.CollectionObservation + dependents []runtimetreeprojection.DependentLeaf + }{ + { + name: "complete collection has no observed page", projection: clusterProjection, + collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { + value.ObservedPages = 0 + }), + }, + { + name: "truncated collection has no observed page", projection: clusterProjection, + collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { + value.Status = reportv1alpha1.RuntimeTreeCollectionStatusTruncated + value.ObservedPages = 0 + }), + }, + { + name: "unavailable collection has items but no observed page", projection: clusterProjection, + collections: replaceCollection(completeCollections(1, 0, 0), reportv1alpha1.RuntimeTreeCollectionServingRuntime, func(value *runtimetreeprojection.CollectionObservation) { + value.Status = reportv1alpha1.RuntimeTreeCollectionStatusUnavailable + value.ObservedPages = 0 + value.ObservedItems = 1 + }), + }, + { + name: "cluster runtime count is below visible identities", projection: clusterProjection, + collections: completeCollections(0, 0, 0), + }, + { + name: "namespaced runtime count is below visible identities", projection: namespacedProjection, + collections: completeCollections(0, 0, 0), + }, + { + name: "inference service count is below visible dependents", projection: clusterProjection, + collections: completeCollections(1, 0, 0), dependents: []runtimetreeprojection.DependentLeaf{directDependent}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: test.projection, + Snapshot: runtimetreeprojection.SnapshotObservation{Collections: test.collections}, + Dependents: test.dependents, + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidSnapshot) + }) + } +} + +func TestProjectAllowsUnavailableEmptyCollectionWithNoVisibleObjects(t *testing.T) { + collections := replaceCollection( + completeCollections(1, 0, 0), + reportv1alpha1.RuntimeTreeCollectionInferenceService, + func(value *runtimetreeprojection.CollectionObservation) { + value.Status = reportv1alpha1.RuntimeTreeCollectionStatusUnavailable + value.ObservedPages = 0 + }, + ) + + got, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: validDirectProjection(), + Snapshot: runtimetreeprojection.SnapshotObservation{Collections: collections}, + }, fixedProjectionClock()) + require.NoError(t, err) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotPartial, got.Content.Snapshot.Completeness) + assert.Equal(t, reportv1alpha1.RuntimeTreeSnapshotComplete, got.Content.Contexts[0].ResolutionCompleteness) +} + +func TestProjectRejectsCrossPathDeclaredParentConflict(t *testing.T) { + baseA := runtimegraph.Runtime{Identity: clusterIdentity("base-a")} + baseB := runtimegraph.Runtime{Identity: clusterIdentity("base-b")} + targetIdentity := clusterIdentity("target") + targetViaA := runtimegraph.Runtime{ + Identity: targetIdentity, ParentName: "base-a", ResolvedParent: graphIdentityPointer(baseA.Identity), + } + targetViaB := runtimegraph.Runtime{ + Identity: targetIdentity, ParentName: "base-b", ResolvedParent: graphIdentityPointer(baseB.Identity), + } + child := runtimegraph.Runtime{ + Identity: clusterIdentity("child"), ParentName: "target", ResolvedParent: graphIdentityPointer(targetIdentity), + } + projection := runtimegraph.Projection{ + Target: targetIdentity, + Contexts: []runtimegraph.ContextProjection{{ + Context: runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeCluster}, + Paths: []runtimegraph.ResolutionPath{ + {Subject: targetIdentity, Runtimes: []runtimegraph.Runtime{baseA, targetViaA}}, + {Subject: child.Identity, Runtimes: []runtimegraph.Runtime{baseB, targetViaB, child}}, + }, + }}, + } + + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(4, 0, 0), + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidProjection) +} + +func TestProjectRejectsCrossPathResolvedParentConflict(t *testing.T) { + clusterBase := runtimegraph.Runtime{Identity: clusterIdentity("base")} + localBase := runtimegraph.Runtime{Identity: namespacedIdentity("team-a", "base")} + targetIdentity := namespacedIdentity("team-a", "target") + targetViaCluster := runtimegraph.Runtime{ + Identity: targetIdentity, ParentName: "base", ResolvedParent: graphIdentityPointer(clusterBase.Identity), + } + targetViaLocal := runtimegraph.Runtime{ + Identity: targetIdentity, ParentName: "base", ResolvedParent: graphIdentityPointer(localBase.Identity), + } + child := runtimegraph.Runtime{ + Identity: namespacedIdentity("team-a", "child"), ParentName: "target", ResolvedParent: graphIdentityPointer(targetIdentity), + } + projection := runtimegraph.Projection{ + Target: targetIdentity, + Contexts: []runtimegraph.ContextProjection{{ + Context: runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeNamespaced, Namespace: "team-a"}, + Paths: []runtimegraph.ResolutionPath{ + {Subject: targetIdentity, Runtimes: []runtimegraph.Runtime{localBase, targetViaLocal}}, + {Subject: child.Identity, Runtimes: []runtimegraph.Runtime{clusterBase, targetViaCluster, child}}, + }, + }}, + } + + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(1, 3, 0), + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidProjection) +} + +func TestProjectRejectsCrossPathMissingVersusResolvedParent(t *testing.T) { + base := runtimegraph.Runtime{Identity: namespacedIdentity("team-a", "base")} + targetIdentity := namespacedIdentity("team-a", "target") + missingTarget := runtimegraph.Runtime{Identity: targetIdentity, ParentName: "base"} + resolvedTarget := runtimegraph.Runtime{ + Identity: targetIdentity, ParentName: "base", ResolvedParent: graphIdentityPointer(base.Identity), + } + child := runtimegraph.Runtime{ + Identity: namespacedIdentity("team-a", "child"), ParentName: "target", ResolvedParent: graphIdentityPointer(targetIdentity), + } + projection := runtimegraph.Projection{ + Target: targetIdentity, + Contexts: []runtimegraph.ContextProjection{{ + Context: runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeNamespaced, Namespace: "team-a"}, + Paths: []runtimegraph.ResolutionPath{ + { + Subject: targetIdentity, + Runtimes: []runtimegraph.Runtime{missingTarget}, + Issue: &runtimegraph.Issue{ + Code: runtimegraph.IssueParentMissing, Subject: targetIdentity, + ParentName: "base", Path: []runtimegraph.Identity{targetIdentity}, + }, + }, + {Subject: child.Identity, Runtimes: []runtimegraph.Runtime{base, resolvedTarget, child}}, + }, + }}, + } + + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(0, 3, 0), + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidProjection) +} + +func TestProjectRejectsMalformedPathsAndFutureGraphEnums(t *testing.T) { + root := runtimegraph.Runtime{Identity: clusterIdentity("root")} + child := runtimegraph.Runtime{ + Identity: clusterIdentity("child"), ParentName: "root", ResolvedParent: graphIdentityPointer(clusterIdentity("root")), + } + base := validDirectProjection() + tests := []struct { + name string + mutate func(*runtimegraph.Projection) + }{ + {name: "no contexts", mutate: func(value *runtimegraph.Projection) { value.Contexts = nil }}, + {name: "empty paths", mutate: func(value *runtimegraph.Projection) { value.Contexts[0].Paths = nil }}, + {name: "empty runtime path", mutate: func(value *runtimegraph.Projection) { value.Contexts[0].Paths[0].Runtimes = nil }}, + {name: "head differs from final runtime", mutate: func(value *runtimegraph.Projection) { value.Contexts[0].Paths[0].Subject = clusterIdentity("other") }}, + {name: "target absent", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0] = runtimegraph.ResolutionPath{Subject: clusterIdentity("child"), Runtimes: []runtimegraph.Runtime{child}} + }}, + {name: "future resolution mode", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Context.Mode = runtimegraph.ResolutionMode("Future") + }}, + {name: "cluster context namespace", mutate: func(value *runtimegraph.Projection) { value.Contexts[0].Context.Namespace = "team-a" }}, + {name: "namespaced head in cluster context", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0] = runtimegraph.ResolutionPath{Subject: namespacedIdentity("team-a", "root"), Runtimes: []runtimegraph.Runtime{{Identity: namespacedIdentity("team-a", "root")}}} + }}, + {name: "bad structural edge", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths = append(value.Contexts[0].Paths, runtimegraph.ResolutionPath{Subject: child.Identity, Runtimes: []runtimegraph.Runtime{root, {Identity: child.Identity, ParentName: "wrong", ResolvedParent: child.ResolvedParent}}}) + }}, + {name: "future target kind", mutate: func(value *runtimegraph.Projection) { value.Target.Kind = runtimegraph.Kind("Future") }}, + {name: "future runtime kind", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0].Runtimes[0].Identity.Kind = runtimegraph.Kind("Future") + }}, + {name: "future issue code", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0].Runtimes[0].ParentName = "missing" + value.Contexts[0].Paths[0].Issue = &runtimegraph.Issue{Code: runtimegraph.IssueCode("Future"), Subject: clusterIdentity("root"), ParentName: "missing", Path: []runtimegraph.Identity{clusterIdentity("root")}} + }}, + {name: "issue path mismatch", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0].Runtimes[0].ParentName = "missing" + value.Contexts[0].Paths[0].Issue = &runtimegraph.Issue{Code: runtimegraph.IssueParentMissing, Subject: clusterIdentity("root"), ParentName: "missing", Path: []runtimegraph.Identity{clusterIdentity("other")}} + }}, + {name: "max depth issue below controller bound", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths[0].Runtimes[0].ParentName = "missing" + value.Contexts[0].Paths[0].Issue = &runtimegraph.Issue{ + Code: runtimegraph.IssueMaxDepthExceeded, Subject: clusterIdentity("root"), + ParentName: "missing", Path: []runtimegraph.Identity{clusterIdentity("root")}, + } + }}, + {name: "terminal control in runtime name", mutate: func(value *runtimegraph.Projection) { + value.Target.Name = "bad\nname" + }}, + {name: "duplicate context", mutate: func(value *runtimegraph.Projection) { value.Contexts = append(value.Contexts, value.Contexts[0]) }}, + {name: "duplicate head", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths = append(value.Contexts[0].Paths, value.Contexts[0].Paths[0]) + }}, + {name: "missing direct target head", mutate: func(value *runtimegraph.Projection) { + value.Contexts[0].Paths = []runtimegraph.ResolutionPath{{Subject: child.Identity, Runtimes: []runtimegraph.Runtime{root, child}}} + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + projection := cloneGraphProjection(t, base) + test.mutate(&projection) + _, err := runtimetreeprojection.Project(runtimetreeprojection.Input{ + Projection: projection, Snapshot: completeSnapshotObservation(2, 0, 0), + }, fixedProjectionClock()) + assert.ErrorIs(t, err, runtimetreeprojection.ErrInvalidProjection) + }) + } +} + +func TestProjectIsDeterministicAndDoesNotMutateInput(t *testing.T) { + input := runtimetreeprojection.Input{ + Projection: graphProjection(t, threeContextSnapshot(), runtimegraph.Target{ + Kind: runtimegraph.KindClusterServingRuntime, Name: "root", + }), + Snapshot: completeSnapshotObservation(2, 2, 2), + Dependents: []runtimetreeprojection.DependentLeaf{ + {Runtime: namespacedIdentity("team-b", "local-b"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "team-b", Name: "z"}, + {Runtime: clusterIdentity("root"), Kind: reportv1alpha1.RuntimeTreeDependentInferenceService, Namespace: "ops", Name: "a"}, + }, + } + before := cloneProjectionInput(t, input) + reversed := cloneProjectionInput(t, input) + reverse(reversed.Projection.Contexts) + for i := range reversed.Projection.Contexts { + reverse(reversed.Projection.Contexts[i].Paths) + } + reverse(reversed.Snapshot.Collections) + reverse(reversed.Dependents) + + first, err := runtimetreeprojection.Project(input, fixedProjectionClock()) + require.NoError(t, err) + second, err := runtimetreeprojection.Project(reversed, fixedProjectionClock()) + require.NoError(t, err) + + assert.Equal(t, first, second) + assert.Equal(t, before, input, "projection mutated caller-owned evidence") + for _, format := range []report.Format{report.FormatJSON, report.FormatYAML} { + assert.Equal(t, renderProjectionReport(t, first, format), renderProjectionReport(t, second, format)) + } +} + +func threeContextSnapshot() runtimegraph.Snapshot { + return runtimegraph.Snapshot{ + ClusterServingRuntimes: []omev1beta1.ClusterServingRuntime{ + clusterRuntime("root", ""), clusterRuntime("cluster-child", "root"), + }, + ServingRuntimes: []omev1beta1.ServingRuntime{ + namespacedRuntime("team-a", "local-a", "root"), + namespacedRuntime("team-b", "local-b", "root"), + }, + } +} + +func validDirectProjection() runtimegraph.Projection { + root := runtimegraph.Runtime{Identity: clusterIdentity("root")} + return runtimegraph.Projection{ + Target: root.Identity, + Contexts: []runtimegraph.ContextProjection{{ + Context: runtimegraph.ResolutionContext{Mode: runtimegraph.ResolutionModeCluster}, + Paths: []runtimegraph.ResolutionPath{{Subject: root.Identity, Runtimes: []runtimegraph.Runtime{root}}}, + }}, + } +} + +func completeSnapshotObservation(clusterRuntimes, namespacedRuntimes, inferenceServices int) runtimetreeprojection.SnapshotObservation { + return runtimetreeprojection.SnapshotObservation{Collections: completeCollections( + clusterRuntimes, namespacedRuntimes, inferenceServices, + )} +} + +func completeCollections(clusterRuntimes, namespacedRuntimes, inferenceServices int) []runtimetreeprojection.CollectionObservation { + return []runtimetreeprojection.CollectionObservation{ + {Kind: reportv1alpha1.RuntimeTreeCollectionClusterServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: clusterRuntimes}, + {Kind: reportv1alpha1.RuntimeTreeCollectionServingRuntime, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: namespacedRuntimes}, + {Kind: reportv1alpha1.RuntimeTreeCollectionInferenceService, Status: reportv1alpha1.RuntimeTreeCollectionStatusComplete, ObservedPages: 1, ObservedItems: inferenceServices}, + } +} + +func replaceCollection(values []runtimetreeprojection.CollectionObservation, kind reportv1alpha1.RuntimeTreeCollectionKind, change func(*runtimetreeprojection.CollectionObservation)) []runtimetreeprojection.CollectionObservation { + result := append([]runtimetreeprojection.CollectionObservation{}, values...) + for i := range result { + if result[i].Kind == kind { + change(&result[i]) + return result + } + } + return result +} + +func graphProjection(t *testing.T, snapshot runtimegraph.Snapshot, target runtimegraph.Target) runtimegraph.Projection { + t.Helper() + graph, err := runtimegraph.Build(snapshot) + require.NoError(t, err) + projection, err := graph.Project(target) + require.NoError(t, err) + return projection +} + +func clusterRuntime(name, parent string) omev1beta1.ClusterServingRuntime { + annotations := map[string]string{} + if parent != "" { + annotations[constants.RuntimeInheritFromAnnotationKey] = parent + } + return omev1beta1.ClusterServingRuntime{ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: annotations}} +} + +func namespacedRuntime(namespace, name, parent string) omev1beta1.ServingRuntime { + annotations := map[string]string{} + if parent != "" { + annotations[constants.RuntimeInheritFromAnnotationKey] = parent + } + return omev1beta1.ServingRuntime{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, Annotations: annotations}} +} + +func clusterIdentity(name string) runtimegraph.Identity { + return runtimegraph.Identity{Kind: runtimegraph.KindClusterServingRuntime, Name: name} +} + +func namespacedIdentity(namespace, name string) runtimegraph.Identity { + return runtimegraph.Identity{Kind: runtimegraph.KindServingRuntime, Namespace: namespace, Name: name} +} + +func graphIdentityPointer(identity runtimegraph.Identity) *runtimegraph.Identity { + return &identity +} + +func reportContexts(content reportv1alpha1.RuntimeTreeContent) []reportv1alpha1.RuntimeTreeResolutionContext { + result := make([]reportv1alpha1.RuntimeTreeResolutionContext, len(content.Contexts)) + for i := range content.Contexts { + result[i] = content.Contexts[i].Context + } + return result +} + +func reportHeads(context reportv1alpha1.RuntimeTreeContext) []string { + result := make([]string, len(context.Paths)) + for i := range context.Paths { + result[i] = context.Paths[i].Head.Name + } + return result +} + +func runtimeNames(path reportv1alpha1.RuntimeTreePath) []string { + result := make([]string, len(path.Runtimes)) + for i := range path.Runtimes { + result[i] = path.Runtimes[i].Identity.Name + } + return result +} + +func countDependent(content reportv1alpha1.RuntimeTreeContent, namespace, name string) int { + result := 0 + for _, context := range content.Contexts { + for _, path := range context.Paths { + for _, dependent := range path.Dependents { + if dependent.Namespace == namespace && dependent.Name == name { + result++ + } + } + } + } + return result +} + +func collectPathIssues(content reportv1alpha1.RuntimeTreeContent) []reportv1alpha1.RuntimeTreeIssue { + result := []reportv1alpha1.RuntimeTreeIssue{} + for _, context := range content.Contexts { + for _, path := range context.Paths { + if path.Issue != nil { + result = append(result, *path.Issue) + } + } + } + return result +} + +func fixedProjectionClock() reportv1alpha1.Clock { + return reportv1alpha1.ClockFunc(func() time.Time { + return time.Date(2026, time.September, 7, 18, 30, 0, 0, time.UTC) + }) +} + +func cloneGraphProjection(t *testing.T, input runtimegraph.Projection) runtimegraph.Projection { + t.Helper() + data, err := json.Marshal(input) + require.NoError(t, err) + var result runtimegraph.Projection + require.NoError(t, json.Unmarshal(data, &result)) + return result +} + +func cloneProjectionInput(t *testing.T, input runtimetreeprojection.Input) runtimetreeprojection.Input { + t.Helper() + data, err := json.Marshal(input) + require.NoError(t, err) + var result runtimetreeprojection.Input + require.NoError(t, json.Unmarshal(data, &result)) + return result +} + +func reverse[T any](values []T) { + for left, right := 0, len(values)-1; left < right; left, right = left+1, right-1 { + values[left], values[right] = values[right], values[left] + } +} + +func renderProjectionReport(t *testing.T, value reportv1alpha1.RuntimeEnvelope[reportv1alpha1.RuntimeTreeContent], format report.Format) string { + t.Helper() + var output bytes.Buffer + require.NoError(t, report.Write(&output, format, value)) + return output.String() +}