mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-09-28 23:09:36 -04:00
AB#2308 / AB#2317 constellation upgrade plan (#3)
Signed-off-by: Daniel Weiße <dw@edgeless.systems>
This commit is contained in:
parent
b27e205399
commit
ce02878019
10 changed files with 844 additions and 17 deletions
|
@ -63,6 +63,34 @@ func (u *Upgrader) Upgrade(ctx context.Context, image string, measurements map[u
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentImage returns the currently used image of the cluster.
|
||||
func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) {
|
||||
imageStruct, err := u.imageUpdater.getCurrent(ctx, "constellation-coreos")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
spec, ok := imageStruct.Object["spec"]
|
||||
if !ok {
|
||||
return nil, "", errors.New("image spec missing")
|
||||
}
|
||||
retErr := errors.New("invalid image spec")
|
||||
specMap, ok := spec.(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", retErr
|
||||
}
|
||||
currentImageDefinition, ok := specMap["image"]
|
||||
if !ok {
|
||||
return nil, "", retErr
|
||||
}
|
||||
imageDefinition, ok := currentImageDefinition.(string)
|
||||
if !ok {
|
||||
return nil, "", retErr
|
||||
}
|
||||
|
||||
return imageStruct, imageDefinition, nil
|
||||
}
|
||||
|
||||
func (u *Upgrader) updateMeasurements(ctx context.Context, measurements map[uint32][]byte) error {
|
||||
existingConf, err := u.measurementsUpdater.getCurrent(ctx, constants.JoinConfigMap)
|
||||
if err != nil {
|
||||
|
@ -107,30 +135,17 @@ func (u *Upgrader) updateMeasurements(ctx context.Context, measurements map[uint
|
|||
}
|
||||
|
||||
func (u *Upgrader) updateImage(ctx context.Context, image string) error {
|
||||
currentImage, err := u.imageUpdater.getCurrent(ctx, "constellation-coreos")
|
||||
currentImage, currentImageDefinition, err := u.GetCurrentImage(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retrieving current image: %w", err)
|
||||
}
|
||||
|
||||
spec, ok := currentImage.Object["spec"]
|
||||
if !ok {
|
||||
return errors.New("current image has no spec")
|
||||
}
|
||||
specMap, ok := spec.(map[string]interface{})
|
||||
if !ok {
|
||||
return errors.New("current image spec is not a map")
|
||||
}
|
||||
currentImageDefinition, ok := specMap["image"]
|
||||
if !ok {
|
||||
return errors.New("unable to read current image")
|
||||
}
|
||||
|
||||
if currentImageDefinition == image {
|
||||
fmt.Fprintln(u.writer, "Cluster is already using the chosen image, skipping image upgrade")
|
||||
return nil
|
||||
}
|
||||
|
||||
currentImage.Object["spec"].(map[string]interface{})["image"] = image
|
||||
currentImage.Object["spec"].(map[string]any)["image"] = image
|
||||
if _, err := u.imageUpdater.update(ctx, currentImage); err != nil {
|
||||
return fmt.Errorf("setting new image: %w", err)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ func NewUpgradeCmd() *cobra.Command {
|
|||
}
|
||||
|
||||
cmd.AddCommand(newUpgradeExecuteCmd())
|
||||
cmd.AddCommand(newUpgradePlanCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
304
cli/internal/cmd/upgradeplan.go
Normal file
304
cli/internal/cmd/upgradeplan.go
Normal file
|
@ -0,0 +1,304 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/edgelesssys/constellation/cli/internal/cloudcmd"
|
||||
"github.com/edgelesssys/constellation/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/internal/config"
|
||||
"github.com/edgelesssys/constellation/internal/constants"
|
||||
"github.com/edgelesssys/constellation/internal/file"
|
||||
"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"
|
||||
)
|
||||
|
||||
const imageReleaseURL = "https://github.com/edgelesssys/constellation/releases/latest/download/image-manifest.json"
|
||||
|
||||
var (
|
||||
azureCVMRxp = regexp.MustCompile(`^\/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 {
|
||||
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, leave empty 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
|
||||
}
|
||||
|
||||
return upgradePlan(cmd, planner, fileHandler, http.DefaultClient, flags)
|
||||
}
|
||||
|
||||
// upgradePlan plans an upgrade of a Constellation cluster.
|
||||
func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
|
||||
fileHandler file.Handler, client *http.Client, flags upgradePlanFlags,
|
||||
) error {
|
||||
config, err := config.FromFile(fileHandler, flags.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get current image version of the cluster
|
||||
csp := config.GetProvider()
|
||||
|
||||
version, err := getCurrentImageVersion(cmd.Context(), planner, csp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking current image version: %w", err)
|
||||
}
|
||||
|
||||
// fetch images definitions from GitHub and filter to only compatible images
|
||||
images, err := fetchImages(cmd.Context(), client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching available images: %w", err)
|
||||
}
|
||||
compatibleImages := getCompatibleImages(csp, version, images)
|
||||
if len(compatibleImages) == 0 {
|
||||
cmd.Println("No compatible images found to upgrade to.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// get expected measurements for each image
|
||||
if err := getCompatibleImageMeasurements(cmd.Context(), client, []byte(flags.cosignPubKey), compatibleImages); err != nil {
|
||||
return fmt.Errorf("fetching measurements for compatible images: %w", err)
|
||||
}
|
||||
|
||||
// interactive mode
|
||||
if flags.filePath == "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Current version: %s\n", version)
|
||||
return upgradePlanInteractive(
|
||||
&nopWriteCloser{cmd.OutOrStdout()},
|
||||
io.NopCloser(cmd.InOrStdin()),
|
||||
flags.configPath, config, fileHandler,
|
||||
compatibleImages,
|
||||
)
|
||||
}
|
||||
|
||||
// write upgrade plan to stdout
|
||||
if flags.filePath == "-" {
|
||||
content, err := encoder.NewEncoder(compatibleImages).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, compatibleImages)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func getCompatibleImages(csp cloudprovider.Provider, currentVersion string, images map[string]imageManifest) map[string]config.UpgradeConfig {
|
||||
compatibleImages := make(map[string]config.UpgradeConfig)
|
||||
|
||||
switch csp {
|
||||
case cloudprovider.Azure:
|
||||
for imgVersion, image := range images {
|
||||
if semver.Compare(currentVersion, imgVersion) < 0 {
|
||||
compatibleImages[imgVersion] = config.UpgradeConfig{Image: image.AzureImage}
|
||||
}
|
||||
}
|
||||
|
||||
case cloudprovider.GCP:
|
||||
for imgVersion, image := range images {
|
||||
if semver.Compare(currentVersion, imgVersion) < 0 {
|
||||
compatibleImages[imgVersion] = config.UpgradeConfig{Image: image.GCPImage}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compatibleImages
|
||||
}
|
||||
|
||||
// getCompatibleImageMeasurements retrieves the expected measurements for each image.
|
||||
func getCompatibleImageMeasurements(ctx context.Context, client *http.Client, pubK []byte, images map[string]config.UpgradeConfig) error {
|
||||
for idx, img := range images {
|
||||
measurementsURL, err := url.Parse(constants.S3PublicBucket + img.Image + "/measurements.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signatureURL, err := url.Parse(constants.S3PublicBucket + img.Image + "/measurements.yaml.sig")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := img.Measurements.FetchAndVerify(ctx, client, measurementsURL, signatureURL, pubK); err != nil {
|
||||
return err
|
||||
}
|
||||
images[idx] = img
|
||||
}
|
||||
|
||||
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, planner upgradePlanner, csp cloudprovider.Provider) (string, error) {
|
||||
_, image, err := planner.GetCurrentImage(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var version string
|
||||
switch csp {
|
||||
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 "", fmt.Errorf("image %q has no valid semantic version", image)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
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,
|
||||
compatibleImages map[string]config.UpgradeConfig,
|
||||
) error {
|
||||
var imageVersions []string
|
||||
for k := range compatibleImages {
|
||||
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", compatibleImages[res].Image)
|
||||
fmt.Fprintln(out, "Measurements:")
|
||||
content, err := encoder.NewEncoder(compatibleImages[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 = compatibleImages[res]
|
||||
return fileHandler.WriteYAML(configPath, config, file.OptOverwrite)
|
||||
}
|
||||
|
||||
type upgradePlanFlags struct {
|
||||
configPath string
|
||||
filePath string
|
||||
cosignPubKey string
|
||||
}
|
||||
|
||||
type imageManifest struct {
|
||||
AzureImage string `json:"AzureCoreOSImage"`
|
||||
GCPImage string `json:"GCPCoreOSImage"`
|
||||
}
|
||||
|
||||
type nopWriteCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (c *nopWriteCloser) Close() error { return nil }
|
||||
|
||||
type upgradePlanner interface {
|
||||
GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error)
|
||||
}
|
477
cli/internal/cmd/upgradeplan_test.go
Normal file
477
cli/internal/cmd/upgradeplan_test.go
Normal file
|
@ -0,0 +1,477 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/edgelesssys/constellation/internal/cloud/cloudprovider"
|
||||
"github.com/edgelesssys/constellation/internal/config"
|
||||
"github.com/edgelesssys/constellation/internal/constants"
|
||||
"github.com/edgelesssys/constellation/internal/file"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/mod/semver"
|
||||
"gopkg.in/yaml.v2"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
)
|
||||
|
||||
func TestGetCurrentImageVersion(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
stubUpgradePlanner stubUpgradePlanner
|
||||
csp cloudprovider.Provider
|
||||
wantErr bool
|
||||
}{
|
||||
"valid Azure": {
|
||||
stubUpgradePlanner: stubUpgradePlanner{
|
||||
image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0",
|
||||
},
|
||||
csp: cloudprovider.Azure,
|
||||
},
|
||||
"invalid Azure": {
|
||||
stubUpgradePlanner: stubUpgradePlanner{
|
||||
image: "/CommunityGalleries/someone-else/Images/constellation/Versions/0.0.1",
|
||||
},
|
||||
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,
|
||||
},
|
||||
"GetCurrentImage error": {
|
||||
stubUpgradePlanner: stubUpgradePlanner{
|
||||
err: errors.New("error"),
|
||||
},
|
||||
csp: cloudprovider.Azure,
|
||||
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, tc.csp)
|
||||
if tc.wantErr {
|
||||
assert.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(err)
|
||||
assert.True(semver.IsValid(version))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
imageList := map[string]imageManifest{
|
||||
"v0.0.0": {
|
||||
AzureImage: "azure-v0.0.0",
|
||||
GCPImage: "gcp-v0.0.0",
|
||||
},
|
||||
"v1.0.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 {
|
||||
images map[string]imageManifest
|
||||
csp cloudprovider.Provider
|
||||
version string
|
||||
wantImages map[string]config.UpgradeConfig
|
||||
}{
|
||||
"azure": {
|
||||
images: imageList,
|
||||
csp: cloudprovider.Azure,
|
||||
version: "v1.0.0",
|
||||
wantImages: map[string]config.UpgradeConfig{
|
||||
"v1.0.1": {
|
||||
Image: "azure-v1.0.1",
|
||||
},
|
||||
"v1.0.2": {
|
||||
Image: "azure-v1.0.2",
|
||||
},
|
||||
"v1.1.0": {
|
||||
Image: "azure-v1.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"gcp": {
|
||||
images: imageList,
|
||||
csp: cloudprovider.GCP,
|
||||
version: "v1.0.0",
|
||||
wantImages: map[string]config.UpgradeConfig{
|
||||
"v1.0.1": {
|
||||
Image: "gcp-v1.0.1",
|
||||
},
|
||||
"v1.0.2": {
|
||||
Image: "gcp-v1.0.2",
|
||||
},
|
||||
"v1.1.0": {
|
||||
Image: "gcp-v1.1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"no compatible images": {
|
||||
images: imageList,
|
||||
csp: cloudprovider.Azure,
|
||||
version: "v999.999.999",
|
||||
wantImages: map[string]config.UpgradeConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
compatibleImages := getCompatibleImages(tc.csp, tc.version, tc.images)
|
||||
assert.Equal(tc.wantImages, compatibleImages)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCompatibleImageMeasurements(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
testImages := map[string]config.UpgradeConfig{
|
||||
"v0.0.0": {
|
||||
Image: "azure-v0.0.0",
|
||||
},
|
||||
"v1.0.0": {
|
||||
Image: "azure-v1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
client := newTestClient(func(req *http.Request) *http.Response {
|
||||
if strings.HasSuffix(req.URL.String(), "/measurements.yaml") {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")),
|
||||
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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----")
|
||||
|
||||
err := getCompatibleImageMeasurements(context.Background(), client, pubK, testImages)
|
||||
assert.NoError(err)
|
||||
|
||||
for _, image := range testImages {
|
||||
assert.NotEmpty(image.Measurements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradePlan(t *testing.T) {
|
||||
testImages := map[string]imageManifest{
|
||||
"v1.0.0": {
|
||||
AzureImage: "azure-v1.0.0",
|
||||
GCPImage: "gcp-v1.0.0",
|
||||
},
|
||||
"v2.0.0": {
|
||||
AzureImage: "azure-v2.0.0",
|
||||
GCPImage: "gcp-v2.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
testCases := map[string]struct {
|
||||
planner stubUpgradePlanner
|
||||
flags upgradePlanFlags
|
||||
csp cloudprovider.Provider
|
||||
imageFetchStatus int
|
||||
measurementsFetchStatus int
|
||||
wantUpgrade bool
|
||||
wantErr bool
|
||||
}{
|
||||
"no compatible images": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "projects/constellation-images/global/images/constellation-v999-999-999",
|
||||
},
|
||||
imageFetchStatus: http.StatusOK,
|
||||
measurementsFetchStatus: http.StatusOK,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "upgrade-plan.yaml",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantUpgrade: false,
|
||||
},
|
||||
"upgrades gcp": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "projects/constellation-images/global/images/constellation-v1-0-0",
|
||||
},
|
||||
imageFetchStatus: http.StatusOK,
|
||||
measurementsFetchStatus: http.StatusOK,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "upgrade-plan.yaml",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantUpgrade: true,
|
||||
},
|
||||
"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: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.Azure,
|
||||
wantUpgrade: true,
|
||||
},
|
||||
"upgrade to stdout": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "projects/constellation-images/global/images/constellation-v1-0-0",
|
||||
},
|
||||
imageFetchStatus: http.StatusOK,
|
||||
measurementsFetchStatus: http.StatusOK,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "-",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantUpgrade: true,
|
||||
},
|
||||
"current image not valid": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "not-valid",
|
||||
},
|
||||
imageFetchStatus: http.StatusOK,
|
||||
measurementsFetchStatus: http.StatusOK,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "upgrade-plan.yaml",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantErr: true,
|
||||
},
|
||||
"image fetch error": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "projects/constellation-images/global/images/constellation-v1-0-0",
|
||||
},
|
||||
imageFetchStatus: http.StatusInternalServerError,
|
||||
measurementsFetchStatus: http.StatusOK,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "upgrade-plan.yaml",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantErr: true,
|
||||
},
|
||||
"measurements fetch error": {
|
||||
planner: stubUpgradePlanner{
|
||||
image: "projects/constellation-images/global/images/constellation-v1-0-0",
|
||||
},
|
||||
imageFetchStatus: http.StatusOK,
|
||||
measurementsFetchStatus: http.StatusInternalServerError,
|
||||
flags: upgradePlanFlags{
|
||||
configPath: constants.ConfigFilename,
|
||||
filePath: "upgrade-plan.yaml",
|
||||
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
|
||||
},
|
||||
csp: cloudprovider.GCP,
|
||||
wantErr: 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 := config.Default()
|
||||
cfg.RemoveProviderExcept(tc.csp)
|
||||
require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg))
|
||||
|
||||
cmd := newUpgradePlanCmd()
|
||||
cmd.SetContext(context.Background())
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
|
||||
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(), "/measurements.yaml") {
|
||||
return &http.Response{
|
||||
StatusCode: tc.measurementsFetchStatus,
|
||||
Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") {
|
||||
return &http.Response{
|
||||
StatusCode: tc.measurementsFetchStatus,
|
||||
Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("Not found.")),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
})
|
||||
|
||||
err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.flags)
|
||||
if tc.wantErr {
|
||||
assert.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(err)
|
||||
if !tc.wantUpgrade {
|
||||
assert.Contains(out.String(), "No compatible images")
|
||||
return
|
||||
}
|
||||
|
||||
var availableUpgrades map[string]config.UpgradeConfig
|
||||
if tc.flags.filePath == "-" {
|
||||
require.NoError(yaml.Unmarshal(out.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 mustMarshal(t *testing.T, v interface{}) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal: %s", err)
|
||||
}
|
||||
return b
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue