cli: change upgrade-plan to upgrade-check

Upgrade check is used to find updates for the current cluster.
Optionally the found upgrades can be persisted to the config
for consumption by the upgrade-execute cmd.
The old `upgrade execute` in this commit does not work with
the new `upgrade plan`.
The current versions are read from the cluster.
Supported versions are read from the cli and the versionsapi.
Adds a new config field MicroserviceVersion that will be used
by `upgrade execute` to update the service versions.
The field is optional until 2.7
A deprecation warning for the upgrade key is printed during
config validation.
Kubernetes versions now specify the patch version to make it
explicit for users if an upgrade changes the k8s version.
This commit is contained in:
Otto Bittner 2023-01-31 12:12:19 +01:00
parent f204c24174
commit c275464634
27 changed files with 1080 additions and 992 deletions

View file

@ -82,30 +82,40 @@ func (u *Upgrader) Upgrade(ctx context.Context, imageReference, imageVersion str
// GetCurrentImage returns the currently used image version of the cluster.
func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) {
imageStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-version")
return u.getFromConstellationVersion(ctx, "imageVersion")
}
// GetCurrentKubernetesVersion returns the currently used Kubernetes version.
func (u *Upgrader) GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error) {
return u.getFromConstellationVersion(ctx, "kubernetesClusterVersion")
}
// getFromConstellationVersion queries the constellation-version object for a given field.
func (u *Upgrader) getFromConstellationVersion(ctx context.Context, fieldName string) (*unstructured.Unstructured, string, error) {
versionStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-version")
if err != nil {
return nil, "", err
}
spec, ok := imageStruct.Object["spec"]
spec, ok := versionStruct.Object["spec"]
if !ok {
return nil, "", errors.New("image spec missing")
return nil, "", errors.New("spec missing")
}
retErr := errors.New("invalid image spec")
retErr := errors.New("invalid spec")
specMap, ok := spec.(map[string]any)
if !ok {
return nil, "", retErr
}
currentImageVersion, ok := specMap["imageVersion"]
fieldValue, ok := specMap[fieldName]
if !ok {
return nil, "", retErr
}
imageVersion, ok := currentImageVersion.(string)
fieldValueString, ok := fieldValue.(string)
if !ok {
return nil, "", retErr
}
return imageStruct, imageVersion, nil
return versionStruct, fieldValueString, nil
}
// UpgradeHelmServices upgrade helm services.

View file

@ -478,7 +478,7 @@ func (s *stubInitServer) Init(ctx context.Context, req *initproto.InitRequest) (
func defaultConfigWithExpectedMeasurements(t *testing.T, conf *config.Config, csp cloudprovider.Provider) *config.Config {
t.Helper()
conf.Image = "image"
conf.Image = constants.VersionInfo
switch csp {
case cloudprovider.Azure:

View file

@ -14,12 +14,12 @@ import (
func NewUpgradeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "upgrade",
Short: "Plan and perform an upgrade of a Constellation cluster",
Long: "Plan and perform an upgrade of a Constellation cluster.",
Short: "Find and execute upgrades to your Constellation cluster",
Long: "Find and execute upgrades to your Constellation cluster.",
Args: cobra.ExactArgs(0),
}
cmd.AddCommand(newUpgradePlanCmd())
cmd.AddCommand(newUpgradeCheckCmd())
cmd.AddCommand(newUpgradeExecuteCmd())
return cmd

View file

@ -0,0 +1,535 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/cli/internal/helm"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/compatibility"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/kubernetes/kubectl"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/edgelesssys/constellation/v2/internal/versions"
"github.com/edgelesssys/constellation/v2/internal/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/versionsapi/fetcher"
"github.com/siderolabs/talos/pkg/machinery/config/encoder"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/mod/semver"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func newUpgradeCheckCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "check",
Short: "Check for possible upgrades.",
Long: "Check which upgrades can be applied to your Constellation Cluster.",
Args: cobra.NoArgs,
RunE: runUpgradeCheck,
}
cmd.Flags().BoolP("write-config", "w", false, "Update the specified config file with the suggested versions")
cmd.Flags().String("ref", versionsapi.ReleaseRef, "Specify the reference used when querying the versionsapi for new versions.")
cmd.Flags().String("stream", "stable", "Specify the stream used when querying the versionsapi for new versions.")
return cmd
}
func runUpgradeCheck(cmd *cobra.Command, args []string) error {
log, err := newCLILogger(cmd)
if err != nil {
return fmt.Errorf("creating logger: %w", err)
}
defer log.Sync()
fileHandler := file.NewHandler(afero.NewOsFs())
flags, err := parseUpgradeCheckFlags(cmd)
if err != nil {
return err
}
checker, err := cloudcmd.NewUpgrader(cmd.OutOrStdout(), log)
if err != nil {
return err
}
versionListFetcher := fetcher.NewFetcher()
rekor, err := sigstore.NewRekor()
if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err)
}
up := &upgradeCheckCmd{
collect: &versionCollector{
writer: cmd.OutOrStderr(),
checker: checker,
verListFetcher: versionListFetcher,
fileHandler: fileHandler,
client: http.DefaultClient,
rekor: rekor,
flags: flags,
cliVersion: constants.VersionInfo,
},
log: log,
}
return up.upgradeCheck(cmd, fileHandler, flags)
}
func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) {
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return upgradeCheckFlags{}, err
}
force, err := cmd.Flags().GetBool("force")
if err != nil {
return upgradeCheckFlags{}, err
}
writeConfig, err := cmd.Flags().GetBool("write-config")
if err != nil {
return upgradeCheckFlags{}, err
}
ref, err := cmd.Flags().GetString("ref")
if err != nil {
return upgradeCheckFlags{}, err
}
stream, err := cmd.Flags().GetString("stream")
if err != nil {
return upgradeCheckFlags{}, err
}
return upgradeCheckFlags{
configPath: configPath,
force: force,
writeConfig: writeConfig,
ref: ref,
stream: stream,
cosignPubKey: constants.CosignPublicKey,
}, nil
}
type upgradeCheckCmd struct {
collect collector
log debugLog
}
// upgradePlan plans an upgrade of a Constellation cluster.
func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fileHandler file.Handler, flags upgradeCheckFlags) error {
conf, err := config.New(fileHandler, flags.configPath, flags.force)
if err != nil {
return config.DisplayValidationErrors(cmd.ErrOrStderr(), err)
}
u.log.Debugf("Read configuration from %q", flags.configPath)
// get current image version of the cluster
csp := conf.GetProvider()
u.log.Debugf("Using provider %s", csp.String())
currentServices, currentImage, currentK8s, err := u.collect.currentVersions(cmd.Context())
if err != nil {
return err
}
supportedServices, supportedImages, supportedK8s, err := u.collect.supportedVersions(cmd.Context(), currentImage, csp)
if err != nil {
return err
}
u.log.Debugf("Current service version: %s", currentServices)
u.log.Debugf("Supported service version: %s", supportedServices)
u.log.Debugf("Current k8s version: %s", currentK8s)
u.log.Debugf("Supported k8s version: %s", supportedK8s)
// Filter versions to only include upgrades
newServices := supportedServices
if err := compatibility.IsValidUpgrade(currentServices, supportedServices); err != nil {
newServices = ""
}
newKubernetes := filterK8sUpgrades(currentK8s, supportedK8s)
sort.Strings(newKubernetes)
supportedImages = filterImageUpgrades(currentImage, supportedImages)
newImages, err := u.collect.newMeasurementes(cmd.Context(), csp, supportedImages)
if err != nil {
return err
}
upgrade := versionUpgrade{
newServices: newServices,
newImages: newImages,
newKubernetes: newKubernetes,
currentServices: currentServices,
currentImage: currentImage,
currentKubernetes: currentK8s,
}
updateMsg, err := upgrade.buildString()
if err != nil {
return err
}
// Using Print over Println as buildString already includes a trailing newline where necessary.
cmd.Print(updateMsg)
if flags.writeConfig {
if err := upgrade.writeConfig(conf, fileHandler, flags.configPath); err != nil {
return fmt.Errorf("writing config: %w", err)
}
cmd.Println("Wrote config successfully.")
}
return nil
}
func sortedMapKeys[T any](a map[string]T) []string {
keys := []string{}
for k := range a {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func filterImageUpgrades(currentVersion string, newVersions []versionsapi.Version) []versionsapi.Version {
newImages := []versionsapi.Version{}
for i := range newVersions {
if err := compatibility.IsValidUpgrade(currentVersion, newVersions[i].Version); err != nil {
continue
}
newImages = append(newImages, newVersions[i])
}
return newImages
}
func filterK8sUpgrades(currentVersion string, newVersions []string) []string {
result := []string{}
for i := range newVersions {
if err := compatibility.IsValidUpgrade(currentVersion, newVersions[i]); err != nil {
continue
}
result = append(result, newVersions[i])
}
return result
}
type collector interface {
currentVersions(ctx context.Context) (serviceVersions string, imageVersion string, k8sVersion string, err error)
supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersions string, imageVersions []versionsapi.Version, k8sVersions []string, err error)
newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error)
newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error)
newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error)
}
type versionCollector struct {
writer io.Writer
checker upgradeChecker
verListFetcher versionListFetcher
fileHandler file.Handler
client *http.Client
rekor rekorVerifier
flags upgradeCheckFlags
cliVersion string
log debugLog
}
func (v *versionCollector) newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error) {
// get expected measurements for each image
upgrades, err := getCompatibleImageMeasurements(ctx, v.writer, v.client, v.rekor, []byte(v.flags.cosignPubKey), csp, images, v.log)
if err != nil {
return nil, fmt.Errorf("fetching measurements for compatible images: %w", err)
}
v.log.Debugf("Compatible image measurements are %v", upgrades)
return upgrades, nil
}
func (v *versionCollector) currentVersions(ctx context.Context) (serviceVersion string, imageVersion string, k8sVersion string, err error) {
helmClient, err := helm.NewClient(kubectl.New(), constants.AdminConfFilename, constants.HelmNamespace, v.log)
if err != nil {
return "", "", "", fmt.Errorf("setting up helm client: %w", err)
}
serviceVersion, err = helmClient.Versions()
if err != nil {
return "", "", "", fmt.Errorf("getting service versions: %w", err)
}
imageVersion, err = getCurrentImageVersion(ctx, v.checker)
if err != nil {
return "", "", "", fmt.Errorf("getting image version: %w", err)
}
k8sVersion, err = getCurrentKubernetesVersion(ctx, v.checker)
if err != nil {
return "", "", "", fmt.Errorf("getting image version: %w", err)
}
return serviceVersion, imageVersion, k8sVersion, nil
}
// supportedVersions returns slices of supported versions.
func (v *versionCollector) supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersion string, imageVersions []versionsapi.Version, k8sVersions []string, err error) {
k8sVersions = versions.SupportedK8sVersions()
serviceVersion, err = helm.AvailableServiceVersions()
if err != nil {
return "", nil, nil, fmt.Errorf("loading service versions: %w", err)
}
imageVersions, err = v.newImages(ctx, version, csp)
if err != nil {
return "", nil, nil, fmt.Errorf("loading image versions: %w", err)
}
return serviceVersion, imageVersions, k8sVersions, nil
}
func (v *versionCollector) newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error) {
// find compatible images
// image updates should always be possible for the current minor version of the cluster
// (e.g. 0.1.0 -> 0.1.1, 0.1.2, 0.1.3, etc.)
// additionally, we allow updates to the next minor version (e.g. 0.1.0 -> 0.2.0)
// if the CLI minor version is newer than the cluster minor version
currentImageMinorVer := semver.MajorMinor(version)
currentCLIMinorVer := semver.MajorMinor(v.cliVersion)
nextImageMinorVer, err := compatibility.NextMinorVersion(currentImageMinorVer)
if err != nil {
return nil, fmt.Errorf("calculating next image minor version: %w", err)
}
v.log.Debugf("Current image minor version is %s", currentImageMinorVer)
v.log.Debugf("Current CLI minor version is %s", currentCLIMinorVer)
v.log.Debugf("Next image minor version is %s", nextImageMinorVer)
allowedMinorVersions := []string{currentImageMinorVer, nextImageMinorVer}
switch cliImageCompare := semver.Compare(currentCLIMinorVer, currentImageMinorVer); {
case cliImageCompare < 0:
if !v.flags.force {
return nil, fmt.Errorf("cluster image version (%s) newer than CLI version (%s)", currentImageMinorVer, currentCLIMinorVer)
}
if _, err := fmt.Fprintf(v.writer, "WARNING: CLI version is older than cluster image version. Continuing due to force flag."); err != nil {
return nil, fmt.Errorf("writing to buffer: %w", err)
}
case cliImageCompare == 0:
allowedMinorVersions = []string{currentImageMinorVer}
case cliImageCompare > 0:
allowedMinorVersions = []string{currentImageMinorVer, nextImageMinorVer}
}
v.log.Debugf("Allowed minor versions are %#v", allowedMinorVersions)
newerImages, err := v.newerVersions(ctx, currentImageMinorVer, allowedMinorVersions)
if err != nil {
return nil, fmt.Errorf("newer versions: %w", err)
}
return newerImages, nil
}
func (v *versionCollector) newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error) {
var updateCandidates []versionsapi.Version
for _, minorVer := range allowedVersions {
patchList := versionsapi.List{
Ref: v.flags.ref,
Stream: v.flags.stream,
Base: minorVer,
Granularity: versionsapi.GranularityMinor,
Kind: versionsapi.VersionKindImage,
}
patchList, err := v.verListFetcher.FetchVersionList(ctx, patchList)
var notFound *fetcher.NotFoundError
if errors.As(err, &notFound) {
v.log.Debugf("Skipping version: %s", err)
continue
}
if err != nil {
return nil, fmt.Errorf("fetching version list: %w", err)
}
updateCandidates = append(updateCandidates, patchList.StructuredVersions()...)
}
v.log.Debugf("Update candidates are %v", updateCandidates)
return updateCandidates, nil
}
type versionUpgrade struct {
newServices string
newImages map[string]measurements.M
newKubernetes []string
currentServices string
currentImage string
currentKubernetes string
}
func (v *versionUpgrade) buildString() (string, error) {
upgradeMsg := strings.Builder{}
if len(v.newKubernetes) > 0 {
upgradeMsg.WriteString(fmt.Sprintf(" Kubernetes: %s --> %s\n", v.currentKubernetes, strings.Join(v.newKubernetes, " ")))
}
if len(v.newImages) > 0 {
imageMsgs := strings.Builder{}
newImagesSorted := sortedMapKeys(v.newImages)
for i, image := range newImagesSorted {
// prevent trailing newlines
if i > 0 {
imageMsgs.WriteString("\n")
}
content, err := encoder.NewEncoder(v.newImages[image]).Encode()
contentFormated := strings.ReplaceAll(string(content), "\n", "\n ")
if err != nil {
return "", fmt.Errorf("marshalling measurements: %w", err)
}
imageMsgs.WriteString(fmt.Sprintf(" %s --> %s\n Includes these measurements:\n %s", v.currentImage, image, contentFormated))
}
upgradeMsg.WriteString(" Images:\n")
upgradeMsg.WriteString(imageMsgs.String())
fmt.Fprintln(&upgradeMsg, "")
}
if v.newServices != "" {
upgradeMsg.WriteString(fmt.Sprintf(" Services: %s --> %s\n", v.currentServices, v.newServices))
}
result := strings.Builder{}
if upgradeMsg.Len() > 0 {
result.WriteString("The following updates are available with this CLI:\n")
result.WriteString(upgradeMsg.String())
return result.String(), nil
}
result.WriteString("No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n")
return result.String(), nil
}
func (v *versionUpgrade) writeConfig(conf *config.Config, fileHandler file.Handler, configPath string) error {
// can't sort image map because maps are unsorted. services is only one string, k8s versions are sorted.
if v.newServices != "" {
conf.MicroserviceVersion = v.newServices
}
if len(v.newServices) > 0 {
conf.KubernetesVersion = v.newKubernetes[0]
}
if len(v.newImages) > 0 {
imageUpgrade := sortedMapKeys(v.newImages)[0]
conf.Image = imageUpgrade
conf.UpdateMeasurements(v.newImages[imageUpgrade])
}
if err := fileHandler.WriteYAML(configPath, conf, file.OptOverwrite); err != nil {
return err
}
return nil
}
// getCurrentImageVersion retrieves the semantic version of the image currently installed in the cluster.
// If the cluster is not using a release image, an error is returned.
func getCurrentImageVersion(ctx context.Context, checker upgradeChecker) (string, error) {
_, imageVersion, err := checker.GetCurrentImage(ctx)
if err != nil {
return "", err
}
if !semver.IsValid(imageVersion) {
return "", fmt.Errorf("current image version is not a release image version: %q", imageVersion)
}
return imageVersion, nil
}
// getCurrentKubernetesVersion retrieves the semantic version of Kubernetes currently installed in the cluster.
func getCurrentKubernetesVersion(ctx context.Context, checker upgradeChecker) (string, error) {
_, k8sVersion, err := checker.GetCurrentKubernetesVersion(ctx)
if err != nil {
return "", err
}
if !semver.IsValid(k8sVersion) {
return "", fmt.Errorf("current kubernetes version is not a valid semver string: %q", k8sVersion)
}
return k8sVersion, nil
}
// getCompatibleImageMeasurements retrieves the expected measurements for each image.
func getCompatibleImageMeasurements(ctx context.Context, writer io.Writer, client *http.Client, rekor rekorVerifier, pubK []byte,
csp cloudprovider.Provider, versions []versionsapi.Version, log debugLog,
) (map[string]measurements.M, error) {
upgrades := make(map[string]measurements.M)
for _, version := range versions {
log.Debugf("Fetching measurements for image: %s", version)
shortPath := version.ShortPath()
measurementsURL, err := measurementURL(csp, shortPath, "measurements.json")
if err != nil {
return nil, err
}
signatureURL, err := measurementURL(csp, shortPath, "measurements.json.sig")
if err != nil {
return nil, err
}
var fetchedMeasurements measurements.M
log.Debugf("Fetching for measurement url: %s", measurementsURL)
hash, err := fetchedMeasurements.FetchAndVerify(
ctx, client,
measurementsURL,
signatureURL,
pubK,
measurements.WithMetadata{
CSP: csp,
Image: shortPath,
},
)
if err != nil {
if _, err := fmt.Fprintf(writer, "Skipping compatible image %q: %s\n", shortPath, err); err != nil {
return nil, fmt.Errorf("writing to buffer: %w", err)
}
continue
}
if err = verifyWithRekor(ctx, rekor, hash); err != nil {
if _, err := fmt.Fprintf(writer, "Warning: Unable to verify '%s' in Rekor.\n", hash); err != nil {
return nil, fmt.Errorf("writing to buffer: %w", err)
}
if _, err := fmt.Fprintf(writer, "Make sure measurements are correct.\n"); err != nil {
return nil, fmt.Errorf("writing to buffer: %w", err)
}
}
upgrades[shortPath] = fetchedMeasurements
}
return upgrades, nil
}
type upgradeCheckFlags struct {
configPath string
force bool
writeConfig bool
ref string
stream string
cosignPubKey string
}
type upgradeChecker interface {
GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error)
GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error)
}
type versionListFetcher interface {
FetchVersionList(ctx context.Context, list versionsapi.List) (versionsapi.List, error)
}

View file

@ -0,0 +1,304 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/internal/versionsapi"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// TestBuildString checks that the resulting user output is as expected. Slow part is the Sscanf in parseCanonicalSemver().
func TestBuildString(t *testing.T) {
testCases := map[string]struct {
upgrade versionUpgrade
expected string
wantError bool
}{
"update everything": {
upgrade: versionUpgrade{
newServices: "v2.5.0",
newImages: map[string]measurements.M{
"v2.5.0": measurements.DefaultsFor(cloudprovider.QEMU),
},
newKubernetes: []string{"v1.24.12", "v1.25.6"},
currentServices: "v2.4.0",
currentImage: "v2.4.0",
currentKubernetes: "v1.24.5",
},
expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n Images:\n v2.4.0 --> v2.5.0\n Includes these measurements:\n 4:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 8:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 9:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 11:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 12:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 13:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 15:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n \n Services: v2.4.0 --> v2.5.0\n",
},
"no upgrades": {
upgrade: versionUpgrade{
newServices: "",
newImages: map[string]measurements.M{},
newKubernetes: []string{},
currentServices: "v2.5.0",
currentImage: "v2.5.0",
currentKubernetes: "v1.25.6",
},
expected: "No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n",
},
"no upgrades #2": {
upgrade: versionUpgrade{},
expected: "No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
result, err := tc.upgrade.buildString()
if tc.wantError {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.expected, result)
})
}
}
func TestGetCurrentImageVersion(t *testing.T) {
testCases := map[string]struct {
stubUpgradeChecker stubUpgradeChecker
wantErr bool
}{
"valid version": {
stubUpgradeChecker: stubUpgradeChecker{
image: "v1.0.0",
},
},
"invalid version": {
stubUpgradeChecker: stubUpgradeChecker{
image: "invalid",
},
wantErr: true,
},
"GetCurrentImage error": {
stubUpgradeChecker: stubUpgradeChecker{
err: errors.New("error"),
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradeChecker)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.True(semver.IsValid(version))
})
}
}
func TestGetCompatibleImageMeasurements(t *testing.T) {
assert := assert.New(t)
csp := cloudprovider.Azure
zero := versionsapi.Version{
Ref: "-",
Stream: "stable",
Version: "v0.0.0",
Kind: versionsapi.VersionKindImage,
}
one := versionsapi.Version{
Ref: "-",
Stream: "stable",
Version: "v1.0.0",
Kind: versionsapi.VersionKindImage,
}
images := []versionsapi.Version{zero, one}
client := newTestClient(func(req *http.Request) *http.Response {
if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v0.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json.sig") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("MEQCIGRR7RaSMs892Ta06/Tz7LqPUxI05X4wQcP+nFFmZtmaAiBNl9X8mUKmUBfxg13LQBfmmpw6JwYQor5hOwM3NFVPAg==")),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json.sig") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("MEQCIFh8CVELp/Da2U2Jt404OXsUeDfqtrf3pqGRuvxnxhI8AiBTHF9tHEPwFedYG3Jgn2ELOxss+Ybc6135vEtClBrbpg==")),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not found.")),
Header: make(http.Header),
}
})
pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----")
upgrades, err := getCompatibleImageMeasurements(context.Background(), &bytes.Buffer{}, client, singleUUIDVerifier(), pubK, csp, images, logger.NewTest(t))
assert.NoError(err)
for _, measurement := range upgrades {
assert.NotEmpty(measurement)
}
}
func TestUpgradeCheck(t *testing.T) {
v2_3 := versionsapi.Version{
Ref: "-",
Stream: "stable",
Version: "v2.3.0",
Kind: versionsapi.VersionKindImage,
}
v2_5 := versionsapi.Version{
Ref: "-",
Stream: "stable",
Version: "v2.5.0",
Kind: versionsapi.VersionKindImage,
}
testCases := map[string]struct {
collector stubVersionCollector
flags upgradeCheckFlags
csp cloudprovider.Provider
cliVersion string
wantError bool
}{
"upgrades gcp": {
collector: stubVersionCollector{
supportedServicesVersions: "v2.5.0",
supportedImages: []versionsapi.Version{v2_3},
supportedImageVersions: map[string]measurements.M{
"v2.3.0": measurements.DefaultsFor(cloudprovider.QEMU),
},
supportedK8sVersions: []string{"v1.24.5", "v1.24.12", "v1.25.6"},
currentServicesVersions: "v2.4.0",
currentImageVersion: "v2.4.0",
currentK8sVersion: "v1.24.5",
images: []versionsapi.Version{v2_5},
newCLIVersions: []string{"v2.5.0", "v2.6.0"},
},
flags: upgradeCheckFlags{
configPath: constants.ConfigFilename,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
constants.VersionInfo = "v0.0.0"
fileHandler := file.NewHandler(afero.NewMemMapFs())
cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), tc.csp)
require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg))
checkCmd := upgradeCheckCmd{
collect: &tc.collector,
log: logger.NewTest(t),
}
cmd := newUpgradeCheckCmd()
err := checkCmd.upgradeCheck(cmd, fileHandler, tc.flags)
if tc.wantError {
assert.Error(err)
return
}
assert.NoError(err)
})
}
}
type stubVersionCollector struct {
supportedServicesVersions string
supportedImages []versionsapi.Version
supportedImageVersions map[string]measurements.M
supportedK8sVersions []string
currentServicesVersions string
currentImageVersion string
currentK8sVersion string
images []versionsapi.Version
newCLIVersions []string
someErr error
}
func (s *stubVersionCollector) newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error) {
return s.supportedImageVersions, nil
}
func (s *stubVersionCollector) currentVersions(ctx context.Context) (serviceVersions string, imageVersion string, k8sVersion string, err error) {
return s.currentServicesVersions, s.currentImageVersion, s.currentK8sVersion, s.someErr
}
func (s *stubVersionCollector) supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersions string, imageVersions []versionsapi.Version, k8sVersions []string, err error) {
return s.supportedServicesVersions, s.supportedImages, s.supportedK8sVersions, s.someErr
}
func (s *stubVersionCollector) newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error) {
return s.images, nil
}
func (s *stubVersionCollector) newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error) {
return s.images, nil
}
type stubUpgradeChecker struct {
image string
k8sVersion string
err error
}
func (u stubUpgradeChecker) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.image, u.err
}
func (u stubUpgradeChecker) GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.k8sVersion, u.err
}

