feat(release): add internal/release/v2 package for chart v3 support

Introduce release/v2 that mirrors pkg/release/v1 but uses *v3.Chart
from internal/chart/v3. The code is structurally identical to v1 with
only import paths changed to reference internal/chart/v3 instead of
pkg/chart/v2.

- Add internal/release/v2 with Release, Info, Hook types
- Add internal/release/v2/util with filter, sorter, manifest utilities
- Update pkg/release/common.go with v2Accessor and v2HookAccessor
- Copy all test files from pkg/release/v1 and add a v2 test in common_test.go

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
pull/31709/head
Evans Mungai 2 weeks ago
parent 2e2cb05855
commit 6de5465762
No known key found for this signature in database
GPG Key ID: BBEB812143DD14E1

@ -0,0 +1,17 @@
/*
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 v2 provides release handling for apiVersion v3 charts.
package v2

@ -0,0 +1,189 @@
/*
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 v2
import (
"encoding/json"
"time"
)
// HookEvent specifies the hook event
type HookEvent string
// Hook event types
const (
HookPreInstall HookEvent = "pre-install"
HookPostInstall HookEvent = "post-install"
HookPreDelete HookEvent = "pre-delete"
HookPostDelete HookEvent = "post-delete"
HookPreUpgrade HookEvent = "pre-upgrade"
HookPostUpgrade HookEvent = "post-upgrade"
HookPreRollback HookEvent = "pre-rollback"
HookPostRollback HookEvent = "post-rollback"
HookTest HookEvent = "test"
)
func (x HookEvent) String() string { return string(x) }
// HookDeletePolicy specifies the hook delete policy
type HookDeletePolicy string
// Hook delete policy types
const (
HookSucceeded HookDeletePolicy = "hook-succeeded"
HookFailed HookDeletePolicy = "hook-failed"
HookBeforeHookCreation HookDeletePolicy = "before-hook-creation"
)
func (x HookDeletePolicy) String() string { return string(x) }
// HookOutputLogPolicy specifies the hook output log policy
type HookOutputLogPolicy string
// Hook output log policy types
const (
HookOutputOnSucceeded HookOutputLogPolicy = "hook-succeeded"
HookOutputOnFailed HookOutputLogPolicy = "hook-failed"
)
func (x HookOutputLogPolicy) String() string { return string(x) }
// HookAnnotation is the label name for a hook
const HookAnnotation = "helm.sh/hook"
// HookWeightAnnotation is the label name for a hook weight
const HookWeightAnnotation = "helm.sh/hook-weight"
// HookDeleteAnnotation is the label name for the delete policy for a hook
const HookDeleteAnnotation = "helm.sh/hook-delete-policy"
// HookOutputLogAnnotation is the label name for the output log policy for a hook
const HookOutputLogAnnotation = "helm.sh/hook-output-log-policy"
// Hook defines a hook object.
type Hook struct {
Name string `json:"name,omitempty"`
// Kind is the Kubernetes kind.
Kind string `json:"kind,omitempty"`
// Path is the chart-relative path to the template.
Path string `json:"path,omitempty"`
// Manifest is the manifest contents.
Manifest string `json:"manifest,omitempty"`
// Events are the events that this hook fires on.
Events []HookEvent `json:"events,omitempty"`
// LastRun indicates the date/time this was last run.
LastRun HookExecution `json:"last_run,omitempty"`
// Weight indicates the sort order for execution among similar Hook type
Weight int `json:"weight,omitempty"`
// DeletePolicies are the policies that indicate when to delete the hook
DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"`
// OutputLogPolicies defines whether we should copy hook logs back to main process
OutputLogPolicies []HookOutputLogPolicy `json:"output_log_policies,omitempty"`
}
// A HookExecution records the result for the last execution of a hook for a given release.
type HookExecution struct {
// StartedAt indicates the date/time this hook was started
StartedAt time.Time `json:"started_at,omitzero"`
// CompletedAt indicates the date/time this hook was completed.
CompletedAt time.Time `json:"completed_at,omitzero"`
// Phase indicates whether the hook completed successfully
Phase HookPhase `json:"phase"`
}
// A HookPhase indicates the state of a hook execution
type HookPhase string
const (
// HookPhaseUnknown indicates that a hook is in an unknown state
HookPhaseUnknown HookPhase = "Unknown"
// HookPhaseRunning indicates that a hook is currently executing
HookPhaseRunning HookPhase = "Running"
// HookPhaseSucceeded indicates that hook execution succeeded
HookPhaseSucceeded HookPhase = "Succeeded"
// HookPhaseFailed indicates that hook execution failed
HookPhaseFailed HookPhase = "Failed"
)
// String converts a hook phase to a printable string
func (x HookPhase) String() string { return string(x) }
// hookExecutionJSON is used for custom JSON marshaling/unmarshaling
type hookExecutionJSON struct {
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Phase HookPhase `json:"phase"`
}
// UnmarshalJSON implements the json.Unmarshaler interface.
// It handles empty string time fields by treating them as zero values.
func (h *HookExecution) UnmarshalJSON(data []byte) error {
// First try to unmarshal into a map to handle empty string time fields
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Replace empty string time fields with nil
for _, field := range []string{"started_at", "completed_at"} {
if val, ok := raw[field]; ok {
if str, ok := val.(string); ok && str == "" {
raw[field] = nil
}
}
}
// Re-marshal with cleaned data
cleaned, err := json.Marshal(raw)
if err != nil {
return err
}
// Unmarshal into temporary struct with pointer time fields
var tmp hookExecutionJSON
if err := json.Unmarshal(cleaned, &tmp); err != nil {
return err
}
// Copy values to HookExecution struct
if tmp.StartedAt != nil {
h.StartedAt = *tmp.StartedAt
}
if tmp.CompletedAt != nil {
h.CompletedAt = *tmp.CompletedAt
}
h.Phase = tmp.Phase
return nil
}
// MarshalJSON implements the json.Marshaler interface.
// It omits zero-value time fields from the JSON output.
func (h HookExecution) MarshalJSON() ([]byte, error) {
tmp := hookExecutionJSON{
Phase: h.Phase,
}
if !h.StartedAt.IsZero() {
tmp.StartedAt = &h.StartedAt
}
if !h.CompletedAt.IsZero() {
tmp.CompletedAt = &h.CompletedAt
}
return json.Marshal(tmp)
}

@ -0,0 +1,231 @@
/*
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 v2
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHookExecutionMarshalJSON(t *testing.T) {
started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC)
tests := []struct {
name string
exec HookExecution
expected string
}{
{
name: "all fields populated",
exec: HookExecution{
StartedAt: started,
CompletedAt: completed,
Phase: HookPhaseSucceeded,
},
expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`,
},
{
name: "only phase",
exec: HookExecution{
Phase: HookPhaseRunning,
},
expected: `{"phase":"Running"}`,
},
{
name: "with started time only",
exec: HookExecution{
StartedAt: started,
Phase: HookPhaseRunning,
},
expected: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`,
},
{
name: "failed phase",
exec: HookExecution{
StartedAt: started,
CompletedAt: completed,
Phase: HookPhaseFailed,
},
expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`,
},
{
name: "unknown phase",
exec: HookExecution{
Phase: HookPhaseUnknown,
},
expected: `{"phase":"Unknown"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(&tt.exec)
require.NoError(t, err)
assert.JSONEq(t, tt.expected, string(data))
})
}
}
func TestHookExecutionUnmarshalJSON(t *testing.T) {
started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC)
tests := []struct {
name string
input string
expected HookExecution
wantErr bool
}{
{
name: "all fields populated",
input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`,
expected: HookExecution{
StartedAt: started,
CompletedAt: completed,
Phase: HookPhaseSucceeded,
},
},
{
name: "only phase",
input: `{"phase":"Running"}`,
expected: HookExecution{
Phase: HookPhaseRunning,
},
},
{
name: "empty string time fields",
input: `{"started_at":"","completed_at":"","phase":"Succeeded"}`,
expected: HookExecution{
Phase: HookPhaseSucceeded,
},
},
{
name: "missing time fields",
input: `{"phase":"Failed"}`,
expected: HookExecution{
Phase: HookPhaseFailed,
},
},
{
name: "null time fields",
input: `{"started_at":null,"completed_at":null,"phase":"Unknown"}`,
expected: HookExecution{
Phase: HookPhaseUnknown,
},
},
{
name: "mixed empty and valid time fields",
input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"","phase":"Running"}`,
expected: HookExecution{
StartedAt: started,
Phase: HookPhaseRunning,
},
},
{
name: "with started time only",
input: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`,
expected: HookExecution{
StartedAt: started,
Phase: HookPhaseRunning,
},
},
{
name: "failed phase with times",
input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`,
expected: HookExecution{
StartedAt: started,
CompletedAt: completed,
Phase: HookPhaseFailed,
},
},
{
name: "invalid time format",
input: `{"started_at":"invalid-time","phase":"Running"}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var exec HookExecution
err := json.Unmarshal([]byte(tt.input), &exec)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected.StartedAt.Unix(), exec.StartedAt.Unix())
assert.Equal(t, tt.expected.CompletedAt.Unix(), exec.CompletedAt.Unix())
assert.Equal(t, tt.expected.Phase, exec.Phase)
})
}
}
func TestHookExecutionRoundTrip(t *testing.T) {
started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC)
original := HookExecution{
StartedAt: started,
CompletedAt: completed,
Phase: HookPhaseSucceeded,
}
data, err := json.Marshal(&original)
require.NoError(t, err)
var decoded HookExecution
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, original.StartedAt.Unix(), decoded.StartedAt.Unix())
assert.Equal(t, original.CompletedAt.Unix(), decoded.CompletedAt.Unix())
assert.Equal(t, original.Phase, decoded.Phase)
}
func TestHookExecutionEmptyStringRoundTrip(t *testing.T) {
// This test specifically verifies that empty string time fields
// are handled correctly during parsing
input := `{"started_at":"","completed_at":"","phase":"Succeeded"}`
var exec HookExecution
err := json.Unmarshal([]byte(input), &exec)
require.NoError(t, err)
// Verify time fields are zero values
assert.True(t, exec.StartedAt.IsZero())
assert.True(t, exec.CompletedAt.IsZero())
assert.Equal(t, HookPhaseSucceeded, exec.Phase)
// Marshal back and verify empty time fields are omitted
data, err := json.Marshal(&exec)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Zero time values should be omitted
assert.NotContains(t, result, "started_at")
assert.NotContains(t, result, "completed_at")
assert.Equal(t, "Succeeded", result["phase"])
}

@ -0,0 +1,125 @@
/*
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 v2
import (
"encoding/json"
"time"
"helm.sh/helm/v4/pkg/release/common"
"k8s.io/apimachinery/pkg/runtime"
)
// Info describes release information.
type Info struct {
// FirstDeployed is when the release was first deployed.
FirstDeployed time.Time `json:"first_deployed,omitzero"`
// LastDeployed is when the release was last deployed.
LastDeployed time.Time `json:"last_deployed,omitzero"`
// Deleted tracks when this object was deleted.
Deleted time.Time `json:"deleted,omitzero"`
// Description is human-friendly "log entry" about this release.
Description string `json:"description,omitempty"`
// Status is the current state of the release
Status common.Status `json:"status,omitempty"`
// Contains the rendered templates/NOTES.txt if available
Notes string `json:"notes,omitempty"`
// Contains the deployed resources information
Resources map[string][]runtime.Object `json:"resources,omitempty"`
}
// infoJSON is used for custom JSON marshaling/unmarshaling
type infoJSON struct {
FirstDeployed *time.Time `json:"first_deployed,omitempty"`
LastDeployed *time.Time `json:"last_deployed,omitempty"`
Deleted *time.Time `json:"deleted,omitempty"`
Description string `json:"description,omitempty"`
Status common.Status `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
Resources map[string][]runtime.Object `json:"resources,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface.
// It handles empty string time fields by treating them as zero values.
func (i *Info) UnmarshalJSON(data []byte) error {
// First try to unmarshal into a map to handle empty string time fields
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Replace empty string time fields with nil
for _, field := range []string{"first_deployed", "last_deployed", "deleted"} {
if val, ok := raw[field]; ok {
if str, ok := val.(string); ok && str == "" {
raw[field] = nil
}
}
}
// Re-marshal with cleaned data
cleaned, err := json.Marshal(raw)
if err != nil {
return err
}
// Unmarshal into temporary struct with pointer time fields
var tmp infoJSON
if err := json.Unmarshal(cleaned, &tmp); err != nil {
return err
}
// Copy values to Info struct
if tmp.FirstDeployed != nil {
i.FirstDeployed = *tmp.FirstDeployed
}
if tmp.LastDeployed != nil {
i.LastDeployed = *tmp.LastDeployed
}
if tmp.Deleted != nil {
i.Deleted = *tmp.Deleted
}
i.Description = tmp.Description
i.Status = tmp.Status
i.Notes = tmp.Notes
i.Resources = tmp.Resources
return nil
}
// MarshalJSON implements the json.Marshaler interface.
// It omits zero-value time fields from the JSON output.
func (i Info) MarshalJSON() ([]byte, error) {
tmp := infoJSON{
Description: i.Description,
Status: i.Status,
Notes: i.Notes,
Resources: i.Resources,
}
if !i.FirstDeployed.IsZero() {
tmp.FirstDeployed = &i.FirstDeployed
}
if !i.LastDeployed.IsZero() {
tmp.LastDeployed = &i.LastDeployed
}
if !i.Deleted.IsZero() {
tmp.Deleted = &i.Deleted
}
return json.Marshal(tmp)
}

@ -0,0 +1,285 @@
/*
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 v2
import (
"encoding/json"
"testing"
"time"
"helm.sh/helm/v4/pkg/release/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInfoMarshalJSON(t *testing.T) {
now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC)
deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC)
tests := []struct {
name string
info Info
expected string
}{
{
name: "all fields populated",
info: Info{
FirstDeployed: now,
LastDeployed: later,
Deleted: deleted,
Description: "Test release",
Status: common.StatusDeployed,
Notes: "Test notes",
},
expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`,
},
{
name: "only required fields",
info: Info{
FirstDeployed: now,
LastDeployed: later,
Status: common.StatusDeployed,
},
expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`,
},
{
name: "zero time values omitted",
info: Info{
Description: "Test release",
Status: common.StatusDeployed,
},
expected: `{"description":"Test release","status":"deployed"}`,
},
{
name: "with pending status",
info: Info{
FirstDeployed: now,
LastDeployed: later,
Status: common.StatusPendingInstall,
Description: "Installing release",
},
expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","description":"Installing release","status":"pending-install"}`,
},
{
name: "uninstalled with deleted time",
info: Info{
FirstDeployed: now,
LastDeployed: later,
Deleted: deleted,
Status: common.StatusUninstalled,
Description: "Uninstalled release",
},
expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(&tt.info)
require.NoError(t, err)
assert.JSONEq(t, tt.expected, string(data))
})
}
}
func TestInfoUnmarshalJSON(t *testing.T) {
now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC)
deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC)
tests := []struct {
name string
input string
expected Info
wantErr bool
}{
{
name: "all fields populated",
input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`,
expected: Info{
FirstDeployed: now,
LastDeployed: later,
Deleted: deleted,
Description: "Test release",
Status: common.StatusDeployed,
Notes: "Test notes",
},
},
{
name: "only required fields",
input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`,
expected: Info{
FirstDeployed: now,
LastDeployed: later,
Status: common.StatusDeployed,
},
},
{
name: "empty string time fields",
input: `{"first_deployed":"","last_deployed":"","deleted":"","description":"Test release","status":"deployed"}`,
expected: Info{
Description: "Test release",
Status: common.StatusDeployed,
},
},
{
name: "missing time fields",
input: `{"description":"Test release","status":"deployed"}`,
expected: Info{
Description: "Test release",
Status: common.StatusDeployed,
},
},
{
name: "null time fields",
input: `{"first_deployed":null,"last_deployed":null,"deleted":null,"description":"Test release","status":"deployed"}`,
expected: Info{
Description: "Test release",
Status: common.StatusDeployed,
},
},
{
name: "mixed empty and valid time fields",
input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"","deleted":"","status":"deployed"}`,
expected: Info{
FirstDeployed: now,
Status: common.StatusDeployed,
},
},
{
name: "pending install status",
input: `{"first_deployed":"2025-10-08T12:00:00Z","status":"pending-install","description":"Installing"}`,
expected: Info{
FirstDeployed: now,
Status: common.StatusPendingInstall,
Description: "Installing",
},
},
{
name: "uninstalled with deleted time",
input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","status":"uninstalled"}`,
expected: Info{
FirstDeployed: now,
LastDeployed: later,
Deleted: deleted,
Status: common.StatusUninstalled,
},
},
{
name: "failed status",
input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"failed","description":"Deployment failed"}`,
expected: Info{
FirstDeployed: now,
LastDeployed: later,
Status: common.StatusFailed,
Description: "Deployment failed",
},
},
{
name: "invalid time format",
input: `{"first_deployed":"invalid-time","status":"deployed"}`,
wantErr: true,
},
{
name: "empty object",
input: `{}`,
expected: Info{
Status: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var info Info
err := json.Unmarshal([]byte(tt.input), &info)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected.FirstDeployed.Unix(), info.FirstDeployed.Unix())
assert.Equal(t, tt.expected.LastDeployed.Unix(), info.LastDeployed.Unix())
assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix())
assert.Equal(t, tt.expected.Description, info.Description)
assert.Equal(t, tt.expected.Status, info.Status)
assert.Equal(t, tt.expected.Notes, info.Notes)
assert.Equal(t, tt.expected.Resources, info.Resources)
})
}
}
func TestInfoRoundTrip(t *testing.T) {
now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC)
later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC)
original := Info{
FirstDeployed: now,
LastDeployed: later,
Description: "Test release",
Status: common.StatusDeployed,
Notes: "Release notes",
}
data, err := json.Marshal(&original)
require.NoError(t, err)
var decoded Info
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, original.FirstDeployed.Unix(), decoded.FirstDeployed.Unix())
assert.Equal(t, original.LastDeployed.Unix(), decoded.LastDeployed.Unix())
assert.Equal(t, original.Deleted.Unix(), decoded.Deleted.Unix())
assert.Equal(t, original.Description, decoded.Description)
assert.Equal(t, original.Status, decoded.Status)
assert.Equal(t, original.Notes, decoded.Notes)
}
func TestInfoEmptyStringRoundTrip(t *testing.T) {
// This test specifically verifies that empty string time fields
// are handled correctly during parsing
input := `{"first_deployed":"","last_deployed":"","deleted":"","status":"deployed","description":"test"}`
var info Info
err := json.Unmarshal([]byte(input), &info)
require.NoError(t, err)
// Verify time fields are zero values
assert.True(t, info.FirstDeployed.IsZero())
assert.True(t, info.LastDeployed.IsZero())
assert.True(t, info.Deleted.IsZero())
assert.Equal(t, common.StatusDeployed, info.Status)
assert.Equal(t, "test", info.Description)
// Marshal back and verify empty time fields are omitted
data, err := json.Marshal(&info)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Zero time values should be omitted due to omitzero tag
assert.NotContains(t, result, "first_deployed")
assert.NotContains(t, result, "last_deployed")
assert.NotContains(t, result, "deleted")
assert.Equal(t, "deployed", result["status"])
assert.Equal(t, "test", result["description"])
}

@ -0,0 +1,143 @@
/*
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 v2
import (
"fmt"
"math/rand"
"time"
v3 "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/pkg/chart/common"
rcommon "helm.sh/helm/v4/pkg/release/common"
)
// MockHookTemplate is the hook template used for all mock release objects.
var MockHookTemplate = `apiVersion: v1
kind: Job
metadata:
annotations:
"helm.sh/hook": pre-install
`
// MockManifest is the manifest used for all mock release objects.
var MockManifest = `apiVersion: v1
kind: Secret
metadata:
name: fixture
`
// MockReleaseOptions allows for user-configurable options on mock release objects.
type MockReleaseOptions struct {
Name string
Version int
Chart *v3.Chart
Status rcommon.Status
Namespace string
Labels map[string]string
}
// Mock creates a mock release object based on options set by MockReleaseOptions. This function should typically not be used outside of testing.
func Mock(opts *MockReleaseOptions) *Release {
date := time.Unix(242085845, 0).UTC()
name := opts.Name
if name == "" {
name = "testrelease-" + fmt.Sprint(rand.Intn(100))
}
version := 1
if opts.Version != 0 {
version = opts.Version
}
namespace := opts.Namespace
if namespace == "" {
namespace = "default"
}
var labels map[string]string
if len(opts.Labels) > 0 {
labels = opts.Labels
}
ch := opts.Chart
if opts.Chart == nil {
ch = &v3.Chart{
Metadata: &v3.Metadata{
Name: "foo",
Version: "0.1.0-beta.1",
AppVersion: "1.0",
APIVersion: v3.APIVersionV3,
Annotations: map[string]string{
"category": "web-apps",
"supported": "true",
},
Dependencies: []*v3.Dependency{
{
Name: "cool-plugin",
Version: "1.0.0",
Repository: "https://coolplugin.io/charts",
Condition: "coolPlugin.enabled",
Enabled: true,
},
{
Name: "crds",
Version: "2.7.1",
Condition: "crds.enabled",
},
},
},
Templates: []*common.File{
{Name: "templates/foo.tpl", ModTime: time.Now(), Data: []byte(MockManifest)},
},
}
}
scode := rcommon.StatusDeployed
if len(opts.Status) > 0 {
scode = opts.Status
}
info := &Info{
FirstDeployed: date,
LastDeployed: date,
Status: scode,
Description: "Release mock",
Notes: "Some mock release notes!",
}
return &Release{
Name: name,
Info: info,
Chart: ch,
Config: map[string]interface{}{"name": "value"},
Version: version,
Namespace: namespace,
Hooks: []*Hook{
{
Name: "pre-install-hook",
Kind: "Job",
Path: "pre-install-hook.yaml",
Manifest: MockHookTemplate,
LastRun: HookExecution{},
Events: []HookEvent{HookPreInstall},
},
},
Manifest: MockManifest,
Labels: labels,
}
}

@ -0,0 +1,60 @@
/*
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 v2
import (
chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/pkg/release/common"
)
type ApplyMethod string
const ApplyMethodClientSideApply ApplyMethod = "csa"
const ApplyMethodServerSideApply ApplyMethod = "ssa"
// Release describes a deployment of a chart, together with the chart
// and the variables used to deploy that chart.
type Release struct {
// Name is the name of the release
Name string `json:"name,omitempty"`
// Info provides information about a release
Info *Info `json:"info,omitempty"`
// Chart is the chart that was released.
Chart *chart.Chart `json:"chart,omitempty"`
// Config is the set of extra Values added to the chart.
// These values override the default values inside of the chart.
Config map[string]interface{} `json:"config,omitempty"`
// Manifest is the string representation of the rendered template.
Manifest string `json:"manifest,omitempty"`
// Hooks are all of the hooks declared for this release.
Hooks []*Hook `json:"hooks,omitempty"`
// Version is an int which represents the revision of the release.
Version int `json:"version,omitempty"`
// Namespace is the kubernetes namespace of the release.
Namespace string `json:"namespace,omitempty"`
// Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"`
// ApplyMethod stores whether server-side or client-side apply was used for the release
// Unset (empty string) should be treated as the default of client-side apply
ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa"
}
// SetStatus is a helper for setting the status on a release.
func (r *Release) SetStatus(status common.Status, msg string) {
r.Info.Status = status
r.Info.Description = msg
}

@ -0,0 +1,81 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
v2 "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/release/common"
)
// FilterFunc returns true if the release object satisfies
// the predicate of the underlying filter func.
type FilterFunc func(*v2.Release) bool
// Check applies the FilterFunc to the release object.
func (fn FilterFunc) Check(rls *v2.Release) bool {
if rls == nil {
return false
}
return fn(rls)
}
// Filter applies the filter(s) to the list of provided releases
// returning the list that satisfies the filtering predicate.
func (fn FilterFunc) Filter(rels []*v2.Release) (rets []*v2.Release) {
for _, rel := range rels {
if fn.Check(rel) {
rets = append(rets, rel)
}
}
return
}
// Any returns a FilterFunc that filters a list of releases
// determined by the predicate 'f0 || f1 || ... || fn'.
func Any(filters ...FilterFunc) FilterFunc {
return func(rls *v2.Release) bool {
for _, filter := range filters {
if filter(rls) {
return true
}
}
return false
}
}
// All returns a FilterFunc that filters a list of releases
// determined by the predicate 'f0 && f1 && ... && fn'.
func All(filters ...FilterFunc) FilterFunc {
return func(rls *v2.Release) bool {
for _, filter := range filters {
if !filter(rls) {
return false
}
}
return true
}
}
// StatusFilter filters a set of releases by status code.
func StatusFilter(status common.Status) FilterFunc {
return FilterFunc(func(rls *v2.Release) bool {
if rls == nil {
return true
}
return rls.Info.Status == status
})
}

@ -0,0 +1,60 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"testing"
rspb "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/release/common"
)
func TestFilterAny(t *testing.T) {
ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases)
if len(ls) != 2 {
t.Fatalf("expected 2 results, got '%d'", len(ls))
}
r0, r1 := ls[0], ls[1]
switch {
case r0.Info.Status != common.StatusUninstalled:
t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String())
case r1.Info.Status != common.StatusUninstalled:
t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String())
}
}
func TestFilterAll(t *testing.T) {
fn := FilterFunc(func(rls *rspb.Release) bool {
// true if not uninstalled and version < 4
v0 := !StatusFilter(common.StatusUninstalled).Check(rls)
v1 := rls.Version < 4
return v0 && v1
})
ls := All(fn).Filter(releases)
if len(ls) != 1 {
t.Fatalf("expected 1 result, got '%d'", len(ls))
}
switch r0 := ls[0]; {
case r0.Version == 4:
t.Fatal("got release with status revision 4")
case r0.Info.Status == common.StatusUninstalled:
t.Fatal("got release with status UNINSTALLED")
}
}

@ -0,0 +1,165 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"sort"
release "helm.sh/helm/v4/internal/release/v2"
)
// KindSortOrder is an ordering of Kinds.
type KindSortOrder []string
// InstallOrder is the order in which manifests should be installed (by Kind).
//
// Those occurring earlier in the list get installed before those occurring later in the list.
var InstallOrder KindSortOrder = []string{
"PriorityClass",
"Namespace",
"NetworkPolicy",
"ResourceQuota",
"LimitRange",
"PodSecurityPolicy",
"PodDisruptionBudget",
"ServiceAccount",
"Secret",
"SecretList",
"ConfigMap",
"StorageClass",
"PersistentVolume",
"PersistentVolumeClaim",
"CustomResourceDefinition",
"ClusterRole",
"ClusterRoleList",
"ClusterRoleBinding",
"ClusterRoleBindingList",
"Role",
"RoleList",
"RoleBinding",
"RoleBindingList",
"Service",
"DaemonSet",
"Pod",
"ReplicationController",
"ReplicaSet",
"Deployment",
"HorizontalPodAutoscaler",
"StatefulSet",
"Job",
"CronJob",
"IngressClass",
"Ingress",
"APIService",
"MutatingWebhookConfiguration",
"ValidatingWebhookConfiguration",
}
// UninstallOrder is the order in which manifests should be uninstalled (by Kind).
//
// Those occurring earlier in the list get uninstalled before those occurring later in the list.
var UninstallOrder KindSortOrder = []string{
// For uninstall, we remove validation before mutation to ensure webhooks don't block removal
"ValidatingWebhookConfiguration",
"MutatingWebhookConfiguration",
"APIService",
"Ingress",
"IngressClass",
"Service",
"CronJob",
"Job",
"StatefulSet",
"HorizontalPodAutoscaler",
"Deployment",
"ReplicaSet",
"ReplicationController",
"Pod",
"DaemonSet",
"RoleBindingList",
"RoleBinding",
"RoleList",
"Role",
"ClusterRoleBindingList",
"ClusterRoleBinding",
"ClusterRoleList",
"ClusterRole",
"CustomResourceDefinition",
"PersistentVolumeClaim",
"PersistentVolume",
"StorageClass",
"ConfigMap",
"SecretList",
"Secret",
"ServiceAccount",
"PodDisruptionBudget",
"PodSecurityPolicy",
"LimitRange",
"ResourceQuota",
"NetworkPolicy",
"Namespace",
"PriorityClass",
}
// sort manifests by kind.
//
// Results are sorted by 'ordering', keeping order of items with equal kind/priority
func sortManifestsByKind(manifests []Manifest, ordering KindSortOrder) []Manifest {
sort.SliceStable(manifests, func(i, j int) bool {
return lessByKind(manifests[i], manifests[j], manifests[i].Head.Kind, manifests[j].Head.Kind, ordering)
})
return manifests
}
// sort hooks by kind, using an out-of-place sort to preserve the input parameters.
//
// Results are sorted by 'ordering', keeping order of items with equal kind/priority
func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.Hook {
h := hooks
sort.SliceStable(h, func(i, j int) bool {
return lessByKind(h[i], h[j], h[i].Kind, h[j].Kind, ordering)
})
return h
}
func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o KindSortOrder) bool {
ordering := make(map[string]int, len(o))
for v, k := range o {
ordering[k] = v
}
first, aok := ordering[kindA]
second, bok := ordering[kindB]
if !aok && !bok {
// if both are unknown then sort alphabetically by kind, keep original order if same kind
if kindA != kindB {
return kindA < kindB
}
return first < second
}
// unknown kind is last
if !aok {
return false
}
if !bok {
return true
}
// sort different kinds, keep original order if same priority
return first < second
}

@ -0,0 +1,347 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"bytes"
"testing"
release "helm.sh/helm/v4/internal/release/v2"
)
func TestKindSorter(t *testing.T) {
manifests := []Manifest{
{
Name: "U",
Head: &SimpleHead{Kind: "IngressClass"},
},
{
Name: "E",
Head: &SimpleHead{Kind: "SecretList"},
},
{
Name: "i",
Head: &SimpleHead{Kind: "ClusterRole"},
},
{
Name: "I",
Head: &SimpleHead{Kind: "ClusterRoleList"},
},
{
Name: "j",
Head: &SimpleHead{Kind: "ClusterRoleBinding"},
},
{
Name: "J",
Head: &SimpleHead{Kind: "ClusterRoleBindingList"},
},
{
Name: "f",
Head: &SimpleHead{Kind: "ConfigMap"},
},
{
Name: "u",
Head: &SimpleHead{Kind: "CronJob"},
},
{
Name: "2",
Head: &SimpleHead{Kind: "CustomResourceDefinition"},
},
{
Name: "n",
Head: &SimpleHead{Kind: "DaemonSet"},
},
{
Name: "r",
Head: &SimpleHead{Kind: "Deployment"},
},
{
Name: "!",
Head: &SimpleHead{Kind: "HonkyTonkSet"},
},
{
Name: "v",
Head: &SimpleHead{Kind: "Ingress"},
},
{
Name: "t",
Head: &SimpleHead{Kind: "Job"},
},
{
Name: "c",
Head: &SimpleHead{Kind: "LimitRange"},
},
{
Name: "a",
Head: &SimpleHead{Kind: "Namespace"},
},
{
Name: "A",
Head: &SimpleHead{Kind: "NetworkPolicy"},
},
{
Name: "g",
Head: &SimpleHead{Kind: "PersistentVolume"},
},
{
Name: "h",
Head: &SimpleHead{Kind: "PersistentVolumeClaim"},
},
{
Name: "o",
Head: &SimpleHead{Kind: "Pod"},
},
{
Name: "3",
Head: &SimpleHead{Kind: "PodDisruptionBudget"},
},
{
Name: "C",
Head: &SimpleHead{Kind: "PodSecurityPolicy"},
},
{
Name: "q",
Head: &SimpleHead{Kind: "ReplicaSet"},
},
{
Name: "p",
Head: &SimpleHead{Kind: "ReplicationController"},
},
{
Name: "b",
Head: &SimpleHead{Kind: "ResourceQuota"},
},
{
Name: "k",
Head: &SimpleHead{Kind: "Role"},
},
{
Name: "K",
Head: &SimpleHead{Kind: "RoleList"},
},
{
Name: "l",
Head: &SimpleHead{Kind: "RoleBinding"},
},
{
Name: "L",
Head: &SimpleHead{Kind: "RoleBindingList"},
},
{
Name: "e",
Head: &SimpleHead{Kind: "Secret"},
},
{
Name: "m",
Head: &SimpleHead{Kind: "Service"},
},
{
Name: "d",
Head: &SimpleHead{Kind: "ServiceAccount"},
},
{
Name: "s",
Head: &SimpleHead{Kind: "StatefulSet"},
},
{
Name: "1",
Head: &SimpleHead{Kind: "StorageClass"},
},
{
Name: "w",
Head: &SimpleHead{Kind: "APIService"},
},
{
Name: "x",
Head: &SimpleHead{Kind: "HorizontalPodAutoscaler"},
},
{
Name: "F",
Head: &SimpleHead{Kind: "PriorityClass"},
},
{
Name: "M",
Head: &SimpleHead{Kind: "MutatingWebhookConfiguration"},
},
{
Name: "V",
Head: &SimpleHead{Kind: "ValidatingWebhookConfiguration"},
},
}
for _, test := range []struct {
description string
order KindSortOrder
expected string
}{
{"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvwMV!"},
{"uninstall", UninstallOrder, "VMwvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"},
} {
var buf bytes.Buffer
t.Run(test.description, func(t *testing.T) {
if got, want := len(test.expected), len(manifests); got != want {
t.Fatalf("Expected %d names in order, got %d", want, got)
}
defer buf.Reset()
orig := manifests
for _, r := range sortManifestsByKind(manifests, test.order) {
buf.WriteString(r.Name)
}
if got := buf.String(); got != test.expected {
t.Errorf("Expected %q, got %q", test.expected, got)
}
for i, manifest := range orig {
if manifest != manifests[i] {
t.Fatal("Expected input to sortManifestsByKind to stay the same")
}
}
})
}
}
// TestKindSorterKeepOriginalOrder verifies manifests of same kind are kept in original order
func TestKindSorterKeepOriginalOrder(t *testing.T) {
manifests := []Manifest{
{
Name: "a",
Head: &SimpleHead{Kind: "ClusterRole"},
},
{
Name: "A",
Head: &SimpleHead{Kind: "ClusterRole"},
},
{
Name: "0",
Head: &SimpleHead{Kind: "ConfigMap"},
},
{
Name: "1",
Head: &SimpleHead{Kind: "ConfigMap"},
},
{
Name: "z",
Head: &SimpleHead{Kind: "ClusterRoleBinding"},
},
{
Name: "!",
Head: &SimpleHead{Kind: "ClusterRoleBinding"},
},
{
Name: "u2",
Head: &SimpleHead{Kind: "Unknown"},
},
{
Name: "u1",
Head: &SimpleHead{Kind: "Unknown"},
},
{
Name: "t3",
Head: &SimpleHead{Kind: "Unknown2"},
},
}
for _, test := range []struct {
description string
order KindSortOrder
expected string
}{
// expectation is sorted by kind (unknown is last) and within each group of same kind, the order is kept
{"cm,clusterRole,clusterRoleBinding,Unknown,Unknown2", InstallOrder, "01aAz!u2u1t3"},
} {
var buf bytes.Buffer
t.Run(test.description, func(t *testing.T) {
defer buf.Reset()
for _, r := range sortManifestsByKind(manifests, test.order) {
buf.WriteString(r.Name)
}
if got := buf.String(); got != test.expected {
t.Errorf("Expected %q, got %q", test.expected, got)
}
})
}
}
func TestKindSorterNamespaceAgainstUnknown(t *testing.T) {
unknown := Manifest{
Name: "a",
Head: &SimpleHead{Kind: "Unknown"},
}
namespace := Manifest{
Name: "b",
Head: &SimpleHead{Kind: "Namespace"},
}
manifests := []Manifest{unknown, namespace}
manifests = sortManifestsByKind(manifests, InstallOrder)
expectedOrder := []Manifest{namespace, unknown}
for i, manifest := range manifests {
if expectedOrder[i].Name != manifest.Name {
t.Errorf("Expected %s, got %s", expectedOrder[i].Name, manifest.Name)
}
}
}
// test hook sorting with a small subset of kinds, since it uses the same algorithm as sortManifestsByKind
func TestKindSorterForHooks(t *testing.T) {
hooks := []*release.Hook{
{
Name: "i",
Kind: "ClusterRole",
},
{
Name: "j",
Kind: "ClusterRoleBinding",
},
{
Name: "c",
Kind: "LimitRange",
},
{
Name: "a",
Kind: "Namespace",
},
}
for _, test := range []struct {
description string
order KindSortOrder
expected string
}{
{"install", InstallOrder, "acij"},
{"uninstall", UninstallOrder, "jica"},
} {
var buf bytes.Buffer
t.Run(test.description, func(t *testing.T) {
if got, want := len(test.expected), len(hooks); got != want {
t.Fatalf("Expected %d names in order, got %d", want, got)
}
defer buf.Reset()
orig := hooks
for _, r := range sortHooksByKind(hooks, test.order) {
buf.WriteString(r.Name)
}
for i, hook := range orig {
if hook != hooks[i] {
t.Fatal("Expected input to sortHooksByKind to stay the same")
}
}
if got := buf.String(); got != test.expected {
t.Errorf("Expected %q, got %q", test.expected, got)
}
})
}
}

@ -0,0 +1,72 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// SimpleHead defines what the structure of the head of a manifest file
type SimpleHead struct {
Version string `json:"apiVersion"`
Kind string `json:"kind,omitempty"`
Metadata *struct {
Name string `json:"name"`
Annotations map[string]string `json:"annotations"`
} `json:"metadata,omitempty"`
}
var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*")
// SplitManifests takes a string of manifest and returns a map contains individual manifests
func SplitManifests(bigFile string) map[string]string {
// Basically, we're quickly splitting a stream of YAML documents into an
// array of YAML docs. The file name is just a place holder, but should be
// integer-sortable so that manifests get output in the same order as the
// input (see `BySplitManifestsOrder`).
tpl := "manifest-%d"
res := map[string]string{}
// Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly.
bigFileTmp := strings.TrimSpace(bigFile)
docs := sep.Split(bigFileTmp, -1)
var count int
for _, d := range docs {
if d == "" {
continue
}
d = strings.TrimSpace(d)
res[fmt.Sprintf(tpl, count)] = d
count = count + 1
}
return res
}
// BySplitManifestsOrder sorts by in-file manifest order, as provided in function `SplitManifests`
type BySplitManifestsOrder []string
func (a BySplitManifestsOrder) Len() int { return len(a) }
func (a BySplitManifestsOrder) Less(i, j int) bool {
// Split `manifest-%d`
anum, _ := strconv.ParseInt(a[i][len("manifest-"):], 10, 0)
bnum, _ := strconv.ParseInt(a[j][len("manifest-"):], 10, 0)
return anum < bnum
}
func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] }

@ -0,0 +1,244 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"fmt"
"log/slog"
"path"
"sort"
"strconv"
"strings"
"sigs.k8s.io/yaml"
v2 "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/chart/common"
)
// Manifest represents a manifest file, which has a name and some content.
type Manifest struct {
Name string
Content string
Head *SimpleHead
}
// manifestFile represents a file that contains a manifest.
type manifestFile struct {
entries map[string]string
path string
}
// result is an intermediate structure used during sorting.
type result struct {
hooks []*v2.Hook
generic []Manifest
}
// TODO: Refactor this out. It's here because naming conventions were not followed through.
// So fix the Test hook names and then remove this.
var events = map[string]v2.HookEvent{
v2.HookPreInstall.String(): v2.HookPreInstall,
v2.HookPostInstall.String(): v2.HookPostInstall,
v2.HookPreDelete.String(): v2.HookPreDelete,
v2.HookPostDelete.String(): v2.HookPostDelete,
v2.HookPreUpgrade.String(): v2.HookPreUpgrade,
v2.HookPostUpgrade.String(): v2.HookPostUpgrade,
v2.HookPreRollback.String(): v2.HookPreRollback,
v2.HookPostRollback.String(): v2.HookPostRollback,
v2.HookTest.String(): v2.HookTest,
// Support test-success for backward compatibility with Helm 2 tests
"test-success": v2.HookTest,
}
// SortManifests takes a map of filename/YAML contents, splits the file
// by manifest entries, and sorts the entries into hook types.
//
// The resulting hooks struct will be populated with all of the generated hooks.
// Any file that does not declare one of the hook types will be placed in the
// 'generic' bucket.
//
// Files that do not parse into the expected format are simply placed into a map and
// returned.
func SortManifests(files map[string]string, _ common.VersionSet, ordering KindSortOrder) ([]*v2.Hook, []Manifest, error) {
result := &result{}
var sortedFilePaths []string
for filePath := range files {
sortedFilePaths = append(sortedFilePaths, filePath)
}
sort.Strings(sortedFilePaths)
for _, filePath := range sortedFilePaths {
content := files[filePath]
// Skip partials. We could return these as a separate map, but there doesn't
// seem to be any need for that at this time.
if strings.HasPrefix(path.Base(filePath), "_") {
continue
}
// Skip empty files and log this.
if strings.TrimSpace(content) == "" {
continue
}
manifestFile := &manifestFile{
entries: SplitManifests(content),
path: filePath,
}
if err := manifestFile.sort(result); err != nil {
return result.hooks, result.generic, err
}
}
return sortHooksByKind(result.hooks, ordering), sortManifestsByKind(result.generic, ordering), nil
}
// sort takes a manifestFile object which may contain multiple resource definition
// entries and sorts each entry by hook types, and saves the resulting hooks and
// generic manifests (or non-hooks) to the result struct.
//
// To determine hook type, it looks for a YAML structure like this:
//
// kind: SomeKind
// apiVersion: v1
// metadata:
// annotations:
// helm.sh/hook: pre-install
//
// To determine the policy to delete the hook, it looks for a YAML structure like this:
//
// kind: SomeKind
// apiVersion: v1
// metadata:
// annotations:
// helm.sh/hook-delete-policy: hook-succeeded
//
// To determine the policy to output logs of the hook (for Pod and Job only), it looks for a YAML structure like this:
//
// kind: Pod
// apiVersion: v1
// metadata:
// annotations:
// helm.sh/hook-output-log-policy: hook-succeeded,hook-failed
func (file *manifestFile) sort(result *result) error {
// Go through manifests in order found in file (function `SplitManifests` creates integer-sortable keys)
var sortedEntryKeys []string
for entryKey := range file.entries {
sortedEntryKeys = append(sortedEntryKeys, entryKey)
}
sort.Sort(BySplitManifestsOrder(sortedEntryKeys))
for _, entryKey := range sortedEntryKeys {
m := file.entries[entryKey]
var entry SimpleHead
if err := yaml.Unmarshal([]byte(m), &entry); err != nil {
return fmt.Errorf("YAML parse error on %s: %w", file.path, err)
}
if !hasAnyAnnotation(entry) {
result.generic = append(result.generic, Manifest{
Name: file.path,
Content: m,
Head: &entry,
})
continue
}
hookTypes, ok := entry.Metadata.Annotations[v2.HookAnnotation]
if !ok {
result.generic = append(result.generic, Manifest{
Name: file.path,
Content: m,
Head: &entry,
})
continue
}
hw := calculateHookWeight(entry)
h := &v2.Hook{
Name: entry.Metadata.Name,
Kind: entry.Kind,
Path: file.path,
Manifest: m,
Events: []v2.HookEvent{},
Weight: hw,
DeletePolicies: []v2.HookDeletePolicy{},
OutputLogPolicies: []v2.HookOutputLogPolicy{},
}
isUnknownHook := false
for hookType := range strings.SplitSeq(hookTypes, ",") {
hookType = strings.ToLower(strings.TrimSpace(hookType))
e, ok := events[hookType]
if !ok {
isUnknownHook = true
break
}
h.Events = append(h.Events, e)
}
if isUnknownHook {
slog.Info("skipping unknown hooks", "hookTypes", hookTypes)
continue
}
result.hooks = append(result.hooks, h)
operateAnnotationValues(entry, v2.HookDeleteAnnotation, func(value string) {
h.DeletePolicies = append(h.DeletePolicies, v2.HookDeletePolicy(value))
})
operateAnnotationValues(entry, v2.HookOutputLogAnnotation, func(value string) {
h.OutputLogPolicies = append(h.OutputLogPolicies, v2.HookOutputLogPolicy(value))
})
}
return nil
}
// hasAnyAnnotation returns true if the given entry has any annotations at all.
func hasAnyAnnotation(entry SimpleHead) bool {
return entry.Metadata != nil &&
entry.Metadata.Annotations != nil &&
len(entry.Metadata.Annotations) != 0
}
// calculateHookWeight finds the weight in the hook weight annotation.
//
// If no weight is found, the assigned weight is 0
func calculateHookWeight(entry SimpleHead) int {
hws := entry.Metadata.Annotations[v2.HookWeightAnnotation]
hw, err := strconv.Atoi(hws)
if err != nil {
hw = 0
}
return hw
}
// operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation
func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) {
if dps, ok := entry.Metadata.Annotations[annotation]; ok {
for dp := range strings.SplitSeq(dps, ",") {
dp = strings.ToLower(strings.TrimSpace(dp))
operate(dp)
}
}
}

@ -0,0 +1,227 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"reflect"
"testing"
"sigs.k8s.io/yaml"
release "helm.sh/helm/v4/internal/release/v2"
)
func TestSortManifests(t *testing.T) {
data := []struct {
name []string
path string
kind []string
hooks map[string][]release.HookEvent
manifest string
}{
{
name: []string{"first"},
path: "one",
kind: []string{"Job"},
hooks: map[string][]release.HookEvent{"first": {release.HookPreInstall}},
manifest: `apiVersion: v1
kind: Job
metadata:
name: first
labels:
doesnot: matter
annotations:
"helm.sh/hook": pre-install
`,
},
{
name: []string{"second"},
path: "two",
kind: []string{"ReplicaSet"},
hooks: map[string][]release.HookEvent{"second": {release.HookPostInstall}},
manifest: `kind: ReplicaSet
apiVersion: v1beta1
metadata:
name: second
annotations:
"helm.sh/hook": post-install
`,
}, {
name: []string{"third"},
path: "three",
kind: []string{"ReplicaSet"},
hooks: map[string][]release.HookEvent{"third": nil},
manifest: `kind: ReplicaSet
apiVersion: v1beta1
metadata:
name: third
annotations:
"helm.sh/hook": no-such-hook
`,
}, {
name: []string{"fourth"},
path: "four",
kind: []string{"Pod"},
hooks: map[string][]release.HookEvent{"fourth": nil},
manifest: `kind: Pod
apiVersion: v1
metadata:
name: fourth
annotations:
nothing: here`,
}, {
name: []string{"fifth"},
path: "five",
kind: []string{"ReplicaSet"},
hooks: map[string][]release.HookEvent{"fifth": {release.HookPostDelete, release.HookPostInstall}},
manifest: `kind: ReplicaSet
apiVersion: v1beta1
metadata:
name: fifth
annotations:
"helm.sh/hook": post-delete, post-install
`,
}, {
// Regression test: files with an underscore in the base name should be skipped.
name: []string{"sixth"},
path: "six/_six",
kind: []string{"ReplicaSet"},
hooks: map[string][]release.HookEvent{"sixth": nil},
manifest: `invalid manifest`, // This will fail if partial is not skipped.
}, {
// Regression test: files with no content should be skipped.
name: []string{"seventh"},
path: "seven",
kind: []string{"ReplicaSet"},
hooks: map[string][]release.HookEvent{"seventh": nil},
manifest: "",
},
{
name: []string{"eighth", "example-test"},
path: "eight",
kind: []string{"ConfigMap", "Pod"},
hooks: map[string][]release.HookEvent{"eighth": nil, "example-test": {release.HookTest}},
manifest: `kind: ConfigMap
apiVersion: v1
metadata:
name: eighth
data:
name: value
---
apiVersion: v1
kind: Pod
metadata:
name: example-test
annotations:
"helm.sh/hook": test
`,
},
}
manifests := make(map[string]string, len(data))
for _, o := range data {
manifests[o.path] = o.manifest
}
hs, generic, err := SortManifests(manifests, nil, InstallOrder)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
// This test will fail if 'six' or 'seven' was added.
if len(generic) != 2 {
t.Errorf("Expected 2 generic manifests, got %d", len(generic))
}
if len(hs) != 4 {
t.Errorf("Expected 4 hooks, got %d", len(hs))
}
for _, out := range hs {
found := false
for _, expect := range data {
if out.Path == expect.path {
found = true
if out.Path != expect.path {
t.Errorf("Expected path %s, got %s", expect.path, out.Path)
}
nameFound := false
for _, expectedName := range expect.name {
if out.Name == expectedName {
nameFound = true
}
}
if !nameFound {
t.Errorf("Got unexpected name %s", out.Name)
}
kindFound := false
for _, expectedKind := range expect.kind {
if out.Kind == expectedKind {
kindFound = true
}
}
if !kindFound {
t.Errorf("Got unexpected kind %s", out.Kind)
}
expectedHooks := expect.hooks[out.Name]
if !reflect.DeepEqual(expectedHooks, out.Events) {
t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events)
}
}
}
if !found {
t.Errorf("Result not found: %v", out)
}
}
// Verify the sort order
sorted := []Manifest{}
for _, s := range data {
manifests := SplitManifests(s.manifest)
for _, m := range manifests {
var sh SimpleHead
if err := yaml.Unmarshal([]byte(m), &sh); err != nil {
// This is expected for manifests that are corrupt or empty.
t.Log(err)
continue
}
name := sh.Metadata.Name
// only keep track of non-hook manifests
if s.hooks[name] == nil {
another := Manifest{
Content: m,
Name: name,
Head: &sh,
}
sorted = append(sorted, another)
}
}
}
sorted = sortManifestsByKind(sorted, InstallOrder)
for i, m := range generic {
if m.Content != sorted[i].Content {
t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content)
}
}
}

@ -0,0 +1,61 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"reflect"
"testing"
)
const mockManifestFile = `
---
apiVersion: v1
kind: Pod
metadata:
name: finding-nemo,
annotations:
"helm.sh/hook": test
spec:
containers:
- name: nemo-test
image: fake-image
cmd: fake-command
`
const expectedManifest = `apiVersion: v1
kind: Pod
metadata:
name: finding-nemo,
annotations:
"helm.sh/hook": test
spec:
containers:
- name: nemo-test
image: fake-image
cmd: fake-command`
func TestSplitManifest(t *testing.T) {
manifests := SplitManifests(mockManifestFile)
if len(manifests) != 1 {
t.Errorf("Expected 1 manifest, got %v", len(manifests))
}
expected := map[string]string{"manifest-0": expectedManifest}
if !reflect.DeepEqual(manifests, expected) {
t.Errorf("Expected %v, got %v", expected, manifests)
}
}

@ -0,0 +1,61 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"sort"
rspb "helm.sh/helm/v4/internal/release/v2"
)
// Reverse reverses the list of releases sorted by the sort func.
func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) {
sortFn(list)
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
list[i], list[j] = list[j], list[i]
}
}
// SortByName returns the list of releases sorted
// in lexicographical order.
func SortByName(list []*rspb.Release) {
sort.Slice(list, func(i, j int) bool {
return list[i].Name < list[j].Name
})
}
// SortByDate returns the list of releases sorted by a
// release's last deployed time (in seconds).
func SortByDate(list []*rspb.Release) {
sort.Slice(list, func(i, j int) bool {
ti := list[i].Info.LastDeployed.Unix()
tj := list[j].Info.LastDeployed.Unix()
if ti != tj {
return ti < tj
}
// Use name as tie-breaker for stable sorting
return list[i].Name < list[j].Name
})
}
// SortByRevision returns the list of releases sorted by a
// release's revision number (release.Version).
func SortByRevision(list []*rspb.Release) {
sort.Slice(list, func(i, j int) bool {
return list[i].Version < list[j].Version
})
}

@ -0,0 +1,109 @@
/*
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 "helm.sh/helm/v4/internal/release/v2/util"
import (
"testing"
"time"
rspb "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/release/common"
)
// note: this test data is shared with filter_test.go.
var releases = []*rspb.Release{
tsRelease("quiet-bear", 2, 2000, common.StatusSuperseded),
tsRelease("angry-bird", 4, 3000, common.StatusDeployed),
tsRelease("happy-cats", 1, 4000, common.StatusUninstalled),
tsRelease("vocal-dogs", 3, 6000, common.StatusUninstalled),
}
func tsRelease(name string, vers int, dur time.Duration, status common.Status) *rspb.Release {
info := &rspb.Info{Status: status, LastDeployed: time.Now().Add(dur)}
return &rspb.Release{
Name: name,
Version: vers,
Info: info,
}
}
func check(t *testing.T, by string, fn func(int, int) bool) {
t.Helper()
for i := len(releases) - 1; i > 0; i-- {
if fn(i, i-1) {
t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by)
}
}
}
func TestSortByName(t *testing.T) {
SortByName(releases)
check(t, "ByName", func(i, j int) bool {
ni := releases[i].Name
nj := releases[j].Name
return ni < nj
})
}
func TestSortByDate(t *testing.T) {
SortByDate(releases)
check(t, "ByDate", func(i, j int) bool {
ti := releases[i].Info.LastDeployed.Second()
tj := releases[j].Info.LastDeployed.Second()
return ti < tj
})
}
func TestSortByRevision(t *testing.T) {
SortByRevision(releases)
check(t, "ByRevision", func(i, j int) bool {
vi := releases[i].Version
vj := releases[j].Version
return vi < vj
})
}
func TestReverseSortByName(t *testing.T) {
Reverse(releases, SortByName)
check(t, "ByName", func(i, j int) bool {
ni := releases[i].Name
nj := releases[j].Name
return ni > nj
})
}
func TestReverseSortByDate(t *testing.T) {
Reverse(releases, SortByDate)
check(t, "ByDate", func(i, j int) bool {
ti := releases[i].Info.LastDeployed.Second()
tj := releases[j].Info.LastDeployed.Second()
return ti > tj
})
}
func TestReverseSortByRevision(t *testing.T) {
Reverse(releases, SortByRevision)
check(t, "ByRevision", func(i, j int) bool {
vi := releases[i].Version
vj := releases[j].Version
return vi > vj
})
}

@ -46,7 +46,7 @@ func TestReleaseTestingRun_UnreachableKubeClient(t *testing.T) {
config.KubeClient = &failingKubeClient
client := NewReleaseTesting(config)
result, err := client.Run("")
result, _, err := client.Run("")
assert.Nil(t, result)
assert.Error(t, err)
}

@ -21,6 +21,7 @@ import (
"fmt"
"time"
v2release "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/chart"
v1release "helm.sh/helm/v4/pkg/release/v1"
)
@ -35,6 +36,10 @@ func newDefaultAccessor(rel Releaser) (Accessor, error) {
return &v1Accessor{&v}, nil
case *v1release.Release:
return &v1Accessor{v}, nil
case v2release.Release:
return &v2Accessor{&v}, nil
case *v2release.Release:
return &v2Accessor{v}, nil
default:
return nil, fmt.Errorf("unsupported release type: %T", rel)
}
@ -46,6 +51,10 @@ func newDefaultHookAccessor(hook Hook) (HookAccessor, error) {
return &v1HookAccessor{&h}, nil
case *v1release.Hook:
return &v1HookAccessor{h}, nil
case v2release.Hook:
return &v2HookAccessor{&h}, nil
case *v2release.Hook:
return &v2HookAccessor{h}, nil
default:
return nil, errors.New("unsupported release hook type")
}
@ -114,3 +123,67 @@ func (a *v1HookAccessor) Path() string {
func (a *v1HookAccessor) Manifest() string {
return a.hook.Manifest
}
type v2Accessor struct {
rel *v2release.Release
}
func (a *v2Accessor) Name() string {
return a.rel.Name
}
func (a *v2Accessor) Namespace() string {
return a.rel.Namespace
}
func (a *v2Accessor) Version() int {
return a.rel.Version
}
func (a *v2Accessor) Hooks() []Hook {
var hooks = make([]Hook, len(a.rel.Hooks))
for i, h := range a.rel.Hooks {
hooks[i] = h
}
return hooks
}
func (a *v2Accessor) Manifest() string {
return a.rel.Manifest
}
func (a *v2Accessor) Notes() string {
return a.rel.Info.Notes
}
func (a *v2Accessor) Labels() map[string]string {
return a.rel.Labels
}
func (a *v2Accessor) Chart() chart.Charter {
return a.rel.Chart
}
func (a *v2Accessor) Status() string {
return a.rel.Info.Status.String()
}
func (a *v2Accessor) ApplyMethod() string {
return a.rel.ApplyMethod
}
func (a *v2Accessor) DeployedAt() time.Time {
return a.rel.Info.LastDeployed
}
type v2HookAccessor struct {
hook *v2release.Hook
}
func (a *v2HookAccessor) Path() string {
return a.hook.Path
}
func (a *v2HookAccessor) Manifest() string {
return a.hook.Manifest
}

@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/assert"
v2release "helm.sh/helm/v4/internal/release/v2"
"helm.sh/helm/v4/pkg/release/common"
rspb "helm.sh/helm/v4/pkg/release/v1"
)
@ -63,3 +64,72 @@ func TestNewDefaultAccessor(t *testing.T) {
is.Equal(rel.ApplyMethod, accessor.ApplyMethod())
is.Equal(rel.Labels, accessor.Labels())
}
func TestNewDefaultAccessorV2(t *testing.T) {
// Testing the default implementation for v2 releases (charts/v3)
is := assert.New(t)
// Create v2 release
info := &v2release.Info{Status: common.StatusDeployed, LastDeployed: time.Now().Add(1000), Notes: "test notes"}
labels := make(map[string]string)
labels["foo"] = "bar"
rel := &v2release.Release{
Name: "happy-cats-v2",
Version: 3,
Info: info,
Labels: labels,
Namespace: "test-namespace",
ApplyMethod: "ssa",
Manifest: "test manifest content",
Hooks: []*v2release.Hook{
{
Name: "test-hook",
Kind: "Job",
Path: "templates/hook.yaml",
Manifest: "hook manifest",
},
},
}
// Test accessor creation
accessor, err := newDefaultAccessor(rel)
is.NoError(err)
// Verify all accessor methods return correct values
is.Equal(rel.Name, accessor.Name())
is.Equal(rel.Namespace, accessor.Namespace())
is.Equal(rel.Version, accessor.Version())
is.Equal(rel.ApplyMethod, accessor.ApplyMethod())
is.Equal(rel.Labels, accessor.Labels())
is.Equal(rel.Manifest, accessor.Manifest())
is.Equal(rel.Info.Notes, accessor.Notes())
is.Equal(rel.Info.Status.String(), accessor.Status())
is.Equal(rel.Info.LastDeployed, accessor.DeployedAt())
// Verify hooks are accessible
hooks := accessor.Hooks()
is.Len(hooks, 1)
// Test hook accessor
hookAccessor, err := newDefaultHookAccessor(hooks[0])
is.NoError(err)
is.Equal("templates/hook.yaml", hookAccessor.Path())
is.Equal("hook manifest", hookAccessor.Manifest())
}
func TestNewDefaultAccessorV2ByValue(t *testing.T) {
// Test that passing v2 release by value also works
is := assert.New(t)
info := &v2release.Info{Status: common.StatusDeployed, LastDeployed: time.Now()}
rel := v2release.Release{
Name: "test-release",
Version: 1,
Info: info,
Namespace: "default",
}
accessor, err := newDefaultAccessor(rel)
is.NoError(err)
is.Equal("test-release", accessor.Name())
}

Loading…
Cancel
Save