constellation/cli/internal/cmd/upgradeplan.go

340 lines
10 KiB
Go
Raw Normal View History

/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
2022-09-21 11:47:57 +00:00
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
2022-09-21 11:47:57 +00:00
"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,
}
2022-09-11 14:24:15 +00:00
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 {
fileHandler := file.NewHandler(afero.NewOsFs())
flags, err := parseUpgradePlanFlags(cmd)
if err != nil {
return err
}
planner, err := cloudcmd.NewUpgrader(cmd.OutOrStdout())
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 {
2022-11-30 10:48:39 +00:00
measurementsURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/", constants.CDNMeasurementsPath, img, strings.ToLower(csp.String()), "measurements.json"))
if err != nil {
return nil, err
}
2022-11-30 10:48:39 +00:00
signatureURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/", constants.CDNMeasurementsPath, img, strings.ToLower(csp.String()), "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)
}