View file

@ -1,366 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/edgelesssys/constellation/v2/internal/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/versionsapi/fetcher"
"github.com/manifoldco/promptui"
"github.com/siderolabs/talos/pkg/machinery/config/encoder"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/mod/semver"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func newUpgradePlanCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "plan",
Short: "Plan an upgrade of a Constellation cluster",
Long: "Plan an upgrade of a Constellation cluster by fetching compatible image versions and their measurements.",
Args: cobra.NoArgs,
RunE: runUpgradePlan,
}
cmd.Flags().StringP("file", "f", "", "path to output file, or '-' for stdout (omit for interactive mode)")
return cmd
}
type upgradePlanCmd struct {
log debugLog
}
func runUpgradePlan(cmd *cobra.Command, args []string) error {
log, err := newCLILogger(cmd)
if err != nil {
return fmt.Errorf("creating logger: %w", err)
}
defer log.Sync()
fileHandler := file.NewHandler(afero.NewOsFs())
flags, err := parseUpgradePlanFlags(cmd)
if err != nil {
return err
}
planner, err := cloudcmd.NewUpgrader(cmd.OutOrStdout(), log)
if err != nil {
return err
}
versionListFetcher := fetcher.NewFetcher()
rekor, err := sigstore.NewRekor()
if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err)
}
cliVersion := getCurrentCLIVersion()
up := &upgradePlanCmd{log: log}
return up.upgradePlan(cmd, planner, versionListFetcher, fileHandler, http.DefaultClient, rekor, flags, cliVersion)
}
// upgradePlan plans an upgrade of a Constellation cluster.
func (up *upgradePlanCmd) upgradePlan(cmd *cobra.Command, planner upgradePlanner, verListFetcher versionListFetcher,
fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags,
cliVersion string,
) error {
conf, err := config.New(fileHandler, flags.configPath, true)
if err != nil {
return config.DisplayValidationErrors(cmd.ErrOrStderr(), err)
}
up.log.Debugf("Read configuration from %q", flags.configPath)
// get current image version of the cluster
csp := conf.GetProvider()
up.log.Debugf("Using provider %s", csp.String())
version, err := getCurrentImageVersion(cmd.Context(), planner)
if err != nil {
return fmt.Errorf("checking current image version: %w", err)
}
up.log.Debugf("Using image version %s", version)
// find compatible images
// image updates should always be possible for the current minor version of the cluster
// (e.g. 0.1.0 -> 0.1.1, 0.1.2, 0.1.3, etc.)
// additionally, we allow updates to the next minor version (e.g. 0.1.0 -> 0.2.0)
// if the CLI minor version is newer than the cluster minor version
currentImageMinorVer := semver.MajorMinor(version)
currentCLIMinorVer := semver.MajorMinor(cliVersion)
nextImageMinorVer, err := nextMinorVersion(currentImageMinorVer)
if err != nil {
return fmt.Errorf("calculating next image minor version: %w", err)
}
up.log.Debugf("Current image minor version is %s", currentImageMinorVer)
up.log.Debugf("Current CLI minor version is %s", currentCLIMinorVer)
up.log.Debugf("Next image minor version is %s", nextImageMinorVer)
var allowedMinorVersions []string
cliImageCompare := semver.Compare(currentCLIMinorVer, currentImageMinorVer)
switch {
case cliImageCompare < 0:
cmd.PrintErrln("Warning: CLI version is older than cluster image version. This is not supported.")
case cliImageCompare == 0:
allowedMinorVersions = []string{currentImageMinorVer}
case cliImageCompare > 0:
allowedMinorVersions = []string{currentImageMinorVer, nextImageMinorVer}
}
up.log.Debugf("Allowed minor versions are %#v", allowedMinorVersions)
var updateCandidates []string
for _, minorVer := range allowedMinorVersions {
patchList := versionsapi.List{
Ref: versionsapi.ReleaseRef,
Stream: "stable",
Base: minorVer,
Granularity: versionsapi.GranularityMinor,
Kind: versionsapi.VersionKindImage,
}
patchList, err = verListFetcher.FetchVersionList(cmd.Context(), patchList)
if err == nil {
updateCandidates = append(updateCandidates, patchList.Versions...)
}
}
up.log.Debugf("Update candidates are %v", updateCandidates)
// filter out versions that are not compatible with the current cluster
compatibleImages := getCompatibleImages(version, updateCandidates)
up.log.Debugf("Of those images, these ones are compatible %v", compatibleImages)
// get expected measurements for each image
upgrades, err := getCompatibleImageMeasurements(cmd.Context(), cmd, client, rekor, []byte(flags.cosignPubKey), csp, compatibleImages)
if err != nil {
return fmt.Errorf("fetching measurements for compatible images: %w", err)
}
up.log.Debugf("Compatible image measurements are %v", upgrades)
if len(upgrades) == 0 {
cmd.PrintErrln("No compatible images found to upgrade to.")
return nil
}
// interactive mode
if flags.filePath == "" {
up.log.Debugf("Writing upgrade plan in interactive mode")
cmd.Printf("Current version: %s\n", version)
return upgradePlanInteractive(
&nopWriteCloser{cmd.OutOrStdout()},
io.NopCloser(cmd.InOrStdin()),
flags.configPath, conf, fileHandler,
upgrades,
)
}
// write upgrade plan to stdout
if flags.filePath == "-" {
up.log.Debugf("Writing upgrade plan to stdout")
content, err := encoder.NewEncoder(upgrades).Encode()
if err != nil {
return fmt.Errorf("encoding compatible images: %w", err)
}
_, err = cmd.OutOrStdout().Write(content)
return err
}
// write upgrade plan to file
up.log.Debugf("Writing upgrade plan to file")
return fileHandler.WriteYAML(flags.filePath, upgrades)
}
// getCompatibleImages trims the list of images to only ones compatible with the current cluster.
func getCompatibleImages(currentImageVersion string, images []string) []string {
var compatibleImages []string
for _, image := range images {
// check if image is newer than current version
if semver.Compare(image, currentImageVersion) <= 0 {
continue
}
compatibleImages = append(compatibleImages, image)
}
return compatibleImages
}
// getCompatibleImageMeasurements retrieves the expected measurements for each image.
func getCompatibleImageMeasurements(ctx context.Context, cmd *cobra.Command, client *http.Client, rekor rekorVerifier, pubK []byte,
csp cloudprovider.Provider, images []string,
) (map[string]config.UpgradeConfig, error) {
upgrades := make(map[string]config.UpgradeConfig)
for _, img := range images {
measurementsURL, err := measurementURL(csp, img, "measurements.json")
if err != nil {
return nil, err
}
signatureURL, err := measurementURL(csp, img, "measurements.json.sig")
if err != nil {
return nil, err
}
var fetchedMeasurements measurements.M
hash, err := fetchedMeasurements.FetchAndVerify(
ctx, client,
measurementsURL,
signatureURL,
pubK,
measurements.WithMetadata{
CSP: csp,
Image: img,
},
)
if err != nil {
cmd.PrintErrf("Skipping image %q: %s\n", img, err)
continue
}
if err = verifyWithRekor(ctx, rekor, hash); err != nil {
cmd.PrintErrf("Warning: Unable to verify '%s' in Rekor.\n", hash)
cmd.PrintErrf("Make sure measurements are correct.\n")
}
upgrades[img] = config.UpgradeConfig{
Image: img,
Measurements: fetchedMeasurements,
CSP: csp,
}
}
return upgrades, nil
}
// getCurrentImageVersion retrieves the semantic version of the image currently installed in the cluster.
// If the cluster is not using a release image, an error is returned.
func getCurrentImageVersion(ctx context.Context, planner upgradePlanner) (string, error) {
_, imageVersion, err := planner.GetCurrentImage(ctx)
if err != nil {
return "", err
}
if !semver.IsValid(imageVersion) {
return "", fmt.Errorf("current image version is not a release image version: %q", imageVersion)
}
return imageVersion, nil
}
func getCurrentCLIVersion() string {
return "v" + constants.VersionInfo
}
func parseUpgradePlanFlags(cmd *cobra.Command) (upgradePlanFlags, error) {
configPath, err := cmd.Flags().GetString("config")
if err != nil {
return upgradePlanFlags{}, err
}
filePath, err := cmd.Flags().GetString("file")
if err != nil {
return upgradePlanFlags{}, err
}
return upgradePlanFlags{
configPath: configPath,
filePath: filePath,
cosignPubKey: constants.CosignPublicKey,
}, nil
}
func upgradePlanInteractive(out io.WriteCloser, in io.ReadCloser,
configPath string, config *config.Config, fileHandler file.Handler,
compatibleUpgrades map[string]config.UpgradeConfig,
) error {
var imageVersions []string
for k := range compatibleUpgrades {
imageVersions = append(imageVersions, k)
}
semver.Sort(imageVersions)
prompt := promptui.Select{
Label: "Select an image version to upgrade to",
Items: imageVersions,
Searcher: func(input string, index int) bool {
version := imageVersions[index]
trimmedVersion := strings.TrimPrefix(strings.Replace(version, ".", "", -1), "v")
input = strings.TrimPrefix(strings.Replace(input, ".", "", -1), "v")
return strings.Contains(trimmedVersion, input)
},
Size: 10,
Stdin: in,
Stdout: out,
}
_, res, err := prompt.Run()
if err != nil {
return err
}
fmt.Fprintln(out, "Updating config to the following:")
fmt.Fprintf(out, "Image: %s\n", compatibleUpgrades[res].Image)
fmt.Fprintln(out, "Measurements:")
content, err := encoder.NewEncoder(compatibleUpgrades[res].Measurements).Encode()
if err != nil {
return fmt.Errorf("encoding measurements: %w", err)
}
measurements := strings.TrimSuffix(strings.Replace("\t"+string(content), "\n", "\n\t", -1), "\n\t")
fmt.Fprintln(out, measurements)
config.Upgrade = compatibleUpgrades[res]
return fileHandler.WriteYAML(configPath, config, file.OptOverwrite)
}
func nextMinorVersion(version string) (string, error) {
major, minor, _, err := parseCanonicalSemver(version)
if err != nil {
return "", err
}
return fmt.Sprintf("v%d.%d", major, minor+1), nil
}
func parseCanonicalSemver(version string) (major int, minor int, patch int, err error) {
version = semver.Canonical(version) // ensure version is in canonical form (vX.Y.Z)
num, err := fmt.Sscanf(version, "v%d.%d.%d", &major, &minor, &patch)
if err != nil {
return 0, 0, 0, fmt.Errorf("parsing version: %w", err)
}
if num != 3 {
return 0, 0, 0, fmt.Errorf("parsing version: expected 3 numbers, got %d", num)
}
return major, minor, patch, nil
}
type upgradePlanFlags struct {
configPath string
filePath string
cosignPubKey string
}
type nopWriteCloser struct {
io.Writer
}
func (c *nopWriteCloser) Close() error { return nil }
type upgradePlanner interface {
GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error)
}
type versionListFetcher interface {
FetchVersionList(ctx context.Context, list versionsapi.List) (versionsapi.List, error)
}

