feat(spec): Task 3 — resource-group annotation parsing and DAG construction

pull/31992/head
caretak3r 8 months ago
parent 5a77ecb401
commit d153a42231

@ -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
}

@ -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)
}
}
Loading…
Cancel
Save