diff --git a/internal/helm/cache_test.go b/internal/helm/cache_test.go index cdf48a5..5ccc009 100644 --- a/internal/helm/cache_test.go +++ b/internal/helm/cache_test.go @@ -87,6 +87,25 @@ func removeChartDir(tb testing.TB, path string) { } } +// cleanupChartCache removes every backing directory still stored in cache +// when the test finishes. Register it before the call that fills the cache. +func cleanupChartCache(tb testing.TB, cache *ChartCache) { + tb.Helper() + tb.Cleanup(func() { + cache.mu.Lock() + paths := make([]string, 0, len(cache.entries)) + for _, entry := range cache.entries { + paths = append(paths, entry.path) + } + cache.mu.Unlock() + for _, path := range paths { + if err := os.RemoveAll(path); err != nil { + tb.Logf("cleanup: remove chart cache %s: %v", path, err) + } + } + }) +} + // TestPrepareChart_RequiresCache verifies PrepareChart rejects a nil cache. func TestPrepareChart_RequiresCache(t *testing.T) { t.Parallel() @@ -133,17 +152,16 @@ func TestNewChartCache_NonPositiveTTLUsesDefault(t *testing.T) { // unknown keys, TTL expiry, and deleted backing directories. func TestChartCache_GetMissExpiredAndMissingDir(t *testing.T) { t.Parallel() - cache := NewChartCache(time.Millisecond) + cache := NewChartCache(time.Hour) _, found := cache.get("missing") assert.False(t, found) dir := t.TempDir() cache.set("expired", dir) - require.Eventually(t, func() bool { - _, ok := cache.get("expired") - return !ok - }, 50*time.Millisecond, time.Millisecond) + cache.backdate(t, "expired", 2*time.Hour) + _, found = cache.get("expired") + assert.False(t, found) assert.Equal(t, 1, cache.entryCount(), "expired entries remain until cleanup") gone := filepath.Join(t.TempDir(), "removed") @@ -203,6 +221,14 @@ func TestCreateChartCopy_CopiesFiles(t *testing.T) { assert.NotEqual(t, src, dst) } +func TestCreateChartCopy_MissingSource(t *testing.T) { + t.Parallel() + _, err := createChartCopy(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.ErrorContains(t, err, "failed to copy cached chart") +} + // TestPrepareChart_CanceledContextReturnsImmediately verifies PrepareChart // honors an already-canceled context before doing chart work. func TestPrepareChart_CanceledContextReturnsImmediately(t *testing.T) { diff --git a/internal/helm/client_test.go b/internal/helm/client_test.go new file mode 100644 index 0000000..a5df9b5 --- /dev/null +++ b/internal/helm/client_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 The Deployah 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 helm + +import ( + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "helm.sh/helm/v4/pkg/kube/fake" +) + +func TestNewClient_RejectsInvalidStorageDriver(t *testing.T) { + t.Parallel() + + c, err := NewClient(WithStorageDriver("sql")) + require.Error(t, err) + assert.Nil(t, c) + assert.ErrorContains(t, err, "invalid storage driver 'sql'") +} + +func TestNewClient_TimeoutAndNamespaceReachHelmActions(t *testing.T) { + t.Parallel() + + c, err := NewClient( + WithStorageDriver("memory"), + WithTimeout(90*time.Second), + WithNamespace("apps"), + ) + require.NoError(t, err) + install := c.newInstallAction("shop-production", nil, nil, false) + upgrade := c.newUpgradeAction(nil, nil) + assert.Equal(t, 90*time.Second, install.Timeout) + assert.Equal(t, 90*time.Second, upgrade.Timeout) + assert.Equal(t, "apps", install.Namespace) + assert.Equal(t, "apps", upgrade.Namespace) + + defaults, err := NewClient(WithStorageDriver("memory"), WithNamespace("")) + require.NoError(t, err) + assert.Equal(t, "default", defaults.Namespace()) + assert.Equal(t, 5*time.Minute, defaults.newInstallAction("shop-production", nil, nil, false).Timeout) +} + +func TestNamespace_EmptyAndNilUseDefault(t *testing.T) { + t.Parallel() + + var c *Client + assert.Equal(t, "default", c.Namespace()) + assert.Equal(t, "default", (&Client{}).Namespace()) + assert.Equal(t, "apps", (&Client{namespace: "apps"}).Namespace()) +} + +func TestIsReachable(t *testing.T) { + t.Parallel() + + c, cfg, _, _ := memoryHelmApp(t, "reach-app") + require.NoError(t, c.IsReachable()) + + cause := errors.New("api server unreachable") + failing := &kubefake.FailingKubeClient{ConnectionError: cause} + failing.Out = io.Discard + cfg.KubeClient = failing + + err := c.IsReachable() + require.Error(t, err) + assert.ErrorIs(t, err, ErrClusterUnreachable) + assert.ErrorIs(t, err, cause) +} diff --git a/internal/helm/dest_test.go b/internal/helm/dest_test.go index a188e7c..4d39b83 100644 --- a/internal/helm/dest_test.go +++ b/internal/helm/dest_test.go @@ -162,6 +162,15 @@ func TestRESTClientGetter_UnconfiguredDiscoveryFails(t *testing.T) { require.ErrorIs(t, err, ErrDestinationNotConfigured) _, err = getter.ToRESTMapper() require.ErrorIs(t, err, ErrDestinationNotConfigured) + + loader := getter.ToRawKubeConfigLoader() + _, err = loader.RawConfig() + require.ErrorIs(t, err, ErrDestinationNotConfigured) + ns, overridden, err := loader.Namespace() + require.NoError(t, err) + assert.Equal(t, "default", ns) + assert.False(t, overridden) + assert.NotNil(t, loader.ConfigAccess()) } func TestRESTClientGetter_NoInClusterFallback(t *testing.T) { @@ -211,11 +220,45 @@ func TestNewClient_UnconfiguredGetterIgnoresAmbientKubeconfig(t *testing.T) { err = client.IsReachable() require.Error(t, err) + assert.ErrorIs(t, err, ErrClusterUnreachable) assert.ErrorIs(t, err, ErrDestinationNotConfigured) }) } } +func TestDefaultKubeCacheDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("KUBECACHEDIR", "") + assert.Equal(t, filepath.Join(home, ".kube", "cache"), defaultKubeCacheDir()) + + explicit := t.TempDir() + t.Setenv("KUBECACHEDIR", explicit) + assert.Equal(t, explicit, defaultKubeCacheDir()) +} + +func TestComputeDiscoverCacheDir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + want string + }{ + {name: "https host and port", host: "https://prod.example:6443", want: "prod.example_6443"}, + {name: "http address", host: "http://127.0.0.1:8080", want: "127.0.0.1_8080"}, + {name: "path separator stays", host: "https://api.example/cluster", want: "api.example/cluster"}, + {name: "space becomes underscore", host: "https://bad host", want: "bad_host"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := computeDiscoverCacheDir("cache", tt.host) + assert.Equal(t, filepath.Join("cache", tt.want), got) + }) + } +} + func writeDestKubeconfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "kubeconfig") diff --git a/internal/helm/generate_test.go b/internal/helm/generate_test.go index 472257a..acfbd0e 100644 --- a/internal/helm/generate_test.go +++ b/internal/helm/generate_test.go @@ -262,6 +262,20 @@ func TestBuildLivenessProbe_RestartAfterOnlyDefaultsInterval(t *testing.T) { assert.Equal(t, 10, p["periodSeconds"]) } +// TestBuildLivenessProbe_InvalidDurationUsesDefaults verifies a duration +// that slips past validation still produces the default probe instead of +// failing the render. +func TestBuildLivenessProbe_InvalidDurationUsesDefaults(t *testing.T) { + t.Parallel() + + p, err := buildLivenessProbe(nil, "/livez", "nope", "also-nope") + require.NoError(t, err) + assert.Equal(t, spec.DefaultLivenessProbePeriod, p["periodSeconds"]) + assert.Equal(t, spec.DefaultLivenessRestartAfterSec/spec.DefaultLivenessProbePeriod, p["failureThreshold"]) + httpGet := mustNestedMap(t, p, "httpGet") + assert.Equal(t, "/livez", httpGet["path"]) +} + // TestMapSpecToChartValues_EnvironmentFilterPrefixMatch verifies the // component environments filter uses the same exact-then-prefix matching as // spec.Resolve, so resolution and the generated chart agree on wildcard @@ -509,6 +523,70 @@ func TestMapSpecToChartValues_CertManagerTLS(t *testing.T) { assert.Equal(t, "letsencrypt-prod", annotations["cert-manager.io/cluster-issuer"]) } +// TestMapSpecToChartValues_ExposeWithoutTLS verifies an exposed service with +// no TLS mode renders ingress TLS off, so the chart does not request a cert. +func TestMapSpecToChartValues_ExposeWithoutTLS(t *testing.T) { + t.Parallel() + + m := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "shop-api:latest", + Port: 8080, + Expose: &spec.Expose{}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, spec.CurrentManifestVersion)) + resolved := &spec.ResolvedSpec{ + Spec: m, + Env: spec.NormalizeEnv("production"), + Components: map[string]spec.ResolvedComponent{ + "api": {FQDN: "api.example.com"}, + }, + } + + vals, err := MapSpecToChartValues(m, "production", resolved) + require.NoError(t, err) + ingress := mustNestedMap(t, mustNestedMap(t, vals, "api"), "ingress") + assert.Equal(t, true, ingress["enabled"]) + assert.Equal(t, "api.example.com", ingress["hostname"]) + assert.Equal(t, false, ingress["tls"]) + assert.NotContains(t, ingress, "secrets") + assert.NotContains(t, ingress, "existingSecret") +} + +func TestMapSpecToChartValues_ImageDigestAndArgs(t *testing.T) { + t.Parallel() + + const digest = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + m := &spec.Spec{ + APIVersion: spec.CurrentManifestVersion, + Project: "shop", + Components: map[string]spec.Component{ + "api": { + Role: spec.ComponentRoleService, + Image: "ghcr.io/acme/shop@" + digest, + Port: 8080, + Args: []string{"--verbose"}, + }, + }, + } + require.NoError(t, spec.FillSpecWithDefaults(m, spec.CurrentManifestVersion)) + + vals, err := MapSpecToChartValues(m, "production", nil) + require.NoError(t, err) + api := mustNestedMap(t, vals, "api") + image := mustNestedMap(t, api, "image") + assert.Equal(t, "ghcr.io/acme/shop", image["repository"]) + assert.Equal(t, digest, image["digest"]) + assert.NotContains(t, image, "tag") + assert.Equal(t, []string{"--verbose"}, api["args"]) +} + // TestMapSpecToChartValues_Autoscaling maps enabled HPA settings into values. func TestMapSpecToChartValues_Autoscaling(t *testing.T) { t.Parallel() @@ -1091,6 +1169,7 @@ func TestMapSpecToChartValues_WorkerStateless(t *testing.T) { assert.Equal(t, 60, worker["terminationGracePeriodSeconds"]) assert.Nil(t, worker["startupProbe"]) assert.Nil(t, worker["readinessProbe"]) + assert.Equal(t, []string{"sleep", "infinity"}, worker["command"]) resolved := mustNestedMap(t, mustNestedMap(t, mustNestedMap(t, values, "deployah"), "resolved"), "components") assert.Equal(t, "worker", mustNestedMap(t, resolved, "worker")["role"]) } diff --git a/internal/helm/helm_error_test.go b/internal/helm/helm_error_test.go index a81b7db..da484ea 100644 --- a/internal/helm/helm_error_test.go +++ b/internal/helm/helm_error_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/release" "helm.sh/helm/v4/pkg/release/common" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" @@ -185,6 +186,21 @@ func TestNewestRelease_SortsByVersion(t *testing.T) { }, want: 5, }, + { + name: "nil entries sort after real revisions", + in: []*v1.Release{ + nil, + {Version: 2}, + nil, + {Version: 4}, + }, + want: 4, + }, + { + name: "only nil entries", + in: []*v1.Release{nil, nil}, + want: 0, + }, } for _, tt := range tests { @@ -201,6 +217,36 @@ func TestNewestRelease_SortsByVersion(t *testing.T) { } } +func TestReleaserToV1(t *testing.T) { + t.Parallel() + + pointer := &v1.Release{Name: "shop", Version: 2} + got, err := releaserToV1(pointer) + require.NoError(t, err) + assert.Same(t, pointer, got) + + value := v1.Release{Name: "shop", Version: 3} + got, err = releaserToV1(value) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, 3, got.Version) + + _, err = releaserToV1(struct{}{}) + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported helm release type") + + list, err := releaserListToV1([]release.Releaser{nil, value, pointer}) + require.NoError(t, err) + require.Len(t, list, 3) + assert.Nil(t, list[0]) + assert.Equal(t, 3, list[1].Version) + assert.Same(t, pointer, list[2]) + + _, err = releaserListToV1([]release.Releaser{struct{}{}}) + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported helm release type") +} + // TestInstallApp_PendingReleaseRejects pins the typed pending pre-check: a // release whose newest revision is pending must fail with // [ErrReleasePending] before Helm upgrade runs. diff --git a/internal/helm/preflight_test.go b/internal/helm/preflight_test.go index 7c9b847..83b1e27 100644 --- a/internal/helm/preflight_test.go +++ b/internal/helm/preflight_test.go @@ -15,6 +15,7 @@ package helm import ( + "context" "errors" "io" "testing" @@ -375,6 +376,35 @@ func TestRenderManifests_MatchesWithPrepResult(t *testing.T) { assert.Equal(t, withPrep.Namespace, wrapped.Namespace) } +func TestInstallApp_DryRunDoesNotWriteHistory(t *testing.T) { + t.Parallel() + + c, cfg, resolved, releaseName := memoryHelmApp(t, "dry-run-app") + cleanupChartCache(t, c.chartCache) + require.NoError(t, c.InstallApp(t.Context(), true, resolved, nil, nil, false)) + _, err := cfg.Releases.History(releaseName) + assert.ErrorIs(t, err, driver.ErrReleaseNotFound) + + seedRelease(t, cfg, releaseName, 1, common.StatusDeployed, applySSA) + require.NoError(t, c.InstallApp(t.Context(), true, resolved, nil, nil, false)) + assert.Equal(t, 1, historyLen(t, cfg, releaseName)) +} + +func TestInstallApp_CanceledContextDoesNotInstall(t *testing.T) { + t.Parallel() + + c, cfg, resolved, releaseName := memoryHelmApp(t, "canceled-app") + cleanupChartCache(t, c.chartCache) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := c.InstallApp(ctx, false, resolved, nil, nil, false) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + _, histErr := cfg.Releases.History(releaseName) + assert.ErrorIs(t, histErr, driver.ErrReleaseNotFound) +} + func memoryHelmApp(t *testing.T, project string) (*Client, *action.Configuration, *spec.ResolvedSpec, string) { t.Helper() diff --git a/internal/helm/prepare_test.go b/internal/helm/prepare_test.go index 4ff8eb6..d09ab9f 100644 --- a/internal/helm/prepare_test.go +++ b/internal/helm/prepare_test.go @@ -225,6 +225,28 @@ func TestPrepareRelease_DoesNotMutateHistory(t *testing.T) { assert.Same(t, second, history[1]) } +func TestPrepareRelease_NewestWithoutStatus(t *testing.T) { + t.Parallel() + + _, err := PrepareRelease([]*v1.Release{{ + Version: 3, + ApplyMethod: applySSA, + }}) + require.Error(t, err) + assert.ErrorContains(t, err, "newest revision 3 has no status") + assert.NotErrorIs(t, err, ErrReleasePending) + assert.NotErrorIs(t, err, ErrNoDeployedRevision) +} + +func TestPrepareReleaseFromHistory_RejectsUnsupportedReleaseType(t *testing.T) { + t.Parallel() + + _, err := prepareReleaseFromHistory([]release.Releaser{struct{ Name string }{Name: "nope"}}, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "failed to convert release history") + assert.ErrorContains(t, err, "unsupported helm release type") +} + func TestPrepareRelease_PendingIsWrappable(t *testing.T) { t.Parallel() diff --git a/internal/helm/release_test.go b/internal/helm/release_test.go new file mode 100644 index 0000000..cbc9a4c --- /dev/null +++ b/internal/helm/release_test.go @@ -0,0 +1,561 @@ +// Copyright 2026 The Deployah 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 helm + +import ( + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/release/common" + "helm.sh/helm/v4/pkg/storage" + "helm.sh/helm/v4/pkg/storage/driver" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" + + chart "helm.sh/helm/v4/pkg/chart/v2" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + v1 "helm.sh/helm/v4/pkg/release/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestListReleases_LatestManagedMatchesSelector(t *testing.T) { + t.Parallel() + + type listed struct { + version int + status common.Status + } + tests := []struct { + name string + selector string + want map[string]listed + }{ + { + name: "project filter", + selector: "deployah.dev/project=shop", + want: map[string]listed{ + "shop-production": {version: 2, status: common.StatusDeployed}, + "shop-staging": {version: 1, status: common.StatusFailed}, + }, + }, + { + name: "managed releases only", + want: map[string]listed{ + "shop-production": {version: 2, status: common.StatusDeployed}, + "shop-staging": {version: 1, status: common.StatusFailed}, + "other-production": {version: 1, status: common.StatusDeployed}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := seededReleaseList(t) + selector := labels.NewSelector() + if tt.selector != "" { + parsed, err := labels.Parse(tt.selector) + require.NoError(t, err) + selector = parsed + } + + got, err := c.ListReleases(t.Context(), selector) + require.NoError(t, err) + require.Len(t, got, len(tt.want)) + byName := map[string]*v1.Release{} + for _, rel := range got { + require.NotNil(t, rel) + byName[rel.Name] = rel + } + require.Len(t, byName, len(tt.want)) + for name, want := range tt.want { + rel, ok := byName[name] + require.Truef(t, ok, "missing release %s", name) + assert.Equal(t, want.version, rel.Version) + assert.Equal(t, want.status, rel.Info.Status) + } + assert.NotContains(t, byName, "plain-helm") + }) + } +} + +func TestGetRelease_RejectsInvalidEnvironment(t *testing.T) { + t.Parallel() + + c, _ := memoryReleaseClient(t) + _, err := c.GetRelease(t.Context(), "shop", "review/PR-1") + require.Error(t, err) + assert.ErrorContains(t, err, "lowercase DNS label") +} + +func TestGetRelease_EmptyEnvironmentUsesReleaseName(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + storeRelease(t, cfg, &v1.Release{ + Name: GenerateReleaseName("shop", ""), + Labels: managedLabels("shop", ""), + Info: &v1.Info{Status: common.StatusDeployed}, + }) + + got, err := c.GetRelease(t.Context(), "shop", "") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "shop", got.Name) +} + +func TestGetRelease_NotFound(t *testing.T) { + t.Parallel() + + c, _ := memoryReleaseClient(t) + _, err := c.GetRelease(t.Context(), "shop", "production") + require.Error(t, err) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} + +func TestGetRelease_ReturnsNewestRevision(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + name := GenerateReleaseName("shop", "review/pr-42") + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 1, + Manifest: "manifest: v1\n", + Labels: managedLabels("shop", "review"), + Info: &v1.Info{Status: common.StatusSuperseded}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 2, + Manifest: "manifest: v2\n", + Labels: managedLabels("shop", "review"), + Info: &v1.Info{Status: common.StatusFailed}, + }) + + got, err := c.GetRelease(t.Context(), "shop", "review/pr-42") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, name, got.Name) + assert.Equal(t, 2, got.Version) + assert.Equal(t, "manifest: v2\n", got.Manifest) + assert.Equal(t, common.StatusFailed, got.Info.Status) +} + +func TestGetReleaseHistory_RejectsInvalidInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + project string + environment string + want string + }{ + {name: "empty project", environment: "production", want: "project or environment cannot be empty"}, + {name: "empty environment", project: "shop", want: "project or environment cannot be empty"}, + {name: "invalid environment", project: "shop", environment: "review/PR-1", want: "lowercase DNS label"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c, _ := memoryReleaseClient(t) + _, err := c.GetReleaseHistory(t.Context(), tt.project, tt.environment) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func TestGetReleaseHistory_NotFound(t *testing.T) { + t.Parallel() + + c, _ := memoryReleaseClient(t) + _, err := c.GetReleaseHistory(t.Context(), "shop", "production") + require.Error(t, err) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} + +func TestGetReleaseHistory_ReturnsEveryRevision(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + name := GenerateReleaseName("shop", "production") + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 1, + Manifest: "manifest: v1\n", + Info: &v1.Info{Status: common.StatusSuperseded}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 2, + Manifest: "manifest: v2\n", + Info: &v1.Info{Status: common.StatusDeployed}, + }) + + got, err := c.GetReleaseHistory(t.Context(), "shop", "production") + require.NoError(t, err) + require.Len(t, got, 2) + byVersion := map[int]string{} + for _, rel := range got { + require.NotNil(t, rel) + byVersion[rel.Version] = rel.Manifest + } + assert.Equal(t, map[int]string{1: "manifest: v1\n", 2: "manifest: v2\n"}, byVersion) +} + +func TestDeleteRelease_RejectsInvalidInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + project string + environment string + want string + }{ + {name: "empty project", environment: "production", want: "project or environment cannot be empty"}, + {name: "empty environment", project: "shop", want: "project or environment cannot be empty"}, + {name: "invalid environment", project: "shop", environment: "review/PR-1", want: "lowercase DNS label"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c, _ := memoryReleaseClient(t) + err := c.DeleteRelease(t.Context(), tt.project, tt.environment, false) + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} + +func TestDeleteRelease_MissingReleaseSucceeds(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + require.NoError(t, c.DeleteRelease(t.Context(), "shop", "production", false)) + _, err := cfg.Releases.History(GenerateReleaseName("shop", "production")) + assert.ErrorIs(t, err, driver.ErrReleaseNotFound) +} + +func TestDeleteRelease_WaitStrategy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wait bool + wantStrategy kube.WaitStrategy + wantPropagation metav1.DeletionPropagation + }{ + { + name: "return after hooks", + wait: false, + wantStrategy: kube.HookOnlyStrategy, + wantPropagation: metav1.DeletePropagationBackground, + }, + { + name: "wait for foreground deletion", + wait: true, + wantStrategy: kube.LegacyStrategy, + wantPropagation: metav1.DeletePropagationForeground, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c, cfg, kubeClient := recordingReleaseClient(t) + name := GenerateReleaseName("shop", "production") + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 1, + Manifest: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n", + Info: &v1.Info{Status: common.StatusDeployed}, + }) + + require.NoError(t, c.DeleteRelease(t.Context(), "shop", "production", tt.wait)) + assert.Equal(t, tt.wantStrategy, kubeClient.strategy) + assert.Equal(t, tt.wantPropagation, kubeClient.propagation) + _, err := c.GetRelease(t.Context(), "shop", "production") + assert.ErrorIs(t, err, ErrReleaseNotFound) + }) + } +} + +func TestRollbackRelease_RejectsEmptyName(t *testing.T) { + t.Parallel() + + c, _ := memoryReleaseClient(t) + err := c.RollbackRelease(t.Context(), "", 1, time.Minute) + require.EqualError(t, err, "release name cannot be empty") +} + +func TestRollbackRelease_NotFound(t *testing.T) { + t.Parallel() + + c, _ := memoryReleaseClient(t) + err := c.RollbackRelease(t.Context(), "shop-production", 1, time.Minute) + require.Error(t, err) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} + +func TestRollbackRelease_MissingRevisionLeavesHistory(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + storeRelease(t, cfg, &v1.Release{ + Name: "shop-production", + Version: 1, + Info: &v1.Info{Status: common.StatusDeployed}, + }) + + err := c.RollbackRelease(t.Context(), "shop-production", 9, time.Minute) + require.Error(t, err) + assert.ErrorContains(t, err, "no 9 version") + assert.Equal(t, 1, historyLen(t, cfg, "shop-production")) +} + +func TestRollbackRelease_RestoresTargetManifest(t *testing.T) { + t.Parallel() + + c, cfg := memoryReleaseClient(t) + const name = "shop-production" + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 1, + Manifest: "manifest: v1\n", + Info: &v1.Info{Status: common.StatusSuperseded}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: name, + Version: 2, + Manifest: "manifest: v2\n", + Info: &v1.Info{Status: common.StatusDeployed}, + }) + + require.NoError(t, c.RollbackRelease(t.Context(), name, 1, time.Minute)) + + last, err := cfg.Releases.Last(name) + require.NoError(t, err) + got, err := releaserToV1(last) + require.NoError(t, err) + assert.Equal(t, 3, got.Version) + assert.Equal(t, "manifest: v1\n", got.Manifest) + assert.Equal(t, common.StatusDeployed, got.Info.Status) + + history, err := c.GetReleaseHistory(t.Context(), "shop", "production") + require.NoError(t, err) + require.Len(t, history, 3) + for _, rel := range history { + if rel.Version == 2 { + assert.Equal(t, common.StatusSuperseded, rel.Info.Status) + } + } +} + +func TestReleaseOperations_UnreachableCluster(t *testing.T) { + t.Parallel() + + cause := errors.New("api server unreachable") + + t.Run("list", func(t *testing.T) { + t.Parallel() + c := unreachableReleaseClient(t, cause) + _, err := c.ListReleases(t.Context(), labels.NewSelector()) + require.Error(t, err) + assert.ErrorIs(t, err, cause) + assert.NotErrorIs(t, err, ErrReleaseNotFound) + assert.ErrorContains(t, err, "helm list failed") + }) + + t.Run("get", func(t *testing.T) { + t.Parallel() + c := unreachableReleaseClient(t, cause) + _, err := c.GetRelease(t.Context(), "shop", "production") + require.Error(t, err) + assert.ErrorIs(t, err, cause) + assert.NotErrorIs(t, err, ErrReleaseNotFound) + assert.ErrorContains(t, err, "helm get failed") + }) + + t.Run("history", func(t *testing.T) { + t.Parallel() + c := unreachableReleaseClient(t, cause) + _, err := c.GetReleaseHistory(t.Context(), "shop", "production") + require.Error(t, err) + assert.ErrorIs(t, err, cause) + assert.NotErrorIs(t, err, ErrReleaseNotFound) + assert.ErrorContains(t, err, "helm history failed") + }) + + t.Run("delete", func(t *testing.T) { + t.Parallel() + c := unreachableReleaseClient(t, cause) + err := c.DeleteRelease(t.Context(), "shop", "production", false) + require.Error(t, err) + assert.ErrorIs(t, err, cause) + assert.NotErrorIs(t, err, ErrReleaseNotFound) + assert.ErrorContains(t, err, "helm uninstall failed") + }) + + t.Run("rollback", func(t *testing.T) { + t.Parallel() + c := unreachableReleaseClient(t, cause) + err := c.RollbackRelease(t.Context(), "shop-production", 1, time.Minute) + require.Error(t, err) + assert.ErrorIs(t, err, cause) + assert.NotErrorIs(t, err, ErrReleaseNotFound) + assert.ErrorContains(t, err, "helm rollback failed") + }) +} + +func memoryReleaseClient(t *testing.T) (*Client, *action.Configuration) { + t.Helper() + cfg := &action.Configuration{ + Releases: storage.Init(driver.NewMemory()), + KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard}, + } + return &Client{ + config: cfg, + namespace: "default", + timeout: time.Minute, + chartCache: NewChartCache(time.Hour), + }, cfg +} + +func unreachableReleaseClient(t *testing.T, cause error) *Client { + t.Helper() + c, cfg := memoryReleaseClient(t) + failing := &kubefake.FailingKubeClient{ConnectionError: cause} + failing.Out = io.Discard + cfg.KubeClient = failing + return c +} + +type recordingKube struct { + *kubefake.FailingKubeClient + strategy kube.WaitStrategy + propagation metav1.DeletionPropagation +} + +func (r *recordingKube) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { + r.strategy = ws + return r.FailingKubeClient.GetWaiter(ws) +} + +func (r *recordingKube) GetWaiterWithOptions(ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) { + r.strategy = ws + return r.FailingKubeClient.GetWaiterWithOptions(ws, opts...) +} + +func (r *recordingKube) Delete(resources kube.ResourceList, propagation metav1.DeletionPropagation) (*kube.Result, []error) { + r.propagation = propagation + return r.FailingKubeClient.Delete(resources, propagation) +} + +func recordingReleaseClient(t *testing.T) (*Client, *action.Configuration, *recordingKube) { + t.Helper() + c, cfg := memoryReleaseClient(t) + // Mapping is set because Helm logs the kind while deleting. + // Client stays nil so the ownership check treats the object as + // part of this release. + var resources kube.ResourceList + resources.Append(&resource.Info{ + Name: "app", + Namespace: "default", + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + GroupVersionKind: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + Scope: meta.RESTScopeNamespace, + }, + }) + failing := &kubefake.FailingKubeClient{DummyResources: resources} + failing.Out = io.Discard + kubeClient := &recordingKube{FailingKubeClient: failing} + cfg.KubeClient = kubeClient + return c, cfg, kubeClient +} + +func seededReleaseList(t *testing.T) *Client { + t.Helper() + c, cfg := memoryReleaseClient(t) + storeRelease(t, cfg, &v1.Release{ + Name: "shop-production", + Version: 1, + Labels: managedLabels("shop", "production"), + Info: &v1.Info{Status: common.StatusSuperseded}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: "shop-production", + Version: 2, + Labels: managedLabels("shop", "production"), + Info: &v1.Info{Status: common.StatusDeployed}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: "shop-staging", + Version: 1, + Labels: managedLabels("shop", "staging"), + Info: &v1.Info{Status: common.StatusFailed}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: "other-production", + Version: 1, + Labels: managedLabels("other", "production"), + Info: &v1.Info{Status: common.StatusDeployed}, + }) + storeRelease(t, cfg, &v1.Release{ + Name: "plain-helm", + Version: 1, + Labels: map[string]string{"managed-by": "helm"}, + Info: &v1.Info{Status: common.StatusDeployed}, + }) + return c +} + +func managedLabels(project, environment string) map[string]string { + return map[string]string{ + "deployah.dev/project": project, + "deployah.dev/environment": environment, + "deployah.dev/managed-by": "deployah", + } +} + +func storeRelease(t *testing.T, cfg *action.Configuration, rel *v1.Release) { + t.Helper() + if rel.Info == nil { + rel.Info = &v1.Info{} + } + if rel.Info.Status == "" { + rel.Info.Status = common.StatusDeployed + } + if rel.Info.FirstDeployed.IsZero() { + rel.Info.FirstDeployed = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + rel.Info.LastDeployed = rel.Info.FirstDeployed + } + if rel.Chart == nil { + rel.Chart = &chart.Chart{ + Metadata: &chart.Metadata{APIVersion: "v2", Name: "app", Version: "0.1.0"}, + } + } + if rel.Namespace == "" { + rel.Namespace = "default" + } + require.NoError(t, cfg.Releases.Create(rel)) +} diff --git a/internal/helm/render_test.go b/internal/helm/render_test.go index 6b5efbd..123a98e 100644 --- a/internal/helm/render_test.go +++ b/internal/helm/render_test.go @@ -119,3 +119,33 @@ func TestRestoreCapabilitiesForDryRun(t *testing.T) { assert.Same(t, originalReleases, cfg.Releases, "Releases must be restored") assert.Same(t, originalCapabilities, cfg.Capabilities, "Capabilities must be restored for the Install dry-run path") } + +func TestWithDebug_KeepsChartDirectory(t *testing.T) { + t.Parallel() + + _, _, resolved, _ := memoryHelmApp(t, "debug-keep") + c, err := NewClient(WithDebug(true), WithStorageDriver("memory"), WithNamespace("default")) + require.NoError(t, err) + cleanupChartCache(t, c.chartCache) + + _, chartPath, cleanup, err := c.prepareAndLoadChart(t.Context(), resolved, nil) + require.NoError(t, err) + t.Cleanup(func() { removeChartDir(t, chartPath) }) + cleanup() + assert.DirExists(t, chartPath) +} + +func TestPrepareAndLoadChart_CleanupRemovesCopy(t *testing.T) { + t.Parallel() + + _, _, resolved, _ := memoryHelmApp(t, "debug-off") + c, err := NewClient(WithDebug(false), WithStorageDriver("memory"), WithNamespace("default")) + require.NoError(t, err) + cleanupChartCache(t, c.chartCache) + + _, chartPath, cleanup, err := c.prepareAndLoadChart(t.Context(), resolved, nil) + require.NoError(t, err) + t.Cleanup(func() { removeChartDir(t, chartPath) }) + cleanup() + assert.NoDirExists(t, chartPath) +}