View file

@ -1,498 +0,0 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/internal/versionsapi"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
"gopkg.in/yaml.v3"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestGetCurrentImageVersion(t *testing.T) {
testCases := map[string]struct {
stubUpgradePlanner stubUpgradePlanner
wantErr bool
}{
"valid version": {
stubUpgradePlanner: stubUpgradePlanner{
image: "v1.0.0",
},
},
"invalid version": {
stubUpgradePlanner: stubUpgradePlanner{
image: "invalid",
},
wantErr: true,
},
"GetCurrentImage error": {
stubUpgradePlanner: stubUpgradePlanner{
err: errors.New("error"),
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradePlanner)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.True(semver.IsValid(version))
})
}
}
func TestGetCompatibleImages(t *testing.T) {
imageList := []string{
"v0.0.0",
"v1.0.0",
"v1.0.1",
"v1.0.2",
"v1.1.0",
}
testCases := map[string]struct {
images []string
version string
wantImages []string
}{
"filters <= v1.0.0": {
images: imageList,
version: "v1.0.0",
wantImages: []string{
"v1.0.1",
"v1.0.2",
"v1.1.0",
},
},
"no compatible images": {
images: imageList,
version: "v999.999.999",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
compatibleImages := getCompatibleImages(tc.version, tc.images)
assert.EqualValues(tc.wantImages, compatibleImages)
})
}
}
func TestGetCompatibleImageMeasurements(t *testing.T) {
assert := assert.New(t)
csp := cloudprovider.Azure
images := []string{"v0.0.0", "v1.0.0"}
client := newTestClient(func(req *http.Request) *http.Response {
if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v0.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json.sig") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("MEQCIGRR7RaSMs892Ta06/Tz7LqPUxI05X4wQcP+nFFmZtmaAiBNl9X8mUKmUBfxg13LQBfmmpw6JwYQor5hOwM3NFVPAg==")),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json.sig") {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("MEQCIFh8CVELp/Da2U2Jt404OXsUeDfqtrf3pqGRuvxnxhI8AiBTHF9tHEPwFedYG3Jgn2ELOxss+Ybc6135vEtClBrbpg==")),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not found.")),
Header: make(http.Header),
}
})
pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----")
upgrades, err := getCompatibleImageMeasurements(context.Background(), &cobra.Command{}, client, singleUUIDVerifier(), pubK, csp, images)
assert.NoError(err)
for _, image := range upgrades {
assert.NotEmpty(image.Measurements)
}
}
func TestUpgradePlan(t *testing.T) {
availablePatches := versionsapi.List{
Versions: []string{"v1.0.0", "v1.0.1"},
}
// Cosign private key used to sign the measurements.
// Generated with: cosign generate-key-pair
// Password left empty.
//
// -----BEGIN ENCRYPTED COSIGN PRIVATE KEY-----
// eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjozMjc2OCwiciI6
// OCwicCI6MX0sInNhbHQiOiJlRHVYMWRQMGtIWVRnK0xkbjcxM0tjbFVJaU92eFVX
// VXgvNi9BbitFVk5BPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94
// Iiwibm9uY2UiOiJwaWhLL2txNmFXa2hqSVVHR3RVUzhTVkdHTDNIWWp4TCJ9LCJj
// aXBoZXJ0ZXh0Ijoidm81SHVWRVFWcUZ2WFlQTTVPaTVaWHM5a255bndZU2dvcyth
// VklIeHcrOGFPamNZNEtvVjVmL3lHRHR0K3BHV2toanJPR1FLOWdBbmtsazFpQ0c5
// a2czUXpPQTZsU2JRaHgvZlowRVRZQ0hLeElncEdPRVRyTDlDenZDemhPZXVSOXJ6
// TDcvRjBBVy9vUDVqZXR3dmJMNmQxOEhjck9kWE8yVmYxY2w0YzNLZjVRcnFSZzlN
// dlRxQWFsNXJCNHNpY1JaMVhpUUJjb0YwNHc9PSJ9
// -----END ENCRYPTED COSIGN PRIVATE KEY-----
pubK := "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----"
testCases := map[string]struct {
patchLister stubVersionListFetcher
planner stubUpgradePlanner
flags upgradePlanFlags
cliVersion string
csp cloudprovider.Provider
verifier rekorVerifier
measurementsFetchStatus int
wantUpgrade bool
wantErr bool
}{
"upgrades gcp": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
cliVersion: "v1.0.0",
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"upgrades azure": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.Azure,
cliVersion: "v999.999.999",
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"current image newer than updates": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v999.999.999",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: false,
},
"current image newer than cli": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v999.999.999",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(),
wantUpgrade: false,
},
"upgrade to stdout": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "-",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"current image not valid": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "not-valid",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(),
wantErr: true,
},
"image fetch error": {
patchLister: stubVersionListFetcher{err: errors.New("error")},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(),
},
"measurements fetch error": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusInternalServerError,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(),
},
"failing search should not result in error": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{},
SearchByHashError: errors.New("some error"),
},
wantUpgrade: true,
},
"failing verify should not result in error": {
patchLister: stubVersionListFetcher{list: availablePatches},
planner: stubUpgradePlanner{
image: "v1.0.0",
},
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
VerifyEntryError: errors.New("some error"),
},
wantUpgrade: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
fileHandler := file.NewHandler(afero.NewMemMapFs())
cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), tc.csp)
require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg))
cmd := newUpgradePlanCmd()
cmd.SetContext(context.Background())
var outTarget bytes.Buffer
cmd.SetOut(&outTarget)
var errTarget bytes.Buffer
cmd.SetErr(&errTarget)
client := newTestClient(func(req *http.Request) *http.Response {
if strings.HasSuffix(req.URL.String(), "azure/measurements.json") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "azure/measurements.json.sig") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("MEYCIQDu2Sft91FjN278uP+r/HFMms6IH/tRtaHzYvIN0xPgdwIhAJhiFxVsHCa0NK6bZOGLE9c4miZHIqFTKvgpTf3rJ9dW")),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "gcp/measurements.json") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader(`{"csp":"gcp","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "gcp/measurements.json.sig") {
return &http.Response{
StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("MEQCIBUssv92LpSMiXE1UAVf2fW8J9pZHiLseo2tdZjxv2OMAiB6K8e8yL0768jWjlFnRe3Rc2x/dX34uzX3h0XUrlYt1A==")),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("Not found.")),
Header: make(http.Header),
}
})
up := &upgradePlanCmd{log: logger.NewTest(t)}
err := up.upgradePlan(cmd, tc.planner, tc.patchLister, fileHandler, client, tc.verifier, tc.flags, tc.cliVersion)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
if !tc.wantUpgrade {
assert.Contains(errTarget.String(), "No compatible images")
return
}
var availableUpgrades map[string]config.UpgradeConfig
if tc.flags.filePath == "-" {
require.NoError(yaml.Unmarshal(outTarget.Bytes(), &availableUpgrades))
} else {
require.NoError(fileHandler.ReadYAMLStrict(tc.flags.filePath, &availableUpgrades))
}
assert.GreaterOrEqual(len(availableUpgrades), 1)
for _, upgrade := range availableUpgrades {
assert.NotEmpty(upgrade.Image)
assert.NotEmpty(upgrade.Measurements)
}
})
}
}
func TestNextMinorVersion(t *testing.T) {
testCases := map[string]struct {
version string
wantNextMinorVersion string
wantErr bool
}{
"gets next": {
version: "v1.0.0",
wantNextMinorVersion: "v1.1",
},
"gets next from minor version": {
version: "v1.0",
wantNextMinorVersion: "v1.1",
},
"empty version": {
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
gotNext, err := nextMinorVersion(tc.version)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantNextMinorVersion, gotNext)
})
}
}
type stubUpgradePlanner struct {
image string
err error
}
func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.image, u.err
}
type stubVersionListFetcher struct {
list versionsapi.List
err error
}
func (s stubVersionListFetcher) FetchVersionList(context.Context, versionsapi.List) (versionsapi.List, error) {
return s.list, s.err
}

View file

@ -118,26 +118,26 @@ func (v *verifyCmd) parseVerifyFlags(cmd *cobra.Command, fileHandler file.Handle
if err != nil {
return verifyFlags{}, fmt.Errorf("parsing config path argument: %w", err)
}
v.log.Debugf("Configuration file flag is %q", configPath)
v.log.Debugf("Flag 'config' set to %q", configPath)
ownerID := ""
clusterID, err := cmd.Flags().GetString("cluster-id")
if err != nil {
return verifyFlags{}, fmt.Errorf("parsing cluster-id argument: %w", err)
}
v.log.Debugf("Cluster ID flag is %q", clusterID)
v.log.Debugf("Flag 'cluster-id' set to %q", clusterID)
endpoint, err := cmd.Flags().GetString("node-endpoint")
if err != nil {
return verifyFlags{}, fmt.Errorf("parsing node-endpoint argument: %w", err)
}
v.log.Debugf("'node-endpoint' flag is %q", endpoint)
v.log.Debugf("Flag 'node-endpoint' set to %q", endpoint)
force, err := cmd.Flags().GetBool("force")
if err != nil {
return verifyFlags{}, fmt.Errorf("parsing force argument: %w", err)
}
v.log.Debugf("'force' flag is %t", force)
v.log.Debugf("Flag 'force' set to %t", force)
// Get empty values from ID file
emptyEndpoint := endpoint == ""

View file

@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/edgelesssys/constellation/v2/internal/compatibility"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/deploy/helm"
@ -91,6 +92,16 @@ func (c *Client) Upgrade(ctx context.Context, config *config.Config, timeout tim
return nil
}
// Versions queries the cluster for running versions and returns a map of releaseName -> version.
func (c *Client) Versions() (string, error) {
serviceVersion, err := c.currentVersion(conServicesReleaseName)
if err != nil {
return "", fmt.Errorf("getting constellation-services version: %w", err)
}
return compatibility.EnsurePrefixV(serviceVersion), nil
}
// currentVersion returns the version of the currently installed helm release.
func (c *Client) currentVersion(release string) (string, error) {
rel, err := c.actions.listAction(release)

View file

@ -18,6 +18,7 @@ import (
"strings"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/compatibility"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/deploy/helm"
@ -90,6 +91,16 @@ func NewLoader(csp cloudprovider.Provider, k8sVersion versions.ValidK8sVersion)
}
}
// AvailableServiceVersions returns the chart version number of the bundled service versions.
func AvailableServiceVersions() (string, error) {
servicesChart, err := loadChartsDir(helmFS, conServicesPath)
if err != nil {
return "", fmt.Errorf("loading constellation-services chart: %w", err)
}
return compatibility.EnsurePrefixV(servicesChart.Metadata.Version), nil
}
// Load the embedded helm charts.
func (i *ChartLoader) Load(config *config.Config, conformanceMode bool, masterSecret, salt []byte) ([]byte, error) {
ciliumRelease, err := i.loadCilium(config.GetProvider(), conformanceMode)