fix(storage): return ErrReleaseNotFound when updating missing releases

Map Kubernetes NotFound errors and detect zero-row SQL updates in both
storage driver implementations. Preserve other failures and add regression
coverage for the missing-release contract and update errors.

Closes helm/helm#32673

Signed-off-by: Weiyi Li <1093656961@qq.com>
pull/32676/head
Weiyi Li 2 weeks ago
parent e9f1921aa5
commit 98219c42cc

@ -196,8 +196,8 @@ func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error {
return nil return nil
} }
// Update updates the ConfigMap holding the release. If not found // Update updates the ConfigMap holding the release. If not found,
// the ConfigMap is created to hold the release. // ErrReleaseNotFound is returned.
func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error {
// set labels for configmaps object meta data // set labels for configmaps object meta data
var lbs labels var lbs labels
@ -224,6 +224,10 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error {
// push the configmap object out into the kubiverse // push the configmap object out into the kubiverse
_, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) _, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
if err != nil { if err != nil {
if apierrors.IsNotFound(err) {
return ErrReleaseNotFound
}
cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err)) cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err))
return err return err
} }

@ -0,0 +1,83 @@
/*
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"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"helm.sh/helm/v4/pkg/release/common"
)
func TestUpdateMissingRelease(t *testing.T) {
tests := []struct {
name string
newDriver func(*testing.T) Driver
}{
{"memory", func(*testing.T) Driver { return NewMemory() }},
{"configmaps", func(t *testing.T) Driver {
t.Helper()
return newTestFixtureCfgMaps(t)
}},
{"secrets", func(t *testing.T) Driver {
t.Helper()
return newTestFixtureSecrets(t)
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := tt.newDriver(t)
rel := releaseStub("missing", 1, "default", common.StatusDeployed)
key := testKey(rel.Name, rel.Version)
require.ErrorIs(t, d.Update(key, rel), ErrReleaseNotFound)
// An update must not create the missing release.
_, err := d.Get(key)
require.ErrorIs(t, err, ErrReleaseNotFound)
})
}
}
func TestUpdateKubernetesErrors(t *testing.T) {
tests := []struct {
name string
err error
}{
{"forbidden", apierrors.NewForbidden(v1.Resource("tests"), "test", errors.New("access denied"))},
{"conflict", apierrors.NewConflict(v1.Resource("tests"), "test", errors.New("stale resource version"))},
{"connection", errors.New("connection failed")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
drivers := []Driver{
NewConfigMaps(&MockConfigMapsInterface{updateError: tt.err}),
NewSecrets(&MockSecretsInterface{updateError: tt.err}),
}
for _, d := range drivers {
t.Run(d.Name(), func(t *testing.T) {
rel := releaseStub("test", 1, "default", common.StatusDeployed)
err := d.Update(testKey(rel.Name, rel.Version), rel)
require.ErrorIs(t, err, tt.err)
require.NotErrorIs(t, err, ErrReleaseNotFound)
})
}
})
}
}

@ -95,6 +95,7 @@ type MockConfigMapsInterface struct {
corev1.ConfigMapInterface corev1.ConfigMapInterface
objects map[string]*v1.ConfigMap objects map[string]*v1.ConfigMap
updateError error
} }
// Init initializes the MockConfigMapsInterface with the set of releases. // Init initializes the MockConfigMapsInterface with the set of releases.
@ -149,6 +150,9 @@ func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.Config
// Update updates a ConfigMap. // Update updates a ConfigMap.
func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) { func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) {
if mock.updateError != nil {
return nil, mock.updateError
}
name := cfgmap.Name name := cfgmap.Name
if _, ok := mock.objects[name]; !ok { if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name) return nil, apierrors.NewNotFound(v1.Resource("tests"), name)
@ -181,6 +185,7 @@ type MockSecretsInterface struct {
corev1.SecretInterface corev1.SecretInterface
objects map[string]*v1.Secret objects map[string]*v1.Secret
updateError error
} }
// Init initializes the MockSecretsInterface with the set of releases. // Init initializes the MockSecretsInterface with the set of releases.
@ -235,6 +240,9 @@ func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _
// Update updates a Secret. // Update updates a Secret.
func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) { func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) {
if mock.updateError != nil {
return nil, mock.updateError
}
name := secret.Name name := secret.Name
if _, ok := mock.objects[name]; !ok { if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name) return nil, apierrors.NewNotFound(v1.Resource("tests"), name)

@ -189,8 +189,8 @@ func (secrets *Secrets) Create(key string, rel release.Releaser) error {
return nil return nil
} }
// Update updates the Secret holding the release. If not found // Update updates the Secret holding the release. If not found,
// the Secret is created to hold the release. // ErrReleaseNotFound is returned.
func (secrets *Secrets) Update(key string, rel release.Releaser) error { func (secrets *Secrets) Update(key string, rel release.Releaser) error {
// set labels for secrets object meta data // set labels for secrets object meta data
var lbs labels var lbs labels
@ -212,6 +212,10 @@ func (secrets *Secrets) Update(key string, rel release.Releaser) error {
// push the secret object out into the kubiverse // push the secret object out into the kubiverse
_, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) _, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
if err != nil { if err != nil {
if apierrors.IsNotFound(err) {
return ErrReleaseNotFound
}
return fmt.Errorf("update: failed to update: %w", err) return fmt.Errorf("update: failed to update: %w", err)
} }
return nil return nil

@ -624,11 +624,20 @@ func (s *SQL) Update(key string, rel release.Releaser) error {
return err return err
} }
if _, err := s.db.Exec(query, args...); err != nil { result, err := s.db.Exec(query, args...)
if err != nil {
s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err)) s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err))
return err return err
} }
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrReleaseNotFound
}
return nil return nil
} }

@ -287,15 +287,19 @@ func TestSqlCreateAlreadyExists(t *testing.T) {
} }
func TestSqlUpdate(t *testing.T) { func TestSqlUpdate(t *testing.T) {
vers := 1 execErr := errors.New("database unavailable")
name := "smug-pigeon" rowsErr := errors.New("rows affected unavailable")
namespace := "default" tests := []struct {
key := testKey(name, vers) name string
rel := releaseStub(name, vers, namespace, common.StatusDeployed) result driver.Result
execErr error
sqlDriver, mock := newTestFixtureSQL(t) wantErr error
body, _ := encodeRelease(rel) }{
{"updated", sqlmock.NewResult(0, 1), nil, nil},
{"missing", sqlmock.NewResult(0, 0), nil, ErrReleaseNotFound},
{"execution error", nil, execErr, execErr},
{"rows affected error", sqlmock.NewErrorResult(rowsErr), nil, rowsErr},
}
query := fmt.Sprintf( 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", "UPDATE %s SET %s = $1, %s = $2, %s = $3, %s = $4, %s = $5, %s = $6, %s = $7 WHERE %s = $8 AND %s = $9",
sqlReleaseTableName, sqlReleaseTableName,
@ -310,13 +314,35 @@ func TestSqlUpdate(t *testing.T) {
sqlReleaseTableNamespaceColumn, sqlReleaseTableNamespaceColumn,
) )
mock. for _, tt := range tests {
ExpectExec(regexp.QuoteMeta(query)). t.Run(tt.name, func(t *testing.T) {
WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, sqlReleaseDefaultType, recentUnixTimestamp(), key, namespace). name := "smug-pigeon"
WillReturnResult(sqlmock.NewResult(0, 1)) namespace := "default"
key := testKey(name, 1)
rel := releaseStub(name, 1, namespace, common.StatusDeployed)
sqlDriver, mock := newTestFixtureSQL(t)
body, err := encodeRelease(rel)
require.NoError(t, err)
require.NoErrorf(t, sqlDriver.Update(key, rel), "failed to update release with key %s", key) expectation := mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, sqlReleaseDefaultType, recentUnixTimestamp(), key, namespace)
assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") if tt.execErr != nil {
expectation.WillReturnError(tt.execErr)
} else {
expectation.WillReturnResult(tt.result)
}
err = sqlDriver.Update(key, rel)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
if !errors.Is(tt.wantErr, ErrReleaseNotFound) {
require.NotErrorIs(t, err, ErrReleaseNotFound)
}
} else {
require.NoError(t, err)
}
require.NoError(t, mock.ExpectationsWereMet())
})
}
} }
func TestSqlQuery(t *testing.T) { func TestSqlQuery(t *testing.T) {

@ -196,8 +196,8 @@ func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error {
return nil return nil
} }
// Update updates the ConfigMap holding the release. If not found // Update updates the ConfigMap holding the release. If not found,
// the ConfigMap is created to hold the release. // ErrReleaseNotFound is returned.
func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error {
// set labels for configmaps object meta data // set labels for configmaps object meta data
var lbs labels var lbs labels
@ -224,6 +224,10 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error {
// push the configmap object out into the kubiverse // push the configmap object out into the kubiverse
_, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) _, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
if err != nil { if err != nil {
if apierrors.IsNotFound(err) {
return ErrReleaseNotFound
}
cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err)) cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err))
return err return err
} }

@ -0,0 +1,83 @@
/*
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"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"helm.sh/helm/v4/pkg/release/common"
)
func TestUpdateMissingRelease(t *testing.T) {
tests := []struct {
name string
newDriver func(*testing.T) Driver
}{
{"memory", func(*testing.T) Driver { return NewMemory() }},
{"configmaps", func(t *testing.T) Driver {
t.Helper()
return newTestFixtureCfgMaps(t)
}},
{"secrets", func(t *testing.T) Driver {
t.Helper()
return newTestFixtureSecrets(t)
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := tt.newDriver(t)
rel := releaseStub("missing", 1, "default", common.StatusDeployed)
key := testKey(rel.Name, rel.Version)
require.ErrorIs(t, d.Update(key, rel), ErrReleaseNotFound)
// An update must not create the missing release.
_, err := d.Get(key)
require.ErrorIs(t, err, ErrReleaseNotFound)
})
}
}
func TestUpdateKubernetesErrors(t *testing.T) {
tests := []struct {
name string
err error
}{
{"forbidden", apierrors.NewForbidden(v1.Resource("tests"), "test", errors.New("access denied"))},
{"conflict", apierrors.NewConflict(v1.Resource("tests"), "test", errors.New("stale resource version"))},
{"connection", errors.New("connection failed")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
drivers := []Driver{
NewConfigMaps(&MockConfigMapsInterface{updateError: tt.err}),
NewSecrets(&MockSecretsInterface{updateError: tt.err}),
}
for _, d := range drivers {
t.Run(d.Name(), func(t *testing.T) {
rel := releaseStub("test", 1, "default", common.StatusDeployed)
err := d.Update(testKey(rel.Name, rel.Version), rel)
require.ErrorIs(t, err, tt.err)
require.NotErrorIs(t, err, ErrReleaseNotFound)
})
}
})
}
}

@ -95,6 +95,7 @@ type MockConfigMapsInterface struct {
corev1.ConfigMapInterface corev1.ConfigMapInterface
objects map[string]*v1.ConfigMap objects map[string]*v1.ConfigMap
updateError error
} }
// Init initializes the MockConfigMapsInterface with the set of releases. // Init initializes the MockConfigMapsInterface with the set of releases.
@ -149,6 +150,9 @@ func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.Config
// Update updates a ConfigMap. // Update updates a ConfigMap.
func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) { func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) {
if mock.updateError != nil {
return nil, mock.updateError
}
name := cfgmap.Name name := cfgmap.Name
if _, ok := mock.objects[name]; !ok { if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name) return nil, apierrors.NewNotFound(v1.Resource("tests"), name)
@ -181,6 +185,7 @@ type MockSecretsInterface struct {
corev1.SecretInterface corev1.SecretInterface
objects map[string]*v1.Secret objects map[string]*v1.Secret
updateError error
} }
// Init initializes the MockSecretsInterface with the set of releases. // Init initializes the MockSecretsInterface with the set of releases.
@ -235,6 +240,9 @@ func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _
// Update updates a Secret. // Update updates a Secret.
func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) { func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) {
if mock.updateError != nil {
return nil, mock.updateError
}
name := secret.Name name := secret.Name
if _, ok := mock.objects[name]; !ok { if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name) return nil, apierrors.NewNotFound(v1.Resource("tests"), name)

@ -189,8 +189,8 @@ func (secrets *Secrets) Create(key string, rel release.Releaser) error {
return nil return nil
} }
// Update updates the Secret holding the release. If not found // Update updates the Secret holding the release. If not found,
// the Secret is created to hold the release. // ErrReleaseNotFound is returned.
func (secrets *Secrets) Update(key string, rel release.Releaser) error { func (secrets *Secrets) Update(key string, rel release.Releaser) error {
// set labels for secrets object meta data // set labels for secrets object meta data
var lbs labels var lbs labels
@ -212,6 +212,10 @@ func (secrets *Secrets) Update(key string, rel release.Releaser) error {
// push the secret object out into the kubiverse // push the secret object out into the kubiverse
_, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) _, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
if err != nil { if err != nil {
if apierrors.IsNotFound(err) {
return ErrReleaseNotFound
}
return fmt.Errorf("update: failed to update: %w", err) return fmt.Errorf("update: failed to update: %w", err)
} }
return nil return nil

@ -623,11 +623,20 @@ func (s *SQL) Update(key string, rel release.Releaser) error {
return err return err
} }
if _, err := s.db.Exec(query, args...); err != nil { result, err := s.db.Exec(query, args...)
if err != nil {
s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err)) s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err))
return err return err
} }
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrReleaseNotFound
}
return nil return nil
} }

@ -287,15 +287,19 @@ func TestSqlCreateAlreadyExists(t *testing.T) {
} }
func TestSqlUpdate(t *testing.T) { func TestSqlUpdate(t *testing.T) {
vers := 1 execErr := errors.New("database unavailable")
name := "smug-pigeon" rowsErr := errors.New("rows affected unavailable")
namespace := "default" tests := []struct {
key := testKey(name, vers) name string
rel := releaseStub(name, vers, namespace, common.StatusDeployed) result driver.Result
execErr error
sqlDriver, mock := newTestFixtureSQL(t) wantErr error
body, _ := encodeRelease(rel) }{
{"updated", sqlmock.NewResult(0, 1), nil, nil},
{"missing", sqlmock.NewResult(0, 0), nil, ErrReleaseNotFound},
{"execution error", nil, execErr, execErr},
{"rows affected error", sqlmock.NewErrorResult(rowsErr), nil, rowsErr},
}
query := fmt.Sprintf( query := fmt.Sprintf(
"UPDATE %s SET %s = $1, %s = $2, %s = $3, %s = $4, %s = $5, %s = $6 WHERE %s = $7 AND %s = $8", "UPDATE %s SET %s = $1, %s = $2, %s = $3, %s = $4, %s = $5, %s = $6 WHERE %s = $7 AND %s = $8",
sqlReleaseTableName, sqlReleaseTableName,
@ -309,13 +313,35 @@ func TestSqlUpdate(t *testing.T) {
sqlReleaseTableNamespaceColumn, sqlReleaseTableNamespaceColumn,
) )
mock. for _, tt := range tests {
ExpectExec(regexp.QuoteMeta(query)). t.Run(tt.name, func(t *testing.T) {
WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp(), key, namespace). name := "smug-pigeon"
WillReturnResult(sqlmock.NewResult(0, 1)) namespace := "default"
key := testKey(name, 1)
rel := releaseStub(name, 1, namespace, common.StatusDeployed)
sqlDriver, mock := newTestFixtureSQL(t)
body, err := encodeRelease(rel)
require.NoError(t, err)
require.NoErrorf(t, sqlDriver.Update(key, rel), "failed to update release with key %s", key) expectation := mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp(), key, namespace)
assert.NoErrorf(t, mock.ExpectationsWereMet(), "sql expectations weren't met") if tt.execErr != nil {
expectation.WillReturnError(tt.execErr)
} else {
expectation.WillReturnResult(tt.result)
}
err = sqlDriver.Update(key, rel)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
if !errors.Is(tt.wantErr, ErrReleaseNotFound) {
require.NotErrorIs(t, err, ErrReleaseNotFound)
}
} else {
require.NoError(t, err)
}
require.NoError(t, mock.ExpectationsWereMet())
})
}
} }
func TestSqlQuery(t *testing.T) { func TestSqlQuery(t *testing.T) {

Loading…
Cancel
Save