mirror of https://github.com/helm/helm
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
743 lines
25 KiB
743 lines
25 KiB
/*
|
|
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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
v1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
|
|
"k8s.io/cli-runtime/pkg/resource"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
restfake "k8s.io/client-go/rest/fake"
|
|
|
|
"helm.sh/helm/v4/pkg/chart/common"
|
|
chart "helm.sh/helm/v4/pkg/chart/v2"
|
|
"helm.sh/helm/v4/pkg/kube"
|
|
kubefake "helm.sh/helm/v4/pkg/kube/fake"
|
|
ri "helm.sh/helm/v4/pkg/release"
|
|
rcommon "helm.sh/helm/v4/pkg/release/common"
|
|
release "helm.sh/helm/v4/pkg/release/v1"
|
|
releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
|
|
)
|
|
|
|
type recordingKubeClient struct {
|
|
kubefake.PrintingKubeClient
|
|
createCalls [][]string
|
|
updateCalls []updateCall
|
|
waitCalls [][]string
|
|
watchUntilReadyCalls [][]string
|
|
deleteWaitCalls [][]string
|
|
deleteCalls [][]string
|
|
operations []string
|
|
waitCallCount int
|
|
waitErrorOnCall int
|
|
waitError error
|
|
onBuild func()
|
|
onCreate func()
|
|
}
|
|
|
|
type updateCall struct {
|
|
current []string
|
|
target []string
|
|
created []string
|
|
}
|
|
|
|
type recordingKubeWaiter struct {
|
|
client *recordingKubeClient
|
|
}
|
|
|
|
func newRecordingKubeClient() *recordingKubeClient {
|
|
return &recordingKubeClient{
|
|
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard},
|
|
}
|
|
}
|
|
|
|
func (c *recordingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) {
|
|
if c.onCreate != nil {
|
|
c.onCreate()
|
|
}
|
|
ids := resourceIDs(resources)
|
|
c.createCalls = append(c.createCalls, ids)
|
|
c.operations = append(c.operations, "create:"+strings.Join(ids, ","))
|
|
return &kube.Result{Created: resources}, nil
|
|
}
|
|
|
|
func (c *recordingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) {
|
|
ids := resourceIDs(resources)
|
|
c.deleteCalls = append(c.deleteCalls, ids)
|
|
c.operations = append(c.operations, "delete:"+strings.Join(ids, ","))
|
|
return c.PrintingKubeClient.Delete(resources, deletionPropagation)
|
|
}
|
|
|
|
func (c *recordingKubeClient) Update(current, target kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) {
|
|
currentIDs := resourceIDs(current)
|
|
targetIDs := resourceIDs(target)
|
|
|
|
currentSet := make(map[string]struct{}, len(currentIDs))
|
|
for _, id := range currentIDs {
|
|
currentSet[id] = struct{}{}
|
|
}
|
|
|
|
var created kube.ResourceList
|
|
var createdIDs []string
|
|
for _, info := range target {
|
|
id := fmt.Sprintf("%s/%s", info.Object.GetObjectKind().GroupVersionKind().Kind, info.Name)
|
|
if _, exists := currentSet[id]; !exists {
|
|
created = append(created, info)
|
|
createdIDs = append(createdIDs, id)
|
|
}
|
|
}
|
|
|
|
c.updateCalls = append(c.updateCalls, updateCall{
|
|
current: currentIDs,
|
|
target: targetIDs,
|
|
created: createdIDs,
|
|
})
|
|
c.operations = append(c.operations, "update:"+strings.Join(targetIDs, ","))
|
|
return &kube.Result{Updated: target, Created: created}, nil
|
|
}
|
|
|
|
func (c *recordingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) {
|
|
if c.onBuild != nil {
|
|
c.onBuild()
|
|
}
|
|
decoder := yamlutil.NewYAMLOrJSONDecoder(reader, 4096)
|
|
var resources kube.ResourceList
|
|
|
|
for {
|
|
var obj map[string]any
|
|
if err := decoder.Decode(&obj); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return nil, err
|
|
}
|
|
if len(obj) == 0 {
|
|
continue
|
|
}
|
|
|
|
u := &unstructured.Unstructured{Object: obj}
|
|
gvk := u.GroupVersionKind()
|
|
namespace := u.GetNamespace()
|
|
if namespace == "" {
|
|
namespace = "spaced"
|
|
u.SetNamespace(namespace)
|
|
}
|
|
info := &resource.Info{
|
|
Name: u.GetName(),
|
|
Namespace: namespace,
|
|
Object: u,
|
|
Mapping: &meta.RESTMapping{
|
|
Resource: schema.GroupVersionResource{Group: gvk.Group, Version: gvk.Version, Resource: strings.ToLower(gvk.Kind) + "s"},
|
|
GroupVersionKind: gvk,
|
|
Scope: meta.RESTScopeNamespace,
|
|
},
|
|
Client: newNotFoundRESTClient(u.GetName(), gvk),
|
|
}
|
|
resources.Append(info)
|
|
}
|
|
|
|
return resources, nil
|
|
}
|
|
|
|
func (c *recordingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) {
|
|
return c.GetWaiterWithOptions(ws)
|
|
}
|
|
|
|
func (c *recordingKubeClient) GetWaiterWithOptions(_ kube.WaitStrategy, _ ...kube.WaitOption) (kube.Waiter, error) {
|
|
return &recordingKubeWaiter{client: c}, nil
|
|
}
|
|
|
|
func (w *recordingKubeWaiter) Wait(resources kube.ResourceList, _ time.Duration) error {
|
|
return w.client.recordWait(resources)
|
|
}
|
|
|
|
func (w *recordingKubeWaiter) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error {
|
|
return w.client.recordWait(resources)
|
|
}
|
|
|
|
func (w *recordingKubeWaiter) WaitForDelete(resources kube.ResourceList, _ time.Duration) error {
|
|
ids := resourceIDs(resources)
|
|
w.client.deleteWaitCalls = append(w.client.deleteWaitCalls, ids)
|
|
w.client.operations = append(w.client.operations, "wait-delete:"+strings.Join(ids, ","))
|
|
return nil
|
|
}
|
|
|
|
func (w *recordingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error {
|
|
ids := resourceIDs(resources)
|
|
w.client.watchUntilReadyCalls = append(w.client.watchUntilReadyCalls, ids)
|
|
w.client.operations = append(w.client.operations, "watch:"+strings.Join(ids, ","))
|
|
return nil
|
|
}
|
|
|
|
func (c *recordingKubeClient) recordWait(resources kube.ResourceList) error {
|
|
c.waitCallCount++
|
|
ids := resourceIDs(resources)
|
|
c.waitCalls = append(c.waitCalls, ids)
|
|
c.operations = append(c.operations, "wait:"+strings.Join(ids, ","))
|
|
if c.waitError != nil && c.waitCallCount == c.waitErrorOnCall {
|
|
return c.waitError
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newNotFoundRESTClient(name string, gvk schema.GroupVersionKind) *restfake.RESTClient {
|
|
body, _ := json.Marshal(metav1.Status{
|
|
Status: metav1.StatusFailure,
|
|
Reason: metav1.StatusReasonNotFound,
|
|
Code: http.StatusNotFound,
|
|
Details: &metav1.StatusDetails{
|
|
Name: name,
|
|
Group: gvk.Group,
|
|
Kind: gvk.Kind,
|
|
},
|
|
})
|
|
|
|
return &restfake.RESTClient{
|
|
GroupVersion: gvk.GroupVersion(),
|
|
NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
|
|
Client: restfake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(bytes.NewReader(body)),
|
|
}, nil
|
|
}),
|
|
}
|
|
}
|
|
|
|
func resourceIDs(resources kube.ResourceList) []string {
|
|
ids := make([]string, 0, len(resources))
|
|
for _, info := range resources {
|
|
kind := info.Object.GetObjectKind().GroupVersionKind().Kind
|
|
if kind == "" {
|
|
kind = "Unknown"
|
|
}
|
|
ids = append(ids, fmt.Sprintf("%s/%s", kind, info.Name))
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func newSequencedInstallAction(t *testing.T, kubeClient kube.Interface) *Install {
|
|
t.Helper()
|
|
cfg := actionConfigFixture(t)
|
|
cfg.KubeClient = kubeClient
|
|
|
|
install := NewInstall(cfg)
|
|
install.Namespace = "spaced"
|
|
install.ReleaseName = "test-install-release"
|
|
install.Timeout = 5 * time.Minute
|
|
install.ReadinessTimeout = time.Minute
|
|
install.WaitStrategy = kube.OrderedWaitStrategy
|
|
return install
|
|
}
|
|
|
|
func releaseFile(name, content string) *common.File {
|
|
return &common.File{Name: name, ModTime: time.Now(), Data: []byte(content)}
|
|
}
|
|
|
|
func configMapManifest(name string, annotations map[string]string) string {
|
|
var b strings.Builder
|
|
b.WriteString("apiVersion: v1\nkind: ConfigMap\nmetadata:\n")
|
|
_, _ = fmt.Fprintf(&b, " name: %s\n", name)
|
|
if len(annotations) > 0 {
|
|
b.WriteString(" annotations:\n")
|
|
for key, value := range annotations {
|
|
_, _ = fmt.Fprintf(&b, " %s: %q\n", key, value)
|
|
}
|
|
}
|
|
b.WriteString("data:\n key: value\n")
|
|
return b.String()
|
|
}
|
|
|
|
func makeConfigMapTemplate(fileName, name string, annotations map[string]string) *common.File {
|
|
return releaseFile(fileName, configMapManifest(name, annotations))
|
|
}
|
|
|
|
func makeHookTemplate(fileName, name, hook string, extraAnnotations map[string]string) *common.File {
|
|
annotations := map[string]string{release.HookAnnotation: hook}
|
|
maps.Copy(annotations, extraAnnotations)
|
|
return makeConfigMapTemplate(fileName, name, annotations)
|
|
}
|
|
|
|
func mustRelease(t *testing.T, rel ri.Releaser) *release.Release {
|
|
t.Helper()
|
|
out, err := releaserToV1Release(rel)
|
|
require.NoError(t, err)
|
|
return out
|
|
}
|
|
|
|
func TestSequencing_GroupManifestsByDirectSubchart(t *testing.T) {
|
|
manifests := []releaseutil.Manifest{
|
|
makeTestManifest("parent", "parent/templates/one.yaml", nil),
|
|
makeTestManifest("db", "parent/charts/database/templates/one.yaml", nil),
|
|
makeTestManifest("cache", "parent/charts/database/charts/cache/templates/one.yaml", nil),
|
|
}
|
|
|
|
grouped := GroupManifestsByDirectSubchart(manifests, "parent")
|
|
|
|
require.Len(t, grouped[""], 1)
|
|
require.Len(t, grouped["database"], 2)
|
|
}
|
|
|
|
// TestSequencing_GroupManifestsByDirectSubchart_Nested verifies that when called
|
|
// with a deeper chartPath (i.e., during recursion into a subchart), nested
|
|
// grandchildren are routed to the correct subchart key instead of being merged
|
|
// into the parent batch.
|
|
func TestSequencing_GroupManifestsByDirectSubchart_Nested(t *testing.T) {
|
|
manifests := []releaseutil.Manifest{
|
|
makeTestManifest("db-own", "parent/charts/database/templates/one.yaml", nil),
|
|
makeTestManifest("cache", "parent/charts/database/charts/cache/templates/one.yaml", nil),
|
|
}
|
|
|
|
grouped := GroupManifestsByDirectSubchart(manifests, "parent/charts/database")
|
|
|
|
require.Len(t, grouped[""], 1, "database's own resources should be under the empty key")
|
|
require.Len(t, grouped["cache"], 1, "nested cache subchart should be routed under its own key")
|
|
require.Equal(t, "parent/charts/database/templates/one.yaml", grouped[""][0].Name)
|
|
require.Equal(t, "parent/charts/database/charts/cache/templates/one.yaml", grouped["cache"][0].Name)
|
|
}
|
|
|
|
func TestSequencing_BuildManifestYAML(t *testing.T) {
|
|
yaml := buildManifestYAML([]releaseutil.Manifest{
|
|
makeTestManifest("one", "chart/templates/one.yaml", nil),
|
|
makeTestManifest("two", "chart/templates/two.yaml", nil),
|
|
})
|
|
|
|
assert.Contains(t, yaml, "name: one")
|
|
assert.Contains(t, yaml, "name: two")
|
|
}
|
|
|
|
func TestInstall_Sequenced_BasicResourceGroups(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "app",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
|
}),
|
|
})
|
|
|
|
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
|
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.createCalls)
|
|
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/app"}}, client.waitCalls)
|
|
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
|
assert.NotNil(t, rel.SequencingInfo)
|
|
assert.True(t, rel.SequencingInfo.Enabled)
|
|
}
|
|
|
|
func TestInstall_Sequenced_SubchartOrdering(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
parent := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
|
}, withName("parent"))
|
|
database := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", nil),
|
|
}, withName("database"))
|
|
api := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/api.yaml", "api", nil),
|
|
}, withName("api"))
|
|
|
|
parent.AddDependency(database, api)
|
|
parent.Metadata.Dependencies = []*chart.Dependency{
|
|
{Name: "database"},
|
|
{Name: "api", DependsOn: []string{"database"}},
|
|
}
|
|
|
|
mustRunInstall(t, install, parent)
|
|
|
|
assert.Equal(t, [][]string{{"ConfigMap/database"}, {"ConfigMap/api"}, {"ConfigMap/parent"}}, client.createCalls)
|
|
assert.Equal(t, client.createCalls, client.waitCalls)
|
|
}
|
|
|
|
func TestInstall_Sequenced_Combined(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
parent := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/parent.yaml", "parent", nil),
|
|
}, withName("parent"))
|
|
database := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/setup.yaml", "db-setup", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "setup",
|
|
}),
|
|
makeConfigMapTemplate("templates/data.yaml", "db-data", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "data",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["setup"]`,
|
|
}),
|
|
}, withName("database"))
|
|
api := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/api.yaml", "api", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "api",
|
|
}),
|
|
}, withName("api"))
|
|
|
|
parent.AddDependency(database, api)
|
|
parent.Metadata.Dependencies = []*chart.Dependency{
|
|
{Name: "database"},
|
|
{Name: "api", DependsOn: []string{"database"}},
|
|
}
|
|
|
|
mustRunInstall(t, install, parent)
|
|
|
|
assert.Equal(t, [][]string{
|
|
{"ConfigMap/db-setup"},
|
|
{"ConfigMap/db-data"},
|
|
{"ConfigMap/api"},
|
|
{"ConfigMap/parent"},
|
|
}, client.createCalls)
|
|
}
|
|
|
|
func TestInstall_Sequenced_NoAnnotations(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/first.yaml", "first", nil),
|
|
makeConfigMapTemplate("templates/second.yaml", "second", nil),
|
|
})
|
|
|
|
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
|
|
|
require.Len(t, client.createCalls, 1)
|
|
assert.ElementsMatch(t, []string{"ConfigMap/first", "ConfigMap/second"}, client.createCalls[0])
|
|
assert.Equal(t, rcommon.StatusDeployed, rel.Info.Status)
|
|
}
|
|
|
|
func TestInstall_Sequenced_UnsequencedLast(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/orphan.yaml", "orphan", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "orphan",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["missing"]`,
|
|
}),
|
|
makeConfigMapTemplate("templates/plain.yaml", "plain", nil),
|
|
})
|
|
|
|
mustRunInstall(t, install, ch)
|
|
|
|
require.Len(t, client.createCalls, 2)
|
|
assert.Equal(t, []string{"ConfigMap/database"}, client.createCalls[0])
|
|
assert.ElementsMatch(t, []string{"ConfigMap/orphan", "ConfigMap/plain"}, client.createCalls[1])
|
|
}
|
|
|
|
func TestInstall_Sequenced_ReadinessTimeout(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
client.waitErrorOnCall = 1
|
|
client.waitError = errors.New("timed out waiting for batch")
|
|
|
|
install := newSequencedInstallAction(t, client)
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "app",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
|
}),
|
|
})
|
|
|
|
rel, err := install.Run(ch, nil)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "timed out waiting for batch")
|
|
assert.Len(t, client.createCalls, 1)
|
|
|
|
last, getErr := install.cfg.Releases.Last(install.ReleaseName)
|
|
require.NoError(t, getErr)
|
|
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, last).Info.Status)
|
|
assert.Equal(t, rcommon.StatusFailed, mustRelease(t, rel).Info.Status)
|
|
}
|
|
|
|
func TestInstall_Sequenced_AtomicRollback(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
client.waitErrorOnCall = 1
|
|
client.waitError = errors.New("timed out waiting for batch")
|
|
|
|
install := newSequencedInstallAction(t, client)
|
|
install.RollbackOnFailure = true
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "app",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
|
}),
|
|
})
|
|
|
|
_, err := install.Run(ch, nil)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "rollback-on-failure")
|
|
assert.NotEmpty(t, client.deleteCalls)
|
|
}
|
|
|
|
func TestInstall_Sequenced_DryRun(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
install.DryRunStrategy = DryRunServer
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "app",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
|
}),
|
|
})
|
|
|
|
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
|
assert.Empty(t, client.createCalls)
|
|
assert.Empty(t, client.waitCalls)
|
|
assert.Equal(t, "Dry run complete", rel.Info.Description)
|
|
}
|
|
|
|
func TestInstall_Sequenced_CyclicDependency(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/a.yaml", "a", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "a",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["b"]`,
|
|
}),
|
|
makeConfigMapTemplate("templates/b.yaml", "b", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "b",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["a"]`,
|
|
}),
|
|
})
|
|
|
|
_, err := install.Run(ch, nil)
|
|
require.Error(t, err)
|
|
assert.True(t, strings.Contains(err.Error(), "cycle") || strings.Contains(err.Error(), "circular"))
|
|
assert.Empty(t, client.createCalls)
|
|
}
|
|
|
|
func TestInstall_Sequenced_SequencingInfoStored(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/config.yaml", "config", nil),
|
|
})
|
|
|
|
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
|
|
|
require.NotNil(t, rel.SequencingInfo)
|
|
assert.True(t, rel.SequencingInfo.Enabled)
|
|
assert.Equal(t, string(kube.OrderedWaitStrategy), rel.SequencingInfo.Strategy)
|
|
|
|
stored, err := install.cfg.Releases.Get(rel.Name, rel.Version)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(kube.OrderedWaitStrategy), mustRelease(t, stored).SequencingInfo.Strategy)
|
|
}
|
|
|
|
func TestInstall_NonSequenced_Unchanged(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
install.WaitStrategy = kube.StatusWatcherStrategy
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/app.yaml", "app", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "app",
|
|
releaseutil.AnnotationDependsOnResourceGroups: `["database"]`,
|
|
}),
|
|
})
|
|
|
|
rel := mustRelease(t, mustRunInstall(t, install, ch))
|
|
|
|
require.Len(t, client.createCalls, 1)
|
|
assert.ElementsMatch(t, []string{"ConfigMap/database", "ConfigMap/app"}, client.createCalls[0])
|
|
assert.Nil(t, rel.SequencingInfo)
|
|
}
|
|
|
|
func TestInstall_Sequenced_HookResourcesExcludedFromDAG(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
})
|
|
|
|
mustRunInstall(t, install, ch)
|
|
|
|
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}}, client.createCalls)
|
|
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}}, client.watchUntilReadyCalls)
|
|
}
|
|
|
|
func TestInstall_Sequenced_HooksAtStandardPositions(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
install := newSequencedInstallAction(t, client)
|
|
|
|
ch := buildChartWithTemplates([]*common.File{
|
|
makeHookTemplate("templates/pre-hook.yaml", "pre-hook", release.HookPreInstall.String(), map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeConfigMapTemplate("templates/database.yaml", "database", map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
makeHookTemplate("templates/post-hook.yaml", "post-hook", release.HookPostInstall.String(), map[string]string{
|
|
releaseutil.AnnotationResourceGroup: "database",
|
|
}),
|
|
})
|
|
|
|
mustRunInstall(t, install, ch)
|
|
|
|
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/database"}, {"ConfigMap/post-hook"}}, client.createCalls)
|
|
assert.Equal(t, [][]string{{"ConfigMap/pre-hook"}, {"ConfigMap/post-hook"}}, client.watchUntilReadyCalls)
|
|
}
|
|
|
|
func TestSequencing_WarnIfPartialReadinessAnnotations(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
|
|
oldLogger := slog.Default()
|
|
slog.SetDefault(slog.New(handler))
|
|
t.Cleanup(func() { slog.SetDefault(oldLogger) })
|
|
|
|
warnIfPartialReadinessAnnotations(slog.Default(), []releaseutil.Manifest{
|
|
makeTestManifest("cm", "chart/templates/cm.yaml", map[string]string{
|
|
kube.AnnotationReadinessSuccess: `["{.ready} == true"]`,
|
|
}),
|
|
})
|
|
|
|
assert.Contains(t, buf.String(), "readiness")
|
|
}
|
|
|
|
func TestSequencedDeployment_CreateAndWait_RespectsContextCancellation(t *testing.T) {
|
|
newSequencedDeploymentForTest := func(client kube.Interface) *sequencedDeployment {
|
|
t.Helper()
|
|
|
|
cfg := actionConfigFixture(t)
|
|
cfg.KubeClient = client
|
|
|
|
return &sequencedDeployment{
|
|
cfg: cfg,
|
|
releaseName: "demo",
|
|
releaseNamespace: "spaced",
|
|
waitStrategy: kube.OrderedWaitStrategy,
|
|
readinessTimeout: time.Minute,
|
|
}
|
|
}
|
|
|
|
t.Run("context canceled before build", func(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
sd := newSequencedDeploymentForTest(client)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
|
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
|
})
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
assert.Empty(t, client.createCalls)
|
|
assert.Empty(t, client.waitCalls)
|
|
})
|
|
|
|
t.Run("context canceled after build before create", func(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
client.onBuild = cancel
|
|
|
|
sd := newSequencedDeploymentForTest(client)
|
|
|
|
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
|
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
|
})
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
assert.Empty(t, client.createCalls)
|
|
assert.Empty(t, client.waitCalls)
|
|
})
|
|
|
|
t.Run("context canceled after create before wait", func(t *testing.T) {
|
|
client := newRecordingKubeClient()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
client.onCreate = cancel
|
|
|
|
sd := newSequencedDeploymentForTest(client)
|
|
|
|
err := sd.createAndWait(ctx, []releaseutil.Manifest{
|
|
makeTestManifest("cm", "chart/templates/cm.yaml", nil),
|
|
})
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.Len(t, client.createCalls, 1)
|
|
assert.Empty(t, client.waitCalls)
|
|
})
|
|
}
|
|
|
|
func makeTestManifest(name, sourcePath string, annotations map[string]string) releaseutil.Manifest {
|
|
content := configMapManifest(name, annotations)
|
|
head := &releaseutil.SimpleHead{
|
|
Version: v1.SchemeGroupVersion.String(),
|
|
Kind: "ConfigMap",
|
|
}
|
|
head.Metadata = &struct {
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace,omitempty"`
|
|
Annotations map[string]string `json:"annotations"`
|
|
}{
|
|
Name: name,
|
|
Annotations: annotations,
|
|
}
|
|
return releaseutil.Manifest{Name: sourcePath, Content: content, Head: head}
|
|
}
|
|
|
|
func mustRunInstall(t *testing.T, install *Install, ch *chart.Chart) ri.Releaser {
|
|
t.Helper()
|
|
rel, err := install.RunWithContext(context.Background(), ch, map[string]any{})
|
|
require.NoError(t, err)
|
|
return rel
|
|
}
|