diff --git a/internal/storage/driver/cfgmaps.go b/internal/storage/driver/cfgmaps.go new file mode 100644 index 000000000..73076fdfe --- /dev/null +++ b/internal/storage/driver/cfgmaps.go @@ -0,0 +1,290 @@ +/* +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 driver + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/validation" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + "helm.sh/helm/v4/internal/logging" + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" +) + +var _ Driver = (*ConfigMaps)(nil) + +// ConfigMapsDriverName is the string name of the driver. +const ConfigMapsDriverName = "ConfigMap" + +// ConfigMaps is a wrapper around an implementation of a kubernetes +// ConfigMapsInterface. +type ConfigMaps struct { + impl corev1.ConfigMapInterface + + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +// NewConfigMaps initializes a new ConfigMaps wrapping an implementation of +// the kubernetes ConfigMapsInterface. +func NewConfigMaps(impl corev1.ConfigMapInterface) *ConfigMaps { + c := &ConfigMaps{ + impl: impl, + } + c.SetLogger(slog.Default().Handler()) + return c +} + +// Name returns the name of the driver. +func (cfgmaps *ConfigMaps) Name() string { + return ConfigMapsDriverName +} + +// Get fetches the release named by key. The corresponding release is returned +// or error if not found. +func (cfgmaps *ConfigMaps) Get(key string) (release.Releaser, error) { + // fetch the configmap holding the release named by key + obj, err := cfgmaps.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ErrReleaseNotFound + } + + cfgmaps.Logger().Debug("failed to get release", slog.String("key", key), slog.Any("error", err)) + return nil, err + } + // found the configmap, decode the base64 data string + r, err := decodeRelease(obj.Data["release"]) + if err != nil { + cfgmaps.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) + return nil, err + } + r.Labels = filterSystemLabels(obj.Labels) + // return the release object + return r, nil +} + +// List fetches all releases and returns the list releases such +// that filter(release) == true. An error is returned if the +// configmap fails to retrieve the releases. +func (cfgmaps *ConfigMaps) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { + lsel := kblabels.Set{"owner": "helm"}.AsSelector() + opts := metav1.ListOptions{LabelSelector: lsel.String()} + + list, err := cfgmaps.impl.List(context.Background(), opts) + if err != nil { + cfgmaps.Logger().Debug("failed to list releases", slog.Any("error", err)) + return nil, err + } + + var results []release.Releaser + + // iterate over the configmaps object list + // and decode each release + for _, item := range list.Items { + rls, err := decodeRelease(item.Data["release"]) + if err != nil { + cfgmaps.Logger().Debug("failed to decode release", slog.Any("item", item), slog.Any("error", err)) + continue + } + + rls.Labels = item.Labels + + if filter(rls) { + results = append(results, rls) + } + } + return results, nil +} + +// Query fetches all releases that match the provided map of labels. +// An error is returned if the configmap fails to retrieve the releases. +func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]release.Releaser, error) { + ls := kblabels.Set{} + for k, v := range labels { + if errs := validation.IsValidLabelValue(v); len(errs) != 0 { + return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + } + ls[k] = v + } + + opts := metav1.ListOptions{LabelSelector: ls.AsSelector().String()} + + list, err := cfgmaps.impl.List(context.Background(), opts) + if err != nil { + cfgmaps.Logger().Debug("failed to query with labels", slog.Any("error", err)) + return nil, err + } + + if len(list.Items) == 0 { + return nil, ErrReleaseNotFound + } + + var results []release.Releaser + for _, item := range list.Items { + rls, err := decodeRelease(item.Data["release"]) + if err != nil { + cfgmaps.Logger().Debug("failed to decode release", slog.Any("error", err)) + continue + } + rls.Labels = item.Labels + results = append(results, rls) + } + return results, nil +} + +// Create creates a new ConfigMap holding the release. If the +// ConfigMap already exists, ErrReleaseExists is returned. +func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { + // set labels for configmaps object meta data + var lbs labels + + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + + lbs.init() + lbs.fromMap(rac.Labels()) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) + + rel, err := releaserToV1Release(rls) + if err != nil { + return err + } + + // create a new configmap to hold the release + obj, err := newConfigMapsObject(key, rel, lbs) + if err != nil { + cfgmaps.Logger().Debug("failed to encode release", slog.String("name", rac.Name()), slog.Any("error", err)) + return err + } + // push the configmap object out into the kubiverse + if _, err := cfgmaps.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return ErrReleaseExists + } + + cfgmaps.Logger().Debug("failed to create release", slog.Any("error", err)) + return err + } + return nil +} + +// Update updates the ConfigMap holding the release. If not found +// the ConfigMap is created to hold the release. +func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { + // set labels for configmaps object meta data + var lbs labels + + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) + + // create a new configmap object to hold the release + obj, err := newConfigMapsObject(key, rls, lbs) + if err != nil { + cfgmaps.Logger().Debug( + "failed to encode release", + slog.String("name", rls.Name), + slog.Any("error", err), + ) + return err + } + // push the configmap object out into the kubiverse + _, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) + if err != nil { + cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err)) + return err + } + return nil +} + +// Delete deletes the ConfigMap holding the release named by key. +func (cfgmaps *ConfigMaps) Delete(key string) (rls release.Releaser, err error) { + // fetch the release to check existence + if rls, err = cfgmaps.Get(key); err != nil { + return nil, err + } + // delete the release + if err := cfgmaps.impl.Delete(context.Background(), key, metav1.DeleteOptions{}); err != nil { + return rls, err + } + return rls, nil +} + +// newConfigMapsObject constructs a kubernetes ConfigMap object +// to store a release. Each configmap data entry is the base64 +// encoded gzipped string of a release. +// +// The following labels are used within each configmap: +// +// "modifiedAt" - timestamp indicating when this configmap was last modified. (set in Update) +// "createdAt" - timestamp indicating when this configmap was created. (set in Create) +// "version" - version of the release. +// "status" - status of the release (see pkg/release/status.go for variants) +// "owner" - owner of the configmap, currently "helm". +// "name" - name of the release. +// "helm.sh/release-version" - release object schema version, see releaseVersionLabel. +func newConfigMapsObject(key string, rls *rspb.Release, lbs labels) (*v1.ConfigMap, error) { + const owner = "helm" + + // encode the release + s, err := encodeRelease(rls) + if err != nil { + return nil, err + } + + if lbs == nil { + lbs.init() + } + + // apply custom labels + lbs.fromMap(rls.Labels) + + // apply labels + lbs.set("name", rls.Name) + lbs.set("owner", owner) + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + lbs.set(releaseVersionLabel, releaseVersion) + + // create and return configmap object + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key, + Labels: lbs.toMap(), + }, + Data: map[string]string{"release": s}, + }, nil +} diff --git a/internal/storage/driver/cfgmaps_test.go b/internal/storage/driver/cfgmaps_test.go new file mode 100644 index 000000000..5b97e8213 --- /dev/null +++ b/internal/storage/driver/cfgmaps_test.go @@ -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 driver + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" +) + +func TestConfigMapName(t *testing.T) { + c := newTestFixtureCfgMaps(t) + assert.Equal(t, ConfigMapsDriverName, c.Name(), "Expected name to be %q, got %q", ConfigMapsDriverName, c.Name()) +} + +func TestConfigMapGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // get release with key + got, err := cfgmaps.Get(key) + require.NoError(t, err, "Failed to get release") + // compare fetched release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestUncompressedConfigMapGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + // Create a test fixture which contains an uncompressed release + cfgmap, err := newConfigMapsObject(key, rel, nil) + require.NoError(t, err, "Failed to create configmap") + b, err := json.Marshal(rel) + require.NoError(t, err, "Failed to marshal release") + cfgmap.Data["release"] = base64.StdEncoding.EncodeToString(b) + var mock MockConfigMapsInterface + mock.objects = map[string]*v1.ConfigMap{key: cfgmap} + cfgmaps := NewConfigMaps(&mock) + + // get release with key + got, err := cfgmaps.Get(key) + require.NoError(t, err, "Failed to get release") + // compare fetched release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestConfigMapReleaseVersionLabel(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + cfgmap, err := newConfigMapsObject(key, rel, nil) + require.NoError(t, err, "Failed to create configmap") + + // ConfigMaps have no Type field, so the release schema version is a label. + assert.Equal(t, releaseVersion, cfgmap.Labels[releaseVersionLabel]) + + // The label is a system label and must not leak into the release's own labels. + assert.True(t, isSystemLabel(releaseVersionLabel)) + assert.NotContains(t, filterSystemLabels(cfgmap.Labels), releaseVersionLabel) +} + +func convertReleaserToV1(t *testing.T, rel release.Releaser) *rspb.Release { + t.Helper() + switch r := rel.(type) { + case rspb.Release: + return &r + case *rspb.Release: + return r + case nil: + return nil + } + + t.Fatalf("Unsupported release type: %T", rel) + return nil +} + +func TestConfigMapList(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), + }...) + + // list all deleted releases + del, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled + }) + // check + require.NoError(t, err, "Failed to list deleted") + assert.Len(t, del, 2, "Expected 2 deleted") + + // list all deployed releases + dpl, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed + }) + // check + require.NoError(t, err, "Failed to list deployed") + assert.Len(t, dpl, 2, "Expected 2 deployed") + + // list all superseded releases + ssd, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded + }) + // check + require.NoError(t, err, "Failed to list superseded") + assert.Len(t, ssd, 2, "Expected 2 superseded") + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := convertReleaserToV1(t, ssd[0]) + require.Contains(t, rls.Labels, "name", "Expected 'name' label in results, actual %v", rls.Labels) + require.Contains(t, rls.Labels, "key1", "Expected 'key1' label in results, actual %v", rls.Labels) +} + +func TestConfigMapQuery(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), + }...) + + rls, err := cfgmaps.Query(map[string]string{"status": "deployed"}) + require.NoError(t, err, "Failed to query") + assert.Len(t, rls, 2, "Expected 2 results") + + _, err = cfgmaps.Query(map[string]string{"name": "notExist"}) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} + +func TestConfigMapCreate(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t) + + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + // store the release in a configmap + require.NoErrorf(t, cfgmaps.Create(key, rel), "Failed to create release with key %q", key) + + // get the release back + got, err := cfgmaps.Get(key) + require.NoError(t, err, "Failed to get release with key %q", key) + + // compare created release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestConfigMapUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // modify release status code + rel.Info.Status = common.StatusSuperseded + + // perform the update + require.NoErrorf(t, cfgmaps.Update(key, rel), "Failed to update release") + + // fetch the updated release + goti, err := cfgmaps.Get(key) + require.NoError(t, err, "Failed to get release with key %q", key) + got := convertReleaserToV1(t, goti) + + // check release has actually been updated by comparing modified fields + assert.Equal(t, got.Info.Status, rel.Info.Status, "Expected status %s, got status %s", rel.Info.Status.String(), got.Info.Status.String()) +} + +func TestConfigMapDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // perform the delete on a non-existent release + _, err := cfgmaps.Delete("nonexistent") + require.ErrorIs(t, err, ErrReleaseNotFound) + + // perform the delete + rls, err := cfgmaps.Delete(key) + require.NoError(t, err, "Failed to delete release with key %q", key) + assert.Equalf(t, rel, rls, "Expected {%v}, got {%v}", rel, rls) + _, err = cfgmaps.Get(key) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} diff --git a/internal/storage/driver/driver.go b/internal/storage/driver/driver.go new file mode 100644 index 000000000..762fb3d86 --- /dev/null +++ b/internal/storage/driver/driver.go @@ -0,0 +1,120 @@ +/* +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 driver + +import ( + "errors" + "fmt" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" +) + +var ( + // ErrReleaseNotFound indicates that a release is not found. + ErrReleaseNotFound = errors.New("release: not found") + // ErrReleaseExists indicates that a release already exists. + ErrReleaseExists = errors.New("release: already exists") + // ErrInvalidKey indicates that a release key could not be parsed. + ErrInvalidKey = errors.New("release: invalid key") + // ErrNoDeployedReleases indicates that there are no releases with the given key in the deployed state + ErrNoDeployedReleases = errors.New("has no deployed releases") +) + +// StorageDriverError records an error and the release name that caused it +type StorageDriverError struct { + ReleaseName string + Err error +} + +func (e *StorageDriverError) Error() string { + return fmt.Sprintf("%q %s", e.ReleaseName, e.Err.Error()) +} + +func (e *StorageDriverError) Unwrap() error { return e.Err } + +func NewErrNoDeployedReleases(releaseName string) error { + return &StorageDriverError{ + ReleaseName: releaseName, + Err: ErrNoDeployedReleases, + } +} + +// Creator is the interface that wraps the Create method. +// +// Create stores the release or returns ErrReleaseExists +// if an identical release already exists. +type Creator interface { + Create(key string, rls release.Releaser) error +} + +// Updator is the interface that wraps the Update method. +// +// Update updates an existing release or returns +// ErrReleaseNotFound if the release does not exist. +type Updator interface { + Update(key string, rls release.Releaser) error +} + +// Deletor is the interface that wraps the Delete method. +// +// Delete deletes the release named by key or returns +// ErrReleaseNotFound if the release does not exist. +type Deletor interface { + Delete(key string) (release.Releaser, error) +} + +// Queryor is the interface that wraps the Get and List methods. +// +// Get returns the release named by key or returns ErrReleaseNotFound +// if the release does not exist. +// +// List returns the set of all releases that satisfy the filter predicate. +// +// Query returns the set of all releases that match the provided label set. +type Queryor interface { + Get(key string) (release.Releaser, error) + List(filter func(release.Releaser) bool) ([]release.Releaser, error) + Query(labels map[string]string) ([]release.Releaser, error) +} + +// Driver is the interface composed of Creator, Updator, Deletor, and Queryor +// interfaces. It defines the behavior for storing, updating, deleted, +// and retrieving Helm releases from some underlying storage mechanism, +// e.g. memory, configmaps. +type Driver interface { + Creator + Updator + Deletor + Queryor + Name() string +} + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} diff --git a/internal/storage/driver/labels.go b/internal/storage/driver/labels.go new file mode 100644 index 000000000..be8262b81 --- /dev/null +++ b/internal/storage/driver/labels.go @@ -0,0 +1,48 @@ +/* +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 driver + +// labels is a map of key value pairs to be included as metadata in a configmap object. +type labels map[string]string + +func (lbs *labels) init() { *lbs = labels(make(map[string]string)) } +func (lbs labels) get(key string) string { return lbs[key] } +func (lbs labels) set(key, val string) { lbs[key] = val } + +func (lbs labels) keys() (ls []string) { + for key := range lbs { + ls = append(ls, key) + } + return ls +} + +func (lbs labels) match(set labels) bool { + for _, key := range set.keys() { + if lbs.get(key) != set.get(key) { + return false + } + } + return true +} + +func (lbs labels) toMap() map[string]string { return lbs } + +func (lbs *labels) fromMap(kvs map[string]string) { + for k, v := range kvs { + lbs.set(k, v) + } +} diff --git a/internal/storage/driver/labels_test.go b/internal/storage/driver/labels_test.go new file mode 100644 index 000000000..81c2c5149 --- /dev/null +++ b/internal/storage/driver/labels_test.go @@ -0,0 +1,55 @@ +/* +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 driver + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLabelsMatch(t *testing.T) { + tests := []struct { + desc string + set1 labels + set2 labels + expect bool + }{ + { + "equal labels sets", + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + true, + }, + { + "disjoint label sets", + labels(map[string]string{"KEY_C": "VAL_C", "KEY_D": "VAL_D"}), + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + if tt.expect { + require.True(t, tt.set1.match(tt.set2)) + } else { + require.False(t, tt.set1.match(tt.set2)) + } + }) + } +} diff --git a/internal/storage/driver/memory.go b/internal/storage/driver/memory.go new file mode 100644 index 000000000..7ea4a014a --- /dev/null +++ b/internal/storage/driver/memory.go @@ -0,0 +1,255 @@ +/* +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 driver + +import ( + "log/slog" + "strconv" + "strings" + "sync" + + "helm.sh/helm/v4/internal/logging" + "helm.sh/helm/v4/pkg/release" +) + +var _ Driver = (*Memory)(nil) + +const ( + // MemoryDriverName is the string name of this driver. + MemoryDriverName = "Memory" + + defaultNamespace = "default" +) + +// A map of release names to list of release records +type memReleases map[string]records + +// Memory is the in-memory storage driver implementation. +type Memory struct { + sync.RWMutex + namespace string + // A map of namespaces to releases + cache map[string]memReleases + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +// NewMemory initializes a new memory driver. +func NewMemory() *Memory { + m := &Memory{cache: map[string]memReleases{}, namespace: "default"} + m.SetLogger(slog.Default().Handler()) + return m +} + +// SetNamespace sets a specific namespace in which releases will be accessed. +// An empty string indicates all namespaces (for the list operation) +func (mem *Memory) SetNamespace(ns string) { + mem.namespace = ns +} + +// Name returns the name of the driver. +func (mem *Memory) Name() string { + return MemoryDriverName +} + +// Get returns the release named by key or returns ErrReleaseNotFound. +func (mem *Memory) Get(key string) (release.Releaser, error) { + defer unlock(mem.rlock()) + + keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") + switch elems := strings.Split(keyWithoutPrefix, ".v"); len(elems) { + case 2: + name, ver := elems[0], elems[1] + if _, err := strconv.Atoi(ver); err != nil { + return nil, ErrInvalidKey + } + if recs, ok := mem.cache[mem.namespace][name]; ok { + if r := recs.Get(key); r != nil { + return r.rls, nil + } + } + return nil, ErrReleaseNotFound + default: + return nil, ErrInvalidKey + } +} + +// List returns the list of all releases such that filter(release) == true +func (mem *Memory) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { + defer unlock(mem.rlock()) + + var ls []release.Releaser + for namespace := range mem.cache { + if mem.namespace != "" { + // Should only list releases of this namespace + namespace = mem.namespace + } + for _, recs := range mem.cache[namespace] { + recs.Iter(func(_ int, rec *record) bool { + if filter(rec.rls) { + ls = append(ls, rec.rls) + } + return true + }) + } + if mem.namespace != "" { + // Should only list releases of this namespace + break + } + } + return ls, nil +} + +// Query returns the set of releases that match the provided set of labels +func (mem *Memory) Query(keyvals map[string]string) ([]release.Releaser, error) { + defer unlock(mem.rlock()) + + var lbs labels + + lbs.init() + lbs.fromMap(keyvals) + + var ls []release.Releaser + for namespace := range mem.cache { + if mem.namespace != "" { + // Should only query releases of this namespace + namespace = mem.namespace + } + for _, recs := range mem.cache[namespace] { + recs.Iter(func(_ int, rec *record) bool { + // A query for a release name that doesn't exist (has been deleted) + // can cause rec to be nil. + if rec == nil { + return false + } + if rec.lbs.match(lbs) { + ls = append(ls, rec.rls) + } + return true + }) + } + if mem.namespace != "" { + // Should only query releases of this namespace + break + } + } + + if len(ls) == 0 { + return nil, ErrReleaseNotFound + } + + return ls, nil +} + +// Create creates a new release or returns ErrReleaseExists. +func (mem *Memory) Create(key string, rel release.Releaser) error { + defer unlock(mem.wlock()) + + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + // For backwards compatibility, we protect against an unset namespace + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + mem.SetNamespace(namespace) + + if _, ok := mem.cache[namespace]; !ok { + mem.cache[namespace] = memReleases{} + } + + if recs, ok := mem.cache[namespace][rls.Name]; ok { + if err := recs.Add(newRecord(key, rls)); err != nil { + return err + } + mem.cache[namespace][rls.Name] = recs + return nil + } + mem.cache[namespace][rls.Name] = records{newRecord(key, rls)} + return nil +} + +// Update updates a release or returns ErrReleaseNotFound. +func (mem *Memory) Update(key string, rel release.Releaser) error { + defer unlock(mem.wlock()) + + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + + // For backwards compatibility, we protect against an unset namespace + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + mem.SetNamespace(namespace) + + if _, ok := mem.cache[namespace]; ok { + if rs, ok := mem.cache[namespace][rls.Name]; ok && rs.Exists(key) { + rs.Replace(key, newRecord(key, rls)) + return nil + } + } + return ErrReleaseNotFound +} + +// Delete deletes a release or returns ErrReleaseNotFound. +func (mem *Memory) Delete(key string) (release.Releaser, error) { + defer unlock(mem.wlock()) + + keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") + elems := strings.Split(keyWithoutPrefix, ".v") + + if len(elems) != 2 { + return nil, ErrInvalidKey + } + + name, ver := elems[0], elems[1] + if _, err := strconv.Atoi(ver); err != nil { + return nil, ErrInvalidKey + } + if _, ok := mem.cache[mem.namespace]; ok { + if recs, ok := mem.cache[mem.namespace][name]; ok { + if r := recs.Remove(key); r != nil { + // recs.Remove changes the slice reference, so we have to re-assign it. + mem.cache[mem.namespace][name] = recs + return r.rls, nil + } + } + } + return nil, ErrReleaseNotFound +} + +// wlock locks mem for writing +func (mem *Memory) wlock() func() { + mem.Lock() + return func() { mem.Unlock() } +} + +// rlock locks mem for reading +func (mem *Memory) rlock() func() { + mem.RLock() + return func() { mem.RUnlock() } +} + +// unlock calls fn which reverses a mem.rlock or mem.wlock. e.g: +// ```defer unlock(mem.rlock())```, locks mem for reading at the +// call point of defer and unlocks upon exiting the block. +func unlock(fn func()) { fn() } diff --git a/internal/storage/driver/memory_test.go b/internal/storage/driver/memory_test.go new file mode 100644 index 000000000..82f605e98 --- /dev/null +++ b/internal/storage/driver/memory_test.go @@ -0,0 +1,266 @@ +/* +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 driver + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" +) + +func TestMemoryName(t *testing.T) { + mem := NewMemory() + assert.Equalf(t, MemoryDriverName, mem.Name(), "Expected name to be %q, got %q", MemoryDriverName, mem.Name()) +} + +func TestMemoryCreate(t *testing.T) { + tests := []struct { + desc string + rls *rspb.Release + err bool + }{ + { + "create should succeed", + releaseStub("rls-c", 1, "default", common.StatusDeployed), + false, + }, + { + "create should fail (release already exists)", + releaseStub("rls-a", 1, "default", common.StatusDeployed), + true, + }, + { + "create in namespace should succeed", + releaseStub("rls-a", 1, "mynamespace", common.StatusDeployed), + false, + }, + { + "create in other namespace should fail (release already exists)", + releaseStub("rls-c", 1, "mynamespace", common.StatusDeployed), + true, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + key := testKey(tt.rls.Name, tt.rls.Version) + rls := tt.rls + err := ts.Create(key, rls) + + if tt.err { + require.Error(t, err, "Did not get expected error for %q\n", tt.desc) + } else { + require.NoError(t, err, "failed to create %q", tt.desc) + } + } +} + +func TestMemoryGet(t *testing.T) { + tests := []struct { + desc string + key string + namespace string + err bool + }{ + {"release key should exist", "rls-a.v1", "default", false}, + {"release key should not exist", "rls-a.v5", "default", true}, + {"release key in namespace should exist", "rls-c.v1", "mynamespace", false}, + {"release key in namespace should not exist", "rls-a.v1", "mynamespace", true}, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + ts.SetNamespace(tt.namespace) + _, err := ts.Get(tt.key) + if tt.err { + require.Error(t, err, "Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } else { + require.NoError(t, err, "Failed %q to get '%s'", tt.desc, tt.key) + } + } +} + +func TestMemoryList(t *testing.T) { + ts := tsFixtureMemory(t) + ts.SetNamespace("default") + + // list all deployed releases + dpl, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed + }) + // check + require.NoError(t, err, "Failed to list deployed releases") + assert.Len(t, dpl, 2, "Expected 2 deployed") + + // list all superseded releases + ssd, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded + }) + // check + require.NoError(t, err, "Failed to list superseded releases") + assert.Len(t, ssd, 6, "Expected 6 superseded") + + // list all deleted releases + del, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled + }) + // check + require.NoError(t, err, "Failed to list deleted releases") + assert.Empty(t, del, "Expected 0 deleted, got %d", len(del)) +} + +func TestMemoryQuery(t *testing.T) { + tests := []struct { + desc string + xlen int + namespace string + lbs map[string]string + }{ + { + "should be 2 query results", + 2, + "default", + map[string]string{"status": "deployed"}, + }, + { + "should be 1 query result", + 1, + "mynamespace", + map[string]string{"status": "deployed"}, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + ts.SetNamespace(tt.namespace) + l, err := ts.Query(tt.lbs) + require.NoError(t, err, "Failed to query") + + require.Equal(t, len(l), tt.xlen, "Expected %d results, actual %d\n", tt.xlen, len(l)) + } +} + +func TestMemoryUpdate(t *testing.T) { + tests := []struct { + desc string + key string + rls *rspb.Release + err bool + }{ + { + "update release status", + "rls-a.v4", + releaseStub("rls-a", 4, "default", common.StatusSuperseded), + false, + }, + { + "update release does not exist", + "rls-c.v1", + releaseStub("rls-c", 1, "default", common.StatusUninstalled), + true, + }, + { + "update release status in namespace", + "rls-c.v4", + releaseStub("rls-c", 4, "mynamespace", common.StatusSuperseded), + false, + }, + { + "update release in namespace does not exist", + "rls-a.v1", + releaseStub("rls-a", 1, "mynamespace", common.StatusUninstalled), + true, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + err := ts.Update(tt.key, tt.rls) + + if tt.err { + require.Error(t, err, "Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } else { + require.NoError(t, err, "Failed %q", tt.desc) + + ts.SetNamespace(tt.rls.Namespace) + + r, err := ts.Get(tt.key) + require.NoError(t, err, "Failed to get") + require.Equalf(t, r, tt.rls, "Expected %v, actual %v\n", tt.rls, r) + } + } +} + +func TestMemoryDelete(t *testing.T) { + tests := []struct { + desc string + key string + namespace string + err bool + }{ + {"release key should exist", "rls-a.v4", "default", false}, + {"release key should not exist", "rls-a.v5", "default", true}, + {"release key from other namespace should not exist", "rls-c.v4", "default", true}, + {"release key from namespace should exist", "rls-c.v4", "mynamespace", false}, + {"release key from namespace should not exist", "rls-c.v5", "mynamespace", true}, + {"release key from namespace2 should not exist", "rls-a.v4", "mynamespace", true}, + } + + ts := tsFixtureMemory(t) + ts.SetNamespace("") + start, err := ts.Query(map[string]string{"status": "deployed"}) + require.NoError(t, err, "Query failed") + startLen := len(start) + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ts.SetNamespace(tt.namespace) + + rel, err := ts.Delete(tt.key) + if tt.err { + require.Errorf(t, err, "Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } else { + require.NoErrorf(t, err, "Failed %q to get '%s'", tt.desc, tt.key) + rls := convertReleaserToV1(t, rel) + require.Equalf(t, tt.key, fmt.Sprintf("%s.v%d", rls.Name, rls.Version), "Asked for delete on %s, but deleted %d", tt.key, rls.Version) + } + _, err = ts.Get(tt.key) + require.Error(t, err, "Expected an error when asking for a deleted key") + }) + } + + // Make sure that the deleted records are gone. + ts.SetNamespace("") + end, err := ts.Query(map[string]string{"status": "deployed"}) + require.NoError(t, err, "Query failed") + + if !assert.Len(t, end, startLen-2) { + for _, ee := range end { + rac, err := release.NewAccessor(ee) + require.NoError(t, err, "unable to get release accessor") + t.Logf("Name: %s, Version: %d", rac.Name(), rac.Version()) + } + } +} diff --git a/internal/storage/driver/mock_test.go b/internal/storage/driver/mock_test.go new file mode 100644 index 000000000..a5eff0fd1 --- /dev/null +++ b/internal/storage/driver/mock_test.go @@ -0,0 +1,267 @@ +/* +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 driver + +import ( + "context" + "fmt" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release/common" +) + +func releaseStub(name string, vers int, namespace string, status common.Status) *rspb.Release { + return &rspb.Release{ + Name: name, + Version: vers, + Namespace: namespace, + Info: &rspb.Info{Status: status}, + Labels: map[string]string{ + "key1": "val1", + "key2": "val2", + }, + } +} + +func testKey(name string, vers int) string { + return fmt.Sprintf("%s.v%d", name, vers) +} + +func tsFixtureMemory(t *testing.T) *Memory { + t.Helper() + hs := []*rspb.Release{ + // rls-a + releaseStub("rls-a", 4, "default", common.StatusDeployed), + releaseStub("rls-a", 1, "default", common.StatusSuperseded), + releaseStub("rls-a", 3, "default", common.StatusSuperseded), + releaseStub("rls-a", 2, "default", common.StatusSuperseded), + // rls-b + releaseStub("rls-b", 4, "default", common.StatusDeployed), + releaseStub("rls-b", 1, "default", common.StatusSuperseded), + releaseStub("rls-b", 3, "default", common.StatusSuperseded), + releaseStub("rls-b", 2, "default", common.StatusSuperseded), + // rls-c in other namespace + releaseStub("rls-c", 4, "mynamespace", common.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 3, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 2, "mynamespace", common.StatusSuperseded), + } + + mem := NewMemory() + for _, tt := range hs { + require.NoError(t, mem.Create(testKey(tt.Name, tt.Version), tt), "Test setup failed to create") + } + return mem +} + +// newTestFixtureCfgMaps initializes a MockConfigMapsInterface. +// ConfigMaps are created for each release provided. +func newTestFixtureCfgMaps(t *testing.T, releases ...*rspb.Release) *ConfigMaps { + t.Helper() + var mock MockConfigMapsInterface + mock.Init(t, releases...) + + return NewConfigMaps(&mock) +} + +// MockConfigMapsInterface mocks a kubernetes ConfigMapsInterface +type MockConfigMapsInterface struct { + corev1.ConfigMapInterface + + objects map[string]*v1.ConfigMap +} + +// Init initializes the MockConfigMapsInterface with the set of releases. +func (mock *MockConfigMapsInterface) Init(t *testing.T, releases ...*rspb.Release) { + t.Helper() + mock.objects = map[string]*v1.ConfigMap{} + + for _, rls := range releases { + objkey := testKey(rls.Name, rls.Version) + + cfgmap, err := newConfigMapsObject(objkey, rls, nil) + require.NoError(t, err, "Failed to create configmap") + mock.objects[objkey] = cfgmap + } +} + +// Get returns the ConfigMap by name. +func (mock *MockConfigMapsInterface) Get(_ context.Context, name string, _ metav1.GetOptions) (*v1.ConfigMap, error) { + object, ok := mock.objects[name] + if !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + return object, nil +} + +// List returns all ConfigMaps. +func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.ConfigMapList, error) { + var list v1.ConfigMapList + + labelSelector, err := kblabels.Parse(opts.LabelSelector) + if err != nil { + return nil, err + } + + for _, cfgmap := range mock.objects { + if labelSelector.Matches(kblabels.Set(cfgmap.Labels)) { + list.Items = append(list.Items, *cfgmap) + } + } + return &list, nil +} + +// Create creates a new ConfigMap. +func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.CreateOptions) (*v1.ConfigMap, error) { + name := cfgmap.Name + if object, ok := mock.objects[name]; ok { + return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) + } + mock.objects[name] = cfgmap + return cfgmap, nil +} + +// Update updates a ConfigMap. +func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) { + name := cfgmap.Name + if _, ok := mock.objects[name]; !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + mock.objects[name] = cfgmap + return cfgmap, nil +} + +// Delete deletes a ConfigMap by name. +func (mock *MockConfigMapsInterface) Delete(_ context.Context, name string, _ metav1.DeleteOptions) error { + if _, ok := mock.objects[name]; !ok { + return apierrors.NewNotFound(v1.Resource("tests"), name) + } + delete(mock.objects, name) + return nil +} + +// newTestFixtureSecrets initializes a MockSecretsInterface. +// Secrets are created for each release provided. +func newTestFixtureSecrets(t *testing.T, releases ...*rspb.Release) *Secrets { + t.Helper() + var mock MockSecretsInterface + mock.Init(t, releases...) + + return NewSecrets(&mock) +} + +// MockSecretsInterface mocks a kubernetes SecretsInterface +type MockSecretsInterface struct { + corev1.SecretInterface + + objects map[string]*v1.Secret +} + +// Init initializes the MockSecretsInterface with the set of releases. +func (mock *MockSecretsInterface) Init(t *testing.T, releases ...*rspb.Release) { + t.Helper() + mock.objects = map[string]*v1.Secret{} + + for _, rls := range releases { + objkey := testKey(rls.Name, rls.Version) + + secret, err := newSecretsObject(objkey, rls, nil) + require.NoError(t, err, "Failed to create secret") + mock.objects[objkey] = secret + } +} + +// Get returns the Secret by name. +func (mock *MockSecretsInterface) Get(_ context.Context, name string, _ metav1.GetOptions) (*v1.Secret, error) { + object, ok := mock.objects[name] + if !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + return object, nil +} + +// List returns all Secrets. +func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.SecretList, error) { + var list v1.SecretList + + labelSelector, err := kblabels.Parse(opts.LabelSelector) + if err != nil { + return nil, err + } + + for _, secret := range mock.objects { + if labelSelector.Matches(kblabels.Set(secret.Labels)) { + list.Items = append(list.Items, *secret) + } + } + return &list, nil +} + +// Create creates a new Secret. +func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) { + name := secret.Name + if object, ok := mock.objects[name]; ok { + return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) + } + mock.objects[name] = secret + return secret, nil +} + +// Update updates a Secret. +func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) { + name := secret.Name + if _, ok := mock.objects[name]; !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + mock.objects[name] = secret + return secret, nil +} + +// Delete deletes a Secret by name. +func (mock *MockSecretsInterface) Delete(_ context.Context, name string, _ metav1.DeleteOptions) error { + if _, ok := mock.objects[name]; !ok { + return apierrors.NewNotFound(v1.Resource("tests"), name) + } + delete(mock.objects, name) + return nil +} + +// newTestFixtureSQL mocks the SQL database (for testing purposes) +func newTestFixtureSQL(t *testing.T, _ ...*rspb.Release) (*SQL, sqlmock.Sqlmock) { + t.Helper() + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err, "error when opening stub database connection") + + sqlxDB := sqlx.NewDb(sqlDB, "sqlmock") + return &SQL{ + db: sqlxDB, + namespace: "default", + statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + }, mock +} diff --git a/internal/storage/driver/records.go b/internal/storage/driver/records.go new file mode 100644 index 000000000..14f13780d --- /dev/null +++ b/internal/storage/driver/records.go @@ -0,0 +1,127 @@ +/* +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 driver + +import ( + "sort" + "strconv" + + rspb "helm.sh/helm/v4/internal/release/v2" +) + +// records holds a list of in-memory release records +type records []*record + +func (rs records) Len() int { return len(rs) } +func (rs records) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } +func (rs records) Less(i, j int) bool { return rs[i].rls.Version < rs[j].rls.Version } + +func (rs *records) Add(r *record) error { + if r == nil { + return nil + } + + if rs.Exists(r.key) { + return ErrReleaseExists + } + + *rs = append(*rs, r) + sort.Sort(*rs) + + return nil +} + +func (rs records) Get(key string) *record { + if i, ok := rs.Index(key); ok { + return rs[i] + } + return nil +} + +func (rs *records) Iter(fn func(int, *record) bool) { + cp := make([]*record, len(*rs)) + copy(cp, *rs) + + for i, r := range cp { + if !fn(i, r) { + return + } + } +} + +func (rs *records) Index(key string) (int, bool) { + for i, r := range *rs { + if r.key == key { + return i, true + } + } + return -1, false +} + +func (rs records) Exists(key string) bool { + _, ok := rs.Index(key) + return ok +} + +func (rs *records) Remove(key string) (r *record) { + if i, ok := rs.Index(key); ok { + return rs.removeAt(i) + } + return nil +} + +func (rs *records) Replace(key string, rec *record) *record { + if i, ok := rs.Index(key); ok { + old := (*rs)[i] + (*rs)[i] = rec + return old + } + return nil +} + +func (rs *records) removeAt(index int) *record { + r := (*rs)[index] + (*rs)[index] = nil + copy((*rs)[index:], (*rs)[index+1:]) + *rs = (*rs)[:len(*rs)-1] + return r +} + +// record is the data structure used to cache releases +// for the in-memory storage driver +type record struct { + key string + lbs labels + rls *rspb.Release +} + +// newRecord creates a new in-memory release record +func newRecord(key string, rls *rspb.Release) *record { + var lbs labels + + lbs.init() + + // apply custom labels + lbs.fromMap(rls.Labels) + + lbs.set("name", rls.Name) + lbs.set("owner", "helm") + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + + return &record{key: key, lbs: lbs, rls: rls} +} diff --git a/internal/storage/driver/records_test.go b/internal/storage/driver/records_test.go new file mode 100644 index 000000000..049536971 --- /dev/null +++ b/internal/storage/driver/records_test.go @@ -0,0 +1,226 @@ +/* +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 driver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/release/common" +) + +func TestRecordsAdd(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + tests := []struct { + desc string + key string + ok bool + rec *record + }{ + { + "add valid key", + "rls-a.v3", + false, + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), + }, + { + "add already existing key", + "rls-a.v1", + true, + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusDeployed)), + }, + } + + for _, tt := range tests { + err := rs.Add(tt.rec) + if !tt.ok { + require.NoError(t, err, "failed: %q:", tt.desc) + } else { + require.Error(t, err) + } + } +} + +func TestRecordsRemove(t *testing.T) { + tests := []struct { + desc string + key string + ok bool + }{ + {"remove valid key", "rls-a.v1", false}, + {"remove invalid key", "rls-a.v", true}, + {"remove non-existent key", "rls-z.v1", true}, + } + + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + startLen := rs.Len() + + for _, tt := range tests { + r := rs.Remove(tt.key) + if tt.ok { + require.Nil(t, r, "Failed to %q (key = %s). Expected nil, got %v", tt.desc, tt.key, r) + } else { + require.NotNil(t, r) + } + } + + // We expect the total number of records will be less now than there were + // when we started. + endLen := rs.Len() + assert.Lessf(t, endLen, startLen, "expected ending length %d to be less than starting length %d", endLen, startLen) +} + +func TestRecordsRemoveAt(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + require.Len(t, rs, 2, "Expected len=2 for mock") + + rs.Remove("rls-a.v1") + require.Len(t, rs, 1, "Expected length of rs to be 1, got %d", len(rs)) +} + +func TestRecordsGet(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + tests := []struct { + desc string + key string + rec *record + }{ + { + "get valid key", + "rls-a.v1", + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + }, + { + "get invalid key", + "rls-a.v3", + nil, + }, + } + + for _, tt := range tests { + got := rs.Get(tt.key) + require.Equal(t, tt.rec, got, "Expected %v, got %v", tt.rec, got) + } +} + +func TestRecordsIndex(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + tests := []struct { + desc string + key string + sort int + }{ + { + "get valid key", + "rls-a.v1", + 0, + }, + { + "get invalid key", + "rls-a.v3", + -1, + }, + } + + for _, tt := range tests { + got, _ := rs.Index(tt.key) + require.Equal(t, tt.sort, got, "Expected %d, got %d", tt.sort, got) + } +} + +func TestRecordsExists(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + tests := []struct { + desc string + key string + ok bool + }{ + { + "get valid key", + "rls-a.v1", + true, + }, + { + "get invalid key", + "rls-a.v3", + false, + }, + } + + for _, tt := range tests { + got := rs.Exists(tt.key) + require.Equal(t, tt.ok, got, "Expected %t, got %t", tt.ok, got) + } +} + +func TestRecordsReplace(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }) + + tests := []struct { + desc string + key string + rec *record + expected *record + }{ + { + "replace with existing key", + "rls-a.v2", + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), + }, + { + "replace with non existing key", + "rls-a.v4", + newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", common.StatusDeployed)), + nil, + }, + } + + for _, tt := range tests { + got := rs.Replace(tt.key, tt.rec) + require.Equalf(t, tt.expected, got, "Expected %v, got %v", tt.expected, got) + } +} diff --git a/internal/storage/driver/secrets.go b/internal/storage/driver/secrets.go new file mode 100644 index 000000000..e12aa2c1d --- /dev/null +++ b/internal/storage/driver/secrets.go @@ -0,0 +1,287 @@ +/* +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 driver + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/validation" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + "helm.sh/helm/v4/internal/logging" + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" +) + +var _ Driver = (*Secrets)(nil) + +// SecretsDriverName is the string name of the driver. +const SecretsDriverName = "Secret" + +// Secrets is a wrapper around an implementation of a kubernetes +// SecretsInterface. +type Secrets struct { + impl corev1.SecretInterface + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +// NewSecrets initializes a new Secrets wrapping an implementation of +// the kubernetes SecretsInterface. +func NewSecrets(impl corev1.SecretInterface) *Secrets { + s := &Secrets{ + impl: impl, + } + s.SetLogger(slog.Default().Handler()) + return s +} + +// Name returns the name of the driver. +func (secrets *Secrets) Name() string { + return SecretsDriverName +} + +// Get fetches the release named by key. The corresponding release is returned +// or error if not found. +func (secrets *Secrets) Get(key string) (release.Releaser, error) { + // fetch the secret holding the release named by key + obj, err := secrets.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ErrReleaseNotFound + } + return nil, fmt.Errorf("get: failed to get %q: %w", key, err) + } + // found the secret, decode the base64 data string + r, err := decodeRelease(string(obj.Data["release"])) + if err != nil { + return r, fmt.Errorf("get: failed to decode data %q: %w", key, err) + } + r.Labels = filterSystemLabels(obj.Labels) + return r, nil +} + +// List fetches all releases and returns the list releases such +// that filter(release) == true. An error is returned if the +// secret fails to retrieve the releases. +func (secrets *Secrets) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { + lsel := kblabels.Set{"owner": "helm"}.AsSelector() + opts := metav1.ListOptions{LabelSelector: lsel.String()} + + list, err := secrets.impl.List(context.Background(), opts) + if err != nil { + return nil, fmt.Errorf("list: failed to list: %w", err) + } + + var results []release.Releaser + + // iterate over the secrets object list + // and decode each release + for _, item := range list.Items { + rls, err := decodeRelease(string(item.Data["release"])) + if err != nil { + secrets.Logger().Debug( + "list failed to decode release", slog.String("key", item.Name), + slog.Any("error", err), + ) + continue + } + + rls.Labels = item.Labels + + if filter(rls) { + results = append(results, rls) + } + } + return results, nil +} + +// Query fetches all releases that match the provided map of labels. +// An error is returned if the secret fails to retrieve the releases. +func (secrets *Secrets) Query(labels map[string]string) ([]release.Releaser, error) { + ls := kblabels.Set{} + for k, v := range labels { + if errs := validation.IsValidLabelValue(v); len(errs) != 0 { + return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + } + ls[k] = v + } + + opts := metav1.ListOptions{LabelSelector: ls.AsSelector().String()} + + list, err := secrets.impl.List(context.Background(), opts) + if err != nil { + return nil, fmt.Errorf("query: failed to query with labels: %w", err) + } + + if len(list.Items) == 0 { + return nil, ErrReleaseNotFound + } + + var results []release.Releaser + for _, item := range list.Items { + rls, err := decodeRelease(string(item.Data["release"])) + if err != nil { + secrets.Logger().Debug( + "failed to decode release", + slog.String("key", item.Name), + slog.Any("error", err), + ) + continue + } + rls.Labels = item.Labels + results = append(results, rls) + } + return results, nil +} + +// Create creates a new Secret holding the release. If the +// Secret already exists, ErrReleaseExists is returned. +func (secrets *Secrets) Create(key string, rel release.Releaser) error { + // set labels for secrets object meta data + var lbs labels + + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) + + // create a new secret to hold the release + obj, err := newSecretsObject(key, rls, lbs) + if err != nil { + return fmt.Errorf("create: failed to encode release %q: %w", rls.Name, err) + } + // push the secret object out into the kubiverse + if _, err := secrets.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return ErrReleaseExists + } + + return fmt.Errorf("create: failed to create: %w", err) + } + return nil +} + +// Update updates the Secret holding the release. If not found +// the Secret is created to hold the release. +func (secrets *Secrets) Update(key string, rel release.Releaser) error { + // set labels for secrets object meta data + var lbs labels + + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) + + // create a new secret object to hold the release + obj, err := newSecretsObject(key, rls, lbs) + if err != nil { + return fmt.Errorf("update: failed to encode release %q: %w", rls.Name, err) + } + // push the secret object out into the kubiverse + _, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("update: failed to update: %w", err) + } + return nil +} + +// Delete deletes the Secret holding the release named by key. +func (secrets *Secrets) Delete(key string) (rls release.Releaser, err error) { + // fetch the release to check existence + if rls, err = secrets.Get(key); err != nil { + return nil, err + } + // delete the release + err = secrets.impl.Delete(context.Background(), key, metav1.DeleteOptions{}) + if err != nil { + return nil, err + } + return rls, nil +} + +// newSecretsObject constructs a kubernetes Secret object +// to store a release. Each secret data entry is the base64 +// encoded gzipped string of a release. +// +// The following labels are used within each secret: +// +// "modifiedAt" - timestamp indicating when this secret was last modified. (set in Update) +// "createdAt" - timestamp indicating when this secret was created. (set in Create) +// "version" - version of the release. +// "status" - status of the release (see pkg/release/status.go for variants) +// "owner" - owner of the secret, currently "helm". +// "name" - name of the release. +func newSecretsObject(key string, rls *rspb.Release, lbs labels) (*v1.Secret, error) { + const owner = "helm" + + // encode the release + s, err := encodeRelease(rls) + if err != nil { + return nil, err + } + + if lbs == nil { + lbs.init() + } + + // apply custom labels + lbs.fromMap(rls.Labels) + + // apply labels + lbs.set("name", rls.Name) + lbs.set("owner", owner) + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + + // create and return secret object. + // Helm 3 introduced setting the 'Type' field + // in the Kubernetes storage object. + // Helm defines the field content as follows: + // /.v + // Type field for Chart v3: helm.sh/release.v2 + // Note: Version starts at 'v1' for Helm 3 and + // should be incremented if the release object + // metadata is modified. + // This would potentially be a breaking change + // and should only happen between major versions. + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: key, + Labels: lbs.toMap(), + }, + Type: "helm.sh/release.v2", + Data: map[string][]byte{"release": []byte(s)}, + }, nil +} diff --git a/internal/storage/driver/secrets_test.go b/internal/storage/driver/secrets_test.go new file mode 100644 index 000000000..4bdff656a --- /dev/null +++ b/internal/storage/driver/secrets_test.go @@ -0,0 +1,198 @@ +/* +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 driver + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" +) + +func TestSecretName(t *testing.T) { + c := newTestFixtureSecrets(t) + assert.Equal(t, SecretsDriverName, c.Name(), "Expected name to be %q, got %q", SecretsDriverName, c.Name()) +} + +func TestSecretGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // get release with key + got, err := secrets.Get(key) + require.NoError(t, err, "Failed to get release") + // compare fetched release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestUNcompressedSecretGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + // Create a test fixture which contains an uncompressed release + secret, err := newSecretsObject(key, rel, nil) + require.NoError(t, err, "Failed to create secret") + b, err := json.Marshal(rel) + require.NoError(t, err, "Failed to marshal release") + secret.Data["release"] = []byte(base64.StdEncoding.EncodeToString(b)) + var mock MockSecretsInterface + mock.objects = map[string]*v1.Secret{key: secret} + secrets := NewSecrets(&mock) + + // get release with key + got, err := secrets.Get(key) + require.NoError(t, err, "Failed to get release") + // compare fetched release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestSecretList(t *testing.T) { + secrets := newTestFixtureSecrets(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), + }...) + + // list all deleted releases + del, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled + }) + // check + require.NoError(t, err, "Failed to list deleted") + assert.Len(t, del, 2, "Expected 2 deleted") + + // list all deployed releases + dpl, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed + }) + // check + require.NoError(t, err, "Failed to list deployed") + assert.Len(t, dpl, 2, "Expected 2 deployed") + + // list all superseded releases + ssd, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded + }) + // check + require.NoError(t, err, "Failed to list superseded") + require.Len(t, ssd, 2, "Expected 2 superseded") + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := convertReleaserToV1(t, ssd[0]) + require.Contains(t, rls.Labels, "name", "Expected 'name' label in results, actual %v", rls.Labels) + require.Contains(t, rls.Labels, "key1", "Expected 'key1' label in results, actual %v", rls.Labels) +} + +func TestSecretQuery(t *testing.T) { + secrets := newTestFixtureSecrets(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), + }...) + + rls, err := secrets.Query(map[string]string{"status": "deployed"}) + require.NoError(t, err, "Failed to query") + require.Len(t, rls, 2, "Expected 2 results, actual %d", len(rls)) + + _, err = secrets.Query(map[string]string{"name": "notExist"}) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} + +func TestSecretCreate(t *testing.T) { + secrets := newTestFixtureSecrets(t) + + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + // store the release in a secret + require.NoErrorf(t, secrets.Create(key, rel), "Failed to create release with key %q", key) + + // get the release back + got, err := secrets.Get(key) + require.NoError(t, err, "Failed to get release with key %q", key) + + // compare created release with original + assert.Equalf(t, rel, got, "Expected {%v}, got {%v}", rel, got) +} + +func TestSecretUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // modify release status code + rel.Info.Status = common.StatusSuperseded + + // perform the update + require.NoErrorf(t, secrets.Update(key, rel), "Failed to update release") + + // fetch the updated release + goti, err := secrets.Get(key) + require.NoError(t, err, "Failed to get release with key %q", key) + got := convertReleaserToV1(t, goti) + + // check release has actually been updated by comparing modified fields + assert.Equal(t, got.Info.Status, rel.Info.Status, "Expected status %s, got status %s", rel.Info.Status.String(), got.Info.Status.String()) +} + +func TestSecretDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // perform the delete on a non-existing release + _, err := secrets.Delete("nonexistent") + require.ErrorIs(t, err, ErrReleaseNotFound, "Expected ErrReleaseNotFound") + + // perform the delete + rls, err := secrets.Delete(key) + require.NoError(t, err, "Failed to delete release with key %q", key) + assert.Equalf(t, rel, rls, "Expected {%v}, got {%v}", rel, rls) + _, err = secrets.Get(key) + assert.ErrorIs(t, err, ErrReleaseNotFound) +} diff --git a/internal/storage/driver/sql.go b/internal/storage/driver/sql.go new file mode 100644 index 000000000..653507ba9 --- /dev/null +++ b/internal/storage/driver/sql.go @@ -0,0 +1,742 @@ +/* +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 driver + +import ( + "fmt" + "log/slog" + "maps" + "sort" + "strconv" + "time" + + "github.com/jmoiron/sqlx" + migrate "github.com/rubenv/sql-migrate" + + sq "github.com/Masterminds/squirrel" + + // Import pq for postgres dialect + _ "github.com/lib/pq" + + "helm.sh/helm/v4/internal/logging" + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" +) + +var _ Driver = (*SQL)(nil) + +var labelMap = map[string]struct{}{ + "modifiedAt": {}, + "createdAt": {}, + "version": {}, + "status": {}, + "owner": {}, + "name": {}, +} + +const postgreSQLDialect = "postgres" + +// SQLDriverName is the string name of this driver. +const SQLDriverName = "SQL" + +const ( + sqlReleaseTableName = "releases_v1" + sqlCustomLabelsTableName = "custom_labels_v1" +) + +const ( + sqlReleaseTableKeyColumn = "key" + sqlReleaseTableTypeColumn = "type" + sqlReleaseTableBodyColumn = "body" + sqlReleaseTableNameColumn = "name" + sqlReleaseTableNamespaceColumn = "namespace" + sqlReleaseTableVersionColumn = "version" + sqlReleaseTableStatusColumn = "status" + sqlReleaseTableOwnerColumn = "owner" + sqlReleaseTableCreatedAtColumn = "createdAt" + sqlReleaseTableModifiedAtColumn = "modifiedAt" + + sqlCustomLabelsTableReleaseKeyColumn = "releaseKey" + sqlCustomLabelsTableReleaseNamespaceColumn = "releaseNamespace" + sqlCustomLabelsTableKeyColumn = "key" + sqlCustomLabelsTableValueColumn = "value" +) + +// Following limits based on k8s labels limits - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set +const ( + sqlCustomLabelsTableKeyMaxLength = 253 + 1 + 63 + sqlCustomLabelsTableValueMaxLength = 63 +) + +const ( + sqlReleaseDefaultOwner = "helm" + sqlReleaseDefaultType = "helm.sh/release.v2" +) + +// SQL is the sql storage driver implementation. +type SQL struct { + db *sqlx.DB + namespace string + statementBuilder sq.StatementBuilderType + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +// Name returns the name of the driver. +func (s *SQL) Name() string { + return SQLDriverName +} + +// Check if all migrations al +func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool { + // make map (set) of ids for fast search + migrationsIDs := make(map[string]struct{}) + for _, migration := range migrations { + migrationsIDs[migration.Id] = struct{}{} + } + + // get list of applied migrations + migrate.SetDisableCreateTable(true) + records, err := migrate.GetMigrationRecords(s.db.DB, postgreSQLDialect) + migrate.SetDisableCreateTable(false) + if err != nil { + s.Logger().Debug("failed to get migration records", slog.Any("error", err)) + return false + } + + for _, record := range records { + if _, ok := migrationsIDs[record.Id]; ok { + s.Logger().Debug("found previous migration", "id", record.Id, "appliedAt", record.AppliedAt) + delete(migrationsIDs, record.Id) + } + } + + // check if all migrations applied + if len(migrationsIDs) != 0 { + for id := range migrationsIDs { + s.Logger().Debug("find unapplied migration", "id", id) + } + return false + } + return true +} + +func (s *SQL) ensureDBSetup() error { + migrations := &migrate.MemoryMigrationSource{ + Migrations: []*migrate.Migration{ + { + Id: "init", + Up: []string{ + fmt.Sprintf(` + CREATE TABLE %s ( + %s VARCHAR(90), + %s VARCHAR(64) NOT NULL, + %s TEXT NOT NULL, + %s VARCHAR(64) NOT NULL, + %s VARCHAR(64) NOT NULL, + %s INTEGER NOT NULL, + %s TEXT NOT NULL, + %s TEXT NOT NULL, + %s INTEGER NOT NULL, + %s INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(%s, %s) + ); + CREATE INDEX ON %s (%s, %s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + + GRANT ALL ON %s TO PUBLIC; + + ALTER TABLE %s ENABLE ROW LEVEL SECURITY; + `, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableName, + sqlReleaseTableVersionColumn, + sqlReleaseTableName, + sqlReleaseTableStatusColumn, + sqlReleaseTableName, + sqlReleaseTableOwnerColumn, + sqlReleaseTableName, + sqlReleaseTableCreatedAtColumn, + sqlReleaseTableName, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableName, + sqlReleaseTableName, + ), + }, + Down: []string{ + fmt.Sprintf(` + DROP TABLE %s; + `, sqlReleaseTableName), + }, + }, + { + Id: "custom_labels", + Up: []string{ + fmt.Sprintf(` + CREATE TABLE %s ( + %s VARCHAR(64), + %s VARCHAR(67), + %s VARCHAR(%d), + %s VARCHAR(%d) + ); + CREATE INDEX ON %s (%s, %s); + + GRANT ALL ON %s TO PUBLIC; + ALTER TABLE %s ENABLE ROW LEVEL SECURITY; + `, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableKeyMaxLength, + sqlCustomLabelsTableValueColumn, + sqlCustomLabelsTableValueMaxLength, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableName, + sqlCustomLabelsTableName, + ), + }, + Down: []string{ + fmt.Sprintf(` + DELETE TABLE %s; + `, sqlCustomLabelsTableName), + }, + }, + }, + } + + // Check that init migration already applied + if s.checkAlreadyApplied(migrations.Migrations) { + return nil + } + + // Populate the database with the relations we need if they don't exist yet + _, err := migrate.Exec(s.db.DB, postgreSQLDialect, migrations, migrate.Up) + return err +} + +// SQLReleaseWrapper describes how Helm releases are stored in an SQL database +type SQLReleaseWrapper struct { + // The primary key, made of {release-name}.{release-version} + Key string `db:"key"` + + // See https://github.com/helm/helm/blob/c9fe3d118caec699eb2565df9838673af379ce12/pkg/storage/driver/secrets.go#L231 + Type string `db:"type"` + + // The rspb.Release body, as a base64-encoded string + Body string `db:"body"` + + // Release "labels" that can be used as filters in the storage.Query(labels map[string]string) + // we implemented. Note that allowing Helm users to filter against new dimensions will require a + // new migration to be added, and the Create and/or update functions to be updated accordingly. + Name string `db:"name"` + Namespace string `db:"namespace"` + Version int `db:"version"` + Status string `db:"status"` + Owner string `db:"owner"` + CreatedAt int `db:"createdAt"` + ModifiedAt int `db:"modifiedAt"` +} + +type SQLReleaseCustomLabelWrapper struct { + ReleaseKey string `db:"release_key"` + ReleaseNamespace string `db:"release_namespace"` + Key string `db:"key"` + Value string `db:"value"` +} + +// NewSQL initializes a new sql driver. +func NewSQL(connectionString, namespace string) (*SQL, error) { + db, err := sqlx.Connect(postgreSQLDialect, connectionString) + if err != nil { + return nil, err + } + + driver := &SQL{ + db: db, + statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + } + + if err := driver.ensureDBSetup(); err != nil { + return nil, err + } + + driver.namespace = namespace + driver.SetLogger(slog.Default().Handler()) + + return driver, nil +} + +// Get returns the release named by key. +func (s *SQL) Get(key string) (release.Releaser, error) { + var record SQLReleaseWrapper + + qb := s.statementBuilder. + Select(sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + + query, args, err := qb.ToSql() + if err != nil { + s.Logger().Debug("failed to build query", slog.Any("error", err)) + return nil, err + } + + // Get will return an error if the result is empty + if err := s.db.Get(&record, query, args...); err != nil { + s.Logger().Debug("got SQL error when getting release", slog.String("key", key), slog.Any("error", err)) + return nil, ErrReleaseNotFound + } + + release, err := decodeRelease(record.Body) + if err != nil { + s.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) + return nil, err + } + + if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err), + ) + return nil, err + } + + return release, nil +} + +// List returns the list of all releases such that filter(release) == true +func (s *SQL) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { + sb := s.statementBuilder. + Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableOwnerColumn: sqlReleaseDefaultOwner}) + + // If a namespace was specified, we only list releases from that namespace + if s.namespace != "" { + sb = sb.Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + } + + query, args, err := sb.ToSql() + if err != nil { + s.Logger().Debug("failed to build query", slog.Any("error", err)) + return nil, err + } + + records := []SQLReleaseWrapper{} + if err := s.db.Select(&records, query, args...); err != nil { + s.Logger().Debug("failed to list", slog.Any("error", err)) + return nil, err + } + + var releases []release.Releaser + for _, record := range records { + release, err := decodeRelease(record.Body) + if err != nil { + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) + continue + } + + if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) + return nil, err + } + maps.Copy(release.Labels, getReleaseSystemLabels(release)) + + if filter(release) { + releases = append(releases, release) + } + } + + return releases, nil +} + +// Query returns the set of releases that match the provided set of labels. +func (s *SQL) Query(labels map[string]string) ([]release.Releaser, error) { + sb := s.statementBuilder. + Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). + From(sqlReleaseTableName) + + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + _, ok := labelMap[key] + if !ok { + s.Logger().Debug("unknown label", "key", key) + return nil, fmt.Errorf("unknown label %s", key) + } + sb = sb.Where(sq.Eq{key: labels[key]}) + } + + // If a namespace was specified, we only list releases from that namespace + if s.namespace != "" { + sb = sb.Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + } + + // Build our query + query, args, err := sb.ToSql() + if err != nil { + s.Logger().Debug("failed to build query", slog.Any("error", err)) + return nil, err + } + + records := []SQLReleaseWrapper{} + if err := s.db.Select(&records, query, args...); err != nil { + s.Logger().Debug("failed to query with labels", slog.Any("error", err)) + return nil, err + } + + if len(records) == 0 { + return nil, ErrReleaseNotFound + } + + var releases []release.Releaser + for _, record := range records { + release, err := decodeRelease(record.Body) + if err != nil { + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) + continue + } + + if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) + return nil, err + } + + releases = append(releases, release) + } + + if len(releases) == 0 { + return nil, ErrReleaseNotFound + } + + return releases, nil +} + +// Create creates a new release. +func (s *SQL) Create(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + s.namespace = namespace + + body, err := encodeRelease(rls) + if err != nil { + s.Logger().Debug("failed to encode release", slog.Any("error", err)) + return err + } + + transaction, err := s.db.Beginx() + if err != nil { + s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) + return fmt.Errorf("error beginning transaction: %w", err) + } + + insertQuery, args, err := s.statementBuilder. + Insert(sqlReleaseTableName). + Columns( + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ). + Values( + key, + sqlReleaseDefaultType, + body, + rls.Name, + namespace, + int(rls.Version), + rls.Info.Status.String(), + sqlReleaseDefaultOwner, + int(time.Now().Unix()), + ).ToSql() + if err != nil { + s.Logger().Debug("failed to build insert query", slog.Any("error", err)) + return err + } + + if _, err := transaction.Exec(insertQuery, args...); err != nil { + defer transaction.Rollback() + + selectQuery, args, buildErr := s.statementBuilder. + Select(sqlReleaseTableKeyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if buildErr != nil { + s.Logger().Debug("failed to build select query", "error", buildErr) + return err + } + + var record SQLReleaseWrapper + if err := transaction.Get(&record, selectQuery, args...); err == nil { + s.Logger().Debug("release already exists", slog.String("key", key)) + return ErrReleaseExists + } + + s.Logger().Debug("failed to store release in SQL database", slog.String("key", key), slog.Any("error", err)) + return err + } + + // Filtering labels before insert cause in SQL storage driver system releases are stored in separate columns of release table + for k, v := range filterSystemLabels(rls.Labels) { + insertLabelsQuery, args, err := s.statementBuilder. + Insert(sqlCustomLabelsTableName). + Columns( + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ). + Values( + key, + namespace, + k, + v, + ).ToSql() + if err != nil { + defer transaction.Rollback() + s.Logger().Debug("failed to build insert query", slog.Any("error", err)) + return err + } + + if _, err := transaction.Exec(insertLabelsQuery, args...); err != nil { + defer transaction.Rollback() + s.Logger().Debug("failed to write Labels", slog.Any("error", err)) + return err + } + } + defer transaction.Commit() + + return nil +} + +// Update updates a release. +// +// Custom labels on an existing revision are meant to be preserved. A release's +// labels are set by Create, and a revision keeps the labels it was created with +// once it is superseded, rather than picking up the labels of the upgrade that +// superseded it. TestUpgradeRelease_Labels in pkg/action is what asserts this. +// +// The drivers arrive at that from opposite directions. Here labels live in a +// separate table written only by Create, so an update leaves them untouched. The +// configmaps, memory and secrets drivers instead store labels on the record +// itself and replace the record wholesale on update, so they have to re-apply +// the labels every time or the update would discard all of them. A side effect +// is that those drivers persist a label change where this driver silently would +// not. No caller changes labels between Create and Update, so the two agree in +// practice. +func (s *SQL) Update(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + s.namespace = namespace + + body, err := encodeRelease(rls) + if err != nil { + s.Logger().Debug("failed to encode release", slog.Any("error", err)) + return err + } + + query, args, err := s.statementBuilder. + Update(sqlReleaseTableName). + Set(sqlReleaseTableBodyColumn, body). + Set(sqlReleaseTableNameColumn, rls.Name). + Set(sqlReleaseTableVersionColumn, int(rls.Version)). + Set(sqlReleaseTableStatusColumn, rls.Info.Status.String()). + Set(sqlReleaseTableOwnerColumn, sqlReleaseDefaultOwner). + Set(sqlReleaseTableTypeColumn, sqlReleaseDefaultType). + Set(sqlReleaseTableModifiedAtColumn, int(time.Now().Unix())). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: namespace}). + ToSql() + if err != nil { + s.Logger().Debug("failed to build update query", slog.Any("error", err)) + return err + } + + if _, err := s.db.Exec(query, args...); err != nil { + s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err)) + return err + } + + return nil +} + +// Delete deletes a release or returns ErrReleaseNotFound. +func (s *SQL) Delete(key string) (release.Releaser, error) { + transaction, err := s.db.Beginx() + if err != nil { + s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) + return nil, fmt.Errorf("error beginning transaction: %w", err) + } + + selectQuery, args, err := s.statementBuilder. + Select(sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Logger().Debug("failed to build select query", slog.Any("error", err)) + return nil, err + } + + var record SQLReleaseWrapper + err = transaction.Get(&record, selectQuery, args...) + if err != nil { + s.Logger().Debug("release not found", slog.String("key", key), slog.Any("error", err)) + return nil, ErrReleaseNotFound + } + + release, err := decodeRelease(record.Body) + if err != nil { + s.Logger().Debug("failed to decode release", slog.String("key", key), slog.Any("error", err)) + transaction.Rollback() + return nil, err + } + defer transaction.Commit() + + deleteQuery, args, err := s.statementBuilder. + Delete(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Logger().Debug("failed to build delete query", slog.Any("error", err)) + return nil, err + } + + _, err = transaction.Exec(deleteQuery, args...) + if err != nil { + s.Logger().Debug("failed perform delete query", slog.Any("error", err)) + return release, err + } + + if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err)) + return nil, err + } + + deleteCustomLabelsQuery, args, err := s.statementBuilder. + Delete(sqlCustomLabelsTableName). + Where(sq.Eq{sqlCustomLabelsTableReleaseKeyColumn: key}). + Where(sq.Eq{sqlCustomLabelsTableReleaseNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Logger().Debug("failed to build delete Labels query", slog.Any("error", err)) + return nil, err + } + _, err = transaction.Exec(deleteCustomLabelsQuery, args...) + return release, err +} + +// Get release custom labels from database +func (s *SQL) getReleaseCustomLabels(key, _ string) (map[string]string, error) { + query, args, err := s.statementBuilder. + Select(sqlCustomLabelsTableKeyColumn, sqlCustomLabelsTableValueColumn). + From(sqlCustomLabelsTableName). + Where(sq.Eq{ + sqlCustomLabelsTableReleaseKeyColumn: key, + sqlCustomLabelsTableReleaseNamespaceColumn: s.namespace, + }). + ToSql() + if err != nil { + return nil, err + } + + labelsList := []SQLReleaseCustomLabelWrapper{} + if err := s.db.Select(&labelsList, query, args...); err != nil { + return nil, err + } + + labelsMap := make(map[string]string) + for _, i := range labelsList { + labelsMap[i.Key] = i.Value + } + + return filterSystemLabels(labelsMap), nil +} + +// Rebuild system labels from release object +func getReleaseSystemLabels(rls *rspb.Release) map[string]string { + return map[string]string{ + "name": rls.Name, + "owner": sqlReleaseDefaultOwner, + "status": rls.Info.Status.String(), + "version": strconv.Itoa(rls.Version), + } +} diff --git a/internal/storage/driver/sql_test.go b/internal/storage/driver/sql_test.go new file mode 100644 index 000000000..99c323680 --- /dev/null +++ b/internal/storage/driver/sql_test.go @@ -0,0 +1,564 @@ +/* +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 driver + +import ( + "database/sql/driver" + "errors" + "fmt" + "reflect" + "regexp" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + migrate "github.com/rubenv/sql-migrate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rspb "helm.sh/helm/v4/internal/release/v2" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" +) + +const recentTimestampTolerance = time.Second + +func recentUnixTimestamp() sqlmock.Argument { + return recentUnixTimestampArgument{} +} + +type recentUnixTimestampArgument struct{} + +func (recentUnixTimestampArgument) Match(value driver.Value) bool { + var ts int64 + switch v := value.(type) { + case int: + ts = int64(v) + case int64: + ts = v + default: + return false + } + + diff := time.Since(time.Unix(ts, 0)) + if diff < 0 { + diff = -diff + } + + return diff <= recentTimestampTolerance +} + +func TestSQLName(t *testing.T) { + sqlDriver, _ := newTestFixtureSQL(t) + assert.Equal(t, SQLDriverName, sqlDriver.Name(), "Expected name to be %s, got %s", SQLDriverName, sqlDriver.Name()) +} + +func TestSQLGet(t *testing.T) { + vers := int(1) + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + body, _ := encodeRelease(rel) + + sqlDriver, mock := newTestFixtureSQL(t) + + query := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(query). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + body, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, key, namespace, rel.Labels) + + got, err := sqlDriver.Get(key) + require.NoError(t, err, "Failed to get release") + + assert.Equalf(t, rel, got, "Expected release {%v}, got {%v}", rel, got) + assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") +} + +func TestSQLList(t *testing.T) { + releases := []*rspb.Release{} + releases = append(releases, + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), + ) + + sqlDriver, mock := newTestFixtureSQL(t) + + for range 3 { + query := fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableOwnerColumn, + sqlReleaseTableNamespaceColumn, + ) + + rows := mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }) + for _, r := range releases { + body, _ := encodeRelease(r) + rows.AddRow(body) + } + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs(sqlReleaseDefaultOwner, sqlDriver.namespace). + WillReturnRows(rows).RowsWillBeClosed() + + for _, r := range releases { + mockGetReleaseCustomLabels(mock, "", r.Namespace, r.Labels) + } + } + + // list all deleted releases + del, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled + }) + // check + require.NoError(t, err, "Failed to list deleted") + assert.Len(t, del, 2, "Expected 2 deleted") + + // list all deployed releases + dpl, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed + }) + // check + require.NoError(t, err, "Failed to list deployed") + assert.Len(t, dpl, 2, "Expected 2 deployed") + + // list all superseded releases + ssd, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded + }) + // check + require.NoError(t, err, "Failed to list superseded") + require.Len(t, ssd, 2, "Expected 2 superseded") + require.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") + + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := convertReleaserToV1(t, ssd[0]) + require.Contains(t, rls.Labels, "name", "Expected 'name' label in results, actual %v", rls.Labels) + require.Contains(t, rls.Labels, "key1", "Expected 'key1' label in results, actual %v", rls.Labels) +} + +func TestSqlCreate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + query := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ) + + mock.ExpectBegin() + mock. + ExpectExec(regexp.QuoteMeta(query)). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + labelsQuery := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s) VALUES ($1,$2,$3,$4)", + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ) + + mock.MatchExpectationsInOrder(false) + for k, v := range filterSystemLabels(rel.Labels) { + mock. + ExpectExec(regexp.QuoteMeta(labelsQuery)). + WithArgs(key, rel.Namespace, k, v). + WillReturnResult(sqlmock.NewResult(1, 1)) + } + mock.ExpectCommit() + + require.NoErrorf(t, sqlDriver.Create(key, rel), "failed to create release with key %s", key) + assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") +} + +func TestSqlCreateAlreadyExists(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + insertQuery := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ) + + // Insert fails (primary key already exists) + mock.ExpectBegin() + mock. + ExpectExec(regexp.QuoteMeta(insertQuery)). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). + WillReturnError(errors.New("dialect dependent SQL error")) + + selectQuery := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlReleaseTableKeyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + // Let's check that we do make sure the error is due to a release already existing + mock. + ExpectQuery(selectQuery). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableKeyColumn, + }).AddRow( + key, + ), + ).RowsWillBeClosed() + mock.ExpectRollback() + + require.Errorf(t, sqlDriver.Create(key, rel), "failed to create release with key %s", key) + assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") +} + +func TestSqlUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + query := fmt.Sprintf( + "UPDATE %s SET %s = $1, %s = $2, %s = $3, %s = $4, %s = $5, %s = $6, %s = $7 WHERE %s = $8 AND %s = $9", + sqlReleaseTableName, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectExec(regexp.QuoteMeta(query)). + WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, sqlReleaseDefaultType, recentUnixTimestamp(), key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoErrorf(t, sqlDriver.Update(key, rel), "failed to update release with key %s", key) + assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") +} + +func TestSqlQuery(t *testing.T) { + // Reflect actual use cases in ../storage.go + labelSetUnknown := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + "status": "unknown", + } + labelSetDeployed := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + "status": "deployed", + } + labelSetAll := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + } + + supersededRelease := releaseStub("smug-pigeon", 1, "default", common.StatusSuperseded) + supersededReleaseBody, _ := encodeRelease(supersededRelease) + deployedRelease := releaseStub("smug-pigeon", 2, "default", common.StatusDeployed) + deployedReleaseBody, _ := encodeRelease(deployedRelease) + + // Let's actually start our test + sqlDriver, mock := newTestFixtureSQL(t) + + query := fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2 AND %s = $3 AND %s = $4", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableNameColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "unknown", "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }), + ).RowsWillBeClosed() + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "deployed", "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + deployedReleaseBody, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, "", deployedRelease.Namespace, deployedRelease.Labels) + + query = fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2 AND %s = $3", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableNameColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + supersededReleaseBody, + ).AddRow( + deployedReleaseBody, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, "", supersededRelease.Namespace, supersededRelease.Labels) + mockGetReleaseCustomLabels(mock, "", deployedRelease.Namespace, deployedRelease.Labels) + + _, err := sqlDriver.Query(labelSetUnknown) + require.Errorf(t, err, "Expected error {%v}, got nil", ErrReleaseNotFound) + require.ErrorIsf(t, err, ErrReleaseNotFound, "failed to query for unknown smug-pigeon release") + + results, err := sqlDriver.Query(labelSetDeployed) + require.NoError(t, err, "failed to query for deployed smug-pigeon release") + + for _, res := range results { + assert.Equalf(t, res, deployedRelease, "Expected release {%v}, got {%v}", deployedRelease, res) + } + + results, err = sqlDriver.Query(labelSetAll) + require.NoError(t, err, "failed to query release history for smug-pigeon") + assert.Len(t, results, 2, "expected a resultset of size 2") + + for _, res := range results { + assert.False(t, !reflect.DeepEqual(res, deployedRelease) && !reflect.DeepEqual(res, supersededRelease), "Expected release {%v} or {%v}, got {%v}", deployedRelease, supersededRelease, res) + } + + assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") +} + +func TestSqlDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + body, _ := encodeRelease(rel) + + sqlDriver, mock := newTestFixtureSQL(t) + + selectQuery := fmt.Sprintf( + "SELECT %s FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock.ExpectBegin() + mock. + ExpectQuery(regexp.QuoteMeta(selectQuery)). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + body, + ), + ).RowsWillBeClosed() + + deleteQuery := fmt.Sprintf( + "DELETE FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectExec(regexp.QuoteMeta(deleteQuery)). + WithArgs(key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mockGetReleaseCustomLabels(mock, key, namespace, rel.Labels) + + deleteLabelsQuery := fmt.Sprintf( + "DELETE FROM %s WHERE %s = $1 AND %s = $2", + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + ) + mock. + ExpectExec(regexp.QuoteMeta(deleteLabelsQuery)). + WithArgs(key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectCommit() + + deletedRelease, err := sqlDriver.Delete(key) + require.NoError(t, err) + err = mock.ExpectationsWereMet() + assert.NoErrorf(t, err, "sql expectations weren't met") + require.NoError(t, err, "failed to delete release with key %q", key) + + assert.Equalf(t, rel, deletedRelease, "Expected release {%v}, got {%v}", rel, deletedRelease) +} + +func mockGetReleaseCustomLabels(mock sqlmock.Sqlmock, key, namespace string, labels map[string]string) { + query := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s, %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + ) + + eq := mock.ExpectQuery(query). + WithArgs(key, namespace) + + returnRows := mock.NewRows([]string{ + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + }) + for k, v := range labels { + returnRows.AddRow(k, v) + } + eq.WillReturnRows(returnRows).RowsWillBeClosed() +} + +func TestSqlCheckAppliedMigrations(t *testing.T) { + cases := []struct { + migrationsToApply []*migrate.Migration + appliedMigrationsIDs []string + expectedResult bool + errorExplanation string + }{ + { + migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, + appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "5"}, + expectedResult: false, + errorExplanation: "Has found one migration id \"init3\" as applied, that was not applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, + appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"}, + expectedResult: true, + errorExplanation: "Has not found one or more migration ids, that was applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init"}}, + appliedMigrationsIDs: []string{"1", "2", "3", "inits", "4", "tinit", "5"}, + expectedResult: false, + errorExplanation: "Has found single \"init\", that was not applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init"}}, + appliedMigrationsIDs: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"}, + expectedResult: true, + errorExplanation: "Has not found single migration id \"init\", that was applied", + }, + } + for i, c := range cases { + sqlDriver, mock := newTestFixtureSQL(t) + rows := sqlmock.NewRows([]string{"id", "applied_at"}) + for _, id := range c.appliedMigrationsIDs { + rows.AddRow(id, time.Time{}) + } + mock. + ExpectQuery(""). + WillReturnRows(rows) + mock.ExpectCommit() + assert.Equal(t, c.expectedResult, sqlDriver.checkAlreadyApplied(c.migrationsToApply), "Test case: %v, Expected: %v, Have: %v, Explanation: %v", i, c.expectedResult, !c.expectedResult, c.errorExplanation) + } +} diff --git a/internal/storage/driver/util.go b/internal/storage/driver/util.go new file mode 100644 index 000000000..10cf1adf1 --- /dev/null +++ b/internal/storage/driver/util.go @@ -0,0 +1,131 @@ +/* +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 driver + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "io" + "slices" + + rspb "helm.sh/helm/v4/internal/release/v2" +) + +var b64 = base64.StdEncoding + +var magicGzip = []byte{0x1f, 0x8b, 0x08} + +// releaseVersionLabel carries the release object schema version on storage +// objects that have no native field for it. Secrets record it in their Type +// field, but ConfigMaps have no equivalent, so the marker is a label instead. +// Absence of the label means release v1, so records written before this label +// existed continue to read correctly. +const releaseVersionLabel = "helm.sh/release-version" + +// releaseVersion is the schema version written by this package. +const releaseVersion = "v2" + +var systemLabels = []string{"name", "owner", "status", "version", "createdAt", "modifiedAt", releaseVersionLabel} + +// encodeRelease encodes a release returning a base64 encoded +// gzipped string representation, or error. +func encodeRelease(rls *rspb.Release) (string, error) { + b, err := json.Marshal(rls) + if err != nil { + return "", err + } + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return "", err + } + if _, err = w.Write(b); err != nil { + w.Close() + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + + return b64.EncodeToString(buf.Bytes()), nil +} + +// decodeRelease decodes the bytes of data into a release +// type. Data must contain a base64 encoded gzipped string of a +// valid release, otherwise an error is returned. +func decodeRelease(data string) (*rspb.Release, error) { + // base64 decode string + b, err := b64.DecodeString(data) + if err != nil { + return nil, err + } + + // For backwards compatibility with releases that were stored before + // compression was introduced we skip decompression if the + // gzip magic header is not found + if len(b) > 3 && bytes.Equal(b[0:3], magicGzip) { + r, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer r.Close() + b2, err := io.ReadAll(r) + if err != nil { + return nil, err + } + b = b2 + } + + var rls rspb.Release + // unmarshal release object bytes + if err := json.Unmarshal(b, &rls); err != nil { + return nil, err + } + return &rls, nil +} + +// Checks if label is system +func isSystemLabel(key string) bool { + return slices.Contains(GetSystemLabels(), key) +} + +// Removes system labels from labels map +func filterSystemLabels(lbs map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range lbs { + if !isSystemLabel(k) { + result[k] = v + } + } + return result +} + +// Checks if labels array contains system labels +func ContainsSystemLabels(lbs map[string]string) bool { + for k := range lbs { + if isSystemLabel(k) { + return true + } + } + return false +} + +func GetSystemLabels() []string { + return systemLabels +} diff --git a/internal/storage/driver/util_test.go b/internal/storage/driver/util_test.go new file mode 100644 index 000000000..f84f6a67a --- /dev/null +++ b/internal/storage/driver/util_test.go @@ -0,0 +1,105 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package driver + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSystemLabel(t *testing.T) { + output := GetSystemLabels() + assert.Equalf(t, systemLabels, output, "Expected {%v}, got {%v}", systemLabels, output) +} + +func TestIsSystemLabel(t *testing.T) { + tests := map[string]bool{ + "name": true, + "owner": true, + "test": false, + "NaMe": false, + } + for label, result := range tests { + output := isSystemLabel(label) + assert.Equalf(t, output, result, "Output %t not equal to expected %t", output, result) + } +} + +func TestFilterSystemLabels(t *testing.T) { + tests := [][2]map[string]string{ + {nil, map[string]string{}}, + {map[string]string{}, map[string]string{}}, + {map[string]string{ + "name": "name", + "owner": "owner", + "status": "status", + "version": "version", + "createdAt": "createdAt", + "modifiedAt": "modifiedAt", + }, map[string]string{}}, + {map[string]string{ + "StaTus": "status", + "name": "name", + "owner": "owner", + "key": "value", + }, map[string]string{ + "StaTus": "status", + "key": "value", + }}, + {map[string]string{ + "key1": "value1", + "key2": "value2", + }, map[string]string{ + "key1": "value1", + "key2": "value2", + }}, + } + for _, test := range tests { + output := filterSystemLabels(test[0]) + assert.Equalf(t, test[1], output, "Expected {%v}, got {%v}", test[1], output) + } +} + +func TestContainsSystemLabels(t *testing.T) { + tests := []struct { + input map[string]string + output bool + }{ + {nil, false}, + {map[string]string{}, false}, + {map[string]string{ + "name": "name", + "owner": "owner", + "status": "status", + "version": "version", + "createdAt": "createdAt", + "modifiedAt": "modifiedAt", + }, true}, + {map[string]string{ + "StaTus": "status", + "name": "name", + "owner": "owner", + "key": "value", + }, true}, + {map[string]string{ + "key1": "value1", + "key2": "value2", + }, false}, + } + for _, test := range tests { + output := ContainsSystemLabels(test.input) + assert.Equalf(t, test.output, output, "Expected {%v}, got {%v}", test.output, output) + } +} diff --git a/pkg/storage/driver/records.go b/pkg/storage/driver/records.go index f78b76b8b..3393bb603 100644 --- a/pkg/storage/driver/records.go +++ b/pkg/storage/driver/records.go @@ -114,6 +114,10 @@ func newRecord(key string, rls *rspb.Release) *record { var lbs labels lbs.init() + + // apply custom labels + lbs.fromMap(rls.Labels) + lbs.set("name", rls.Name) lbs.set("owner", "helm") lbs.set("status", rls.Info.Status.String()) diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go index 6602f2ee3..2b278f7cb 100644 --- a/pkg/storage/driver/sql.go +++ b/pkg/storage/driver/sql.go @@ -576,6 +576,20 @@ func (s *SQL) Create(key string, rel release.Releaser) error { } // Update updates a release. +// +// Custom labels on an existing revision are meant to be preserved. A release's +// labels are set by Create, and a revision keeps the labels it was created with +// once it is superseded, rather than picking up the labels of the upgrade that +// superseded it. TestUpgradeRelease_Labels in pkg/action is what asserts this. +// +// The drivers arrive at that from opposite directions. Here labels live in a +// separate table written only by Create, so an update leaves them untouched. The +// configmaps, memory and secrets drivers instead store labels on the record +// itself and replace the record wholesale on update, so they have to re-apply +// the labels every time or the update would discard all of them. A side effect +// is that those drivers persist a label change where this driver silently would +// not. No caller changes labels between Create and Update, so the two agree in +// practice. func (s *SQL) Update(key string, rel release.Releaser) error { rls, err := releaserToV1Release(rel) if err != nil { diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 1d7fb9103..04e863e2c 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -30,11 +30,23 @@ import ( "helm.sh/helm/v4/pkg/storage/driver" ) -// HelmStorageType is the type field of the Kubernetes storage object which stores the Helm release -// version. It is modified slightly replacing the '/': sh.helm/release.v1 -// Note: The version 'v1' is incremented if the release object metadata is -// modified between major releases. -// This constant is used as a prefix for the Kubernetes storage object name. +// HelmStorageType is the prefix used for the name of the Kubernetes storage object +// that holds a release. It is derived from that object's 'Type' field +// (helm.sh/release.v1) by reversing the domain and replacing the '/' with a '.'. +// +// The prefix is deliberately decoupled from the 'Type' field +// (helm.sh/release.v1, helm.sh/release.v2). Type records the schema +// of the encoded release body, so it tracks the release object version. This +// prefix only keeps release names unique, and was added to stop Helm 3 records +// colliding with Helm 2 ones (https://github.com/helm/helm/issues/6435). +// +// The prefix is therefore not incremented with the release object version. +// Kubernetes objects cannot be renamed in place, so doing so would mean +// recreating every record and deleting the old one, per revision, per namespace. +// Until that completed, Get, Update and Delete would all miss, since they address +// objects by exact key, while List, Query and History would keep returning the old +// records because they select on labels. The only thing gained is seeing the +// release object version in the object name, which Type already records. const HelmStorageType = "sh.helm.release.v1" // Storage represents a storage engine for a Release.