mirror of
https://github.com/edgelesssys/constellation.git
synced 2024-10-01 01:36:09 -04:00
efcd0337b4
Run with: constellation upgrade execute --helm. This will only upgrade the helm charts. No config is needed. Upgrades are implemented via helm's upgrade action, i.e. they automatically roll back if something goes wrong. Releases could still be managed via helm, even after an upgrade with constellation has been done. Currently not user facing as CRD/CR backups are still in progress. These backups should be automatically created and saved to the user's disk as updates may delete CRs. This happens implicitly through CRD upgrades, which are part of microservice upgrades.
344 lines
10 KiB
Go
344 lines
10 KiB
Go
/*
|
|
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/manifoldco/promptui"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/talos-systems/talos/pkg/machinery/config/encoder"
|
|
"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
|
|
}
|
|
|
|
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
|
|
}
|
|
patchLister := versionsapi.New()
|
|
rekor, err := sigstore.NewRekor()
|
|
if err != nil {
|
|
return fmt.Errorf("constructing Rekor client: %w", err)
|
|
}
|
|
cliVersion := getCurrentCLIVersion()
|
|
|
|
return upgradePlan(cmd, planner, patchLister, fileHandler, http.DefaultClient, rekor, flags, cliVersion)
|
|
}
|
|
|
|
// upgradePlan plans an upgrade of a Constellation cluster.
|
|
func upgradePlan(cmd *cobra.Command, planner upgradePlanner, patchLister patchLister,
|
|
fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags,
|
|
cliVersion string,
|
|
) error {
|
|
conf, err := config.New(fileHandler, flags.configPath)
|
|
if err != nil {
|
|
return displayConfigValidationErrors(cmd.ErrOrStderr(), err)
|
|
}
|
|
|
|
// get current image version of the cluster
|
|
csp := conf.GetProvider()
|
|
|
|
version, err := getCurrentImageVersion(cmd.Context(), planner)
|
|
if err != nil {
|
|
return fmt.Errorf("checking current image version: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
var updateCandidates []string
|
|
for _, minorVer := range allowedMinorVersions {
|
|
versionList, err := patchLister.PatchVersionsOf(cmd.Context(), "-", "stable", minorVer, "image")
|
|
if err == nil {
|
|
updateCandidates = append(updateCandidates, versionList.Versions...)
|
|
}
|
|
}
|
|
|
|
// filter out versions that are not compatible with the current cluster
|
|
compatibleImages := getCompatibleImages(version, updateCandidates)
|
|
|
|
// 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)
|
|
}
|
|
|
|
if len(upgrades) == 0 {
|
|
cmd.PrintErrln("No compatible images found to upgrade to.")
|
|
return nil
|
|
}
|
|
|
|
// interactive mode
|
|
if flags.filePath == "" {
|
|
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 == "-" {
|
|
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
|
|
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 patchLister interface {
|
|
PatchVersionsOf(ctx context.Context, ref, stream, minor, kind string) (*versionsapi.List, error)
|
|
}
|