mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-07-19 21:38:44 -04:00
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:
parent
f204c24174
commit
c275464634
27 changed files with 1080 additions and 992 deletions
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
535
cli/internal/cmd/upgradecheck.go
Normal file
535
cli/internal/cmd/upgradecheck.go
Normal 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, ¬Found) {
|
||||
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)
|
||||
}
|
304
cli/internal/cmd/upgradecheck_test.go
Normal file
304
cli/internal/cmd/upgradecheck_test.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 == ""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue