cli: ask user to confirm cert-manager upgrades

This commit is contained in:
Otto Bittner 2023-01-04 13:55:10 +01:00
parent e7c7e35f51
commit 075a0e0ad6
5 changed files with 174 additions and 36 deletions

View File

@ -109,8 +109,8 @@ func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructu
}
// UpgradeHelmServices upgrade helm services.
func (u *Upgrader) UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration) error {
return u.helmClient.Upgrade(ctx, config, timeout)
func (u *Upgrader) UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration, allowDestructive bool) error {
return u.helmClient.Upgrade(ctx, config, timeout, allowDestructive)
}
// KubernetesVersion returns the version of Kubernetes the Constellation is currently running on.
@ -234,7 +234,7 @@ func (u *stableClient) kubernetesVersion() (string, error) {
}
type helmInterface interface {
Upgrade(ctx context.Context, config *config.Config, timeout time.Duration) error
Upgrade(ctx context.Context, config *config.Config, timeout time.Duration, allowDestructive bool) error
}
type debugLog interface {

View File

@ -8,10 +8,12 @@ package cmd
import (
"context"
"errors"
"fmt"
"time"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/cli/internal/helm"
"github.com/edgelesssys/constellation/v2/cli/internal/image"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/config"
@ -30,6 +32,7 @@ func newUpgradeExecuteCmd() *cobra.Command {
}
cmd.Flags().Bool("helm", false, "Execute helm upgrade. This feature is still in development an may change without anounncement. Upgrades all helm charts deployed during constellation-init.")
cmd.Flags().BoolP("yes", "y", false, "Run upgrades without further confirmation. WARNING: might delete your resources in case you are using cert-manager in your cluster. Please read the docs.")
cmd.Flags().Duration("timeout", 3*time.Minute, "Change helm upgrade timeout. This feature is still in development an may change without anounncement. Might be useful for slow connections or big clusters.")
if err := cmd.Flags().MarkHidden("helm"); err != nil {
panic(err)
@ -59,27 +62,35 @@ func runUpgradeExecute(cmd *cobra.Command, args []string) error {
}
func upgradeExecute(cmd *cobra.Command, imageFetcher imageFetcher, upgrader cloudUpgrader, fileHandler file.Handler) error {
configPath, err := cmd.Flags().GetString("config")
flags, err := parseUpgradeExecuteFlags(cmd)
if err != nil {
return err
return fmt.Errorf("parsing flags: %w", err)
}
conf, err := config.New(fileHandler, configPath)
conf, err := config.New(fileHandler, flags.configPath)
if err != nil {
return displayConfigValidationErrors(cmd.ErrOrStderr(), err)
}
helm, err := cmd.Flags().GetBool("helm")
if err != nil {
return err
}
if helm {
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
return err
if flags.helmFlag {
err = upgrader.UpgradeHelmServices(cmd.Context(), conf, flags.upgradeTimeout, helm.DenyDestructive)
if errors.Is(err, helm.ErrConfirmationMissing) {
if !flags.yes {
cmd.PrintErrln("WARNING: Upgrading cert-manager will destroy all custom resources you have manually created that are based on the current version of cert-manager.")
ok, askErr := askToConfirm(cmd, "Do you want to upgrade cert-manager anyway?")
if askErr != nil {
return fmt.Errorf("asking for confirmation: %w", err)
}
if !ok {
cmd.Println("Aborting upgrade.")
return nil
}
}
err = upgrader.UpgradeHelmServices(cmd.Context(), conf, flags.upgradeTimeout, helm.AllowDestructive)
}
if err := upgrader.UpgradeHelmServices(cmd.Context(), conf, timeout); err != nil {
if err != nil {
return fmt.Errorf("upgrading helm: %w", err)
}
return nil
}
@ -96,9 +107,39 @@ func upgradeExecute(cmd *cobra.Command, imageFetcher imageFetcher, upgrader clou
return upgrader.Upgrade(cmd.Context(), imageReference, conf.Upgrade.Image, conf.Upgrade.Measurements)
}
func parseUpgradeExecuteFlags(cmd *cobra.Command) (upgradeExecuteFlags, error) {
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return upgradeExecuteFlags{}, err
}
helmFlag, err := cmd.Flags().GetBool("helm")
if err != nil {
return upgradeExecuteFlags{}, err
}
yes, err := cmd.Flags().GetBool("yes")
if err != nil {
return upgradeExecuteFlags{}, err
}
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
return upgradeExecuteFlags{}, err
}
return upgradeExecuteFlags{configPath: configPath, helmFlag: helmFlag, yes: yes, upgradeTimeout: timeout}, nil
}
type upgradeExecuteFlags struct {
configPath string
helmFlag bool
yes bool
upgradeTimeout time.Duration
}
type cloudUpgrader interface {
Upgrade(ctx context.Context, imageReference, imageVersion string, measurements measurements.M) error
UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration) error
UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration, allowDestructive bool) error
}
type imageFetcher interface {

View File

@ -75,7 +75,7 @@ func (u stubUpgrader) Upgrade(context.Context, string, string, measurements.M) e
return u.err
}
func (u stubUpgrader) UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration) error {
func (u stubUpgrader) UpgradeHelmServices(ctx context.Context, config *config.Config, timeout time.Duration, allowDestructive bool) error {
return u.helmErr
}

View File

@ -16,21 +16,31 @@ import (
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/deploy/helm"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/pkg/errors"
"github.com/spf13/afero"
"golang.org/x/mod/semver"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/release"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
// AllowDestructive is a named bool to signal that destructive actions have been confirmed by the user.
AllowDestructive = true
// DenyDestructive is a named bool to signal that destructive actions have not been confirmed by the user yet.
DenyDestructive = false
)
// Client handles interaction with helm.
type Client struct {
config *action.Configuration
kubectl crdClient
fs file.Handler
actions actionWrapper
log debugLog
}
@ -55,26 +65,26 @@ func NewClient(client crdClient, kubeConfigPath, helmNamespace string, log debug
return nil, fmt.Errorf("initializing kubectl: %w", err)
}
return &Client{config: actionConfig, kubectl: client, fs: fileHandler, log: log}, nil
return &Client{kubectl: client, fs: fileHandler, actions: actions{config: actionConfig}, log: log}, nil
}
// Upgrade runs a helm-upgrade on all deployments that are managed via Helm.
// If the CLI receives an interrupt signal it will cancel the context.
// Canceling the context will prompt helm to abort and roll back the ongoing upgrade.
func (c *Client) Upgrade(ctx context.Context, config *config.Config, timeout time.Duration) error {
if err := c.upgradeRelease(ctx, timeout, ciliumPath, ciliumReleaseName, false); err != nil {
func (c *Client) Upgrade(ctx context.Context, config *config.Config, timeout time.Duration, allowDestructive bool) error {
if err := c.upgradeRelease(ctx, timeout, ciliumPath, ciliumReleaseName, false, allowDestructive); err != nil {
return fmt.Errorf("upgrading cilium: %w", err)
}
if err := c.upgradeRelease(ctx, timeout, certManagerPath, certManagerReleaseName, false); err != nil {
if err := c.upgradeRelease(ctx, timeout, certManagerPath, certManagerReleaseName, false, allowDestructive); err != nil {
return fmt.Errorf("upgrading cert-manager: %w", err)
}
if err := c.upgradeRelease(ctx, timeout, conOperatorsPath, conOperatorsReleaseName, true); err != nil {
if err := c.upgradeRelease(ctx, timeout, conOperatorsPath, conOperatorsReleaseName, true, allowDestructive); err != nil {
return fmt.Errorf("upgrading constellation operators: %w", err)
}
if err := c.upgradeRelease(ctx, timeout, conServicesPath, conServicesReleaseName, false); err != nil {
if err := c.upgradeRelease(ctx, timeout, conServicesPath, conServicesReleaseName, false, allowDestructive); err != nil {
return fmt.Errorf("upgrading constellation-services: %w", err)
}
@ -83,9 +93,7 @@ func (c *Client) Upgrade(ctx context.Context, config *config.Config, timeout tim
// currentVersion returns the version of the currently installed helm release.
func (c *Client) currentVersion(release string) (string, error) {
client := action.NewList(c.config)
client.Filter = release
rel, err := client.Run()
rel, err := c.actions.listAction(release)
if err != nil {
return "", err
}
@ -104,8 +112,11 @@ func (c *Client) currentVersion(release string) (string, error) {
return rel[0].Chart.Metadata.Version, nil
}
// ErrConfirmationMissing signals that an action requires user confirmation.
var ErrConfirmationMissing = errors.New("action requires user confirmation")
func (c *Client) upgradeRelease(
ctx context.Context, timeout time.Duration, chartPath, releaseName string, hasCRDs bool,
ctx context.Context, timeout time.Duration, chartPath, releaseName string, hasCRDs bool, allowDestructive bool,
) error {
chart, err := loadChartsDir(helmFS, chartPath)
if err != nil {
@ -126,6 +137,10 @@ func (c *Client) upgradeRelease(
return nil
}
if releaseName == certManagerReleaseName && !allowDestructive {
return ErrConfirmationMissing
}
if hasCRDs {
if err := c.updateCRDs(ctx, chart); err != nil {
return fmt.Errorf("updating CRDs: %w", err)
@ -137,13 +152,9 @@ func (c *Client) upgradeRelease(
}
c.log.Debugf("Upgrading %s from %s to %s", releaseName, currentVersion, chart.Metadata.Version)
action := action.NewUpgrade(c.config)
action.Atomic = true
action.Namespace = constants.HelmNamespace
action.ReuseValues = false
action.Timeout = timeout
if _, err := action.RunWithContext(ctx, releaseName, chart, values); err != nil {
return fmt.Errorf("upgrading %s: %w", releaseName, err)
err = c.actions.upgradeAction(ctx, releaseName, chart, values, timeout)
if err != nil {
return err
}
return nil
@ -159,9 +170,9 @@ func (c *Client) prepareValues(chart *chart.Chart, releaseName string) (map[stri
if releaseName == certManagerReleaseName {
chart.Values["installCRDs"] = true
}
values, err := c.GetValues(releaseName)
values, err := c.actions.getValues(releaseName)
if err != nil {
return nil, fmt.Errorf("getting values: %w", err)
return nil, fmt.Errorf("getting values for %s: %w", releaseName, err)
}
return helm.MergeMaps(chart.Values, values), nil
}
@ -230,3 +241,40 @@ type crdClient interface {
GetCRDs(ctx context.Context) ([]apiextensionsv1.CustomResourceDefinition, error)
GetCRs(ctx context.Context, gvr schema.GroupVersionResource) ([]unstructured.Unstructured, error)
}
type actionWrapper interface {
listAction(release string) ([]*release.Release, error)
getValues(release string) (map[string]any, error)
upgradeAction(ctx context.Context, releaseName string, chart *chart.Chart, values map[string]any, timeout time.Duration) error
}
type actions struct {
config *action.Configuration
}
// listAction execute a List action by wrapping helm's action package.
// It creates the action, runs it at returns results and errors.
func (a actions) listAction(release string) ([]*release.Release, error) {
action := action.NewList(a.config)
action.Filter = release
return action.Run()
}
func (a actions) getValues(release string) (map[string]any, error) {
client := action.NewGetValues(a.config)
// Version corresponds to the releases revision. Specifying a Version <= 0 yields the latest release.
client.Version = 0
return client.Run(release)
}
func (a actions) upgradeAction(ctx context.Context, releaseName string, chart *chart.Chart, values map[string]any, timeout time.Duration) error {
action := action.NewUpgrade(a.config)
action.Atomic = true
action.Namespace = constants.HelmNamespace
action.ReuseValues = false
action.Timeout = timeout
if _, err := action.RunWithContext(ctx, releaseName, chart, values); err != nil {
return fmt.Errorf("upgrading %s: %w", releaseName, err)
}
return nil
}

View File

@ -7,9 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only
package helm
import (
"context"
"testing"
"time"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/release"
)
func TestIsUpgrade(t *testing.T) {
@ -77,3 +82,47 @@ func TestIsUpgrade(t *testing.T) {
})
}
}
func TestUpgradeRelease(t *testing.T) {
testCases := map[string]struct {
allowDestructive bool
wantError bool
}{
"allow": {
allowDestructive: true,
},
"deny": {
allowDestructive: false,
wantError: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
client := Client{kubectl: nil, actions: &stubActionWrapper{}, log: logger.NewTest(t)}
err := client.upgradeRelease(context.Background(), 0, certManagerPath, certManagerReleaseName, false, tc.allowDestructive)
if tc.wantError {
assert.ErrorIs(err, ErrConfirmationMissing)
return
}
assert.NoError(err)
})
}
}
type stubActionWrapper struct{}
// listAction returns a list of len 1 with a release that has only it's version set.
func (a *stubActionWrapper) listAction(_ string) ([]*release.Release, error) {
return []*release.Release{{Chart: &chart.Chart{Metadata: &chart.Metadata{Version: "1.0.0"}}}}, nil
}
func (a *stubActionWrapper) getValues(release string) (map[string]any, error) {
return nil, nil
}
func (a *stubActionWrapper) upgradeAction(ctx context.Context, releaseName string, chart *chart.Chart, values map[string]any, timeout time.Duration) error {
return nil
}