mirror of https://github.com/helm/helm
feat(storage): add internal/storage/driver for release v2 (#32667)
* feat(storage): add internal/storage/driver for release v2 Copy of pkg/storage/driver operating on internal/release/v2, with the storage object Type field set to helm.sh/release.v2. The sh.helm.release.v1 name prefix is unchanged. Signed-off-by: Evans Mungai <mbuevans@gmail.com> * feat(storage): mark configmap release schema with a label ConfigMaps have no Type field to record it in, unlike Secrets. Absence of the label means v1. Also documents why the sh.helm.release.v1 name prefix is decoupled from Type. Signed-off-by: Evans Mungai <mbuevans@gmail.com> * Update internal/storage/driver/sql.go Co-authored-by: Terry Howe <terrylhowe@gmail.com> Signed-off-by: Evans Mungai <mbuevans@gmail.com> * Update test Signed-off-by: Evans Mungai <mbuevans@gmail.com> * PR comment fixes - Add comment to explain sql driver update behaviour when it comes to updating custom labels - Apply custom labels in memory driver Signed-off-by: Evans Mungai <mbuevans@gmail.com> * Update internal/storage/driver/records.go Co-authored-by: Terry Howe <terrylhowe@gmail.com> Signed-off-by: Evans Mungai <mbuevans@gmail.com> --------- Signed-off-by: Evans Mungai <mbuevans@gmail.com> Co-authored-by: Terry Howe <terrylhowe@gmail.com>pull/32676/head
parent
7fd3c5e8f2
commit
6f5c2f5f72
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
// <helm_domain>/<helm_object>.v<helm_object_version>
|
||||
// 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue