From d153a422314ed6bf8feba4de9bfe4ea7decabe65 Mon Sep 17 00:00:00 2001 From: caretak3r <50377477+caretak3r@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:33:33 -0500 Subject: [PATCH] =?UTF-8?q?feat(spec):=20Task=203=20=E2=80=94=20resource-g?= =?UTF-8?q?roup=20annotation=20parsing=20and=20DAG=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/release/v1/util/resource_group.go | 149 +++++++++++++++ pkg/release/v1/util/resource_group_test.go | 202 +++++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 pkg/release/v1/util/resource_group.go create mode 100644 pkg/release/v1/util/resource_group_test.go diff --git a/pkg/release/v1/util/resource_group.go b/pkg/release/v1/util/resource_group.go new file mode 100644 index 000000000..5b66d8b2e --- /dev/null +++ b/pkg/release/v1/util/resource_group.go @@ -0,0 +1,149 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "encoding/json" + "fmt" + + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" +) + +const ( + // AnnotationResourceGroup declares the resource-group a resource belongs to. + // Value is a single group name string. + // + // Example: helm.sh/resource-group: "database" + AnnotationResourceGroup = "helm.sh/resource-group" + + // AnnotationDependsOnResourceGroups declares which resource-groups must be + // deployed before this resource's group. Value is a JSON array of group names. + // + // Example: helm.sh/depends-on/resource-groups: '["database", "queue"]' + AnnotationDependsOnResourceGroups = "helm.sh/depends-on/resource-groups" +) + +// ResourceGroupResult holds the output of ParseResourceGroups. +type ResourceGroupResult struct { + // Groups maps group name → list of manifests belonging to that group. + Groups map[string][]Manifest + + // GroupDeps maps group name → list of group names it depends on. + GroupDeps map[string][]string + + // Unsequenced holds manifests that have no valid group assignment. + // These are deployed after all sequenced groups. + Unsequenced []Manifest +} + +// ParseResourceGroups scans a slice of rendered manifests, extracts +// helm.sh/resource-group and helm.sh/depends-on/resource-groups annotations, +// and returns a ResourceGroupResult. +// +// Manifests with no annotations are placed in Unsequenced. +// Manifests referencing non-existent groups via depends-on are moved to +// Unsequenced and a warning string is returned. +// +// Groups with valid annotations but no depends-on edges are root nodes (batch 0). +func ParseResourceGroups(manifests []Manifest) (ResourceGroupResult, []string) { + result := ResourceGroupResult{ + Groups: make(map[string][]Manifest), + GroupDeps: make(map[string][]string), + } + var warnings []string + + type pendingDep struct { + groupName string + manifest Manifest + deps []string + } + var pending []pendingDep + + for _, m := range manifests { + if m.Head == nil || m.Head.Metadata == nil || len(m.Head.Metadata.Annotations) == 0 { + result.Unsequenced = append(result.Unsequenced, m) + continue + } + + groupName, hasGroup := m.Head.Metadata.Annotations[AnnotationResourceGroup] + if !hasGroup || groupName == "" { + result.Unsequenced = append(result.Unsequenced, m) + continue + } + + // Parse optional depends-on + var deps []string + if rawDeps, ok := m.Head.Metadata.Annotations[AnnotationDependsOnResourceGroups]; ok { + if err := json.Unmarshal([]byte(rawDeps), &deps); err != nil { + warnings = append(warnings, fmt.Sprintf( + "manifest %q: invalid JSON in %s annotation: %v; moving to unsequenced batch", + m.Name, AnnotationDependsOnResourceGroups, err, + )) + result.Unsequenced = append(result.Unsequenced, m) + continue + } + } + + result.Groups[groupName] = append(result.Groups[groupName], m) + pending = append(pending, pendingDep{groupName: groupName, manifest: m, deps: deps}) + } + + // Now process depends-on edges; validate that referenced groups exist. + // We need all group names known first (already populated above). + for _, p := range pending { + for _, dep := range p.deps { + if _, ok := result.Groups[dep]; !ok { + warnings = append(warnings, fmt.Sprintf( + "group %q in manifest %q depends-on non-existent group %q; moving %q to unsequenced batch", + p.groupName, p.manifest.Name, dep, p.groupName, + )) + // Move all manifests of this group to unsequenced. + result.Unsequenced = append(result.Unsequenced, result.Groups[p.groupName]...) + delete(result.Groups, p.groupName) + delete(result.GroupDeps, p.groupName) + goto nextPending + } + } + // Only record deps if we haven't already moved this group to unsequenced. + if _, ok := result.Groups[p.groupName]; ok { + if len(p.deps) > 0 { + existing := result.GroupDeps[p.groupName] + result.GroupDeps[p.groupName] = append(existing, p.deps...) + } + } + nextPending: + } + + return result, warnings +} + +// BuildResourceGroupDAG constructs a DAG from the resource-group parse result. +// Each group name becomes a DAG node; GroupDeps entries become edges. +func BuildResourceGroupDAG(r ResourceGroupResult) (*chartutil.DAG, error) { + d := chartutil.NewDAG() + for groupName := range r.Groups { + d.AddNode(groupName) + } + for groupName, deps := range r.GroupDeps { + for _, dep := range deps { + if err := d.AddEdge(dep, groupName); err != nil { + return nil, fmt.Errorf("resource-group sequencing edge %s→%s: %w", dep, groupName, err) + } + } + } + return d, nil +} diff --git a/pkg/release/v1/util/resource_group_test.go b/pkg/release/v1/util/resource_group_test.go new file mode 100644 index 000000000..8286477b0 --- /dev/null +++ b/pkg/release/v1/util/resource_group_test.go @@ -0,0 +1,202 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "testing" +) + +// makeManifest creates a minimal K8s resource YAML with the given annotations. +func makeManifest(name, sourcePath string, annotations map[string]string) Manifest { + content := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\n" + if len(annotations) > 0 { + content += " annotations:\n" + for k, v := range annotations { + content += " " + k + ": \"" + v + "\"\n" + } + } + head := &SimpleHead{} + head.Metadata = &struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + }{ + Name: name, + Annotations: annotations, + } + return Manifest{ + Name: sourcePath, + Content: content, + Head: head, + } +} + +func TestParseResourceGroups_NoAnnotations(t *testing.T) { + manifests := []Manifest{ + makeManifest("cm1", "chart/templates/cm1.yaml", nil), + makeManifest("cm2", "chart/templates/cm2.yaml", nil), + } + result, warnings := ParseResourceGroups(manifests) + if len(result.Groups) != 0 { + t.Errorf("expected no groups, got %v", result.Groups) + } + if len(result.Unsequenced) != 2 { + t.Errorf("expected 2 unsequenced manifests, got %d", len(result.Unsequenced)) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestParseResourceGroups_SingleGroup(t *testing.T) { + manifests := []Manifest{ + makeManifest("svc", "chart/templates/svc.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + } + result, warnings := ParseResourceGroups(manifests) + if len(result.Groups) != 1 { + t.Fatalf("expected 1 group, got %d: %v", len(result.Groups), result.Groups) + } + if _, ok := result.Groups["database"]; !ok { + t.Errorf("expected group 'database', got %v", result.Groups) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestParseResourceGroups_GroupWithDependency(t *testing.T) { + manifests := []Manifest{ + makeManifest("db", "chart/templates/db.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["database"]`, + }), + } + result, warnings := ParseResourceGroups(manifests) + if len(result.Groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(result.Groups)) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } + + dag, err := BuildResourceGroupDAG(result) + if err != nil { + t.Fatalf("BuildResourceGroupDAG: %v", err) + } + batches, err := dag.GetBatches() + if err != nil { + t.Fatalf("GetBatches: %v", err) + } + if len(batches) != 2 { + t.Fatalf("expected 2 batches, got %d: %v", len(batches), batches) + } + if batches[0][0] != "database" { + t.Errorf("expected 'database' in batch 0, got %v", batches[0]) + } + if batches[1][0] != "app" { + t.Errorf("expected 'app' in batch 1, got %v", batches[1]) + } +} + +func TestParseResourceGroups_RootGroupInBatch0(t *testing.T) { + // A group with no depends-on is a root → batch 0, NOT unsequenced. + manifests := []Manifest{ + makeManifest("db", "chart/templates/db.yaml", map[string]string{ + AnnotationResourceGroup: "standalone", + }), + } + result, warnings := ParseResourceGroups(manifests) + if len(result.Groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(result.Groups)) + } + if len(result.Unsequenced) != 0 { + t.Errorf("root group should not be unsequenced, got %v", result.Unsequenced) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } + + dag, err := BuildResourceGroupDAG(result) + if err != nil { + t.Fatalf("BuildResourceGroupDAG: %v", err) + } + batches, err := dag.GetBatches() + if err != nil { + t.Fatalf("GetBatches: %v", err) + } + if len(batches) != 1 || batches[0][0] != "standalone" { + t.Errorf("expected batch 0 = [standalone], got %v", batches) + } +} + +func TestParseResourceGroups_NonExistentGroupReference_Warning(t *testing.T) { + // 'app' depends on 'nonexistent' which has no resources. + manifests := []Manifest{ + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `["nonexistent"]`, + }), + } + result, warnings := ParseResourceGroups(manifests) + if len(warnings) == 0 { + t.Error("expected warning for non-existent group reference, got none") + } + // 'app' should be moved to unsequenced batch + if len(result.Unsequenced) == 0 { + t.Error("expected app to be in unsequenced batch") + } +} + +func TestParseResourceGroups_InvalidDependsOnJSON(t *testing.T) { + manifests := []Manifest{ + makeManifest("app", "chart/templates/app.yaml", map[string]string{ + AnnotationResourceGroup: "app", + AnnotationDependsOnResourceGroups: `not-valid-json`, + }), + } + result, warnings := ParseResourceGroups(manifests) + if len(warnings) == 0 { + t.Error("expected warning for invalid JSON, got none") + } + // app should go to unsequenced batch + if len(result.Unsequenced) == 0 { + t.Error("expected app in unsequenced batch") + } +} + +func TestParseResourceGroups_MixedSequencedAndUnsequenced(t *testing.T) { + manifests := []Manifest{ + makeManifest("db", "chart/templates/db.yaml", map[string]string{ + AnnotationResourceGroup: "database", + }), + makeManifest("plain", "chart/templates/plain.yaml", nil), // no annotations + } + result, warnings := ParseResourceGroups(manifests) + if len(result.Groups) != 1 { + t.Errorf("expected 1 group, got %d", len(result.Groups)) + } + if len(result.Unsequenced) != 1 { + t.Errorf("expected 1 unsequenced manifest, got %d", len(result.Unsequenced)) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +}