diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 06556399..3d0516eb 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -498,11 +498,13 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } } - releaseIsUpToDate, err := release.IsReleaseUpToDate(prevRelease, newRelease) + result, err := release.IsReleaseUpToDate(prevRelease, newRelease) if err != nil { return fmt.Errorf("check if release is up to date: %w", err) } + releaseIsUpToDate := result.UpToDate + installPlanIsUseless := lo.NoneBy(installPlan.Operations(), func(op *plan.Operation) bool { switch op.Category { case plan.OperationCategoryResource, plan.OperationCategoryTrack: @@ -910,11 +912,13 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, } } - releaseIsUpToDate, err := release.IsReleaseUpToDate(failedRelease, newRelease) + releaseUpToDateResult, err := release.IsReleaseUpToDate(failedRelease, newRelease) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("check if release is up to date: %w", err)) } + releaseIsUpToDate := releaseUpToDateResult.UpToDate + planIsUseless := lo.NoneBy(rollbackPlan.Operations(), func(op *plan.Operation) bool { switch op.Category { case plan.OperationCategoryResource, plan.OperationCategoryTrack: diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index e2106e7e..2829ae69 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -403,11 +403,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc } } - releaseIsUpToDate, err := release.IsReleaseUpToDate(prevRelease, newRelease) + result, err := release.IsReleaseUpToDate(prevRelease, newRelease) if err != nil { return fmt.Errorf("check if release is up to date: %w", err) } + releaseIsUpToDate := result.UpToDate + installPlanIsUseless := lo.NoneBy(installPlan.Operations(), func(op *plan.Operation) bool { switch op.Category { case plan.OperationCategoryResource, plan.OperationCategoryTrack: @@ -427,7 +429,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc if releaseIsUpToDate && installPlanIsUseless { log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render(fmt.Sprintf("No changes planned for release %q (namespace: %q)", releaseName, releaseNamespace))) } else if installPlanIsUseless || len(changes) == 0 { - log.Default.Info(ctx, color.Style{color.Bold, color.Yellow}.Render(fmt.Sprintf("No resource changes planned, but still must install release %q (namespace: %q)", releaseName, releaseNamespace))) + log.Default.Info(ctx, color.Style{color.Bold, color.Yellow}.Render(releaseMustInstallMessage(releaseName, releaseNamespace, result.Reason))) } if err := logPlannedChanges(ctx, releaseName, releaseNamespace, changes, opts.ResourceDiffOptions); err != nil { @@ -593,3 +595,12 @@ func logSummaryLine(ctx context.Context, changes []*plan.ResourceChange, changeT log.Default.Info(ctx, "- %s: %d resources", filteredChanges[0].TypeStyle.Render(changeType), len(filteredChanges)) } } + +func releaseMustInstallMessage(releaseName, releaseNamespace string, reason release.ReleaseOutdatedReason) string { + msg := fmt.Sprintf("No resource changes planned, but still must install release %q (namespace: %q)", releaseName, releaseNamespace) + if reason != release.ReleaseOutdatedReasonNone { + msg += " because " + string(reason) + } + + return msg +} diff --git a/pkg/action/release_plan_install_test.go b/pkg/action/release_plan_install_test.go new file mode 100644 index 00000000..4b701236 --- /dev/null +++ b/pkg/action/release_plan_install_test.go @@ -0,0 +1,48 @@ +package action //nolint:testpackage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/release" +) + +func TestReleaseMustInstallMessage(t *testing.T) { + tests := []struct { + name string + releaseName string + releaseNamespace string + reason release.ReleaseOutdatedReason + want string + }{ + { + name: "no-reason", + releaseName: "myrelease", + releaseNamespace: "mynamespace", + reason: release.ReleaseOutdatedReasonNone, + want: `No resource changes planned, but still must install release "myrelease" (namespace: "mynamespace")`, + }, + { + name: "values-changed", + releaseName: "myrelease", + releaseNamespace: "mynamespace", + reason: release.ReleaseOutdatedReasonValuesChanged, + want: `No resource changes planned, but still must install release "myrelease" (namespace: "mynamespace") because ` + string(release.ReleaseOutdatedReasonValuesChanged), + }, + { + name: "notes-changed", + releaseName: "myrelease", + releaseNamespace: "mynamespace", + reason: release.ReleaseOutdatedReasonNotesChanged, + want: `No resource changes planned, but still must install release "myrelease" (namespace: "mynamespace") because ` + string(release.ReleaseOutdatedReasonNotesChanged), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := releaseMustInstallMessage(tt.releaseName, tt.releaseNamespace, tt.reason) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 3a6a38e4..3a17e017 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -337,11 +337,13 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } } - releaseIsUpToDate, err := release.IsReleaseUpToDate(prevRelease, newRelease) + result, err := release.IsReleaseUpToDate(prevRelease, newRelease) if err != nil { return fmt.Errorf("check if release is up to date: %w", err) } + releaseIsUpToDate := result.UpToDate + installPlanIsUseless := lo.NoneBy(installPlan.Operations(), func(op *plan.Operation) bool { switch op.Category { case plan.OperationCategoryResource, plan.OperationCategoryTrack: diff --git a/pkg/release/release.go b/pkg/release/release.go index 17eb27cb..8b581642 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -22,40 +22,63 @@ import ( "github.com/werf/nelm/pkg/resource/spec" ) +const ( + ReleaseOutdatedReasonNone ReleaseOutdatedReason = "" + ReleaseOutdatedReasonNoPreviousRelease ReleaseOutdatedReason = "there is no previously deployed release" + ReleaseOutdatedReasonReleaseStatusNotDeployed ReleaseOutdatedReason = "the previously deployed release was not successful" + ReleaseOutdatedReasonNotesChanged ReleaseOutdatedReason = "the release notes changed" + ReleaseOutdatedReasonValuesChanged ReleaseOutdatedReason = "the release values changed" + ReleaseOutdatedReasonHooksChanged ReleaseOutdatedReason = "the release hooks changed" + ReleaseOutdatedReasonManifestsChanged ReleaseOutdatedReason = "the release manifests changed" +) + +type ReleaseOutdatedReason string + type ReleaseOptions struct { InfoAnnotations map[string]string Labels map[string]string Notes string } +type IsReleaseUpToDateResult struct { + Reason ReleaseOutdatedReason + UpToDate bool +} + // Check if the new Release is up-to-date compared to the old Release. It doesn't check any // resources of the release in the cluster, just compares Release objects. -func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { +func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (IsReleaseUpToDateResult, error) { if oldRel == nil { - return false, nil + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonNoPreviousRelease}, nil } cmpOpts := cmp.Options{ cmpopts.EquateEmpty(), } - if oldRel.Info.Status != helmrelease.StatusDeployed || - oldRel.Info.Notes != newRel.Info.Notes || - !cmp.Equal(oldRel.Config, newRel.Config, cmpOpts) { - return false, nil + if oldRel.Info.Status != helmrelease.StatusDeployed { + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonReleaseStatusNotDeployed}, nil + } + + if oldRel.Info.Notes != newRel.Info.Notes { + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonNotesChanged}, nil + } + + if !cmp.Equal(oldRel.Config, newRel.Config, cmpOpts) { + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonValuesChanged}, nil } oldHookResourcesHash := fnv.New32a() for _, oldHook := range oldRel.Hooks { obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(oldHook.Manifest), nil, &unstructured.Unstructured{}) if err != nil { - return false, fmt.Errorf("decode old hook: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("decode old hook: %w", err) } unstruct := cleanUnstruct(obj.(*unstructured.Unstructured)) if err := writeUnstructHash(unstruct, oldHookResourcesHash); err != nil { - return false, fmt.Errorf("write old hook hash: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("write old hook hash: %w", err) } } @@ -63,18 +86,18 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { for _, newHook := range newRel.Hooks { obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(newHook.Manifest), nil, &unstructured.Unstructured{}) if err != nil { - return false, fmt.Errorf("decode new hook: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("decode new hook: %w", err) } unstruct := cleanUnstruct(obj.(*unstructured.Unstructured)) if err := writeUnstructHash(unstruct, newHookResourcesHash); err != nil { - return false, fmt.Errorf("write new hook hash: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("write new hook hash: %w", err) } } if oldHookResourcesHash.Sum32() != newHookResourcesHash.Sum32() { - return false, nil + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonHooksChanged}, nil } oldRelManifests := releaseutil.SplitManifestsToSlice(oldRel.Manifest) @@ -83,13 +106,13 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { for _, manifest := range oldRelManifests { obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) if err != nil { - return false, fmt.Errorf("decode old regular resource: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("decode old regular resource: %w", err) } unstruct := cleanUnstruct(obj.(*unstructured.Unstructured)) if err := writeUnstructHash(unstruct, oldRegularResourcesHash); err != nil { - return false, fmt.Errorf("write old regular resource hash: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("write old regular resource hash: %w", err) } } @@ -99,21 +122,21 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { for _, manifest := range newRelManifests { obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) if err != nil { - return false, fmt.Errorf("decode new regular resource: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("decode new regular resource: %w", err) } unstruct := cleanUnstruct(obj.(*unstructured.Unstructured)) if err := writeUnstructHash(unstruct, newRegularResourcesHash); err != nil { - return false, fmt.Errorf("write new regular resource hash: %w", err) + return IsReleaseUpToDateResult{}, fmt.Errorf("write new regular resource hash: %w", err) } } if oldRegularResourcesHash.Sum32() != newRegularResourcesHash.Sum32() { - return false, nil + return IsReleaseUpToDateResult{Reason: ReleaseOutdatedReasonManifestsChanged}, nil } - return true, nil + return IsReleaseUpToDateResult{UpToDate: true}, nil } // Construct Helm release. diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go new file mode 100644 index 00000000..cf0262b2 --- /dev/null +++ b/pkg/release/release_test.go @@ -0,0 +1,154 @@ +package release_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/release" +) + +func TestIsReleaseUpToDate(t *testing.T) { + cmManifest := func(dataVal string) string { + return `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: ` + dataVal + "\n" + } + + baseOldRel := func() *helmrelease.Release { + return &helmrelease.Release{ + Info: &helmrelease.Info{Status: helmrelease.StatusDeployed}, + Config: map[string]interface{}{}, + Manifest: cmManifest("value1"), + Hooks: []*helmrelease.Hook{{Manifest: cmManifest("hookval1")}}, + } + } + + baseNewRel := func() *helmrelease.Release { + return &helmrelease.Release{ + Info: &helmrelease.Info{Status: helmrelease.StatusDeployed}, + Config: map[string]interface{}{}, + Manifest: cmManifest("value1"), + Hooks: []*helmrelease.Hook{{Manifest: cmManifest("hookval1")}}, + } + } + + tests := []struct { + name string + oldRel *helmrelease.Release + newRel *helmrelease.Release + expectedUpToDate bool + expectedReason release.ReleaseOutdatedReason + }{ + { + name: "up-to-date", + oldRel: baseOldRel(), + newRel: baseNewRel(), + expectedUpToDate: true, + expectedReason: release.ReleaseOutdatedReasonNone, + }, + { + name: "no-previous-release", + oldRel: nil, + newRel: baseNewRel(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonNoPreviousRelease, + }, + { + name: "status-not-deployed", + oldRel: func() *helmrelease.Release { + r := baseOldRel() + r.Info.Status = helmrelease.StatusFailed + + return r + }(), + newRel: baseNewRel(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonReleaseStatusNotDeployed, + }, + { + name: "notes-changed", + oldRel: func() *helmrelease.Release { + r := baseOldRel() + r.Info.Notes = "old" + + return r + }(), + newRel: func() *helmrelease.Release { + r := baseNewRel() + r.Info.Notes = "new" + + return r + }(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonNotesChanged, + }, + { + name: "values-changed", + oldRel: func() *helmrelease.Release { + r := baseOldRel() + r.Config = map[string]interface{}{"a": 1} + + return r + }(), + newRel: func() *helmrelease.Release { + r := baseNewRel() + r.Config = map[string]interface{}{"a": 2} + + return r + }(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonValuesChanged, + }, + { + name: "hooks-changed", + oldRel: func() *helmrelease.Release { + r := baseOldRel() + r.Hooks = []*helmrelease.Hook{{Manifest: cmManifest("hookval1")}} + + return r + }(), + newRel: func() *helmrelease.Release { + r := baseNewRel() + r.Hooks = []*helmrelease.Hook{{Manifest: cmManifest("hookval2")}} + + return r + }(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonHooksChanged, + }, + { + name: "manifests-changed", + oldRel: func() *helmrelease.Release { + r := baseOldRel() + r.Hooks = nil + r.Manifest = cmManifest("value1") + + return r + }(), + newRel: func() *helmrelease.Release { + r := baseNewRel() + r.Hooks = nil + r.Manifest = cmManifest("value2") + + return r + }(), + expectedUpToDate: false, + expectedReason: release.ReleaseOutdatedReasonManifestsChanged, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := release.IsReleaseUpToDate(tt.oldRel, tt.newRel) + require.NoError(t, err) + assert.Equal(t, tt.expectedUpToDate, result.UpToDate) + assert.Equal(t, tt.expectedReason, result.Reason) + }) + } +}