Add image update API and use for "upgrade plan"

This commit is contained in:
Malte Poll 2022-11-29 11:39:07 +01:00 committed by Malte Poll
parent 954cbad214
commit ebf852b3ba
9 changed files with 806 additions and 394 deletions

View File

@ -66,18 +66,18 @@ func NewUpgrader(outWriter io.Writer) (*Upgrader, error) {
} }
// Upgrade upgrades the cluster to the given measurements and image. // Upgrade upgrades the cluster to the given measurements and image.
func (u *Upgrader) Upgrade(ctx context.Context, image string, measurements measurements.M) error { func (u *Upgrader) Upgrade(ctx context.Context, imageReference, imageVersion string, measurements measurements.M) error {
if err := u.updateMeasurements(ctx, measurements); err != nil { if err := u.updateMeasurements(ctx, measurements); err != nil {
return fmt.Errorf("updating measurements: %w", err) return fmt.Errorf("updating measurements: %w", err)
} }
if err := u.updateImage(ctx, image); err != nil { if err := u.updateImage(ctx, imageReference, imageVersion); err != nil {
return fmt.Errorf("updating image: %w", err) return fmt.Errorf("updating image: %w", err)
} }
return nil return nil
} }
// GetCurrentImage returns the currently used image of the cluster. // GetCurrentImage returns the currently used image version of the cluster.
func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) { func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) {
imageStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-os") imageStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-os")
if err != nil { if err != nil {
@ -93,16 +93,16 @@ func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructu
if !ok { if !ok {
return nil, "", retErr return nil, "", retErr
} }
currentImageDefinition, ok := specMap["image"] currentImageVersion, ok := specMap["imageVersion"]
if !ok { if !ok {
return nil, "", retErr return nil, "", retErr
} }
imageDefinition, ok := currentImageDefinition.(string) imageVersion, ok := currentImageVersion.(string)
if !ok { if !ok {
return nil, "", retErr return nil, "", retErr
} }
return imageStruct, imageDefinition, nil return imageStruct, imageVersion, nil
} }
// CurrentHelmVersion returns the version of the currently installed helm release. // CurrentHelmVersion returns the version of the currently installed helm release.
@ -154,18 +154,19 @@ func (u *Upgrader) updateMeasurements(ctx context.Context, newMeasurements measu
return nil return nil
} }
func (u *Upgrader) updateImage(ctx context.Context, image string) error { func (u *Upgrader) updateImage(ctx context.Context, imageReference, imageVersion string) error {
currentImage, currentImageDefinition, err := u.GetCurrentImage(ctx) currentImage, currentImageVersion, err := u.GetCurrentImage(ctx)
if err != nil { if err != nil {
return fmt.Errorf("retrieving current image: %w", err) return fmt.Errorf("retrieving current image: %w", err)
} }
if currentImageDefinition == image { if currentImageVersion == imageVersion {
fmt.Fprintln(u.outWriter, "Cluster is already using the chosen image, skipping image upgrade") fmt.Fprintln(u.outWriter, "Cluster is already using the chosen image, skipping image upgrade")
return nil return nil
} }
currentImage.Object["spec"].(map[string]any)["image"] = image currentImage.Object["spec"].(map[string]any)["image"] = imageReference
currentImage.Object["spec"].(map[string]any)["imageVersion"] = imageVersion
if _, err := u.dynamicInterface.update(ctx, currentImage); err != nil { if _, err := u.dynamicInterface.update(ctx, currentImage); err != nil {
return fmt.Errorf("setting new image: %w", err) return fmt.Errorf("setting new image: %w", err)
} }

View File

@ -149,35 +149,40 @@ func (u *stubClientInterface) kubernetesVersion() (string, error) {
func TestUpdateImage(t *testing.T) { func TestUpdateImage(t *testing.T) {
someErr := errors.New("error") someErr := errors.New("error")
testCases := map[string]struct { testCases := map[string]struct {
updater *stubImageUpdater updater *stubImageUpdater
newImage string newImageReference string
wantUpdate bool newImageVersion string
wantErr bool wantUpdate bool
wantErr bool
}{ }{
"success": { "success": {
updater: &stubImageUpdater{ updater: &stubImageUpdater{
setImage: &unstructured.Unstructured{ setImage: &unstructured.Unstructured{
Object: map[string]any{ Object: map[string]any{
"spec": map[string]any{ "spec": map[string]any{
"image": "old-image", "image": "old-image-ref",
"imageVersion": "old-image-ver",
}, },
}, },
}, },
}, },
newImage: "new-image", newImageReference: "new-image-ref",
wantUpdate: true, newImageVersion: "new-image-ver",
wantUpdate: true,
}, },
"image is the same": { "image is the same": {
updater: &stubImageUpdater{ updater: &stubImageUpdater{
setImage: &unstructured.Unstructured{ setImage: &unstructured.Unstructured{
Object: map[string]any{ Object: map[string]any{
"spec": map[string]any{ "spec": map[string]any{
"image": "old-image", "image": "old-image-ref",
"imageVersion": "old-image-ver",
}, },
}, },
}, },
}, },
newImage: "old-image", newImageReference: "old-image-ref",
newImageVersion: "old-image-ver",
}, },
"getCurrent error": { "getCurrent error": {
updater: &stubImageUpdater{getErr: someErr}, updater: &stubImageUpdater{getErr: someErr},
@ -188,14 +193,16 @@ func TestUpdateImage(t *testing.T) {
setImage: &unstructured.Unstructured{ setImage: &unstructured.Unstructured{
Object: map[string]any{ Object: map[string]any{
"spec": map[string]any{ "spec": map[string]any{
"image": "old-image", "image": "old-image-ref",
"imageVersion": "old-image-ver",
}, },
}, },
}, },
updateErr: someErr, updateErr: someErr,
}, },
newImage: "new-image", newImageReference: "new-image-ref",
wantErr: true, newImageVersion: "new-image-ver",
wantErr: true,
}, },
"no spec": { "no spec": {
updater: &stubImageUpdater{ updater: &stubImageUpdater{
@ -203,8 +210,9 @@ func TestUpdateImage(t *testing.T) {
Object: map[string]any{}, Object: map[string]any{},
}, },
}, },
newImage: "new-image", newImageReference: "new-image-ref",
wantErr: true, newImageVersion: "new-image-ver",
wantErr: true,
}, },
"not a map": { "not a map": {
updater: &stubImageUpdater{ updater: &stubImageUpdater{
@ -214,8 +222,9 @@ func TestUpdateImage(t *testing.T) {
}, },
}, },
}, },
newImage: "new-image", newImageReference: "new-image-ref",
wantErr: true, newImageVersion: "new-image-ver",
wantErr: true,
}, },
"no spec.image": { "no spec.image": {
updater: &stubImageUpdater{ updater: &stubImageUpdater{
@ -225,8 +234,9 @@ func TestUpdateImage(t *testing.T) {
}, },
}, },
}, },
newImage: "new-image", newImageReference: "new-image-ref",
wantErr: true, newImageVersion: "new-image-ver",
wantErr: true,
}, },
} }
@ -239,7 +249,7 @@ func TestUpdateImage(t *testing.T) {
outWriter: &bytes.Buffer{}, outWriter: &bytes.Buffer{},
} }
err := upgrader.updateImage(context.Background(), tc.newImage) err := upgrader.updateImage(context.Background(), tc.newImageReference, tc.newImageVersion)
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
@ -248,7 +258,8 @@ func TestUpdateImage(t *testing.T) {
assert.NoError(err) assert.NoError(err)
if tc.wantUpdate { if tc.wantUpdate {
assert.Equal(tc.newImage, tc.updater.updatedImage.Object["spec"].(map[string]any)["image"]) assert.Equal(tc.newImageReference, tc.updater.updatedImage.Object["spec"].(map[string]any)["image"])
assert.Equal(tc.newImageVersion, tc.updater.updatedImage.Object["spec"].(map[string]any)["imageVersion"])
} else { } else {
assert.Nil(tc.updater.updatedImage) assert.Nil(tc.updater.updatedImage)
} }

View File

@ -13,6 +13,7 @@ import (
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements" "github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/config" "github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/image"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -31,15 +32,16 @@ func newUpgradeExecuteCmd() *cobra.Command {
func runUpgradeExecute(cmd *cobra.Command, args []string) error { func runUpgradeExecute(cmd *cobra.Command, args []string) error {
fileHandler := file.NewHandler(afero.NewOsFs()) fileHandler := file.NewHandler(afero.NewOsFs())
imageFetcher := image.New()
upgrader, err := cloudcmd.NewUpgrader(cmd.OutOrStdout()) upgrader, err := cloudcmd.NewUpgrader(cmd.OutOrStdout())
if err != nil { if err != nil {
return err return err
} }
return upgradeExecute(cmd, upgrader, fileHandler) return upgradeExecute(cmd, imageFetcher, upgrader, fileHandler)
} }
func upgradeExecute(cmd *cobra.Command, upgrader cloudUpgrader, fileHandler file.Handler) error { func upgradeExecute(cmd *cobra.Command, imageFetcher imageFetcher, upgrader cloudUpgrader, fileHandler file.Handler) error {
configPath, err := cmd.Flags().GetString("config") configPath, err := cmd.Flags().GetString("config")
if err != nil { if err != nil {
return err return err
@ -52,9 +54,20 @@ func upgradeExecute(cmd *cobra.Command, upgrader cloudUpgrader, fileHandler file
// TODO: validate upgrade config? Should be basic things like checking image is not an empty string // TODO: validate upgrade config? Should be basic things like checking image is not an empty string
// More sophisticated validation, like making sure we don't downgrade the cluster, should be done by `constellation upgrade plan` // More sophisticated validation, like making sure we don't downgrade the cluster, should be done by `constellation upgrade plan`
return upgrader.Upgrade(cmd.Context(), conf.Upgrade.Image, conf.Upgrade.Measurements) // this config modification is temporary until we can remove the upgrade section from the config
conf.Image = conf.Upgrade.Image
imageReference, err := imageFetcher.FetchReference(cmd.Context(), conf)
if err != nil {
return err
}
return upgrader.Upgrade(cmd.Context(), imageReference, conf.Upgrade.Image, conf.Upgrade.Measurements)
} }
type cloudUpgrader interface { type cloudUpgrader interface {
Upgrade(ctx context.Context, image string, measurements measurements.M) error Upgrade(ctx context.Context, imageReference, imageVersion string, measurements measurements.M) error
}
type imageFetcher interface {
FetchReference(ctx context.Context, config *config.Config) (string, error)
} }

View File

@ -23,11 +23,20 @@ import (
func TestUpgradeExecute(t *testing.T) { func TestUpgradeExecute(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
upgrader stubUpgrader upgrader stubUpgrader
wantErr bool imageFetcher stubImageFetcher
wantErr bool
}{ }{
"success": { "success": {
upgrader: stubUpgrader{}, imageFetcher: stubImageFetcher{
reference: "someReference",
},
},
"fetch error": {
imageFetcher: stubImageFetcher{
fetchReferenceErr: errors.New("error"),
},
wantErr: true,
}, },
"upgrade error": { "upgrade error": {
upgrader: stubUpgrader{err: errors.New("error")}, upgrader: stubUpgrader{err: errors.New("error")},
@ -46,7 +55,7 @@ func TestUpgradeExecute(t *testing.T) {
cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.Azure) cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.Azure)
require.NoError(handler.WriteYAML(constants.ConfigFilename, cfg)) require.NoError(handler.WriteYAML(constants.ConfigFilename, cfg))
err := upgradeExecute(cmd, tc.upgrader, handler) err := upgradeExecute(cmd, &tc.imageFetcher, tc.upgrader, handler)
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
} else { } else {
@ -60,6 +69,15 @@ type stubUpgrader struct {
err error err error
} }
func (u stubUpgrader) Upgrade(context.Context, string, measurements.M) error { func (u stubUpgrader) Upgrade(context.Context, string, string, measurements.M) error {
return u.err return u.err
} }
type stubImageFetcher struct {
reference string
fetchReferenceErr error
}
func (f *stubImageFetcher) FetchReference(_ context.Context, _ *config.Config) (string, error) {
return f.reference, f.fetchReferenceErr
}

View File

@ -8,16 +8,15 @@ package cmd
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"regexp"
"strings" "strings"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd" "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/cli/internal/update"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements" "github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config" "github.com/edgelesssys/constellation/v2/internal/config"
@ -32,13 +31,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
) )
const imageReleaseURL = "https://github.com/edgelesssys/constellation/releases/latest/download/versions-manifest.json"
var (
azureCVMRxp = regexp.MustCompile(`^(?i)\/CommunityGalleries\/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df\/Images\/constellation\/Versions\/[\d]+.[\d]+.[\d]+$`)
gcpCVMRxp = regexp.MustCompile(`^projects\/constellation-images\/global\/images\/constellation-(v[\d]+-[\d]+-[\d]+)$`)
)
func newUpgradePlanCmd() *cobra.Command { func newUpgradePlanCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "plan", Use: "plan",
@ -63,17 +55,20 @@ func runUpgradePlan(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
patchLister := update.New()
rekor, err := sigstore.NewRekor() rekor, err := sigstore.NewRekor()
if err != nil { if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err) return fmt.Errorf("constructing Rekor client: %w", err)
} }
cliVersion := getCurrentCLIVersion()
return upgradePlan(cmd, planner, fileHandler, http.DefaultClient, rekor, flags) return upgradePlan(cmd, planner, patchLister, fileHandler, http.DefaultClient, rekor, flags, cliVersion)
} }
// upgradePlan plans an upgrade of a Constellation cluster. // upgradePlan plans an upgrade of a Constellation cluster.
func upgradePlan(cmd *cobra.Command, planner upgradePlanner, func upgradePlan(cmd *cobra.Command, planner upgradePlanner, patchLister patchLister,
fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags, fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags,
cliVersion string,
) error { ) error {
conf, err := config.New(fileHandler, flags.configPath) conf, err := config.New(fileHandler, flags.configPath)
if err != nil { if err != nil {
@ -83,27 +78,58 @@ func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
// get current image version of the cluster // get current image version of the cluster
csp := conf.GetProvider() csp := conf.GetProvider()
version, err := getCurrentImageVersion(cmd.Context(), planner, csp) version, err := getCurrentImageVersion(cmd.Context(), planner)
if err != nil { if err != nil {
return fmt.Errorf("checking current image version: %w", err) return fmt.Errorf("checking current image version: %w", err)
} }
// fetch images definitions from GitHub and filter to only compatible images // find compatible images
images, err := fetchImages(cmd.Context(), client) // 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 { if err != nil {
return fmt.Errorf("fetching available images: %w", err) return fmt.Errorf("calculating next image minor version: %w", err)
}
compatibleImages := getCompatibleImages(csp, version, images)
if len(compatibleImages) == 0 {
cmd.PrintErrln("No compatible images found to upgrade to.")
return nil
} }
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 // get expected measurements for each image
if err := getCompatibleImageMeasurements(cmd.Context(), cmd, client, rekor, []byte(flags.cosignPubKey), compatibleImages); err != nil { 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) 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 // interactive mode
if flags.filePath == "" { if flags.filePath == "" {
cmd.Printf("Current version: %s\n", version) cmd.Printf("Current version: %s\n", version)
@ -111,13 +137,13 @@ func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
&nopWriteCloser{cmd.OutOrStdout()}, &nopWriteCloser{cmd.OutOrStdout()},
io.NopCloser(cmd.InOrStdin()), io.NopCloser(cmd.InOrStdin()),
flags.configPath, conf, fileHandler, flags.configPath, conf, fileHandler,
compatibleImages, upgrades,
) )
} }
// write upgrade plan to stdout // write upgrade plan to stdout
if flags.filePath == "-" { if flags.filePath == "-" {
content, err := encoder.NewEncoder(compatibleImages).Encode() content, err := encoder.NewEncoder(upgrades).Encode()
if err != nil { if err != nil {
return fmt.Errorf("encoding compatible images: %w", err) return fmt.Errorf("encoding compatible images: %w", err)
} }
@ -126,91 +152,53 @@ func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
} }
// write upgrade plan to file // write upgrade plan to file
return fileHandler.WriteYAML(flags.filePath, compatibleImages) return fileHandler.WriteYAML(flags.filePath, upgrades)
}
// fetchImages retrieves a list of the latest Constellation node images from GitHub.
func fetchImages(ctx context.Context, client *http.Client) (map[string]imageManifest, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageReleaseURL, http.NoBody)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
imagesJSON, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
images := make(map[string]imageManifest)
if err := json.Unmarshal(imagesJSON, &images); err != nil {
return nil, err
}
return images, nil
} }
// getCompatibleImages trims the list of images to only ones compatible with the current cluster. // getCompatibleImages trims the list of images to only ones compatible with the current cluster.
func getCompatibleImages(csp cloudprovider.Provider, currentVersion string, images map[string]imageManifest) map[string]config.UpgradeConfig { func getCompatibleImages(currentImageVersion string, images []string) []string {
compatibleImages := make(map[string]config.UpgradeConfig) var compatibleImages []string
switch csp { for _, image := range images {
case cloudprovider.Azure: // check if image is newer than current version
for imgVersion, image := range images { if semver.Compare(image, currentImageVersion) <= 0 {
if semver.Compare(currentVersion, imgVersion) < 0 { continue
compatibleImages[imgVersion] = config.UpgradeConfig{
Image: image.AzureImage,
CSP: cloudprovider.Azure,
}
}
}
case cloudprovider.GCP:
for imgVersion, image := range images {
if semver.Compare(currentVersion, imgVersion) < 0 {
compatibleImages[imgVersion] = config.UpgradeConfig{
Image: image.GCPImage,
CSP: cloudprovider.GCP,
}
}
} }
compatibleImages = append(compatibleImages, image)
} }
return compatibleImages return compatibleImages
} }
// getCompatibleImageMeasurements retrieves the expected measurements for each image. // getCompatibleImageMeasurements retrieves the expected measurements for each image.
func getCompatibleImageMeasurements(ctx context.Context, cmd *cobra.Command, client *http.Client, rekor rekorVerifier, pubK []byte, images map[string]config.UpgradeConfig) error { func getCompatibleImageMeasurements(ctx context.Context, cmd *cobra.Command, client *http.Client, rekor rekorVerifier, pubK []byte,
for idx, img := range images { csp cloudprovider.Provider, images []string,
measurementsURL, err := url.Parse(constants.CDNRepositoryURL + "/" + path.Join(img.Image, strings.ToLower(img.CSP.String()), "measurements.json")) ) (map[string]config.UpgradeConfig, error) {
upgrades := make(map[string]config.UpgradeConfig)
for _, img := range images {
measurementsURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/constellation/v1/measurements/", img, strings.ToLower(csp.String()), "measurements.json"))
if err != nil { if err != nil {
return err return nil, err
} }
signatureURL, err := url.Parse(constants.CDNRepositoryURL + "/" + path.Join(img.Image, strings.ToLower(img.CSP.String()), "measurements.json.sig")) signatureURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/constellation/v1/measurements/", img, strings.ToLower(csp.String()), "measurements.json.sig"))
if err != nil { if err != nil {
return err return nil, err
} }
hash, err := img.Measurements.FetchAndVerify( var fetchedMeasurements measurements.M
hash, err := fetchedMeasurements.FetchAndVerify(
ctx, client, ctx, client,
measurementsURL, measurementsURL,
signatureURL, signatureURL,
pubK, pubK,
measurements.WithMetadata{ measurements.WithMetadata{
Image: img.Image, CSP: csp,
CSP: img.CSP, Image: img,
}, },
) )
if err != nil { if err != nil {
return err cmd.PrintErrf("Skipping image %q: %s\n", img, err)
continue
} }
if err = verifyWithRekor(ctx, rekor, hash); err != nil { if err = verifyWithRekor(ctx, rekor, hash); err != nil {
@ -218,42 +206,34 @@ func getCompatibleImageMeasurements(ctx context.Context, cmd *cobra.Command, cli
cmd.PrintErrf("Make sure measurements are correct.\n") cmd.PrintErrf("Make sure measurements are correct.\n")
} }
images[idx] = img upgrades[img] = config.UpgradeConfig{
Image: img,
Measurements: fetchedMeasurements,
CSP: csp,
}
} }
return nil return upgrades, nil
} }
// getCurrentImageVersion retrieves the semantic version of the image currently installed in the cluster. // 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. // If the cluster is not using a release image, an error is returned.
func getCurrentImageVersion(ctx context.Context, planner upgradePlanner, csp cloudprovider.Provider) (string, error) { func getCurrentImageVersion(ctx context.Context, planner upgradePlanner) (string, error) {
_, image, err := planner.GetCurrentImage(ctx) _, imageVersion, err := planner.GetCurrentImage(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
var version string if !semver.IsValid(imageVersion) {
switch csp { return "", fmt.Errorf("current image version is not a release image version: %q", imageVersion)
case cloudprovider.Azure:
if !azureCVMRxp.MatchString(image) {
return "", fmt.Errorf("image %q does not look like a released production image for Azure", image)
}
versionRxp := regexp.MustCompile(`[\d]+.[\d]+.[\d]+$`)
version = "v" + versionRxp.FindString(image)
case cloudprovider.GCP:
gcpVersion := gcpCVMRxp.FindStringSubmatch(image)
if len(gcpVersion) != 2 {
return "", fmt.Errorf("image %q does not look like a released production image for GCP", image)
}
version = strings.ReplaceAll(gcpVersion[1], "-", ".")
default:
return "", fmt.Errorf("unsupported cloud provider: %s", csp.String())
} }
if !semver.IsValid(version) { return imageVersion, nil
return "", fmt.Errorf("image %q has no valid semantic version", image) }
}
return version, nil func getCurrentCLIVersion() string {
return "v" + constants.VersionInfo
} }
func parseUpgradePlanFlags(cmd *cobra.Command) (upgradePlanFlags, error) { func parseUpgradePlanFlags(cmd *cobra.Command) (upgradePlanFlags, error) {
@ -275,10 +255,10 @@ func parseUpgradePlanFlags(cmd *cobra.Command) (upgradePlanFlags, error) {
func upgradePlanInteractive(out io.WriteCloser, in io.ReadCloser, func upgradePlanInteractive(out io.WriteCloser, in io.ReadCloser,
configPath string, config *config.Config, fileHandler file.Handler, configPath string, config *config.Config, fileHandler file.Handler,
compatibleImages map[string]config.UpgradeConfig, compatibleUpgrades map[string]config.UpgradeConfig,
) error { ) error {
var imageVersions []string var imageVersions []string
for k := range compatibleImages { for k := range compatibleUpgrades {
imageVersions = append(imageVersions, k) imageVersions = append(imageVersions, k)
} }
semver.Sort(imageVersions) semver.Sort(imageVersions)
@ -304,30 +284,46 @@ func upgradePlanInteractive(out io.WriteCloser, in io.ReadCloser,
fmt.Fprintln(out, "Updating config to the following:") fmt.Fprintln(out, "Updating config to the following:")
fmt.Fprintf(out, "Image: %s\n", compatibleImages[res].Image) fmt.Fprintf(out, "Image: %s\n", compatibleUpgrades[res].Image)
fmt.Fprintln(out, "Measurements:") fmt.Fprintln(out, "Measurements:")
content, err := encoder.NewEncoder(compatibleImages[res].Measurements).Encode() content, err := encoder.NewEncoder(compatibleUpgrades[res].Measurements).Encode()
if err != nil { if err != nil {
return fmt.Errorf("encoding measurements: %w", err) return fmt.Errorf("encoding measurements: %w", err)
} }
measurements := strings.TrimSuffix(strings.Replace("\t"+string(content), "\n", "\n\t", -1), "\n\t") measurements := strings.TrimSuffix(strings.Replace("\t"+string(content), "\n", "\n\t", -1), "\n\t")
fmt.Fprintln(out, measurements) fmt.Fprintln(out, measurements)
config.Upgrade = compatibleImages[res] config.Upgrade = compatibleUpgrades[res]
return fileHandler.WriteYAML(configPath, config, file.OptOverwrite) 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 { type upgradePlanFlags struct {
configPath string configPath string
filePath string filePath string
cosignPubKey string cosignPubKey string
} }
type imageManifest struct {
AzureImage string `json:"AzureOSImage"`
GCPImage string `json:"GCPOSImage"`
}
type nopWriteCloser struct { type nopWriteCloser struct {
io.Writer io.Writer
} }
@ -337,3 +333,7 @@ func (c *nopWriteCloser) Close() error { return nil }
type upgradePlanner interface { type upgradePlanner interface {
GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error)
} }
type patchLister interface {
PatchVersionsOf(ctx context.Context, stream, minor, kind string) (*update.VersionsList, error)
}

View File

@ -9,13 +9,13 @@ package cmd
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"github.com/edgelesssys/constellation/v2/cli/internal/update"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config" "github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/constants"
@ -32,47 +32,23 @@ import (
func TestGetCurrentImageVersion(t *testing.T) { func TestGetCurrentImageVersion(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
stubUpgradePlanner stubUpgradePlanner stubUpgradePlanner stubUpgradePlanner
csp cloudprovider.Provider
wantErr bool wantErr bool
}{ }{
"valid Azure": { "valid version": {
stubUpgradePlanner: stubUpgradePlanner{ stubUpgradePlanner: stubUpgradePlanner{
image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0", image: "v1.0.0",
}, },
csp: cloudprovider.Azure,
}, },
"invalid Azure": { "invalid version": {
stubUpgradePlanner: stubUpgradePlanner{ stubUpgradePlanner: stubUpgradePlanner{
image: "/CommunityGalleries/someone-else/Images/constellation/Versions/0.0.1", image: "invalid",
}, },
csp: cloudprovider.Azure,
wantErr: true,
},
"valid GCP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0",
},
csp: cloudprovider.GCP,
},
"invalid GCP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-debug-image",
},
csp: cloudprovider.GCP,
wantErr: true,
},
"invalid CSP": {
stubUpgradePlanner: stubUpgradePlanner{
image: "some-image",
},
csp: cloudprovider.Unknown,
wantErr: true, wantErr: true,
}, },
"GetCurrentImage error": { "GetCurrentImage error": {
stubUpgradePlanner: stubUpgradePlanner{ stubUpgradePlanner: stubUpgradePlanner{
err: errors.New("error"), err: errors.New("error"),
}, },
csp: cloudprovider.Azure,
wantErr: true, wantErr: true,
}, },
} }
@ -81,7 +57,7 @@ func TestGetCurrentImageVersion(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradePlanner, tc.csp) version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradePlanner)
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
return return
@ -93,141 +69,32 @@ func TestGetCurrentImageVersion(t *testing.T) {
} }
} }
type stubUpgradePlanner struct {
image string
err error
}
func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.image, u.err
}
func TestFetchImages(t *testing.T) {
testImages := map[string]imageManifest{
"v0.0.0": {
AzureImage: "azure-v0.0.0",
GCPImage: "gcp-v0.0.0",
},
"v999.999.999": {
AzureImage: "azure-v999.999.999",
GCPImage: "gcp-v999.999.999",
},
}
testCases := map[string]struct {
client *http.Client
wantErr bool
}{
"success": {
client: newTestClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))),
Header: make(http.Header),
}
}),
},
"error": {
client: newTestClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewBuffer([]byte{})),
Header: make(http.Header),
}
}),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
images, err := fetchImages(context.Background(), tc.client)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.NotNil(images)
})
}
}
func TestGetCompatibleImages(t *testing.T) { func TestGetCompatibleImages(t *testing.T) {
imageList := map[string]imageManifest{ imageList := []string{
"v0.0.0": { "v0.0.0",
AzureImage: "azure-v0.0.0", "v1.0.0",
GCPImage: "gcp-v0.0.0", "v1.0.1",
}, "v1.0.2",
"v1.0.0": { "v1.1.0",
AzureImage: "azure-v1.0.0",
GCPImage: "gcp-v1.0.0",
},
"v1.0.1": {
AzureImage: "azure-v1.0.1",
GCPImage: "gcp-v1.0.1",
},
"v1.0.2": {
AzureImage: "azure-v1.0.2",
GCPImage: "gcp-v1.0.2",
},
"v1.1.0": {
AzureImage: "azure-v1.1.0",
GCPImage: "gcp-v1.1.0",
},
} }
testCases := map[string]struct { testCases := map[string]struct {
images map[string]imageManifest images []string
csp cloudprovider.Provider
version string version string
wantImages map[string]config.UpgradeConfig wantImages []string
}{ }{
"azure": { "filters <= v1.0.0": {
images: imageList, images: imageList,
csp: cloudprovider.Azure,
version: "v1.0.0", version: "v1.0.0",
wantImages: map[string]config.UpgradeConfig{ wantImages: []string{
"v1.0.1": { "v1.0.1",
Image: "azure-v1.0.1", "v1.0.2",
CSP: cloudprovider.Azure, "v1.1.0",
},
"v1.0.2": {
Image: "azure-v1.0.2",
CSP: cloudprovider.Azure,
},
"v1.1.0": {
Image: "azure-v1.1.0",
CSP: cloudprovider.Azure,
},
},
},
"gcp": {
images: imageList,
csp: cloudprovider.GCP,
version: "v1.0.0",
wantImages: map[string]config.UpgradeConfig{
"v1.0.1": {
Image: "gcp-v1.0.1",
CSP: cloudprovider.GCP,
},
"v1.0.2": {
Image: "gcp-v1.0.2",
CSP: cloudprovider.GCP,
},
"v1.1.0": {
Image: "gcp-v1.1.0",
CSP: cloudprovider.GCP,
},
}, },
}, },
"no compatible images": { "no compatible images": {
images: imageList, images: imageList,
csp: cloudprovider.Azure, version: "v999.999.999",
version: "v999.999.999",
wantImages: map[string]config.UpgradeConfig{},
}, },
} }
@ -235,8 +102,8 @@ func TestGetCompatibleImages(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
compatibleImages := getCompatibleImages(tc.csp, tc.version, tc.images) compatibleImages := getCompatibleImages(tc.version, tc.images)
assert.Equal(tc.wantImages, compatibleImages) assert.EqualValues(tc.wantImages, compatibleImages)
}) })
} }
} }
@ -244,16 +111,8 @@ func TestGetCompatibleImages(t *testing.T) {
func TestGetCompatibleImageMeasurements(t *testing.T) { func TestGetCompatibleImageMeasurements(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
testImages := map[string]config.UpgradeConfig{ csp := cloudprovider.Azure
"v0.0.0": { images := []string{"v0.0.0", "v1.0.0"}
Image: "v0.0.0",
CSP: cloudprovider.Azure,
},
"v1.0.0": {
Image: "v1.0.0",
CSP: cloudprovider.Azure,
},
}
client := newTestClient(func(req *http.Request) *http.Response { client := newTestClient(func(req *http.Request) *http.Response {
if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") { if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") {
@ -295,20 +154,17 @@ func TestGetCompatibleImageMeasurements(t *testing.T) {
pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----") pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----")
err := getCompatibleImageMeasurements(context.Background(), &cobra.Command{}, client, singleUUIDVerifier(), pubK, testImages) upgrades, err := getCompatibleImageMeasurements(context.Background(), &cobra.Command{}, client, singleUUIDVerifier(), pubK, csp, images)
assert.NoError(err) assert.NoError(err)
for _, image := range testImages { for _, image := range upgrades {
assert.NotEmpty(image.Measurements) assert.NotEmpty(image.Measurements)
} }
} }
func TestUpgradePlan(t *testing.T) { func TestUpgradePlan(t *testing.T) {
testImages := map[string]imageManifest{ availablePatches := update.VersionsList{
"v1.0.0": { Versions: []string{"v1.0.0", "v1.0.1"},
AzureImage: "v1.0.0",
GCPImage: "v1.0.0",
},
} }
// Cosign private key used to sign the measurements. // Cosign private key used to sign the measurements.
@ -329,20 +185,53 @@ func TestUpgradePlan(t *testing.T) {
pubK := "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----" pubK := "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----"
testCases := map[string]struct { testCases := map[string]struct {
patchLister stubPatchLister
planner stubUpgradePlanner planner stubUpgradePlanner
flags upgradePlanFlags flags upgradePlanFlags
cliVersion string
csp cloudprovider.Provider csp cloudprovider.Provider
verifier rekorVerifier verifier rekorVerifier
imageFetchStatus int
measurementsFetchStatus int measurementsFetchStatus int
wantUpgrade bool wantUpgrade bool
wantErr bool wantErr bool
}{ }{
"no compatible images": { "upgrades gcp": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v999-999-999", 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: stubPatchLister{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: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{
image: "v999.999.999",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
@ -353,11 +242,11 @@ func TestUpgradePlan(t *testing.T) {
verifier: singleUUIDVerifier(), verifier: singleUUIDVerifier(),
wantUpgrade: false, wantUpgrade: false,
}, },
"upgrades gcp": { "current image newer than cli": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v999.999.999",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
@ -365,29 +254,15 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(), verifier: singleUUIDVerifier(),
wantUpgrade: true, wantUpgrade: false,
},
"upgrades azure": {
planner: stubUpgradePlanner{
image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: pubK,
},
csp: cloudprovider.Azure,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
}, },
"upgrade to stdout": { "upgrade to stdout": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v1.0.0",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
@ -395,66 +270,69 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: singleUUIDVerifier(), verifier: singleUUIDVerifier(),
wantUpgrade: true, wantUpgrade: true,
}, },
"current image not valid": { "current image not valid": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "not-valid", image: "not-valid",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml", filePath: "upgrade-plan.yaml",
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(), cliVersion: "v1.0.0",
wantErr: true, verifier: singleUUIDVerifier(),
wantErr: true,
}, },
"image fetch error": { "image fetch error": {
patchLister: stubPatchLister{err: errors.New("error")},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v1.0.0",
}, },
imageFetchStatus: http.StatusInternalServerError,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml", filePath: "upgrade-plan.yaml",
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(), cliVersion: "v1.0.0",
wantErr: true, verifier: singleUUIDVerifier(),
}, },
"measurements fetch error": { "measurements fetch error": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v1.0.0",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusInternalServerError, measurementsFetchStatus: http.StatusInternalServerError,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml", filePath: "upgrade-plan.yaml",
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(), cliVersion: "v1.0.0",
wantErr: true, verifier: singleUUIDVerifier(),
}, },
"failing search should not result in error": { "failing search should not result in error": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v1.0.0",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml", filePath: "upgrade-plan.yaml",
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: &stubRekorVerifier{ verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{}, SearchByHashUUIDs: []string{},
SearchByHashError: errors.New("some error"), SearchByHashError: errors.New("some error"),
@ -462,17 +340,18 @@ func TestUpgradePlan(t *testing.T) {
wantUpgrade: true, wantUpgrade: true,
}, },
"failing verify should not result in error": { "failing verify should not result in error": {
patchLister: stubPatchLister{list: availablePatches},
planner: stubUpgradePlanner{ planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v0-0-0", image: "v1.0.0",
}, },
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK, measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{ flags: upgradePlanFlags{
configPath: constants.ConfigFilename, configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml", filePath: "upgrade-plan.yaml",
cosignPubKey: pubK, cosignPubKey: pubK,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
verifier: &stubRekorVerifier{ verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"}, SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
VerifyEntryError: errors.New("some error"), VerifyEntryError: errors.New("some error"),
@ -499,24 +378,17 @@ func TestUpgradePlan(t *testing.T) {
cmd.SetErr(&errTarget) cmd.SetErr(&errTarget)
client := newTestClient(func(req *http.Request) *http.Response { client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.String() == imageReleaseURL {
return &http.Response{
StatusCode: tc.imageFetchStatus,
Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))),
Header: make(http.Header),
}
}
if strings.HasSuffix(req.URL.String(), "azure/measurements.json") { if strings.HasSuffix(req.URL.String(), "azure/measurements.json") {
return &http.Response{ return &http.Response{
StatusCode: tc.measurementsFetchStatus, StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header), Header: make(http.Header),
} }
} }
if strings.HasSuffix(req.URL.String(), "azure/measurements.json.sig") { if strings.HasSuffix(req.URL.String(), "azure/measurements.json.sig") {
return &http.Response{ return &http.Response{
StatusCode: tc.measurementsFetchStatus, StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("MEQCIFh8CVELp/Da2U2Jt404OXsUeDfqtrf3pqGRuvxnxhI8AiBTHF9tHEPwFedYG3Jgn2ELOxss+Ybc6135vEtClBrbpg==")), Body: io.NopCloser(strings.NewReader("MEYCIQDu2Sft91FjN278uP+r/HFMms6IH/tRtaHzYvIN0xPgdwIhAJhiFxVsHCa0NK6bZOGLE9c4miZHIqFTKvgpTf3rJ9dW")),
Header: make(http.Header), Header: make(http.Header),
} }
} }
@ -524,14 +396,14 @@ func TestUpgradePlan(t *testing.T) {
if strings.HasSuffix(req.URL.String(), "gcp/measurements.json") { if strings.HasSuffix(req.URL.String(), "gcp/measurements.json") {
return &http.Response{ return &http.Response{
StatusCode: tc.measurementsFetchStatus, StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader(`{"csp":"gcp","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), Body: io.NopCloser(strings.NewReader(`{"csp":"gcp","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)),
Header: make(http.Header), Header: make(http.Header),
} }
} }
if strings.HasSuffix(req.URL.String(), "gcp/measurements.json.sig") { if strings.HasSuffix(req.URL.String(), "gcp/measurements.json.sig") {
return &http.Response{ return &http.Response{
StatusCode: tc.measurementsFetchStatus, StatusCode: tc.measurementsFetchStatus,
Body: io.NopCloser(strings.NewReader("MEYCIQCr/gDGjj11mR5OeImwOLjxnBqMbBmqoK7yXqy0cXR3HQIhALpVDdYwR9VNJnWwtl8bTfrezyJbc7UNZJO4PJe+stFP")), Body: io.NopCloser(strings.NewReader("MEQCIBUssv92LpSMiXE1UAVf2fW8J9pZHiLseo2tdZjxv2OMAiB6K8e8yL0768jWjlFnRe3Rc2x/dX34uzX3h0XUrlYt1A==")),
Header: make(http.Header), Header: make(http.Header),
} }
} }
@ -543,7 +415,7 @@ func TestUpgradePlan(t *testing.T) {
} }
}) })
err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.verifier, tc.flags) err := upgradePlan(cmd, tc.planner, tc.patchLister, fileHandler, client, tc.verifier, tc.flags, tc.cliVersion)
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
return return
@ -571,11 +443,55 @@ func TestUpgradePlan(t *testing.T) {
} }
} }
func mustMarshal(t *testing.T, v any) []byte { func TestNextMinorVersion(t *testing.T) {
t.Helper() testCases := map[string]struct {
b, err := json.Marshal(v) version string
if err != nil { wantNextMinorVersion string
t.Fatalf("failed to marshal: %s", err) 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)
})
} }
return b }
type stubUpgradePlanner struct {
image string
err error
}
func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) {
return nil, u.image, u.err
}
type stubPatchLister struct {
list update.VersionsList
err error
}
func (s stubPatchLister) PatchVersionsOf(ctx context.Context, stream, minor, kind string) (*update.VersionsList, error) {
return &s.list, s.err
} }

View File

@ -0,0 +1,173 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package update
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"github.com/edgelesssys/constellation/v2/internal/constants"
"golang.org/x/mod/semver"
)
// VersionsList represents a list of versions for a kind of resource.
// It has a granularity of either "major" or "minor".
//
// For example, a VersionsList with granularity "major" could contain
// the base version "v1" and a list of minor versions "v1.0", "v1.1", "v1.2" etc.
// A VersionsList with granularity "minor" could contain the base version
// "v1.0" and a list of patch versions "v1.0.0", "v1.0.1", "v1.0.2" etc.
type VersionsList struct {
// Stream is the update stream of the list.
// Currently, only "stable" is supported.
Stream string `json:"stream"`
// Granularity is the granularity of the base version of this list.
// It can be either "major" or "minor".
Granularity string `json:"granularity"`
// Base is the base version of the list.
// Every version in the list is a finer-grained version of this base version.
Base string `json:"base"`
// Kind is the kind of resource this list is for.
Kind string `json:"kind"`
// Versions is a list of all versions in this list.
Versions []string `json:"versions"`
}
// validate checks if the list is valid.
// This performs the following checks:
// - The stream is supported.
// - The granularity is "major" or "minor".
// - The kind is supported.
// - The base version is a valid semantic version that matches the granularity.
// - All versions in the list are valid semantic versions that are finer-grained than the base version.
func (l *VersionsList) validate() error {
var issues []string
if l.Stream != "stable" {
issues = append(issues, fmt.Sprintf("stream %q is not supported", l.Stream))
}
if l.Granularity != "major" && l.Granularity != "minor" {
issues = append(issues, fmt.Sprintf("granularity %q is not supported", l.Granularity))
}
if l.Kind != "image" {
issues = append(issues, fmt.Sprintf("kind %q is not supported", l.Kind))
}
if !semver.IsValid(l.Base) {
issues = append(issues, fmt.Sprintf("base version %q is not a valid semantic version", l.Base))
}
var normalizeFunc func(string) string
switch l.Granularity {
case "major":
normalizeFunc = semver.Major
case "minor":
normalizeFunc = semver.MajorMinor
default:
normalizeFunc = func(s string) string { return s }
}
if normalizeFunc(l.Base) != l.Base {
issues = append(issues, fmt.Sprintf("base version %q is not a %v version", l.Base, l.Granularity))
}
for _, ver := range l.Versions {
if !semver.IsValid(ver) {
issues = append(issues, fmt.Sprintf("version %q in list is not a valid semantic version", ver))
}
if normalizeFunc(ver) != l.Base {
issues = append(issues, fmt.Sprintf("version %q in list is not a finer-grained version of base version %q", ver, l.Base))
}
}
if len(issues) > 0 {
return fmt.Errorf("version list is invalid:\n%s", strings.Join(issues, "\n"))
}
return nil
}
// VersionsFetcher fetches a list of versions.
type VersionsFetcher struct {
httpc httpc
}
// New returns a new VersionsFetcher.
func New() *VersionsFetcher {
return &VersionsFetcher{
httpc: http.DefaultClient,
}
}
// MinorVersionsOf fetches the list of minor versions for a given stream, major version and kind.
func (f *VersionsFetcher) MinorVersionsOf(ctx context.Context, stream, major, kind string) (*VersionsList, error) {
return f.list(ctx, stream, "major", major, kind)
}
// PatchVersionsOf fetches the list of patch versions for a given stream, minor version and kind.
func (f *VersionsFetcher) PatchVersionsOf(ctx context.Context, stream, minor, kind string) (*VersionsList, error) {
return f.list(ctx, stream, "minor", minor, kind)
}
// list fetches the list of versions for a given stream, granularity, base and kind.
func (f *VersionsFetcher) list(ctx context.Context, stream, granularity, base, kind string) (*VersionsList, error) {
raw, err := getFromURL(ctx, f.httpc, stream, granularity, base, kind)
if err != nil {
return nil, fmt.Errorf("fetching versions list: %w", err)
}
list := &VersionsList{}
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("decoding versions list: %w", err)
}
if err := list.validate(); err != nil {
return nil, fmt.Errorf("validating versions list: %w", err)
}
if !f.listMatchesRequest(list, stream, granularity, base, kind) {
return nil, fmt.Errorf("versions list does not match request")
}
return list, nil
}
func (f *VersionsFetcher) listMatchesRequest(list *VersionsList, stream, granularity, base, kind string) bool {
return list.Stream == stream && list.Granularity == granularity && list.Base == base && list.Kind == kind
}
// getFromURL fetches the versions list from a URL.
func getFromURL(ctx context.Context, client httpc, stream, granularity, base, kind string) ([]byte, error) {
url, err := url.Parse(constants.CDNRepositoryURL)
if err != nil {
return nil, fmt.Errorf("parsing image version repository URL: %w", err)
}
kindFilename := path.Base(kind) + ".json"
url.Path = path.Join("constellation/v1/updates", stream, granularity, base, kindFilename)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), http.NoBody)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusNotFound:
return nil, fmt.Errorf("versions list %q does not exist", url.String())
default:
return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return content, nil
}
type httpc interface {
Do(req *http.Request) (*http.Response, error)
}

View File

@ -0,0 +1,279 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package update
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestValidate(t *testing.T) {
testCases := map[string]struct {
listFunc func() *VersionsList
overrideFunc func(list *VersionsList)
wantErr bool
}{
"valid major list": {
listFunc: majorList,
},
"valid minor list": {
listFunc: minorList,
},
"invalid stream": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Stream = "invalid"
},
wantErr: true,
},
"invalid granularity": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Granularity = "invalid"
},
wantErr: true,
},
"invalid kind": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Kind = "invalid"
},
wantErr: true,
},
"base ver is not semantic version": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Base = "invalid"
},
wantErr: true,
},
"base ver does not reflect major granularity": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Base = "v1.0"
},
wantErr: true,
},
"base ver does not reflect minor granularity": {
listFunc: minorList,
overrideFunc: func(list *VersionsList) {
list.Base = "v1"
},
wantErr: true,
},
"version in list is not semantic version": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Versions[0] = "invalid"
},
wantErr: true,
},
"version in list is not sub version of base": {
listFunc: majorList,
overrideFunc: func(list *VersionsList) {
list.Versions[0] = "v2.1"
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
list := tc.listFunc()
if tc.overrideFunc != nil {
tc.overrideFunc(list)
}
err := list.validate()
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
})
}
}
func TestList(t *testing.T) {
majorListJSON, err := json.Marshal(majorList())
require.NoError(t, err)
minorListJSON, err := json.Marshal(minorList())
require.NoError(t, err)
inconsistentList := majorList()
inconsistentList.Base = "v2"
inconsistentListJSON, err := json.Marshal(inconsistentList)
require.NoError(t, err)
client := newTestClient(func(req *http.Request) *http.Response {
switch req.URL.Path {
case "/constellation/v1/updates/stable/major/v1/image.json":
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(majorListJSON)),
Header: make(http.Header),
}
case "/constellation/v1/updates/stable/minor/v1.1/image.json":
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(minorListJSON)),
Header: make(http.Header),
}
case "/constellation/v1/updates/stable/major/v1/500.json": // 500 error
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewBufferString("Server Error.")),
Header: make(http.Header),
}
case "/constellation/v1/updates/stable/major/v1/nojson.json": // invalid format
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("not json")),
Header: make(http.Header),
}
case "/constellation/v1/updates/stable/major/v2/image.json": // inconsistent list
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(inconsistentListJSON)),
Header: make(http.Header),
}
case "/constellation/v1/updates/stable/major/v3/image.json": // does not match requested version
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(minorListJSON)),
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("Not found.")),
Header: make(http.Header),
}
})
testCases := map[string]struct {
stream, granularity, base, kind string
overrideFile string
wantList VersionsList
wantErr bool
}{
"major list fetched remotely": {
wantList: *majorList(),
},
"minor list fetched remotely": {
granularity: "minor",
base: "v1.1",
wantList: *minorList(),
},
"list does not exist": {
stream: "unknown",
wantErr: true,
},
"unexpected error code": {
kind: "500",
wantErr: true,
},
"invalid json returned": {
kind: "nojson",
wantErr: true,
},
"invalid list returned": {
base: "v2",
wantErr: true,
},
"response does not match request": {
base: "v3",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
stream := "stable"
granularity := "major"
base := "v1"
kind := "image"
if tc.stream != "" {
stream = tc.stream
}
if tc.granularity != "" {
granularity = tc.granularity
}
if tc.base != "" {
base = tc.base
}
if tc.kind != "" {
kind = tc.kind
}
fetcher := &VersionsFetcher{
httpc: client,
}
list, err := fetcher.list(context.Background(), stream, granularity, base, kind)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantList, *list)
})
}
}
// roundTripFunc .
type roundTripFunc func(req *http.Request) *http.Response
// RoundTrip .
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
// newTestClient returns *http.Client with Transport replaced to avoid making real calls.
func newTestClient(fn roundTripFunc) *http.Client {
return &http.Client{
Transport: fn,
}
}
func majorList() *VersionsList {
return &VersionsList{
Stream: "stable",
Granularity: "major",
Base: "v1",
Kind: "image",
Versions: []string{
"v1.0", "v1.1", "v1.2",
},
}
}
func minorList() *VersionsList {
return &VersionsList{
Stream: "stable",
Granularity: "minor",
Base: "v1.1",
Kind: "image",
Versions: []string{
"v1.1.0", "v1.1.1", "v1.1.2",
},
}
}

View File

@ -12,6 +12,7 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"strings"
"testing" "testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
@ -149,7 +150,7 @@ func TestVariant(t *testing.T) {
func TestFetchReference(t *testing.T) { func TestFetchReference(t *testing.T) {
imageVersionUID := "someImageVersionUID" imageVersionUID := "someImageVersionUID"
client := newTestClient(func(req *http.Request) *http.Response { client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.String() == "https://cdn.confidential.cloud/constellation/v1/images/someImageVersionUID.json" { if strings.HasSuffix(req.URL.String(), "/constellation/v1/images/someImageVersionUID.json") {
return &http.Response{ return &http.Response{
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(lut)), Body: io.NopCloser(bytes.NewBufferString(lut)),