AB#2308 / AB#2317 constellation upgrade plan (#3)

Signed-off-by: Daniel Weiße <dw@edgeless.systems>
This commit is contained in:
Daniel Weiße 2022-08-31 11:59:07 +02:00 committed by GitHub
parent b27e205399
commit ce02878019
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 844 additions and 17 deletions

View File

@ -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)
}

View File

@ -14,6 +14,7 @@ func NewUpgradeCmd() *cobra.Command {
}
cmd.AddCommand(newUpgradeExecuteCmd())
cmd.AddCommand(newUpgradePlanCmd())
return cmd
}

View 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)
}

View 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
}

3
go.mod
View File

@ -73,6 +73,7 @@ require (
github.com/googleapis/gax-go/v2 v2.4.0
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
github.com/hashicorp/go-multierror v1.1.1
github.com/manifoldco/promptui v0.9.0
github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6
github.com/microsoft/ApplicationInsights-Go v0.4.4
github.com/schollz/progressbar/v3 v3.8.6
@ -84,6 +85,7 @@ require (
go.uber.org/multierr v1.8.0
go.uber.org/zap v1.21.0
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3
google.golang.org/api v0.86.0
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f
google.golang.org/grpc v1.48.0
@ -128,6 +130,7 @@ require (
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
github.com/dnaeon/go-vcr v1.2.0 // indirect
github.com/docker/cli v20.10.17+incompatible // indirect

6
go.sum
View File

@ -366,8 +366,11 @@ github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1
github.com/charithe/durationcheck v0.0.9/go.mod h1:SSbRIBVfMjCi/kEB6K65XEA83D6prSM8ap1UCpNKtgg=
github.com/chavacava/garif v0.0.0-20210405164556-e8a0a408d6af/go.mod h1:Qjyv4H3//PWVzTeCezG2b9IRn6myJxJSr4TD/xo6ojU=
github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
@ -1061,6 +1064,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/malt3/go-attestation v0.0.0-20220816131639-92b6394e4e0e h1:WXYRe8U97J11rpmUAZgQtlQbkrrk5S+sIMK197RKJkU=
github.com/malt3/go-attestation v0.0.0-20220816131639-92b6394e4e0e/go.mod h1:kA3RhI4h6nMuXW85izOMUNfDza/Yyd4tzRFiCHTkZbw=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/maratori/testpackage v1.0.1/go.mod h1:ddKdw+XG0Phzhx8BFDTKgpWP4i7MpApTE5fXSKAqwDU=
github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI=
github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc=
@ -1731,6 +1736,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=

View File

@ -121,6 +121,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 // indirect
github.com/aws/smithy-go v1.11.3 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
@ -157,6 +158,7 @@ require (
github.com/leodido/go-urn v1.2.1 // indirect
github.com/letsencrypt/boulder v0.0.0-20220331220046-b23ab962616e // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/matryer/is v1.4.0 // indirect
github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect

View File

@ -304,8 +304,11 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@ -807,6 +810,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=

View File

@ -376,6 +376,20 @@ func (c *Config) IsImageDebug() bool {
}
}
// GetProvider returns the configured cloud provider.
func (c *Config) GetProvider() cloudprovider.Provider {
if c.Provider.Azure != nil {
return cloudprovider.Azure
}
if c.Provider.GCP != nil {
return cloudprovider.GCP
}
if c.Provider.QEMU != nil {
return cloudprovider.QEMU
}
return cloudprovider.Unknown
}
// IsAzureNonCVM checks whether the chosen provider is azure and confidential VMs are disabled.
func (c *Config) IsAzureNonCVM() bool {
return c.Provider.Azure != nil && c.Provider.Azure.ConfidentialVM != nil && !*c.Provider.Azure.ConfidentialVM

View File

@ -41,11 +41,11 @@ var (
func (m *Measurements) FetchAndVerify(ctx context.Context, client *http.Client, measurementsURL *url.URL, signatureURL *url.URL, publicKey []byte) error {
measurements, err := getFromURL(ctx, client, measurementsURL)
if err != nil {
return err
return fmt.Errorf("failed to fetch measurements: %w", err)
}
signature, err := getFromURL(ctx, client, signatureURL)
if err != nil {
return err
return fmt.Errorf("failed to fetch signature: %w", err)
}
if err := sigstore.VerifySignature(measurements, signature, publicKey); err != nil {
return err