mirror of https://github.com/helm/helm
feat(hip-0025): implement resource-group sequencing, custom readiness, upgrade/rollback/uninstall ordering, template output, and lint rules
Complete HIP-0025 implementation across worktrees 2-7: - Resource-group sequencing: parse helm.sh/resource-group and helm.sh/depends-on/resource-groups annotations, build per-chart DAG, deploy in batch order with unsequenced resources last - Custom readiness: JSONPath-based readiness-success/readiness-failure evaluation engine with --readiness-timeout flag - Upgrade/rollback/uninstall: ordered upgrade mirrors install batching, reverse-order deletion for uninstall/rollback using stored SequencingMetadata - helm template: reorder manifest output by DAG batch order with resource-group delimiter comments - Lint rules: validate subchart depends-on references and detect DAG cycles at lint time - DAG visualization: String() method for human-readable batch output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>pull/31991/head
parent
ae29473779
commit
55e74f0644
@ -0,0 +1,282 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/util/jsonpath"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnnotationReadinessSuccess is the annotation key for custom readiness success conditions.
|
||||
// Value is a JSON array of "{.jsonpath} op value" expressions.
|
||||
AnnotationReadinessSuccess = "helm.sh/readiness-success"
|
||||
|
||||
// AnnotationReadinessFailure is the annotation key for custom readiness failure conditions.
|
||||
// Value is a JSON array of "{.jsonpath} op value" expressions.
|
||||
// Failure takes precedence over success.
|
||||
AnnotationReadinessFailure = "helm.sh/readiness-failure"
|
||||
)
|
||||
|
||||
// ReadinessResult represents the outcome of a custom readiness evaluation.
|
||||
type ReadinessResult int
|
||||
|
||||
const (
|
||||
// ReadinessUnknown means readiness could not be determined (fall back to kstatus).
|
||||
ReadinessUnknown ReadinessResult = iota
|
||||
// ReadinessReady means all success conditions are met and no failure conditions triggered.
|
||||
ReadinessReady
|
||||
// ReadinessFailed means a failure condition was triggered.
|
||||
ReadinessFailed
|
||||
// ReadinessPending means no failure triggered but success conditions not yet met.
|
||||
ReadinessPending
|
||||
)
|
||||
|
||||
// ReadinessExpression represents a parsed "{.jsonpath} op value" expression.
|
||||
type ReadinessExpression struct {
|
||||
// JSONPath is the path expression scoped to the resource object (e.g., "{.status.phase}").
|
||||
JSONPath string
|
||||
// Operator is the comparison operator (==, !=, <, <=, >, >=).
|
||||
Operator string
|
||||
// Value is the expected scalar value to compare against.
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParseReadinessExpressions parses a JSON array of readiness expressions.
|
||||
// Each expression has the format: "{.jsonpath} op value"
|
||||
func ParseReadinessExpressions(raw string) ([]ReadinessExpression, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var exprs []string
|
||||
if err := json.Unmarshal([]byte(raw), &exprs); err != nil {
|
||||
return nil, fmt.Errorf("invalid readiness expression JSON: %w", err)
|
||||
}
|
||||
|
||||
result := make([]ReadinessExpression, 0, len(exprs))
|
||||
for _, expr := range exprs {
|
||||
parsed, err := parseOneExpression(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid readiness expression %q: %w", expr, err)
|
||||
}
|
||||
result = append(result, parsed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseOneExpression parses a single "{.jsonpath} op value" string.
|
||||
func parseOneExpression(expr string) (ReadinessExpression, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return ReadinessExpression{}, fmt.Errorf("empty expression")
|
||||
}
|
||||
|
||||
// Find the end of the JSONPath (closing brace)
|
||||
braceEnd := strings.Index(expr, "}")
|
||||
if braceEnd < 0 || !strings.HasPrefix(expr, "{") {
|
||||
return ReadinessExpression{}, fmt.Errorf("expression must start with a JSONPath like {.status.phase}")
|
||||
}
|
||||
|
||||
jp := expr[:braceEnd+1]
|
||||
rest := strings.TrimSpace(expr[braceEnd+1:])
|
||||
|
||||
// Parse operator
|
||||
var op string
|
||||
for _, candidate := range []string{"==", "!=", "<=", ">=", "<", ">"} {
|
||||
if strings.HasPrefix(rest, candidate) {
|
||||
op = candidate
|
||||
rest = strings.TrimSpace(rest[len(candidate):])
|
||||
break
|
||||
}
|
||||
}
|
||||
if op == "" {
|
||||
return ReadinessExpression{}, fmt.Errorf("no valid operator found (expected ==, !=, <, <=, >, >=)")
|
||||
}
|
||||
|
||||
// Remaining is the value (trimmed)
|
||||
val := strings.TrimSpace(rest)
|
||||
if val == "" {
|
||||
return ReadinessExpression{}, fmt.Errorf("missing comparison value")
|
||||
}
|
||||
|
||||
return ReadinessExpression{
|
||||
JSONPath: jp,
|
||||
Operator: op,
|
||||
Value: val,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EvaluateReadiness evaluates custom readiness for an unstructured Kubernetes object.
|
||||
// Returns ReadinessUnknown if the object has no readiness annotations (or only one of the pair).
|
||||
// Failure conditions take precedence over success conditions per the spec.
|
||||
func EvaluateReadiness(obj *unstructured.Unstructured) (ReadinessResult, string, error) {
|
||||
annotations := obj.GetAnnotations()
|
||||
if annotations == nil {
|
||||
return ReadinessUnknown, "", nil
|
||||
}
|
||||
|
||||
successRaw := annotations[AnnotationReadinessSuccess]
|
||||
failureRaw := annotations[AnnotationReadinessFailure]
|
||||
|
||||
// Both must be present; if only one, warn and fall back
|
||||
hasSuccess := successRaw != ""
|
||||
hasFailure := failureRaw != ""
|
||||
|
||||
if !hasSuccess && !hasFailure {
|
||||
return ReadinessUnknown, "", nil
|
||||
}
|
||||
if hasSuccess != hasFailure {
|
||||
which := AnnotationReadinessSuccess
|
||||
if hasFailure {
|
||||
which = AnnotationReadinessFailure
|
||||
}
|
||||
slog.Warn("only one readiness annotation present, both required; falling back to kstatus",
|
||||
"annotation", which,
|
||||
"resource", obj.GetName(),
|
||||
)
|
||||
return ReadinessUnknown, "incomplete readiness annotations", nil
|
||||
}
|
||||
|
||||
// Parse failure expressions (evaluate first — failure takes precedence)
|
||||
failureExprs, err := ParseReadinessExpressions(failureRaw)
|
||||
if err != nil {
|
||||
return ReadinessUnknown, "", fmt.Errorf("resource %s: %w", obj.GetName(), err)
|
||||
}
|
||||
|
||||
successExprs, err := ParseReadinessExpressions(successRaw)
|
||||
if err != nil {
|
||||
return ReadinessUnknown, "", fmt.Errorf("resource %s: %w", obj.GetName(), err)
|
||||
}
|
||||
|
||||
// Check failure conditions first
|
||||
for _, expr := range failureExprs {
|
||||
match, err := evaluateExpression(obj.Object, expr)
|
||||
if err != nil {
|
||||
// If we can't evaluate, skip this expression
|
||||
continue
|
||||
}
|
||||
if match {
|
||||
return ReadinessFailed, fmt.Sprintf("failure condition met: %s %s %s", expr.JSONPath, expr.Operator, expr.Value), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check success conditions
|
||||
allSuccess := true
|
||||
for _, expr := range successExprs {
|
||||
match, err := evaluateExpression(obj.Object, expr)
|
||||
if err != nil {
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
if allSuccess && len(successExprs) > 0 {
|
||||
return ReadinessReady, "all success conditions met", nil
|
||||
}
|
||||
|
||||
return ReadinessPending, "waiting for success conditions", nil
|
||||
}
|
||||
|
||||
// evaluateExpression evaluates a single readiness expression against a resource object.
|
||||
func evaluateExpression(obj map[string]interface{}, expr ReadinessExpression) (bool, error) {
|
||||
// Parse and execute the JSONPath
|
||||
jp := jsonpath.New("readiness")
|
||||
if err := jp.Parse(expr.JSONPath); err != nil {
|
||||
return false, fmt.Errorf("invalid JSONPath %q: %w", expr.JSONPath, err)
|
||||
}
|
||||
|
||||
results, err := jp.FindResults(obj)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("JSONPath %q evaluation failed: %w", expr.JSONPath, err)
|
||||
}
|
||||
|
||||
if len(results) == 0 || len(results[0]) == 0 {
|
||||
return false, fmt.Errorf("JSONPath %q returned no results", expr.JSONPath)
|
||||
}
|
||||
|
||||
// Get the actual value as a string for comparison
|
||||
actual := fmt.Sprintf("%v", results[0][0].Interface())
|
||||
|
||||
return compareValues(actual, expr.Operator, expr.Value)
|
||||
}
|
||||
|
||||
// compareValues compares two string values using the given operator.
|
||||
// Attempts numeric comparison first; falls back to string comparison.
|
||||
func compareValues(actual, op, expected string) (bool, error) {
|
||||
// Try numeric comparison
|
||||
actualNum, aErr := strconv.ParseFloat(actual, 64)
|
||||
expectedNum, eErr := strconv.ParseFloat(expected, 64)
|
||||
if aErr == nil && eErr == nil {
|
||||
return compareNumeric(actualNum, op, expectedNum)
|
||||
}
|
||||
|
||||
// String comparison (only == and != are valid for strings)
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
case "<", "<=", ">", ">=":
|
||||
// For non-numeric values with ordering operators, compare lexicographically
|
||||
return compareLexicographic(actual, op, expected)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
func compareNumeric(actual float64, op string, expected float64) (bool, error) {
|
||||
switch op {
|
||||
case "==":
|
||||
return actual == expected, nil
|
||||
case "!=":
|
||||
return actual != expected, nil
|
||||
case "<":
|
||||
return actual < expected, nil
|
||||
case "<=":
|
||||
return actual <= expected, nil
|
||||
case ">":
|
||||
return actual > expected, nil
|
||||
case ">=":
|
||||
return actual >= expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
func compareLexicographic(actual, op, expected string) (bool, error) {
|
||||
switch op {
|
||||
case "<":
|
||||
return actual < expected, nil
|
||||
case "<=":
|
||||
return actual <= expected, nil
|
||||
case ">":
|
||||
return actual > expected, nil
|
||||
case ">=":
|
||||
return actual >= expected, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestParseReadinessExpressions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
expected []ReadinessExpression
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
raw: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "single expression",
|
||||
raw: `["{.status.phase} == Running"]`,
|
||||
expected: []ReadinessExpression{
|
||||
{JSONPath: "{.status.phase}", Operator: "==", Value: "Running"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple expressions",
|
||||
raw: `["{.status.replicas} >= 3", "{.status.phase} != Failed"]`,
|
||||
expected: []ReadinessExpression{
|
||||
{JSONPath: "{.status.replicas}", Operator: ">=", Value: "3"},
|
||||
{JSONPath: "{.status.phase}", Operator: "!=", Value: "Failed"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
raw: "not-json",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid expression format",
|
||||
raw: `["no-jsonpath here"]`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "missing operator",
|
||||
raw: `["{.status.phase} Running"]`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseReadinessExpressions(tt.raw)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReadiness(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj *unstructured.Unstructured
|
||||
expectedResult ReadinessResult
|
||||
}{
|
||||
{
|
||||
name: "no annotations - unknown",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessUnknown,
|
||||
},
|
||||
{
|
||||
name: "only success annotation - unknown (incomplete pair)",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
"annotations": map[string]interface{}{
|
||||
AnnotationReadinessSuccess: `["{.status.phase} == Running"]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessUnknown,
|
||||
},
|
||||
{
|
||||
name: "success conditions met",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
"annotations": map[string]interface{}{
|
||||
AnnotationReadinessSuccess: `["{.status.phase} == Running"]`,
|
||||
AnnotationReadinessFailure: `["{.status.phase} == Failed"]`,
|
||||
},
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"phase": "Running",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessReady,
|
||||
},
|
||||
{
|
||||
name: "failure condition met - takes precedence",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
"annotations": map[string]interface{}{
|
||||
AnnotationReadinessSuccess: `["{.status.phase} == Running"]`,
|
||||
AnnotationReadinessFailure: `["{.status.phase} == Failed"]`,
|
||||
},
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"phase": "Failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessFailed,
|
||||
},
|
||||
{
|
||||
name: "pending - conditions not yet met",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
"annotations": map[string]interface{}{
|
||||
AnnotationReadinessSuccess: `["{.status.phase} == Running"]`,
|
||||
AnnotationReadinessFailure: `["{.status.phase} == Failed"]`,
|
||||
},
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"phase": "Pending",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessPending,
|
||||
},
|
||||
{
|
||||
name: "numeric comparison",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test",
|
||||
"annotations": map[string]interface{}{
|
||||
AnnotationReadinessSuccess: `["{.status.readyReplicas} >= 3"]`,
|
||||
AnnotationReadinessFailure: `["{.status.readyReplicas} < 0"]`,
|
||||
},
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"readyReplicas": int64(3),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedResult: ReadinessReady,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, _, err := EvaluateReadiness(tt.obj)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
actual string
|
||||
op string
|
||||
expected string
|
||||
result bool
|
||||
}{
|
||||
{"Running", "==", "Running", true},
|
||||
{"Running", "!=", "Failed", true},
|
||||
{"3", ">=", "3", true},
|
||||
{"5", ">", "3", true},
|
||||
{"2", "<", "3", true},
|
||||
{"3", "<=", "3", true},
|
||||
{"abc", "<", "bcd", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.actual+" "+tt.op+" "+tt.expected, func(t *testing.T) {
|
||||
result, err := compareValues(tt.actual, tt.op, tt.expected)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 action
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
release "helm.sh/helm/v4/pkg/release/v1"
|
||||
)
|
||||
|
||||
func TestBuildInstallBatchesWithResourceGroups(t *testing.T) {
|
||||
// Test that BuildInstallBatches correctly builds subchart batches
|
||||
// Resource-group batching is tested separately through BuildResourceGroupBatches
|
||||
chrt := &chart.Chart{
|
||||
Metadata: &chart.Metadata{
|
||||
Name: "myapp",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "database", DependsOn: []string{}},
|
||||
{Name: "cache"},
|
||||
{Name: "api", DependsOn: []string{"database", "cache"}},
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
chartutil.AnnotationDependsOnSubcharts: `["api"]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
batches, err := BuildInstallBatches(chrt)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batches, 3)
|
||||
assert.Equal(t, []string{"cache", "database"}, batches[0])
|
||||
assert.Equal(t, []string{"api"}, batches[1])
|
||||
assert.Equal(t, []string{"myapp"}, batches[2])
|
||||
}
|
||||
|
||||
func TestSequencingMetadataResourceGroupBatches(t *testing.T) {
|
||||
// Verify the release SequencingMetadata can store resource-group batches
|
||||
seq := &release.SequencingMetadata{
|
||||
Enabled: true,
|
||||
Strategy: "ordered",
|
||||
Batches: [][]string{{"db"}, {"app"}, {"parent"}},
|
||||
ResourceGroupBatches: map[string][][]string{
|
||||
"app": {{"database"}, {"application"}},
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, seq.Enabled)
|
||||
assert.Len(t, seq.Batches, 3)
|
||||
assert.Contains(t, seq.ResourceGroupBatches, "app")
|
||||
assert.Len(t, seq.ResourceGroupBatches["app"], 2)
|
||||
}
|
||||
|
||||
func TestSplitManifestsBySubchartConsistency(t *testing.T) {
|
||||
// Ensure subchart splitting works consistently for ordered operations
|
||||
manifest := `---
|
||||
# Source: myapp/templates/service.yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: myapp-svc
|
||||
---
|
||||
# Source: myapp/charts/redis/templates/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
---
|
||||
# Source: myapp/charts/nginx/templates/deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx`
|
||||
|
||||
result := SplitManifestsBySubchart(manifest, "myapp")
|
||||
|
||||
// Should produce 3 buckets
|
||||
assert.Len(t, result, 3)
|
||||
assert.Contains(t, result, "myapp")
|
||||
assert.Contains(t, result, "redis")
|
||||
assert.Contains(t, result, "nginx")
|
||||
|
||||
// Parent chart
|
||||
assert.Contains(t, result["myapp"], "myapp-svc")
|
||||
// Subcharts
|
||||
assert.Contains(t, result["redis"], "redis")
|
||||
assert.Contains(t, result["nginx"], "nginx")
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
/*
|
||||
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 rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
"helm.sh/helm/v4/pkg/chart/v2/lint/support"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
)
|
||||
|
||||
// Sequencing runs HIP-0025 sequencing lint rules.
|
||||
func Sequencing(linter *support.Linter) {
|
||||
chartPath := filepath.Join(linter.ChartDir, "Chart.yaml")
|
||||
|
||||
chartFile, err := loadChartForSequencing(chartPath)
|
||||
if err != nil || chartFile == nil {
|
||||
return // Can't lint sequencing without a valid Chart.yaml
|
||||
}
|
||||
|
||||
// Validate subchart depends-on references
|
||||
linter.RunLinterRule(support.ErrorSev, "Chart.yaml",
|
||||
validateSubchartDependsOn(chartFile))
|
||||
|
||||
// Validate no cycles in subchart DAG
|
||||
linter.RunLinterRule(support.ErrorSev, "Chart.yaml",
|
||||
validateSubchartDAG(chartFile))
|
||||
}
|
||||
|
||||
func loadChartForSequencing(chartPath string) (*chart.Metadata, error) {
|
||||
data, err := os.ReadFile(chartPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var md chart.Metadata
|
||||
if err := yaml.Unmarshal(data, &md); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &md, nil
|
||||
}
|
||||
|
||||
// validateSubchartDependsOn checks that all depends-on references point to known dependencies.
|
||||
func validateSubchartDependsOn(md *chart.Metadata) error {
|
||||
if md == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
depNames := make(map[string]bool)
|
||||
for _, dep := range md.Dependencies {
|
||||
key := dep.Name
|
||||
if dep.Alias != "" {
|
||||
key = dep.Alias
|
||||
}
|
||||
depNames[key] = true
|
||||
}
|
||||
|
||||
// Check DependsOn field references
|
||||
for _, dep := range md.Dependencies {
|
||||
key := dep.Name
|
||||
if dep.Alias != "" {
|
||||
key = dep.Alias
|
||||
}
|
||||
for _, upstream := range dep.DependsOn {
|
||||
if !depNames[upstream] {
|
||||
return fmt.Errorf(
|
||||
"dependency %q declares depends-on %q, but %q is not a known dependency",
|
||||
key, upstream, upstream,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check annotation references
|
||||
annotationDeps, err := chartutil.ParseDependsOnSubcharts(md)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, upstream := range annotationDeps {
|
||||
if !depNames[upstream] {
|
||||
return fmt.Errorf(
|
||||
"annotation %s references %q, but %q is not a known dependency",
|
||||
chartutil.AnnotationDependsOnSubcharts, upstream, upstream,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSubchartDAG builds the subchart DAG and checks for cycles.
|
||||
func validateSubchartDAG(md *chart.Metadata) error {
|
||||
if md == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only validate if there are sequencing declarations
|
||||
hasDependsOn := false
|
||||
for _, dep := range md.Dependencies {
|
||||
if len(dep.DependsOn) > 0 {
|
||||
hasDependsOn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
hasAnnotation := false
|
||||
if md.Annotations != nil {
|
||||
_, hasAnnotation = md.Annotations[chartutil.AnnotationDependsOnSubcharts]
|
||||
}
|
||||
if !hasDependsOn && !hasAnnotation {
|
||||
return nil
|
||||
}
|
||||
|
||||
c := &chart.Chart{Metadata: md}
|
||||
_, err := chartutil.BuildSubchartDAG(c)
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
/*
|
||||
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 rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
chart "helm.sh/helm/v4/pkg/chart/v2"
|
||||
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
|
||||
)
|
||||
|
||||
func TestValidateSubchartDependsOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata *chart.Metadata
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid depends-on",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "redis"},
|
||||
{Name: "app", DependsOn: []string{"redis"}},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unknown depends-on reference",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "app", DependsOn: []string{"ghost"}},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid annotation reference",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Annotations: map[string]string{
|
||||
chartutil.AnnotationDependsOnSubcharts: `["redis"]`,
|
||||
},
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "redis"},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unknown annotation reference",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Annotations: map[string]string{
|
||||
chartutil.AnnotationDependsOnSubcharts: `["ghost"]`,
|
||||
},
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "redis"},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateSubchartDependsOn(tt.metadata)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSubchartDAG(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metadata *chart.Metadata
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no sequencing - no error",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "redis"},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid DAG",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "redis"},
|
||||
{Name: "app", DependsOn: []string{"redis"}},
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "circular dependency",
|
||||
metadata: &chart.Metadata{
|
||||
Name: "parent",
|
||||
Dependencies: []*chart.Dependency{
|
||||
{Name: "A", DependsOn: []string{"B"}},
|
||||
{Name: "B", DependsOn: []string{"A"}},
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateSubchartDAG(tt.metadata)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue