api: rename /api/versions to versionsapi and /api/attestationcfig to attestationconfigapi (#1876)

* rename to attestationconfigapi + put client and fetcher inside pkg

* rename api/version to versionsapi and put fetcher + client inside pkg

* rename AttestationConfigAPIFetcher to Fetcher
This commit is contained in:
Adrian Stobbe 2023-06-07 16:16:32 +02:00 committed by GitHub
parent 25037026e1
commit 4284f892ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
98 changed files with 385 additions and 490 deletions

View file

@ -0,0 +1,46 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
go_library(
name = "versionsapi",
srcs = [
"apiconstants.go",
"client.go",
"cliinfo.go",
"fetcher.go",
"imageinfo.go",
"latest.go",
"list.go",
"version.go",
"versionsapi.go",
],
importpath = "github.com/edgelesssys/constellation/v2/internal/api/versionsapi",
visibility = ["//:__subpackages__"],
deps = [
"//internal/api/client",
"//internal/api/fetcher",
"//internal/constants",
"//internal/logger",
"@org_golang_x_mod//semver",
],
)
go_test(
name = "versionsapi_test",
srcs = [
"cliinfo_test.go",
"fetcher_test.go",
"imageinfo_test.go",
"latest_test.go",
"list_test.go",
"version_test.go",
],
embed = [":versionsapi"],
deps = [
"//internal/cloud/cloudprovider",
"//internal/constants",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
"@org_uber_go_goleak//:goleak",
],
)

View file

@ -0,0 +1,22 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
var (
// APIV1 is the v1 API version.
APIV1 = apiVersion{slug: "v1"}
// APIV2 is the v2 API version.
APIV2 = apiVersion{slug: "v2"}
)
type apiVersion struct {
slug string
}
func (v apiVersion) String() string {
return v.slug
}

View file

@ -0,0 +1,38 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "cli_lib",
srcs = [
"add.go",
"latest.go",
"list.go",
"main.go",
"rm.go",
],
importpath = "github.com/edgelesssys/constellation/v2/internal/api/versionsapi/cli",
visibility = ["//visibility:private"],
deps = [
"//internal/api/client",
"//internal/api/versionsapi",
"//internal/constants",
"//internal/logger",
"@com_github_aws_aws_sdk_go_v2_config//:config",
"@com_github_aws_aws_sdk_go_v2_service_ec2//:ec2",
"@com_github_aws_smithy_go//:smithy-go",
"@com_github_azure_azure_sdk_for_go_sdk_azcore//runtime",
"@com_github_azure_azure_sdk_for_go_sdk_azidentity//:azidentity",
"@com_github_azure_azure_sdk_for_go_sdk_resourcemanager_compute_armcompute_v4//:armcompute",
"@com_github_googleapis_gax_go_v2//:gax-go",
"@com_github_spf13_cobra//:cobra",
"@com_google_cloud_go_compute//apiv1",
"@com_google_cloud_go_compute//apiv1/computepb",
"@org_golang_x_mod//semver",
"@org_uber_go_zap//zapcore",
],
)
go_binary(
name = "cli",
embed = [":cli_lib"],
visibility = ["//:__subpackages__"],
)

View file

@ -0,0 +1,294 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"context"
"errors"
"fmt"
apiclient "github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/api/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
"golang.org/x/mod/semver"
)
func newAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: "Add a new version",
Long: `Add a new version to the versions API.
Developers should not use this command directly. It is invoked by the CI/CD pipeline.
If you've build a local image, use a local override instead of adding a new version.
If you use the command nevertheless, you better know what you do.
`,
RunE: runAdd,
}
cmd.Flags().String("ref", "", "Ref of the version to add")
cmd.Flags().String("stream", "", "Stream of the version to add")
cmd.Flags().String("version", "", "Version to add (format: \"v1.2.3\")")
cmd.Flags().String("kind", "", "Version kind to add (e.g. image, cli)")
cmd.Flags().Bool("latest", false, "Whether the version is the latest version of the ref/stream")
cmd.Flags().Bool("release", false, "Whether the version is a release version")
cmd.Flags().Bool("dryrun", false, "Whether to run in dry-run mode (no changes are made)")
cmd.MarkFlagsMutuallyExclusive("ref", "release")
must(cmd.MarkFlagRequired("version"))
return cmd
}
func runAdd(cmd *cobra.Command, _ []string) (retErr error) {
flags, err := parseAddFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Validating flags")
if err := flags.validate(log); err != nil {
return err
}
log.Debugf("Creating version struct")
ver := versionsapi.Version{
Ref: flags.ref,
Stream: flags.stream,
Version: flags.version,
Kind: flags.kind,
}
if err := ver.Validate(); err != nil {
return err
}
log.Debugf("Creating versions API client")
client, clientClose, err := versionsapi.NewClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, flags.dryRun, log)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer func(retErr *error) {
log.Infof("Invalidating cache. This may take some time")
if err := clientClose(cmd.Context()); err != nil && retErr == nil {
*retErr = fmt.Errorf("invalidating cache: %w", err)
}
}(&retErr)
log.Infof("Adding version")
if err := ensureVersion(cmd.Context(), client, flags.kind, ver, versionsapi.GranularityMajor, log); err != nil {
return err
}
if err := ensureVersion(cmd.Context(), client, flags.kind, ver, versionsapi.GranularityMinor, log); err != nil {
return err
}
if flags.latest {
if err := updateLatest(cmd.Context(), client, flags.kind, ver, log); err != nil {
return fmt.Errorf("setting latest version: %w", err)
}
}
log.Infof("List major->minor URL: %s", ver.ListURL(versionsapi.GranularityMajor))
log.Infof("List minor->patch URL: %s", ver.ListURL(versionsapi.GranularityMinor))
return nil
}
func ensureVersion(ctx context.Context, client *versionsapi.Client, kind versionsapi.VersionKind, ver versionsapi.Version, gran versionsapi.Granularity,
log *logger.Logger,
) error {
verListReq := versionsapi.List{
Ref: ver.Ref,
Stream: ver.Stream,
Granularity: gran,
Base: ver.WithGranularity(gran),
Kind: kind,
}
verList, err := client.FetchVersionList(ctx, verListReq)
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
log.Infof("Version list for %s versions under %q does not exist. Creating new list", gran.String(), ver.Major())
verList = verListReq
} else if err != nil {
return fmt.Errorf("failed to list minor versions: %w", err)
}
log.Debugf("%s version list: %v", gran.String(), verList)
insertGran := gran + 1
insertVersion := ver.WithGranularity(insertGran)
if verList.Contains(insertVersion) {
log.Infof("Version %q already exists in list %v", insertVersion, verList.Versions)
return nil
}
log.Infof("Inserting %s version %q into list", insertGran.String(), insertVersion)
verList.Versions = append(verList.Versions, insertVersion)
log.Debugf("New %s version list: %v", gran.String(), verList)
if err := client.UpdateVersionList(ctx, verList); err != nil {
return fmt.Errorf("failed to add %s version: %w", gran.String(), err)
}
log.Infof("Added %q to list", insertVersion)
return nil
}
func updateLatest(ctx context.Context, client *versionsapi.Client, kind versionsapi.VersionKind, ver versionsapi.Version, log *logger.Logger) error {
latest := versionsapi.Latest{
Ref: ver.Ref,
Stream: ver.Stream,
Kind: kind,
}
latest, err := client.FetchVersionLatest(ctx, latest)
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
log.Debugf("Latest version for ref %q and stream %q not found", ver.Ref, ver.Stream)
} else if err != nil {
return fmt.Errorf("fetching latest version: %w", err)
}
if latest.Version == ver.Version {
log.Infof("Version %q is already latest version", ver)
return nil
}
log.Infof("Setting %q as latest version", ver)
latest = versionsapi.Latest{
Ref: ver.Ref,
Stream: ver.Stream,
Version: ver.Version,
Kind: kind,
}
if err := client.UpdateVersionLatest(ctx, latest); err != nil {
return fmt.Errorf("updating latest version: %w", err)
}
return nil
}
type addFlags struct {
version string
stream string
ref string
release bool
latest bool
dryRun bool
region string
bucket string
distributionID string
kind versionsapi.VersionKind
logLevel zapcore.Level
}
func (f *addFlags) validate(log *logger.Logger) error {
if !semver.IsValid(f.version) {
return fmt.Errorf("version %q is not a valid semantic version", f.version)
}
if semver.Canonical(f.version) != f.version {
return fmt.Errorf("version %q is not a canonical semantic version", f.version)
}
if f.ref == "" && !f.release {
return fmt.Errorf("either --ref or --release must be set")
}
if f.kind == versionsapi.VersionKindUnknown {
return fmt.Errorf("unknown version kind %q", f.kind)
}
if f.release {
log.Debugf("Setting ref to %q, as release flag is set", versionsapi.ReleaseRef)
f.ref = versionsapi.ReleaseRef
} else {
log.Debugf("Setting latest to true, as release flag is not set")
f.latest = true // always set latest for non-release versions
}
if err := versionsapi.ValidateRef(f.ref); err != nil {
return fmt.Errorf("invalid ref %w", err)
}
if err := versionsapi.ValidateStream(f.ref, f.stream); err != nil {
return fmt.Errorf("invalid stream %w", err)
}
return nil
}
func parseAddFlags(cmd *cobra.Command) (addFlags, error) {
ref, err := cmd.Flags().GetString("ref")
if err != nil {
return addFlags{}, err
}
ref = versionsapi.CanonicalizeRef(ref)
stream, err := cmd.Flags().GetString("stream")
if err != nil {
return addFlags{}, err
}
kindFlag, err := cmd.Flags().GetString("kind")
if err != nil {
return addFlags{}, err
}
kind := versionsapi.VersionKindFromString(kindFlag)
version, err := cmd.Flags().GetString("version")
if err != nil {
return addFlags{}, err
}
release, err := cmd.Flags().GetBool("release")
if err != nil {
return addFlags{}, err
}
latest, err := cmd.Flags().GetBool("latest")
if err != nil {
return addFlags{}, err
}
dryRun, err := cmd.Flags().GetBool("dryrun")
if err != nil {
return addFlags{}, err
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return addFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
region, err := cmd.Flags().GetString("region")
if err != nil {
return addFlags{}, err
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return addFlags{}, err
}
distributionID, err := cmd.Flags().GetString("distribution-id")
if err != nil {
return addFlags{}, err
}
return addFlags{
version: version,
stream: stream,
ref: versionsapi.CanonicalizeRef(ref),
release: release,
latest: latest,
dryRun: dryRun,
region: region,
bucket: bucket,
distributionID: distributionID,
logLevel: logLevel,
kind: kind,
}, nil
}

View file

@ -0,0 +1,148 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"encoding/json"
"fmt"
"github.com/edgelesssys/constellation/v2/internal/api/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
)
func newLatestCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "latest",
Short: "Find latest version",
Long: "Find latest version of a ref/stream. The returned version is in short format, if --json flag is not set.",
RunE: runLatest,
Args: cobra.ExactArgs(0),
}
cmd.Flags().String("ref", "-", "Ref to query")
cmd.Flags().String("stream", "stable", "Stream to query")
cmd.Flags().Bool("json", false, "Whether to output the result as JSON")
return cmd
}
func runLatest(cmd *cobra.Command, _ []string) error {
flags, err := parseLatestFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Validating flags")
if err := flags.validate(); err != nil {
return err
}
log.Debugf("Creating versions API client")
client, clientClose, err := versionsapi.NewReadOnlyClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, log)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer func() {
if err := clientClose(cmd.Context()); err != nil {
log.Errorf("Closing versions API client: %v", err)
}
}()
log.Debugf("Requesting latest version")
latest := versionsapi.Latest{
Ref: flags.ref,
Stream: flags.stream,
Kind: versionsapi.VersionKindImage,
}
latest, err = client.FetchVersionLatest(cmd.Context(), latest)
if err != nil {
return fmt.Errorf("fetching latest version: %w", err)
}
if flags.json {
out, err := json.MarshalIndent(latest, "", " ")
if err != nil {
return fmt.Errorf("marshaling JSON: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), string(out))
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), latest.ShortPath())
return nil
}
type latestFlags struct {
ref string
stream string
json bool
region string
bucket string
distributionID string
logLevel zapcore.Level
}
func (l *latestFlags) validate() error {
if err := versionsapi.ValidateRef(l.ref); err != nil {
return fmt.Errorf("invalid ref: %w", err)
}
if err := versionsapi.ValidateStream(l.ref, l.stream); err != nil {
return fmt.Errorf("invalid stream: %w", err)
}
return nil
}
func parseLatestFlags(cmd *cobra.Command) (latestFlags, error) {
ref, err := cmd.Flags().GetString("ref")
if err != nil {
return latestFlags{}, err
}
ref = versionsapi.CanonicalizeRef(ref)
stream, err := cmd.Flags().GetString("stream")
if err != nil {
return latestFlags{}, err
}
json, err := cmd.Flags().GetBool("json")
if err != nil {
return latestFlags{}, err
}
region, err := cmd.Flags().GetString("region")
if err != nil {
return latestFlags{}, err
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return latestFlags{}, err
}
distributionID, err := cmd.Flags().GetString("distribution-id")
if err != nil {
return latestFlags{}, err
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return latestFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return latestFlags{
ref: ref,
stream: stream,
json: json,
region: region,
bucket: bucket,
distributionID: distributionID,
logLevel: logLevel,
}, nil
}

View file

@ -0,0 +1,228 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
"golang.org/x/mod/semver"
apiclient "github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/api/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/logger"
)
func newListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List versions",
Long: "List all versions of a ref/stream. The returned version are in short format, if --json flag is not set.",
RunE: runList,
Args: cobra.ExactArgs(0),
}
cmd.Flags().String("ref", "-", "Ref to query")
cmd.Flags().String("stream", "stable", "Stream to query")
cmd.Flags().String("minor-version", "", "Minor version to query (format: \"v1.2\")")
cmd.Flags().Bool("json", false, "Whether to output the result as JSON")
return cmd
}
func runList(cmd *cobra.Command, _ []string) error {
flags, err := parseListFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Validating flags")
if err := flags.validate(); err != nil {
return err
}
log.Debugf("Creating versions API client")
client, clientClose, err := versionsapi.NewReadOnlyClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, log)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer func() {
if err := clientClose(cmd.Context()); err != nil {
log.Errorf("Closing versions API client: %v", err)
}
}()
var minorVersions []string
if flags.minorVersion != "" {
minorVersions = []string{flags.minorVersion}
} else {
log.Debugf("Getting minor versions")
minorVersions, err = listMinorVersions(cmd.Context(), client, flags.ref, flags.stream)
var errNotFound *apiclient.NotFoundError
if err != nil && errors.As(err, &errNotFound) {
log.Infof("No minor versions found for ref %q and stream %q.", flags.ref, flags.stream)
return nil
} else if err != nil {
return err
}
}
log.Debugf("Getting patch versions")
patchVersions, err := listPatchVersions(cmd.Context(), client, flags.ref, flags.stream, minorVersions)
var errNotFound *apiclient.NotFoundError
if err != nil && errors.As(err, &errNotFound) {
log.Infof("No patch versions found for ref %q, stream %q and minor versions %v.", flags.ref, flags.stream, minorVersions)
return nil
} else if err != nil {
return err
}
if flags.json {
log.Debugf("Printing versions as JSON")
var vers []string
for _, v := range patchVersions {
vers = append(vers, v.Version)
}
raw, err := json.Marshal(vers)
if err != nil {
return fmt.Errorf("marshaling versions: %w", err)
}
fmt.Println(string(raw))
return nil
}
log.Debugf("Printing versions")
for _, v := range patchVersions {
fmt.Println(v.ShortPath())
}
return nil
}
func listMinorVersions(ctx context.Context, client *versionsapi.Client, ref string, stream string) ([]string, error) {
list := versionsapi.List{
Ref: ref,
Stream: stream,
Granularity: versionsapi.GranularityMajor,
Base: "v2",
Kind: versionsapi.VersionKindImage,
}
list, err := client.FetchVersionList(ctx, list)
if err != nil {
return nil, fmt.Errorf("listing minor versions: %w", err)
}
return list.Versions, nil
}
func listPatchVersions(ctx context.Context, client *versionsapi.Client, ref string, stream string, minorVer []string,
) ([]versionsapi.Version, error) {
var patchVers []versionsapi.Version
list := versionsapi.List{
Ref: ref,
Stream: stream,
Granularity: versionsapi.GranularityMinor,
Kind: versionsapi.VersionKindImage,
}
for _, ver := range minorVer {
list.Base = ver
list, err := client.FetchVersionList(ctx, list)
if err != nil {
return nil, fmt.Errorf("listing patch versions: %w", err)
}
patchVers = append(patchVers, list.StructuredVersions()...)
}
return patchVers, nil
}
type listFlags struct {
ref string
stream string
minorVersion string
region string
bucket string
distributionID string
json bool
logLevel zapcore.Level
}
func (l *listFlags) validate() error {
if err := versionsapi.ValidateRef(l.ref); err != nil {
return fmt.Errorf("invalid ref: %w", err)
}
if err := versionsapi.ValidateStream(l.ref, l.stream); err != nil {
return fmt.Errorf("invalid stream: %w", err)
}
if l.minorVersion != "" {
if !semver.IsValid(l.minorVersion) || semver.MajorMinor(l.minorVersion) != l.minorVersion {
return fmt.Errorf("invalid minor version: %q", l.minorVersion)
}
}
return nil
}
func parseListFlags(cmd *cobra.Command) (listFlags, error) {
ref, err := cmd.Flags().GetString("ref")
if err != nil {
return listFlags{}, err
}
ref = versionsapi.CanonicalizeRef(ref)
stream, err := cmd.Flags().GetString("stream")
if err != nil {
return listFlags{}, err
}
minorVersion, err := cmd.Flags().GetString("minor-version")
if err != nil {
return listFlags{}, err
}
region, err := cmd.Flags().GetString("region")
if err != nil {
return listFlags{}, err
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return listFlags{}, err
}
distributionID, err := cmd.Flags().GetString("distribution-id")
if err != nil {
return listFlags{}, err
}
json, err := cmd.Flags().GetBool("json")
if err != nil {
return listFlags{}, err
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return listFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return listFlags{
ref: ref,
stream: stream,
minorVersion: minorVersion,
region: region,
bucket: bucket,
distributionID: distributionID,
json: json,
logLevel: logLevel,
}, nil
}

View file

@ -0,0 +1,101 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
/*
This package provides a CLI tool to interact with the Constellation versions API.
The tool can be used to request information from the API, but also for admin tasks.
All actions require an authentication against AWS with the common permissions.
Andministrative tasks like adding or removing versions require further AWS permissions
as well as permissions to GCP and Azure.
The CLI is commonly used in the CI pipeline. Most actions shouldn't be executed manually
by a developer. Notice that there is no synchronization on API operations.
*/
package main
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/spf13/cobra"
)
func main() {
if err := execute(); err != nil {
os.Exit(1)
}
}
func execute() error {
rootCmd := newRootCmd()
ctx, cancel := signalContext(context.Background(), os.Interrupt)
defer cancel()
return rootCmd.ExecuteContext(ctx)
}
func newRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "versionsapi",
Short: "Interact with the Constellation versions API",
Long: "Interact with the Constellation versions API.",
PersistentPreRun: preRunRoot,
}
rootCmd.SetOut(os.Stdout)
rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output")
rootCmd.PersistentFlags().String("region", "eu-central-1", "AWS region of the API S3 bucket")
rootCmd.PersistentFlags().String("bucket", "cdn-constellation-backend", "S3 bucket name of the API")
rootCmd.PersistentFlags().String("distribution-id", constants.CDNDefaultDistributionID, "CloudFront distribution ID of the API")
rootCmd.AddCommand(newAddCmd())
rootCmd.AddCommand(newLatestCmd())
rootCmd.AddCommand(newListCmd())
rootCmd.AddCommand(newRemoveCmd())
return rootCmd
}
// signalContext returns a context that is canceled on the handed signal.
// The signal isn't watched after its first occurrence. Call the cancel
// function to ensure the internal goroutine is stopped and the signal isn't
// watched any longer.
func signalContext(ctx context.Context, sig os.Signal) (context.Context, context.CancelFunc) {
sigCtx, stop := signal.NotifyContext(ctx, sig)
done := make(chan struct{}, 1)
stopDone := make(chan struct{}, 1)
go func() {
defer func() { stopDone <- struct{}{} }()
defer stop()
select {
case <-sigCtx.Done():
fmt.Println(" Signal caught. Press ctrl+c again to terminate the program immediately.")
case <-done:
}
}()
cancelFunc := func() {
done <- struct{}{}
<-stopDone
}
return sigCtx, cancelFunc
}
func preRunRoot(cmd *cobra.Command, _ []string) {
cmd.SilenceUsage = true
}
func must(err error) {
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,771 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"regexp"
"strings"
"time"
compute "cloud.google.com/go/compute/apiv1"
"cloud.google.com/go/compute/apiv1/computepb"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
armcomputev4 "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/smithy-go"
apiclient "github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/api/versionsapi"
"github.com/edgelesssys/constellation/v2/internal/logger"
gaxv2 "github.com/googleapis/gax-go/v2"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
)
func newRemoveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: "Remove a version/ref",
Long: `Remove a version/ref from the versions API.
Developers should not use this command directly. It is invoked by the CI/CD pipeline.
Most developers won't have the required permissions to use this command.
If you use the command nevertheless, you better know what you do.
`,
RunE: runRemove,
Args: cobra.ExactArgs(0),
}
cmd.Flags().String("ref", "", "Ref to delete from.")
cmd.Flags().String("stream", "", "Stream to delete from.")
cmd.Flags().String("version", "", "Version to delete. The versioned objects are deleted.")
cmd.Flags().String("version-path", "", "Short path of a single version to delete. The versioned objects are deleted.")
cmd.Flags().Bool("all", false, "Delete the entire ref. All versions and versioned objects are deleted.")
cmd.Flags().Bool("dryrun", false, "Whether to run in dry-run mode (no changes are made)")
cmd.Flags().String("gcp-project", "constellation-images", "GCP project to use")
cmd.Flags().String("az-subscription", "0d202bbb-4fa7-4af8-8125-58c269a05435", "Azure subscription to use")
cmd.Flags().String("az-location", "northeurope", "Azure location to use")
cmd.Flags().String("az-resource-group", "constellation-images", "Azure resource group to use")
cmd.MarkFlagsRequiredTogether("stream", "version")
cmd.MarkFlagsMutuallyExclusive("all", "stream")
cmd.MarkFlagsMutuallyExclusive("all", "version")
cmd.MarkFlagsMutuallyExclusive("all", "version-path")
cmd.MarkFlagsMutuallyExclusive("version-path", "ref")
cmd.MarkFlagsMutuallyExclusive("version-path", "stream")
cmd.MarkFlagsMutuallyExclusive("version-path", "version")
return cmd
}
func runRemove(cmd *cobra.Command, _ []string) (retErr error) {
flags, err := parseRmFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Validating flags")
if err := flags.validate(); err != nil {
return err
}
log.Debugf("Creating GCP client")
gcpClient, err := newGCPClient(cmd.Context(), flags.gcpProject)
if err != nil {
return fmt.Errorf("creating GCP client: %w", err)
}
log.Debugf("Creating AWS client")
awsClient, err := newAWSClient()
if err != nil {
return fmt.Errorf("creating AWS client: %w", err)
}
log.Debugf("Creating Azure client")
azClient, err := newAzureClient(flags.azSubscription, flags.azLocation, flags.azResourceGroup)
if err != nil {
return fmt.Errorf("creating Azure client: %w", err)
}
log.Debugf("Creating versions API client")
verclient, verclientClose, err := versionsapi.NewClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, flags.dryrun, log)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
defer func(retErr *error) {
log.Infof("Invalidating cache. This may take some time")
if err := verclientClose(cmd.Context()); err != nil && retErr == nil {
*retErr = fmt.Errorf("invalidating cache: %w", err)
}
}(&retErr)
imageClients := rmImageClients{
version: verclient,
gcp: gcpClient,
aws: awsClient,
az: azClient,
}
if flags.all {
log.Infof("Deleting ref %s", flags.ref)
if err := deleteRef(cmd.Context(), imageClients, flags.ref, flags.dryrun, log); err != nil {
return fmt.Errorf("deleting ref: %w", err)
}
return nil
}
log.Infof("Deleting single version %s", flags.ver.ShortPath())
if err := deleteSingleVersion(cmd.Context(), imageClients, flags.ver, flags.dryrun, log); err != nil {
return fmt.Errorf("deleting single version: %w", err)
}
return nil
}
func deleteSingleVersion(ctx context.Context, clients rmImageClients, ver versionsapi.Version, dryrun bool, log *logger.Logger) error {
var retErr error
log.Debugf("Deleting images for %s", ver.Version)
if err := deleteImage(ctx, clients, ver, dryrun, log); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting images: %w", err))
}
log.Debugf("Deleting version %s from versions API", ver.Version)
if err := clients.version.DeleteVersion(ctx, ver); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting version from versions API: %w", err))
}
return retErr
}
func deleteRef(ctx context.Context, clients rmImageClients, ref string, dryrun bool, log *logger.Logger) error {
var vers []versionsapi.Version
for _, stream := range []string{"nightly", "console", "debug"} {
log.Infof("Listing versions of stream %s", stream)
minorVersions, err := listMinorVersions(ctx, clients.version, ref, stream)
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
log.Debugf("No minor versions found for stream %s", stream)
continue
} else if err != nil {
return fmt.Errorf("listing minor versions for stream %s: %w", stream, err)
}
patchVersions, err := listPatchVersions(ctx, clients.version, ref, stream, minorVersions)
if errors.As(err, &notFoundErr) {
log.Debugf("No patch versions found for stream %s", stream)
continue
} else if err != nil {
return fmt.Errorf("listing patch versions for stream %s: %w", stream, err)
}
vers = append(vers, patchVersions...)
}
log.Infof("Found %d versions to delete", len(vers))
var retErr error
for _, ver := range vers {
if err := deleteImage(ctx, clients, ver, dryrun, log); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting images for version %s: %w", ver.Version, err))
}
}
log.Infof("Deleting ref %s from versions API", ref)
if err := clients.version.DeleteRef(ctx, ref); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting ref from versions API: %w", err))
}
return retErr
}
func deleteImage(ctx context.Context, clients rmImageClients, ver versionsapi.Version, dryrun bool, log *logger.Logger) error {
var retErr error
imageInfo := versionsapi.ImageInfo{
Ref: ver.Ref,
Stream: ver.Stream,
Version: ver.Version,
}
imageInfo, err := clients.version.FetchImageInfo(ctx, imageInfo)
var notFound *apiclient.NotFoundError
if errors.As(err, &notFound) {
log.Warnf("Image info for %s not found", ver.Version)
log.Warnf("Skipping image deletion")
return nil
} else if err != nil {
return fmt.Errorf("fetching image info: %w", err)
}
for _, entry := range imageInfo.List {
switch entry.CSP {
case "aws":
log.Infof("Deleting AWS images from %s", imageInfo.JSONPath())
if err := clients.aws.deleteImage(ctx, entry.Reference, entry.Region, dryrun, log); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting AWS image %s: %w", entry.Reference, err))
}
case "gcp":
log.Infof("Deleting GCP images from %s", imageInfo.JSONPath())
if err := clients.gcp.deleteImage(ctx, entry.Reference, dryrun, log); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting GCP image %s: %w", entry.Reference, err))
}
case "azure":
log.Infof("Deleting Azure images from %s", imageInfo.JSONPath())
if err := clients.az.deleteImage(ctx, entry.Reference, dryrun, log); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting Azure image %s: %w", entry.Reference, err))
}
}
}
// TODO(katexochen): Implement versions API trash. In case of failure, we should
// collect the resources that couldn't be deleted and store them in the trash, so
// that we can retry deleting them later.
return retErr
}
type rmImageClients struct {
version *versionsapi.Client
gcp *gcpClient
aws *awsClient
az *azureClient
}
type rmFlags struct {
ref string
stream string
version string
versionPath string
all bool
dryrun bool
region string
bucket string
distributionID string
gcpProject string
azSubscription string
azLocation string
azResourceGroup string
logLevel zapcore.Level
ver versionsapi.Version
}
func (f *rmFlags) validate() error {
if f.ref == versionsapi.ReleaseRef {
return fmt.Errorf("cannot delete from release ref")
}
if f.all {
if err := versionsapi.ValidateRef(f.ref); err != nil {
return fmt.Errorf("invalid ref: %w", err)
}
if f.ref == "main" {
return fmt.Errorf("cannot delete 'main' ref")
}
return nil
}
if f.versionPath != "" {
ver, err := versionsapi.NewVersionFromShortPath(f.versionPath, versionsapi.VersionKindImage)
if err != nil {
return fmt.Errorf("invalid version path: %w", err)
}
f.ver = ver
return nil
}
ver := versionsapi.Version{
Ref: f.ref,
Stream: f.stream,
Version: f.version,
Kind: versionsapi.VersionKindImage,
}
if err := ver.Validate(); err != nil {
return fmt.Errorf("invalid version: %w", err)
}
f.ver = ver
return nil
}
func parseRmFlags(cmd *cobra.Command) (*rmFlags, error) {
ref, err := cmd.Flags().GetString("ref")
if err != nil {
return nil, err
}
ref = versionsapi.CanonicalizeRef(ref)
stream, err := cmd.Flags().GetString("stream")
if err != nil {
return nil, err
}
version, err := cmd.Flags().GetString("version")
if err != nil {
return nil, err
}
versionPath, err := cmd.Flags().GetString("version-path")
if err != nil {
return nil, err
}
all, err := cmd.Flags().GetBool("all")
if err != nil {
return nil, err
}
dryrun, err := cmd.Flags().GetBool("dryrun")
if err != nil {
return nil, err
}
region, err := cmd.Flags().GetString("region")
if err != nil {
return nil, err
}
bucket, err := cmd.Flags().GetString("bucket")
if err != nil {
return nil, err
}
distributionID, err := cmd.Flags().GetString("distribution-id")
if err != nil {
return nil, err
}
gcpProject, err := cmd.Flags().GetString("gcp-project")
if err != nil {
return nil, err
}
azSubscription, err := cmd.Flags().GetString("az-subscription")
if err != nil {
return nil, err
}
azLocation, err := cmd.Flags().GetString("az-location")
if err != nil {
return nil, err
}
azResourceGroup, err := cmd.Flags().GetString("az-resource-group")
if err != nil {
return nil, err
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return nil, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return &rmFlags{
ref: ref,
stream: stream,
version: version,
versionPath: versionPath,
all: all,
dryrun: dryrun,
region: region,
bucket: bucket,
distributionID: distributionID,
gcpProject: gcpProject,
azSubscription: azSubscription,
azLocation: azLocation,
azResourceGroup: azResourceGroup,
logLevel: logLevel,
}, nil
}
type awsClient struct {
ec2 ec2API
}
// newAWSClient creates a new awsClient.
// Requires IAM permission 'ec2:DeregisterImage'.
func newAWSClient() (*awsClient, error) {
return &awsClient{}, nil
}
type ec2API interface {
DeregisterImage(ctx context.Context, params *ec2.DeregisterImageInput, optFns ...func(*ec2.Options),
) (*ec2.DeregisterImageOutput, error)
DescribeImages(ctx context.Context, params *ec2.DescribeImagesInput, optFns ...func(*ec2.Options),
) (*ec2.DescribeImagesOutput, error)
DeleteSnapshot(ctx context.Context, params *ec2.DeleteSnapshotInput, optFns ...func(*ec2.Options),
) (*ec2.DeleteSnapshotOutput, error)
}
func (a *awsClient) deleteImage(ctx context.Context, ami string, region string, dryrun bool, log *logger.Logger) error {
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return err
}
a.ec2 = ec2.NewFromConfig(cfg)
log.Debugf("Deleting resources in AWS region %s", region)
snapshotID, err := a.getSnapshotID(ctx, ami, log)
if err != nil {
log.Warnf("Failed to get AWS snapshot ID for image %s: %v", ami, err)
}
if err := a.deregisterImage(ctx, ami, dryrun, log); err != nil {
return fmt.Errorf("deregistering image %s: %w", ami, err)
}
if snapshotID != "" {
if err := a.deleteSnapshot(ctx, snapshotID, dryrun, log); err != nil {
return fmt.Errorf("deleting snapshot %s: %w", snapshotID, err)
}
}
return nil
}
func (a *awsClient) deregisterImage(ctx context.Context, ami string, dryrun bool, log *logger.Logger) error {
log.Debugf("Deregistering image %s", ami)
deregisterReq := ec2.DeregisterImageInput{
ImageId: &ami,
DryRun: &dryrun,
}
_, err := a.ec2.DeregisterImage(ctx, &deregisterReq)
var apiErr smithy.APIError
if errors.As(err, &apiErr) &&
(apiErr.ErrorCode() == "InvalidAMIID.NotFound" ||
apiErr.ErrorCode() == "InvalidAMIID.Unavailable") {
log.Warnf("AWS image %s not found", ami)
return nil
}
return err
}
func (a *awsClient) getSnapshotID(ctx context.Context, ami string, log *logger.Logger) (string, error) {
log.Debugf("Describing image %s", ami)
req := ec2.DescribeImagesInput{
ImageIds: []string{ami},
}
resp, err := a.ec2.DescribeImages(ctx, &req)
if err != nil {
return "", fmt.Errorf("describing image %s: %w", ami, err)
}
if len(resp.Images) == 0 {
return "", fmt.Errorf("image %s not found", ami)
}
if len(resp.Images) > 1 {
return "", fmt.Errorf("found multiple images with ami %s", ami)
}
image := resp.Images[0]
if len(image.BlockDeviceMappings) != 1 {
return "", fmt.Errorf("found %d block device mappings for image %s, expected 1", len(image.BlockDeviceMappings), ami)
}
if image.BlockDeviceMappings[0].Ebs == nil {
return "", fmt.Errorf("image %s does not have an EBS block device mapping", ami)
}
ebs := image.BlockDeviceMappings[0].Ebs
if ebs.SnapshotId == nil {
return "", fmt.Errorf("image %s does not have an EBS snapshot", ami)
}
snapshotID := *ebs.SnapshotId
return snapshotID, nil
}
func (a *awsClient) deleteSnapshot(ctx context.Context, snapshotID string, dryrun bool, log *logger.Logger) error {
log.Debugf("Deleting AWS snapshot %s", snapshotID)
req := ec2.DeleteSnapshotInput{
SnapshotId: &snapshotID,
DryRun: &dryrun,
}
_, err := a.ec2.DeleteSnapshot(ctx, &req)
var apiErr smithy.APIError
if errors.As(err, &apiErr) &&
(apiErr.ErrorCode() == "InvalidSnapshot.NotFound" ||
apiErr.ErrorCode() == "InvalidSnapshot.Unavailable") {
log.Warnf("AWS snapshot %s not found", snapshotID)
return nil
}
return err
}
type gcpClient struct {
project string
compute gcpComputeAPI
}
func newGCPClient(ctx context.Context, project string) (*gcpClient, error) {
compute, err := compute.NewImagesRESTClient(ctx)
if err != nil {
return nil, err
}
return &gcpClient{
compute: compute,
project: project,
}, nil
}
type gcpComputeAPI interface {
Delete(ctx context.Context, req *computepb.DeleteImageRequest, opts ...gaxv2.CallOption,
) (*compute.Operation, error)
io.Closer
}
func (g *gcpClient) deleteImage(ctx context.Context, imageURI string, dryrun bool, log *logger.Logger) error {
// Extract image name from image URI
// Expected input into function: "projects/constellation-images/global/images/v2-6-0-stable"
// Required for computepb.DeleteImageRequest: "v2-6-0-stable"
imageURIParts := strings.Split(imageURI, "/")
image := imageURIParts[len(imageURIParts)-1] // Don't need to check if len(imageURIParts) == 0 since sep is not empty and thus length must be ≥ 1
req := &computepb.DeleteImageRequest{
Image: image,
Project: g.project,
}
if dryrun {
log.Debugf("DryRun: delete image request: %v", req)
return nil
}
log.Debugf("Deleting image %s", image)
op, err := g.compute.Delete(ctx, req)
if err != nil && strings.Contains(err.Error(), "404") {
log.Warnf("GCP image %s not found", image)
return nil
} else if err != nil {
return fmt.Errorf("deleting image %s: %w", image, err)
}
log.Debugf("Waiting for operation to finish")
if err := op.Wait(ctx); err != nil {
return fmt.Errorf("waiting for operation: %w", err)
}
return nil
}
func (g *gcpClient) Close() error {
return g.compute.Close()
}
type azureClient struct {
subscription string
location string
resourceGroup string
galleries azureGalleriesAPI
image azureGalleriesImageAPI
imageVersions azureGalleriesImageVersionAPI
}
func newAzureClient(subscription, location, resourceGroup string) (*azureClient, error) {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
log.Fatal(err)
}
galleriesClient, err := armcomputev4.NewGalleriesClient(subscription, cred, nil)
if err != nil {
return nil, err
}
galleriesImageClient, err := armcomputev4.NewGalleryImagesClient(subscription, cred, nil)
if err != nil {
return nil, err
}
galleriesImageVersionClient, err := armcomputev4.NewGalleryImageVersionsClient(subscription, cred, nil)
if err != nil {
return nil, err
}
return &azureClient{
subscription: subscription,
location: location,
resourceGroup: resourceGroup,
galleries: galleriesClient,
image: galleriesImageClient,
imageVersions: galleriesImageVersionClient,
}, nil
}
type azureGalleriesAPI interface {
NewListPager(options *armcomputev4.GalleriesClientListOptions,
) *runtime.Pager[armcomputev4.GalleriesClientListResponse]
}
type azureGalleriesImageAPI interface {
BeginDelete(ctx context.Context, resourceGroupName string, galleryName string, galleryImageName string,
options *armcomputev4.GalleryImagesClientBeginDeleteOptions,
) (*runtime.Poller[armcomputev4.GalleryImagesClientDeleteResponse], error)
}
type azureGalleriesImageVersionAPI interface {
NewListByGalleryImagePager(resourceGroupName string, galleryName string, galleryImageName string,
options *armcomputev4.GalleryImageVersionsClientListByGalleryImageOptions,
) *runtime.Pager[armcomputev4.GalleryImageVersionsClientListByGalleryImageResponse]
BeginDelete(ctx context.Context, resourceGroupName string, galleryName string, galleryImageName string,
galleryImageVersionName string, options *armcomputev4.GalleryImageVersionsClientBeginDeleteOptions,
) (*runtime.Poller[armcomputev4.GalleryImageVersionsClientDeleteResponse], error)
}
var (
azImageRegex = regexp.MustCompile("^/subscriptions/[[:alnum:]._-]+/resourceGroups/([[:alnum:]._-]+)/providers/Microsoft.Compute/galleries/([[:alnum:]._-]+)/images/([[:alnum:]._-]+)/versions/([[:alnum:]._-]+)$")
azCommunityImageRegex = regexp.MustCompile("^/CommunityGalleries/([[:alnum:]-]+)/Images/([[:alnum:]._-]+)/Versions/([[:alnum:]._-]+)$")
)
func (a *azureClient) deleteImage(ctx context.Context, image string, dryrun bool, log *logger.Logger) error {
azImage, err := a.parseImage(ctx, image, log)
if err != nil {
return err
}
if dryrun {
log.Debugf("DryRun: delete image %v", azImage)
return nil
}
log.Debugf("Deleting image %q, version %q", azImage.imageDefinition, azImage.version)
poller, err := a.imageVersions.BeginDelete(ctx, azImage.resourceGroup, azImage.gallery,
azImage.imageDefinition, azImage.version, nil)
if err != nil {
return fmt.Errorf("begin delete image version: %w", err)
}
log.Debugf("Waiting for operation to finish")
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
return fmt.Errorf("waiting for operation: %w", err)
}
log.Debugf("Checking if image definition %q still has versions left", azImage.imageDefinition)
pager := a.imageVersions.NewListByGalleryImagePager(azImage.resourceGroup, azImage.gallery,
azImage.imageDefinition, nil)
for pager.More() {
nextResult, err := pager.NextPage(ctx)
if err != nil {
return fmt.Errorf("listing image versions of image definition %s: %w", azImage.imageDefinition, err)
}
if len(nextResult.Value) != 0 {
log.Debugf("Image definition %q still has versions left, won't be deleted", azImage.imageDefinition)
return nil
}
}
time.Sleep(15 * time.Second) // Azure needs time understand that there is no version left...
log.Debugf("Deleting image definition %s", azImage.imageDefinition)
op, err := a.image.BeginDelete(ctx, azImage.resourceGroup, azImage.gallery, azImage.imageDefinition, nil)
if err != nil {
return fmt.Errorf("deleting image definition %s: %w", azImage.imageDefinition, err)
}
log.Debugf("Waiting for operation to finish")
if _, err := op.PollUntilDone(ctx, nil); err != nil {
return fmt.Errorf("waiting for operation: %w", err)
}
return nil
}
type azImage struct {
resourceGroup string
gallery string
imageDefinition string
version string
}
func (a *azureClient) parseImage(ctx context.Context, image string, log *logger.Logger) (azImage, error) {
if m := azImageRegex.FindStringSubmatch(image); len(m) == 5 {
log.Debugf(
"Image matches local image format, resource group: %s, gallery: %s, image definition: %s, version: %s",
m[1], m[2], m[3], m[4],
)
return azImage{
resourceGroup: m[1],
gallery: m[2],
imageDefinition: m[3],
version: m[4],
}, nil
}
if !azCommunityImageRegex.MatchString(image) {
return azImage{}, fmt.Errorf("invalid image %s", image)
}
m := azCommunityImageRegex.FindStringSubmatch(image)
galleryPublicName := m[1]
imageDefinition := m[2]
version := m[3]
log.Debugf(
"Image matches community image format, gallery public name: %s, image definition: %s, version: %s",
galleryPublicName, imageDefinition, version,
)
var galleryName string
pager := a.galleries.NewListPager(nil)
for pager.More() {
nextResult, err := pager.NextPage(ctx)
if err != nil {
return azImage{}, fmt.Errorf("failed to advance page: %w", err)
}
for _, v := range nextResult.Value {
if v.Name == nil {
log.Debugf("Skipping gallery with nil name")
continue
}
if v.Properties.SharingProfile == nil {
log.Debugf("Skipping gallery %s with nil sharing profile", *v.Name)
continue
}
if v.Properties.SharingProfile.CommunityGalleryInfo == nil {
log.Debugf("Skipping gallery %s with nil community gallery info", *v.Name)
continue
}
if v.Properties.SharingProfile.CommunityGalleryInfo.PublicNames == nil {
log.Debugf("Skipping gallery %s with nil public names", *v.Name)
continue
}
for _, publicName := range v.Properties.SharingProfile.CommunityGalleryInfo.PublicNames {
if publicName == nil {
log.Debugf("Skipping nil public name")
continue
}
if *publicName == galleryPublicName {
galleryName = *v.Name
break
}
}
if galleryName != "" {
break
}
}
}
if galleryName == "" {
return azImage{}, fmt.Errorf("failed to find gallery for public name %s", galleryPublicName)
}
return azImage{
resourceGroup: a.resourceGroup,
gallery: galleryName,
imageDefinition: imageDefinition,
version: version,
}, nil
}

View file

@ -0,0 +1,254 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"context"
"errors"
"fmt"
"path"
"golang.org/x/mod/semver"
apiclient "github.com/edgelesssys/constellation/v2/internal/api/client"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/logger"
)
// Client is a client for the versions API.
type Client struct {
*apiclient.Client
clientClose func(ctx context.Context) error
}
// NewClient creates a new client for the versions API.
func NewClient(ctx context.Context, region, bucket, distributionID string, dryRun bool,
log *logger.Logger,
) (*Client, CloseFunc, error) {
genericClient, genericClientClose, err := apiclient.NewClient(ctx, region, bucket, distributionID, dryRun, log)
versionsClient := &Client{
genericClient,
genericClientClose,
}
versionsClientClose := func(ctx context.Context) error {
return versionsClient.Close(ctx)
}
return versionsClient, versionsClientClose, err
}
// NewReadOnlyClient creates a new read-only client.
// This client can be used to fetch objects but cannot write updates.
func NewReadOnlyClient(ctx context.Context, region, bucket, distributionID string,
log *logger.Logger,
) (*Client, CloseFunc, error) {
genericClient, genericClientClose, err := apiclient.NewReadOnlyClient(ctx, region, bucket, distributionID, log)
if err != nil {
return nil, nil, err
}
versionsClient := &Client{
genericClient,
genericClientClose,
}
versionsClientClose := func(ctx context.Context) error {
return versionsClient.Close(ctx)
}
return versionsClient, versionsClientClose, err
}
// Close closes the client.
func (c *Client) Close(ctx context.Context) error {
if c.clientClose == nil {
return nil
}
return c.clientClose(ctx)
}
// FetchVersionList fetches the given version list from the versions API.
func (c *Client) FetchVersionList(ctx context.Context, list List) (List, error) {
return apiclient.Fetch(ctx, c.Client, list)
}
// UpdateVersionList updates the given version list in the versions API.
func (c *Client) UpdateVersionList(ctx context.Context, list List) error {
semver.Sort(list.Versions)
return apiclient.Update(ctx, c.Client, list)
}
// FetchVersionLatest fetches the latest version from the versions API.
func (c *Client) FetchVersionLatest(ctx context.Context, latest Latest) (Latest, error) {
return apiclient.Fetch(ctx, c.Client, latest)
}
// UpdateVersionLatest updates the latest version in the versions API.
func (c *Client) UpdateVersionLatest(ctx context.Context, latest Latest) error {
return apiclient.Update(ctx, c.Client, latest)
}
// FetchImageInfo fetches the given image info from the versions API.
func (c *Client) FetchImageInfo(ctx context.Context, imageInfo ImageInfo) (ImageInfo, error) {
return apiclient.Fetch(ctx, c.Client, imageInfo)
}
// UpdateImageInfo updates the given image info in the versions API.
func (c *Client) UpdateImageInfo(ctx context.Context, imageInfo ImageInfo) error {
return apiclient.Update(ctx, c.Client, imageInfo)
}
// FetchCLIInfo fetches the given CLI info from the versions API.
func (c *Client) FetchCLIInfo(ctx context.Context, cliInfo CLIInfo) (CLIInfo, error) {
return apiclient.Fetch(ctx, c.Client, cliInfo)
}
// UpdateCLIInfo updates the given CLI info in the versions API.
func (c *Client) UpdateCLIInfo(ctx context.Context, cliInfo CLIInfo) error {
return apiclient.Update(ctx, c.Client, cliInfo)
}
// DeleteRef deletes the given ref from the versions API.
func (c *Client) DeleteRef(ctx context.Context, ref string) error {
if err := ValidateRef(ref); err != nil {
return fmt.Errorf("validating ref: %w", err)
}
refPath := path.Join(constants.CDNAPIPrefix, "ref", ref)
if err := c.Client.DeletePath(ctx, refPath); err != nil {
return fmt.Errorf("deleting ref path: %w", err)
}
return nil
}
// DeleteVersion deletes the given version from the versions API.
// The version will be removed from version lists and latest versions, and the versioned
// objects are deleted.
// Notice that the versions API can get into an inconsistent state if the version is the latest
// version but there is no older version of the same minor version available.
// Manual update of latest versions is required in this case.
func (c *Client) DeleteVersion(ctx context.Context, ver Version) error {
var retErr error
c.Client.Log.Debugf("Deleting version %s from minor version list", ver.Version)
possibleNewLatest, err := c.deleteVersionFromMinorVersionList(ctx, ver)
if err != nil {
retErr = errors.Join(retErr, fmt.Errorf("removing from minor version list: %w", err))
}
c.Client.Log.Debugf("Checking latest version for %s", ver.Version)
if err := c.deleteVersionFromLatest(ctx, ver, possibleNewLatest); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("updating latest version: %w", err))
}
c.Client.Log.Debugf("Deleting artifact path %s for %s", ver.ArtifactPath(APIV1), ver.Version)
if err := c.Client.DeletePath(ctx, ver.ArtifactPath(APIV1)); err != nil {
retErr = errors.Join(retErr, fmt.Errorf("deleting artifact path: %w", err))
}
return retErr
}
func (c *Client) deleteVersionFromMinorVersionList(ctx context.Context, ver Version,
) (*Latest, error) {
minorList := List{
Ref: ver.Ref,
Stream: ver.Stream,
Granularity: GranularityMinor,
Base: ver.WithGranularity(GranularityMinor),
Kind: VersionKindImage,
}
c.Client.Log.Debugf("Fetching minor version list for version %s", ver.Version)
minorList, err := c.FetchVersionList(ctx, minorList)
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
c.Client.Log.Warnf("Minor version list for version %s not found", ver.Version)
c.Client.Log.Warnf("Skipping update of minor version list")
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("fetching minor version list for version %s: %w", ver.Version, err)
}
if !minorList.Contains(ver.Version) {
c.Client.Log.Warnf("Version %s is not in minor version list %s", ver.Version, minorList.JSONPath())
c.Client.Log.Warnf("Skipping update of minor version list")
return nil, nil
}
semver.Sort(minorList.Versions)
for i, v := range minorList.Versions {
if v == ver.Version {
minorList.Versions = append(minorList.Versions[:i], minorList.Versions[i+1:]...)
break
}
}
var latest *Latest
if len(minorList.Versions) != 0 {
latest = &Latest{
Ref: ver.Ref,
Stream: ver.Stream,
Kind: VersionKindImage,
Version: minorList.Versions[len(minorList.Versions)-1],
}
c.Client.Log.Debugf("Possible latest version replacement %q", latest.Version)
}
if c.Client.DryRun {
c.Client.Log.Debugf("DryRun: Updating minor version list %s to %v", minorList.JSONPath(), minorList)
return latest, nil
}
c.Client.Log.Debugf("Updating minor version list %s", minorList.JSONPath())
if err := c.UpdateVersionList(ctx, minorList); err != nil {
return latest, fmt.Errorf("updating minor version list %s: %w", minorList.JSONPath(), err)
}
c.Client.Log.Debugf("Removed version %s from minor version list %s", ver.Version, minorList.JSONPath())
return latest, nil
}
func (c *Client) deleteVersionFromLatest(ctx context.Context, ver Version, possibleNewLatest *Latest,
) error {
latest := Latest{
Ref: ver.Ref,
Stream: ver.Stream,
Kind: VersionKindImage,
}
c.Client.Log.Debugf("Fetching latest version from %s", latest.JSONPath())
latest, err := c.FetchVersionLatest(ctx, latest)
var notFoundErr *apiclient.NotFoundError
if errors.As(err, &notFoundErr) {
c.Client.Log.Warnf("Latest version for %s not found", latest.JSONPath())
return nil
} else if err != nil {
return fmt.Errorf("fetching latest version: %w", err)
}
if latest.Version != ver.Version {
c.Client.Log.Debugf("Latest version is %s, not the deleted version %s", latest.Version, ver.Version)
return nil
}
if possibleNewLatest == nil {
c.Client.Log.Errorf("Latest version is %s, but no new latest version was found", latest.Version)
c.Client.Log.Errorf("A manual update of latest at %s might be needed", latest.JSONPath())
return fmt.Errorf("latest version is %s, but no new latest version was found", latest.Version)
}
if c.Client.DryRun {
c.Client.Log.Debugf("Would update latest version from %s to %s", latest.Version, possibleNewLatest.Version)
return nil
}
c.Client.Log.Infof("Updating latest version from %s to %s", latest.Version, possibleNewLatest.Version)
if err := c.UpdateVersionLatest(ctx, *possibleNewLatest); err != nil {
return fmt.Errorf("updating latest version: %w", err)
}
return nil
}
// CloseFunc is a function that closes the client.
type CloseFunc func(ctx context.Context) error

View file

@ -0,0 +1,95 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"errors"
"fmt"
"net/url"
"path"
"github.com/edgelesssys/constellation/v2/internal/constants"
"golang.org/x/mod/semver"
)
// CLIInfo contains information about a specific CLI version (i.e. it's compatibility with Kubernetes versions).
type CLIInfo struct {
// Ref is the reference name of the CLI.
Ref string `json:"ref,omitempty"`
// Stream is the stream name of the CLI.
Stream string `json:"stream,omitempty"`
// Version is the version of the CLI.
Version string `json:"version,omitempty"`
// Kubernetes contains all compatible Kubernetes versions.
Kubernetes []string `json:"kubernetes,omitempty"`
}
// JSONPath returns the S3 JSON path for this object.
func (c CLIInfo) JSONPath() string {
return path.Join(
constants.CDNAPIPrefix,
"ref", c.Ref,
"stream", c.Stream,
c.Version,
"cli",
"info.json",
)
}
// URL returns the URL to the JSON file for this object.
func (c CLIInfo) URL() (string, error) {
url, err := url.Parse(constants.CDNRepositoryURL)
if err != nil {
return "", fmt.Errorf("parsing CDN URL: %w", err)
}
url.Path = c.JSONPath()
return url.String(), nil
}
// ValidateRequest validates the request parameters of the list.
// The Kubernetes slice must be empty.
func (c CLIInfo) ValidateRequest() error {
var retErr error
if err := ValidateRef(c.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(c.Ref, c.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if !semver.IsValid(c.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semver", c.Version))
}
if len(c.Kubernetes) != 0 {
retErr = errors.Join(retErr, errors.New("Kubernetes slice must be empty for request"))
}
return retErr
}
// Validate checks if the CLI info is valid.
func (c CLIInfo) Validate() error {
var retErr error
if err := ValidateRef(c.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(c.Ref, c.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if !semver.IsValid(c.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semver", c.Version))
}
if len(c.Kubernetes) == 0 {
retErr = errors.Join(retErr, errors.New("Kubernetes slice must not be empty"))
}
for _, k := range c.Kubernetes {
if !semver.IsValid(k) {
retErr = errors.Join(retErr, fmt.Errorf("Kubernetes version %q is not a valid semver", k))
}
}
return retErr
}

View file

@ -0,0 +1,221 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/stretchr/testify/assert"
)
func TestCLIInfoJSONPath(t *testing.T) {
testCases := map[string]struct {
info CLIInfo
wantPath string
}{
"cli info": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
wantPath: constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/v1.0.0/cli/info.json",
},
"cli info release": {
info: CLIInfo{
Ref: ReleaseRef,
Stream: "stable",
Version: "v1.0.0",
},
wantPath: constants.CDNAPIPrefix + "/ref/-/stream/stable/v1.0.0/cli/info.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.wantPath, tc.info.JSONPath())
})
}
}
func TestCLIInfoURL(t *testing.T) {
testCases := map[string]struct {
info CLIInfo
wantURL string
wantPath string
}{
"cli info": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/v1.0.0/cli/info.json",
},
"cli info release": {
info: CLIInfo{
Ref: ReleaseRef,
Stream: "stable",
Version: "v1.0.0",
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/-/stream/stable/v1.0.0/cli/info.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
url, err := tc.info.URL()
assert.NoError(err)
assert.Equal(tc.wantURL, url)
})
}
}
func TestCLIInfoValidate(t *testing.T) {
testCases := map[string]struct {
info CLIInfo
wantErr bool
}{
"valid cli info": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
},
"invalid ref": {
info: CLIInfo{
Ref: "",
Stream: "nightly",
Version: "v1.0.0",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
wantErr: true,
},
"invalid stream": {
info: CLIInfo{
Ref: "test-ref",
Stream: "",
Version: "v1.0.0",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
wantErr: true,
},
"invalid version": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
wantErr: true,
},
"invalid k8s versions": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
Kubernetes: []string{"1", "", "1.32"},
},
wantErr: true,
},
"multiple errors": {
info: CLIInfo{
Ref: "",
Stream: "",
Version: "",
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.info.Validate()
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
func TestCLIInfoValidateRequest(t *testing.T) {
testCases := map[string]struct {
info CLIInfo
wantErr bool
}{
"valid cli info": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
},
"invalid ref": {
info: CLIInfo{
Ref: "",
Stream: "nightly",
Version: "v1.0.0",
},
wantErr: true,
},
"invalid stream": {
info: CLIInfo{
Ref: "test-ref",
Stream: "",
Version: "v1.0.0",
},
wantErr: true,
},
"invalid version": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "",
},
wantErr: true,
},
"invalid k8s versions": {
info: CLIInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
wantErr: true,
},
"multiple errors": {
info: CLIInfo{
Ref: "",
Stream: "",
Version: "",
Kubernetes: []string{"v1.26.1", "v1.3.3", "v1.32"},
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.info.ValidateRequest()
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}

View file

@ -0,0 +1,43 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"context"
"github.com/edgelesssys/constellation/v2/internal/api/fetcher"
)
// Fetcher fetches version API resources without authentication.
type Fetcher struct {
fetcher.HTTPClient
}
// NewFetcher returns a new Fetcher.
func NewFetcher() *Fetcher {
return &Fetcher{fetcher.NewHTTPClient()}
}
// FetchVersionList fetches the given version list from the versions API.
func (f *Fetcher) FetchVersionList(ctx context.Context, list List) (List, error) {
return fetcher.Fetch(ctx, f.HTTPClient, list)
}
// FetchVersionLatest fetches the latest version from the versions API.
func (f *Fetcher) FetchVersionLatest(ctx context.Context, latest Latest) (Latest, error) {
return fetcher.Fetch(ctx, f.HTTPClient, latest)
}
// FetchImageInfo fetches the given image info from the versions API.
func (f *Fetcher) FetchImageInfo(ctx context.Context, imageInfo ImageInfo) (ImageInfo, error) {
return fetcher.Fetch(ctx, f.HTTPClient, imageInfo)
}
// FetchCLIInfo fetches the given cli info from the versions API.
func (f *Fetcher) FetchCLIInfo(ctx context.Context, cliInfo CLIInfo) (CLIInfo, error) {
return fetcher.Fetch(ctx, f.HTTPClient, cliInfo)
}

View file

@ -0,0 +1,217 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
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 TestFetchVersionList(t *testing.T) {
require := require.New(t)
majorList := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
Versions: []string{"v1.0", "v1.1", "v1.2"},
}
}
minorList := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
Versions: []string{"v1.1.0", "v1.1.1", "v1.1.2"},
}
}
majorListJSON, err := json.Marshal(majorList())
require.NoError(err)
minorListJSON, err := json.Marshal(minorList())
require.NoError(err)
inconsistentList := majorList()
inconsistentList.Base = "v2"
inconsistentListJSON, err := json.Marshal(inconsistentList)
require.NoError(err)
testCases := map[string]struct {
list List
serverPath string
serverResp *http.Response
wantList List
wantErr bool
}{
"major list fetched": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/major/v1/image.json",
serverResp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(majorListJSON)),
},
wantList: *majorList(),
},
"minor list fetched": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
},
serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/minor/v1.1/image.json",
serverResp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(minorListJSON)),
},
wantList: *minorList(),
},
"list does not exist": {
list: List{
Ref: "another-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
wantErr: true,
},
"invalid list requested": {
list: List{
Ref: "",
Stream: "unknown",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
wantErr: true,
},
"unexpected error code": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/major/v1/image.json",
serverResp: &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(bytes.NewBufferString("Internal Server Error")),
},
wantErr: true,
},
"invalid json returned": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/major/v1/image.json",
serverResp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString("invalid json")),
},
wantErr: true,
},
"invalid list returned": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v2",
Kind: VersionKindImage,
},
serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/major/v2/image.json",
serverResp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBuffer(inconsistentListJSON)),
},
wantErr: true,
},
// TODO(katexochen): Remove or find strategy to implement this check in a generic way
// "response does not match request": {
// list: List{
// Ref: "test-ref",
// Stream: "nightly",
// Granularity: GranularityMajor,
// Base: "v3",
// Kind: VersionKindImage,
// },
// serverPath: "/constellation/v1/ref/test-ref/stream/nightly/versions/major/v3/image.json",
// serverResp: &http.Response{
// StatusCode: http.StatusOK,
// Body: io.NopCloser(bytes.NewBuffer(minorListJSON)),
// },
// wantErr: true,
// },
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.Path != tc.serverPath {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("Not found.")),
}
}
return tc.serverResp
})
fetcher := Fetcher{client}
list, err := fetcher.FetchVersionList(context.Background(), tc.list)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantList, list)
})
}
}
type roundTripFunc func(req *http.Request) *http.Response
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,
}
}

View file

@ -0,0 +1,141 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"errors"
"fmt"
"net/url"
"path"
"sort"
"github.com/edgelesssys/constellation/v2/internal/constants"
"golang.org/x/mod/semver"
)
// ImageInfo contains information about the OS images for a specific version.
type ImageInfo struct {
// Ref is the reference name of the image.
Ref string `json:"ref,omitempty"`
// Stream is the stream name of the image.
Stream string `json:"stream,omitempty"`
// Version is the version of the image.
Version string `json:"version,omitempty"`
// List contains the image variants for this version.
List []ImageInfoEntry `json:"list,omitempty"`
}
// ImageInfoEntry contains information about a single image variant.
type ImageInfoEntry struct {
CSP string `json:"csp"`
AttestationVariant string `json:"attestationVariant"`
Reference string `json:"reference"`
Region string `json:"region,omitempty"`
}
// JSONPath returns the S3 JSON path for this object.
func (i ImageInfo) JSONPath() string {
return path.Join(
constants.CDNAPIPrefixV2,
"ref", i.Ref,
"stream", i.Stream,
i.Version,
"image",
"info.json",
)
}
// URL returns the URL to the JSON file for this object.
func (i ImageInfo) URL() (string, error) {
url, err := url.Parse(constants.CDNRepositoryURL)
if err != nil {
return "", fmt.Errorf("parsing CDN URL: %w", err)
}
url.Path = i.JSONPath()
return url.String(), nil
}
// ValidateRequest validates the request parameters of the list.
// The provider specific maps must be empty.
func (i ImageInfo) ValidateRequest() error {
var retErr error
if err := ValidateRef(i.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(i.Ref, i.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if !semver.IsValid(i.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semver", i.Version))
}
if len(i.List) != 0 {
retErr = errors.Join(retErr, errors.New("list must be empty for request"))
}
return retErr
}
// Validate checks if the image info is valid.
func (i ImageInfo) Validate() error {
var retErr error
if err := ValidateRef(i.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(i.Ref, i.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if !semver.IsValid(i.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semver", i.Version))
}
if len(i.List) == 0 {
retErr = errors.Join(retErr, errors.New("one or more image variants must be specified in the list"))
}
return retErr
}
// MergeImageInfos combines the image info entries from multiple sources into a single
// image info object.
func MergeImageInfos(infos ...ImageInfo) (ImageInfo, error) {
if len(infos) == 0 {
return ImageInfo{}, errors.New("no image info objects specified")
}
if len(infos) == 1 {
return infos[0], nil
}
out := ImageInfo{
Ref: infos[0].Ref,
Stream: infos[0].Stream,
Version: infos[0].Version,
List: []ImageInfoEntry{},
}
for _, info := range infos {
if info.Ref != out.Ref {
return ImageInfo{}, errors.New("image info objects have different refs")
}
if info.Stream != out.Stream {
return ImageInfo{}, errors.New("image info objects have different streams")
}
if info.Version != out.Version {
return ImageInfo{}, errors.New("image info objects have different versions")
}
out.List = append(out.List, info.List...)
}
sort.SliceStable(out.List, func(i, j int) bool {
if out.List[i].CSP != out.List[j].CSP {
return out.List[i].CSP < out.List[j].CSP
}
if out.List[i].AttestationVariant != out.List[j].AttestationVariant {
return out.List[i].AttestationVariant < out.List[j].AttestationVariant
}
if out.List[i].Region != out.List[j].Region {
return out.List[i].Region < out.List[j].Region
}
return out.List[i].Reference < out.List[j].Reference
})
return out, nil
}

View file

@ -0,0 +1,463 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/stretchr/testify/assert"
)
func TestImageInfoJSONPath(t *testing.T) {
testCases := map[string]struct {
info ImageInfo
wantPath string
}{
"image info": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
wantPath: constants.CDNAPIPrefixV2 + "/ref/test-ref/stream/nightly/v1.0.0/image/info.json",
},
"image info release": {
info: ImageInfo{
Ref: ReleaseRef,
Stream: "stable",
Version: "v1.0.0",
},
wantPath: constants.CDNAPIPrefixV2 + "/ref/-/stream/stable/v1.0.0/image/info.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.wantPath, tc.info.JSONPath())
})
}
}
func TestImageInfoURL(t *testing.T) {
testCases := map[string]struct {
info ImageInfo
wantURL string
wantPath string
}{
"image info": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefixV2 + "/ref/test-ref/stream/nightly/v1.0.0/image/info.json",
},
"image info release": {
info: ImageInfo{
Ref: ReleaseRef,
Stream: "stable",
Version: "v1.0.0",
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefixV2 + "/ref/-/stream/stable/v1.0.0/image/info.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
url, err := tc.info.URL()
assert.NoError(err)
assert.Equal(tc.wantURL, url)
})
}
}
func TestImageInfoValidate(t *testing.T) {
testCases := map[string]struct {
info ImageInfo
wantErr bool
}{
"valid image info": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-2",
},
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "gcp-123",
},
{
CSP: "azure",
AttestationVariant: "azure-sev-snp",
Reference: "azure-123",
},
{
CSP: "qemu",
AttestationVariant: "qemu-vtpm",
Reference: "https://example.com/qemu-123/image.raw",
},
},
},
},
"invalid ref": {
info: ImageInfo{
Ref: "",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
wantErr: true,
},
"invalid stream": {
info: ImageInfo{
Ref: "test-ref",
Stream: "",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
wantErr: true,
},
"invalid version": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
wantErr: true,
},
"no entries in list": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
wantErr: true,
},
"multiple errors": {
info: ImageInfo{
Ref: "",
Stream: "",
Version: "",
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.info.Validate()
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
func TestImageInfoValidateRequest(t *testing.T) {
testCases := map[string]struct {
info ImageInfo
wantErr bool
}{
"valid image info": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
},
},
"invalid ref": {
info: ImageInfo{
Ref: "",
Stream: "nightly",
Version: "v1.0.0",
},
wantErr: true,
},
"invalid stream": {
info: ImageInfo{
Ref: "test-ref",
Stream: "",
Version: "v1.0.0",
},
wantErr: true,
},
"invalid version": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "",
},
wantErr: true,
},
"request contains entries": {
info: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
wantErr: true,
},
"multiple errors": {
info: ImageInfo{
Ref: "",
Stream: "",
Version: "",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.info.ValidateRequest()
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
func TestMergeImageInfos(t *testing.T) {
testCases := map[string]struct {
infos []ImageInfo
wantInfo ImageInfo
wantErr bool
}{
"only one element": {
infos: []ImageInfo{
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
},
wantInfo: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
},
"valid image info": {
infos: []ImageInfo{
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "image-123",
},
},
},
},
wantInfo: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "image-123",
},
},
},
},
"sorting": {
infos: []ImageInfo{
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "image-123",
},
},
},
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
},
wantInfo: ImageInfo{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "image-123",
},
},
},
},
"mismatch in base info": {
infos: []ImageInfo{
{
Ref: "test-ref",
Stream: "nightly",
Version: "v1.0.0",
List: []ImageInfoEntry{
{
CSP: "gcp",
AttestationVariant: "gcp-sev-es",
Reference: "image-123",
},
},
},
{
Ref: "other-ref",
Stream: "stable",
Version: "v2.0.0",
List: []ImageInfoEntry{
{
CSP: "aws",
AttestationVariant: "aws-nitro-tpm",
Reference: "ami-123",
Region: "us-east-1",
},
},
},
},
wantErr: true,
},
"empty list": {
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
gotInfo, err := MergeImageInfos(tc.infos...)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantInfo, gotInfo)
})
}
}

View file

@ -0,0 +1,93 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"errors"
"fmt"
"net/url"
"path"
"github.com/edgelesssys/constellation/v2/internal/constants"
"golang.org/x/mod/semver"
)
// Latest is the latest version of a kind of resource.
type Latest struct {
// Ref is the branch name this latest version belongs to.
Ref string `json:"ref,omitempty"`
// Stream is stream name this latest version belongs to.
Stream string `json:"stream,omitempty"`
// Kind is the kind of resource this latest version is for.
Kind VersionKind `json:"kind,omitempty"`
// Version is the latest version for this ref, stream and kind.
Version string `json:"version,omitempty"`
}
// JSONPath returns the S3 JSON path for this object.
func (l Latest) JSONPath() string {
return path.Join(
constants.CDNAPIPrefix,
"ref", l.Ref,
"stream", l.Stream,
"versions",
"latest",
l.Kind.String()+".json",
)
}
// URL returns the URL for this object.
func (l Latest) URL() (string, error) {
url, err := url.Parse(constants.CDNRepositoryURL)
if err != nil {
return "", fmt.Errorf("parsing CDN URL: %w", err)
}
url.Path = l.JSONPath()
return url.String(), nil
}
// Validate checks if this latest version is valid.
func (l Latest) Validate() error {
var retErr error
if err := ValidateRef(l.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(l.Ref, l.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if l.Kind == VersionKindUnknown {
retErr = errors.Join(retErr, fmt.Errorf("version of kind %q is not supported", l.Kind))
}
if !semver.IsValid(l.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semver", l.Version))
}
return retErr
}
// ValidateRequest checks if this latest version beside values that are fetched.
func (l Latest) ValidateRequest() error {
var retErr error
if err := ValidateRef(l.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(l.Ref, l.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if l.Kind == VersionKindUnknown {
retErr = errors.Join(retErr, fmt.Errorf("version of kind %q is not supported", l.Kind))
}
if l.Version != "" {
retErr = errors.Join(retErr, fmt.Errorf("version %q must be empty for request", l.Version))
}
return retErr
}
// ShortPath returns the short path of the latest version.
func (l Latest) ShortPath() string {
return shortPath(l.Ref, l.Stream, l.Version)
}

View file

@ -0,0 +1,212 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/stretchr/testify/assert"
)
func TestLatestJSONPath(t *testing.T) {
testCases := map[string]struct {
list Latest
wantPath string
}{
"latest list": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/latest/image.json",
},
"latest list release": {
list: Latest{
Ref: ReleaseRef,
Stream: "stable",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/-/stream/stable/versions/latest/image.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.wantPath, tc.list.JSONPath())
})
}
}
func TestLatestURL(t *testing.T) {
testCases := map[string]struct {
list Latest
wantURL string
wantPath string
}{
"latest list": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/latest/image.json",
},
"latest list release": {
list: Latest{
Ref: ReleaseRef,
Stream: "stable",
Kind: VersionKindImage,
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/-/stream/stable/versions/latest/image.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
url, err := tc.list.URL()
assert.NoError(err)
assert.Equal(tc.wantURL, url)
})
}
}
func TestLatestValidate(t *testing.T) {
testCases := map[string]struct {
list Latest
wantErr bool
}{
"valid": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
Version: "v1.0.0",
},
},
"invalid ref": {
list: Latest{
Ref: "",
Stream: "nightly",
Kind: VersionKindImage,
Version: "v1.0.0",
},
wantErr: true,
},
"invalid stream": {
list: Latest{
Ref: "test-ref",
Stream: "invalid-stream",
Kind: VersionKindImage,
Version: "v1.0.0",
},
wantErr: true,
},
"invalid kind": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindUnknown,
Version: "v1.0.0",
},
wantErr: true,
},
"invalid version": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
Version: "",
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
if tc.wantErr {
assert.Error(t, tc.list.Validate())
} else {
assert.NoError(t, tc.list.Validate())
}
})
}
}
func TestLatestValidateRequest(t *testing.T) {
testCases := map[string]struct {
list Latest
wantErr bool
}{
"valid": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
},
},
"invalid ref": {
list: Latest{
Ref: "",
Stream: "nightly",
Kind: VersionKindImage,
},
wantErr: true,
},
"invalid stream": {
list: Latest{
Ref: "test-ref",
Stream: "invalid-stream",
Kind: VersionKindImage,
},
wantErr: true,
},
"invalid kind": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindUnknown,
},
wantErr: true,
},
"version not empty": {
list: Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
Version: "v1.1.1",
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
if tc.wantErr {
assert.Error(t, tc.list.ValidateRequest())
} else {
assert.NoError(t, tc.list.ValidateRequest())
}
})
}
}
func TestLatestShortPath(t *testing.T) {
assert := assert.New(t)
latest := Latest{
Ref: "test-ref",
Stream: "nightly",
Kind: VersionKindImage,
Version: "v1.0.0",
}
assert.Equal(shortPath(latest.Ref, latest.Stream, latest.Version), latest.ShortPath())
}

View file

@ -0,0 +1,173 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"errors"
"fmt"
"net/url"
"path"
"github.com/edgelesssys/constellation/v2/internal/constants"
"golang.org/x/mod/semver"
)
// List represents a list of versions for a kind of resource.
// It has a granularity of either "major" or "minor".
//
// For example, a List with granularity "major" could contain
// the base version "v1" and a list of minor versions "v1.0", "v1.1", "v1.2" etc.
// A List 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 List struct {
// Ref is the branch name the list belongs to.
Ref string `json:"ref,omitempty"`
// Stream is the update stream of the list.
Stream string `json:"stream,omitempty"`
// Granularity is the granularity of the base version of this list.
// It can be either "major" or "minor".
Granularity Granularity `json:"granularity,omitempty"`
// 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,omitempty"`
// Kind is the kind of resource this list is for.
Kind VersionKind `json:"kind,omitempty"`
// Versions is a list of all versions in this list.
Versions []string `json:"versions,omitempty"`
}
// JSONPath returns the S3 JSON path for this object.
func (l List) JSONPath() string {
return path.Join(
constants.CDNAPIPrefix,
"ref", l.Ref,
"stream", l.Stream,
"versions", l.Granularity.String(), l.Base,
l.Kind.String()+".json",
)
}
// URL returns the URL for this object.
func (l List) URL() (string, error) {
url, err := url.Parse(constants.CDNRepositoryURL)
if err != nil {
return "", fmt.Errorf("parsing CDN URL: %w", err)
}
url.Path = l.JSONPath()
return url.String(), nil
}
// ValidateRequest validates the request parameters of the list.
// The versions field must be empty.
func (l List) ValidateRequest() error {
var retErr error
if err := ValidateRef(l.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(l.Ref, l.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if l.Granularity != GranularityMajor && l.Granularity != GranularityMinor {
retErr = errors.Join(retErr, fmt.Errorf("granularity %q is not supported", l.Granularity))
}
if l.Kind == VersionKindUnknown {
retErr = errors.Join(retErr, fmt.Errorf("kind %q is not supported", l.Kind))
}
if !semver.IsValid(l.Base) {
retErr = errors.Join(retErr, fmt.Errorf("base version %q is not a valid semantic version", l.Base))
}
var normalizeFunc func(string) string
switch l.Granularity {
case GranularityMajor:
normalizeFunc = semver.Major
case GranularityMinor:
normalizeFunc = semver.MajorMinor
default:
normalizeFunc = func(s string) string { return s }
}
if normalizeFunc(l.Base) != l.Base {
retErr = errors.Join(retErr, fmt.Errorf("base version %q does not match granularity %q", l.Base, l.Granularity))
}
if len(l.Versions) != 0 {
retErr = errors.Join(retErr, fmt.Errorf("versions must be empty for request"))
}
return retErr
}
// Validate checks if the list is valid.
// This performs the following checks:
// - The ref is set.
// - 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 List) Validate() error {
var retErr error
if err := ValidateRef(l.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(l.Ref, l.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if l.Granularity != GranularityMajor && l.Granularity != GranularityMinor {
retErr = errors.Join(retErr, fmt.Errorf("granularity %q is not supported", l.Granularity))
}
if l.Kind == VersionKindUnknown {
retErr = errors.Join(retErr, fmt.Errorf("kind %q is not supported", l.Kind))
}
if !semver.IsValid(l.Base) {
retErr = errors.Join(retErr, fmt.Errorf("base version %q is not a valid semantic version", l.Base))
}
var normalizeFunc func(string) string
switch l.Granularity {
case GranularityMajor:
normalizeFunc = semver.Major
case GranularityMinor:
normalizeFunc = semver.MajorMinor
default:
normalizeFunc = func(s string) string { return s }
}
if normalizeFunc(l.Base) != l.Base {
retErr = errors.Join(retErr, fmt.Errorf("base version %q does not match granularity %q", l.Base, l.Granularity))
}
for _, ver := range l.Versions {
if !semver.IsValid(ver) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semantic version", ver))
}
if normalizeFunc(ver) != l.Base || normalizeFunc(ver) == ver {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not finer-grained than base version %q", ver, l.Base))
}
}
return retErr
}
// Contains returns true if the list contains the given version.
func (l List) Contains(version string) bool {
for _, v := range l.Versions {
if v == version {
return true
}
}
return false
}
// StructuredVersions returns the versions of the list as slice of
// Version structs.
func (l List) StructuredVersions() []Version {
versions := make([]Version, len(l.Versions))
for i, v := range l.Versions {
versions[i] = Version{
Ref: l.Ref,
Stream: l.Stream,
Version: v,
Kind: l.Kind,
}
}
return versions
}

View file

@ -0,0 +1,354 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"testing"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListJSONPath(t *testing.T) {
testCases := map[string]struct {
list List
wantPath string
}{
"major list": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/major/v1/image.json",
},
"minor list": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/minor/v1.1/image.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.wantPath, tc.list.JSONPath())
})
}
}
func TestListURL(t *testing.T) {
testCases := map[string]struct {
list List
wantURL string
wantPath string
}{
"major list": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/major/v1/image.json",
},
"minor list": {
list: List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
},
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/test-ref/stream/nightly/versions/minor/v1.1/image.json",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
url, err := tc.list.URL()
assert.NoError(err)
assert.Equal(tc.wantURL, url)
})
}
}
func TestListValidate(t *testing.T) {
majorList := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
Versions: []string{
"v1.0", "v1.1", "v1.2",
},
}
}
minorList := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
Versions: []string{
"v1.1.0", "v1.1.1", "v1.1.2",
},
}
}
testCases := map[string]struct {
listFunc func() *List
overrideFunc func(list *List)
wantErr bool
}{
"valid major list": {
listFunc: majorList,
},
"valid minor list": {
listFunc: minorList,
},
"invalid ref": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Ref = "" },
wantErr: true,
},
"invalid stream": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Stream = "invalid" },
wantErr: true,
},
"invalid granularity": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Granularity = GranularityUnknown },
wantErr: true,
},
"invalid kind": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Kind = VersionKindUnknown },
wantErr: true,
},
"base ver is not semantic version": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Base = "invalid" },
wantErr: true,
},
"base ver does not reflect major granularity": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Base = "v1.0" },
wantErr: true,
},
"base ver does not reflect minor granularity": {
listFunc: minorList,
overrideFunc: func(list *List) { list.Base = "v1" },
wantErr: true,
},
"version in list is not semantic version": {
listFunc: majorList,
overrideFunc: func(list *List) { list.Versions[0] = "invalid" },
wantErr: true,
},
"version in list is not sub version of base": {
listFunc: majorList,
overrideFunc: func(list *List) { 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 TestListValidateRequest(t *testing.T) {
majorListReq := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMajor,
Base: "v1",
Kind: VersionKindImage,
}
}
minorListReq := func() *List {
return &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
}
}
testCases := map[string]struct {
listFunc func() *List
overrideFunc func(list *List)
wantErr bool
}{
"valid major list": {
listFunc: majorListReq,
},
"valid minor list": {
listFunc: minorListReq,
},
"invalid ref": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Ref = "" },
wantErr: true,
},
"invalid stream": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Stream = "invalid" },
wantErr: true,
},
"invalid granularity": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Granularity = GranularityUnknown },
wantErr: true,
},
"invalid kind": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Kind = VersionKindUnknown },
wantErr: true,
},
"base ver is not semantic version": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Base = "invalid" },
wantErr: true,
},
"base ver does not reflect major granularity": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Base = "v1.0" },
wantErr: true,
},
"base ver does not reflect minor granularity": {
listFunc: minorListReq,
overrideFunc: func(list *List) { list.Base = "v1" },
wantErr: true,
},
"version in list is not empty": {
listFunc: majorListReq,
overrideFunc: func(list *List) { list.Versions = []string{"v1.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.ValidateRequest()
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
})
}
}
func TestListContainer(t *testing.T) {
testCases := map[string]struct {
versions []string
version string
want bool
}{
"empty list": {
versions: []string{},
version: "v1.1.1",
want: false,
},
"version not in list": {
versions: []string{"v1.1.1"},
version: "v1.1.2",
want: false,
},
"version in list": {
versions: []string{"v1.1.1", "v1.1.2"},
version: "v1.1.1",
want: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
list := &List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
Versions: tc.versions,
}
assert.Equal(tc.want, list.Contains(tc.version))
})
}
}
func TestListStructuredVersions(t *testing.T) {
assert := assert.New(t)
list := List{
Ref: "test-ref",
Stream: "nightly",
Granularity: GranularityMinor,
Base: "v1.1",
Kind: VersionKindImage,
Versions: []string{"v1.1.1", "v1.1.2", "v1.1.3", "v1.1.4", "v1.1.5"},
}
versions := list.StructuredVersions()
assert.Len(versions, 5)
verStrs := make([]string, len(versions))
for i, v := range versions {
assert.Equal(list.Ref, v.Ref)
assert.Equal(list.Stream, v.Stream)
assert.Equal(list.Kind, v.Kind)
verStrs[i] = v.Version
}
assert.ElementsMatch(list.Versions, verStrs)
}

View file

@ -0,0 +1,419 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"path"
"regexp"
"strings"
"golang.org/x/mod/semver"
"github.com/edgelesssys/constellation/v2/internal/constants"
)
// ReleaseRef is the ref used for release versions.
const ReleaseRef = "-"
// Version represents a version. A version has a ref, stream, version string and kind.
//
// Notice that version is a meta object to the versions API and there isn't an
// actual corresponding object in the S3 bucket.
// Therefore, the version object doesn't have a URL or JSON path.
type Version struct {
Ref string
Stream string
Version string
Kind VersionKind
}
// NewVersionFromShortPath creates a new Version from a version short path.
func NewVersionFromShortPath(shortPath string, kind VersionKind) (Version, error) {
ref, stream, version, err := parseShortPath(shortPath)
if err != nil {
return Version{}, err
}
ver := Version{
Ref: ref,
Stream: stream,
Version: version,
Kind: kind,
}
if err := ver.Validate(); err != nil {
return Version{}, err
}
return ver, nil
}
// ShortPath returns the short path of the version.
func (v Version) ShortPath() string {
return shortPath(v.Ref, v.Stream, v.Version)
}
// Validate validates the version.
func (v Version) Validate() error {
var retErr error
if err := ValidateRef(v.Ref); err != nil {
retErr = errors.Join(retErr, err)
}
if err := ValidateStream(v.Ref, v.Stream); err != nil {
retErr = errors.Join(retErr, err)
}
if !semver.IsValid(v.Version) {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a valid semantic version", v.Version))
}
if semver.Canonical(v.Version) != v.Version {
retErr = errors.Join(retErr, fmt.Errorf("version %q is not a canonical semantic version", v.Version))
}
if v.Kind == VersionKindUnknown {
retErr = errors.Join(retErr, errors.New("version kind is unknown"))
}
return retErr
}
// Equal returns true if the versions are equal.
func (v Version) Equal(other Version) bool {
return v.Ref == other.Ref &&
v.Stream == other.Stream &&
v.Version == other.Version &&
v.Kind == other.Kind
}
// Major returns the major version corresponding to the version.
// For example, if the version is "v1.2.3", the major version is "v1".
func (v Version) Major() string {
return semver.Major(v.Version)
}
// MajorMinor returns the major and minor version corresponding to the version.
// For example, if the version is "v1.2.3", the major and minor version is "v1.2".
func (v Version) MajorMinor() string {
return semver.MajorMinor(v.Version)
}
// WithGranularity returns the version with the given granularity.
//
// For example, if the version is "v1.2.3" and the granularity is GranularityMajor,
// the returned version is "v1".
// This is a helper function for Major() and MajorMinor() and v.Version.
// In case of an unknown granularity, an empty string is returned.
func (v Version) WithGranularity(gran Granularity) string {
switch gran {
case GranularityMajor:
return v.Major()
case GranularityMinor:
return v.MajorMinor()
case GranularityPatch:
return v.Version
default:
return ""
}
}
// ListURL returns the URL of the list with the given granularity,
// this version is listed in.
// For example, assing GranularityMajor returns the URL of the versions list
// that maps the major version of this version to its minor version.
// In case of an unknown granularity, an empty string is returned.
func (v Version) ListURL(gran Granularity) string {
if gran == GranularityUnknown || gran == GranularityPatch {
return ""
}
return constants.CDNRepositoryURL + "/" + v.ListPath(gran)
}
// ListPath returns the path of the list with the given granularity,
// this version is listed in.
// For example, assing GranularityMajor returns the path of the versions list
// that maps the major version of this version to its minor version.
// In case of an unknown granularity, an empty string is returned.
func (v Version) ListPath(gran Granularity) string {
if gran == GranularityUnknown || gran == GranularityPatch {
return ""
}
return path.Join(
constants.CDNAPIPrefix,
"ref", v.Ref,
"stream", v.Stream,
"versions",
gran.String(), v.WithGranularity(gran),
v.Kind.String()+".json",
)
}
// ArtifactsURL returns the URL to the artifacts stored for this version.
// The URL points to a directory.
func (v Version) ArtifactsURL(apiVer apiVersion) string {
return constants.CDNRepositoryURL + "/" + v.ArtifactPath(apiVer)
}
// ArtifactPath returns the path to the artifacts stored for this version.
// The path points to a directory.
func (v Version) ArtifactPath(apiVer apiVersion) string {
return path.Join(
constants.CDNAPIBase,
apiVer.String(),
"ref", v.Ref,
"stream", v.Stream,
v.Version,
)
}
// VersionKind represents the kind of resource the version versions.
type VersionKind int
const (
// VersionKindUnknown is the default value for VersionKind.
VersionKindUnknown VersionKind = iota
// VersionKindImage is the kind for image versions.
VersionKindImage
// VersionKindCLI is the kind for CLI versions.
VersionKindCLI
)
// MarshalJSON marshals the VersionKind to JSON.
func (k VersionKind) MarshalJSON() ([]byte, error) {
return json.Marshal(k.String())
}
// UnmarshalJSON unmarshals the VersionKind from JSON.
func (k *VersionKind) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
*k = VersionKindFromString(s)
return nil
}
// String returns the string representation of the VersionKind.
func (k VersionKind) String() string {
switch k {
case VersionKindImage:
return "image"
case VersionKindCLI:
return "cli"
default:
return "unknown"
}
}
// VersionKindFromString returns the VersionKind for the given string.
func VersionKindFromString(s string) VersionKind {
switch strings.ToLower(s) {
case "image":
return VersionKindImage
case "cli":
return VersionKindCLI
default:
return VersionKindUnknown
}
}
// Granularity represents the granularity of a semantic version.
type Granularity int
const (
// GranularityUnknown is the default granularity.
GranularityUnknown Granularity = iota
// GranularityMajor is the granularity of a major version, e.g. "v1".
// Lists with granularity "major" map from a major version to a list of minor versions.
GranularityMajor
// GranularityMinor is the granularity of a minor version, e.g. "v1.0".
// Lists with granularity "minor" map from a minor version to a list of patch versions.
GranularityMinor
// GranularityPatch is the granularity of a patch version, e.g. "v1.0.0".
// There are no lists with granularity "patch".
GranularityPatch
)
// MarshalJSON marshals the granularity to JSON.
func (g Granularity) MarshalJSON() ([]byte, error) {
return json.Marshal(g.String())
}
// UnmarshalJSON unmarshals the granularity from JSON.
func (g *Granularity) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
*g = GranularityFromString(s)
return nil
}
// String returns the string representation of the granularity.
func (g Granularity) String() string {
switch g {
case GranularityMajor:
return "major"
case GranularityMinor:
return "minor"
case GranularityPatch:
return "patch"
default:
return "unknown"
}
}
// GranularityFromString returns the granularity for the given string.
func GranularityFromString(s string) Granularity {
switch strings.ToLower(s) {
case "major":
return GranularityMajor
case "minor":
return GranularityMinor
case "patch":
return GranularityPatch
default:
return GranularityUnknown
}
}
var notAZ09Regexp = regexp.MustCompile("[^a-zA-Z0-9-]")
// CanonicalizeRef returns the canonicalized ref for the given ref.
func CanonicalizeRef(ref string) string {
if ref == ReleaseRef {
return ref
}
canRef := notAZ09Regexp.ReplaceAllString(ref, "-")
if canRef == ReleaseRef {
return "" // No ref should be cannonicalized to the release ref.
}
return canRef
}
// ValidateRef checks if the given ref is a valid ref.
// Canonicalize the ref before checking if it is valid.
func ValidateRef(ref string) error {
if ref == "" {
return errors.New("ref must not be empty")
}
if notAZ09Regexp.FindString(ref) != "" {
return errors.New("ref must only contain alphanumeric characters and dashes")
}
if strings.HasPrefix(ref, "refs-heads") {
return errors.New("ref must not start with 'refs-heads'")
}
return nil
}
// ValidateStream checks if the given stream is a valid stream for the given ref.
func ValidateStream(ref, stream string) error {
validReleaseStreams := []string{"stable", "console", "debug"}
validStreams := []string{"nightly", "console", "debug"}
if ref == ReleaseRef {
validStreams = validReleaseStreams
}
for _, validStream := range validStreams {
if stream == validStream {
return nil
}
}
return fmt.Errorf("stream %q is unknown or not supported on ref %q", stream, ref)
}
// MeasurementURL builds the measurement and signature URLs for the given version.
func MeasurementURL(version Version) (measurementURL, signatureURL *url.URL, err error) {
if version.Kind != VersionKindImage {
return &url.URL{}, &url.URL{}, fmt.Errorf("kind %q is not supported", version.Kind)
}
measurementPath, err := url.JoinPath(version.ArtifactsURL(APIV2), "image", constants.CDNMeasurementsFile)
if err != nil {
return &url.URL{}, &url.URL{}, fmt.Errorf("joining path for measurement: %w", err)
}
signaturePath, err := url.JoinPath(version.ArtifactsURL(APIV2), "image", constants.CDNMeasurementsSignature)
if err != nil {
return &url.URL{}, &url.URL{}, fmt.Errorf("joining path for signature: %w", err)
}
measurementURL, err = url.Parse(measurementPath)
if err != nil {
return &url.URL{}, &url.URL{}, fmt.Errorf("parsing path for measurement: %w", err)
}
signatureURL, err = url.Parse(signaturePath)
if err != nil {
return &url.URL{}, &url.URL{}, fmt.Errorf("parsing path for signature: %w", err)
}
return measurementURL, signatureURL, nil
}
var (
shortPathRegex = regexp.MustCompile(`^ref/([a-zA-Z0-9-]+)/stream/([a-zA-Z0-9-]+)/([a-zA-Z0-9.-]+)$`)
shortPathReleaseRegex = regexp.MustCompile(`^stream/([a-zA-Z0-9-]+)/([a-zA-Z0-9.-]+)$`)
)
func shortPath(ref, stream, version string) string {
var sp string
if ref != ReleaseRef {
return path.Join("ref", ref, "stream", stream, version)
}
if stream != "stable" {
return path.Join(sp, "stream", stream, version)
}
return version
}
func parseShortPath(shortPath string) (ref, stream, version string, err error) {
if shortPathRegex.MatchString(shortPath) {
matches := shortPathRegex.FindStringSubmatch(shortPath)
ref := matches[1]
if err := ValidateRef(ref); err != nil {
return "", "", "", err
}
stream := matches[2]
if err := ValidateStream(ref, stream); err != nil {
return "", "", "", err
}
version := matches[3]
if !semver.IsValid(version) {
return "", "", "", fmt.Errorf("invalid version %q", version)
}
return ref, stream, version, nil
}
if shortPathReleaseRegex.MatchString(shortPath) {
matches := shortPathReleaseRegex.FindStringSubmatch(shortPath)
stream := matches[1]
if err := ValidateStream(ref, stream); err != nil {
return "", "", "", err
}
version := matches[2]
if !semver.IsValid(version) {
return "", "", "", fmt.Errorf("invalid version %q", version)
}
return ReleaseRef, stream, version, nil
}
if semver.IsValid(shortPath) {
return ReleaseRef, "stable", shortPath, nil
}
return "", "", "", fmt.Errorf("invalid short path %q", shortPath)
}

View file

@ -0,0 +1,843 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package versionsapi
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/constants"
)
func TestNewVersionFromShortPath(t *testing.T) {
testCases := map[string]struct {
path string
kind VersionKind
wantVer Version
wantErr bool
}{
"stable release image": {
path: "v9.9.9",
kind: VersionKindImage,
wantVer: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
},
"release debug image": {
path: "stream/debug/v9.9.9",
kind: VersionKindImage,
wantVer: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
},
"stable release cli": {
path: "v9.9.9",
kind: VersionKindCLI,
wantVer: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
},
"release debug cli": {
path: "stream/debug/v9.9.9",
kind: VersionKindCLI,
wantVer: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
},
"unknown kind": {
path: "v9.9.9",
kind: VersionKindUnknown,
wantErr: true,
},
"invalid path image": {
path: "va.b.c",
kind: VersionKindImage,
wantErr: true,
},
"invalid path cli": {
path: "va.b.c",
kind: VersionKindCLI,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
ver, err := NewVersionFromShortPath(tc.path, tc.kind)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantVer, ver)
})
}
}
func TestVersionShortPath(t *testing.T) {
testCases := map[string]struct {
ver Version
want string
}{
"stable release image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
want: "v9.9.9",
},
"release debug image": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
want: "stream/debug/v9.9.9",
},
"branch image": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
want: "ref/foo/stream/debug/v9.9.9",
},
"stable release cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
want: "v9.9.9",
},
"release debug cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
want: "stream/debug/v9.9.9",
},
"branch cli": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
want: "ref/foo/stream/debug/v9.9.9",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
got := tc.ver.ShortPath()
assert.Equal(tc.want, got)
})
}
}
func TestVersionValidate(t *testing.T) {
testCases := map[string]struct {
ver Version
wantErr bool
}{
"valid image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
},
"invalid ref image": {
ver: Version{
Ref: "foo/bar",
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
wantErr: true,
},
"invalid stream image": {
ver: Version{
Ref: ReleaseRef,
Stream: "foo/bar",
Version: "v9.9.9",
Kind: VersionKindImage,
},
wantErr: true,
},
"invalid version image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9/foo",
Kind: VersionKindImage,
},
wantErr: true,
},
"valid cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
},
"invalid ref cli": {
ver: Version{
Ref: "foo/bar",
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
wantErr: true,
},
"invalid stream cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "foo/bar",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
wantErr: true,
},
"invalid version cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9/foo",
Kind: VersionKindCLI,
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.ver.Validate()
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
})
}
}
func TestVersionMajor(t *testing.T) {
testCases := map[string]string{
"v9.9.9": "v9",
"v9.6.9-foo": "v9",
"v7.9.9": "v7",
}
for version, major := range testCases {
t.Run(version, func(t *testing.T) {
assert := assert.New(t)
ver := Version{Version: version}
assert.Equal(major, ver.Major())
})
}
}
func TestVersionMajorMinor(t *testing.T) {
testCases := map[string]string{
"v9.9.9": "v9.9",
"v9.6.9-foo": "v9.6",
"v7.9.9": "v7.9",
}
for version, major := range testCases {
t.Run(version, func(t *testing.T) {
assert := assert.New(t)
ver := Version{Version: version}
assert.Equal(major, ver.MajorMinor())
})
}
}
func TestVersionWithGranularity(t *testing.T) {
testCases := []struct {
ver string
gran Granularity
want string
}{
{
ver: "v9.9.9",
gran: GranularityMajor,
want: "v9",
},
{
ver: "v9.9.9",
gran: GranularityMinor,
want: "v9.9",
},
{
ver: "v9.9.9",
gran: GranularityPatch,
want: "v9.9.9",
},
{
ver: "v9.9.9-foo",
gran: GranularityMajor,
want: "v9",
},
{
ver: "v9.9.9-foo",
gran: GranularityPatch,
want: "v9.9.9-foo",
},
{
ver: "v9.9.9-foo",
gran: GranularityUnknown,
want: "",
},
}
for _, tc := range testCases {
t.Run(tc.ver, func(t *testing.T) {
assert := assert.New(t)
ver := Version{Version: tc.ver}
assert.Equal(tc.want, ver.WithGranularity(tc.gran))
})
}
}
func TestVersionListPathURL(t *testing.T) {
testCases := map[string]struct {
ver Version
gran Granularity
wantPath string
wantURL string
}{
"release image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMajor,
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/versions/major/v9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/versions/major/v9/image.json",
},
"release with minor image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMinor,
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/versions/minor/v9.9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/versions/minor/v9.9/image.json",
},
"release with patch image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityPatch,
wantPath: "",
wantURL: "",
},
"release with unknown image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityUnknown,
wantPath: "",
wantURL: "",
},
"release debug stream image": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMajor,
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/versions/major/v9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/versions/major/v9/image.json",
},
"release debug stream with minor image": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMinor,
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/versions/minor/v9.9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/versions/minor/v9.9/image.json",
},
"branch ref image": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMajor,
wantPath: constants.CDNAPIPrefix + "/ref/foo/stream/debug/versions/major/v9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/foo/stream/debug/versions/major/v9/image.json",
},
"branch ref with minor image": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
gran: GranularityMinor,
wantPath: constants.CDNAPIPrefix + "/ref/foo/stream/debug/versions/minor/v9.9/image.json",
wantURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefix + "/ref/foo/stream/debug/versions/minor/v9.9/image.json",
},
}
for name, tc := range testCases {
t.Run(fmt.Sprintf("%s url", name), func(t *testing.T) {
assert := assert.New(t)
url := tc.ver.ListURL(tc.gran)
assert.Equal(tc.wantURL, url)
})
t.Run(fmt.Sprintf("%s path", name), func(t *testing.T) {
assert := assert.New(t)
path := tc.ver.ListPath(tc.gran)
assert.Equal(tc.wantPath, path)
})
}
}
func TestVersionArtifactURL(t *testing.T) {
testCases := map[string]struct {
ver Version
csp cloudprovider.Provider
wantMeasurementURL string
wantSignatureURL string
wantErr bool
}{
"nightly-feature": {
ver: Version{
Ref: "feat-some-feature",
Stream: "nightly",
Version: "v2.6.0-pre.0.20230217095603-193dd48ca19f",
Kind: VersionKindImage,
},
csp: cloudprovider.GCP,
wantMeasurementURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefixV2 + "/ref/feat-some-feature/stream/nightly/v2.6.0-pre.0.20230217095603-193dd48ca19f/image/measurements.json",
wantSignatureURL: constants.CDNRepositoryURL + "/" + constants.CDNAPIPrefixV2 + "/ref/feat-some-feature/stream/nightly/v2.6.0-pre.0.20230217095603-193dd48ca19f/image/measurements.json.sig",
},
"fail for wrong kind": {
ver: Version{
Kind: VersionKindCLI,
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
measurementURL, signatureURL, err := MeasurementURL(tc.ver)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
assert.Equal(tc.wantMeasurementURL, measurementURL.String())
assert.Equal(tc.wantSignatureURL, signatureURL.String())
})
}
}
func TestVersionArtifactPathURL(t *testing.T) {
testCases := map[string]struct {
ver Version
wantPath string
}{
"release image": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/v9.9.9",
},
"release debug stream image": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/v9.9.9",
},
"branch ref image": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindImage,
},
wantPath: constants.CDNAPIPrefix + "/ref/foo/stream/debug/v9.9.9",
},
"release cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "stable",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/stable/v9.9.9",
},
"release debug stream cli": {
ver: Version{
Ref: ReleaseRef,
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
wantPath: constants.CDNAPIPrefix + "/ref/" + ReleaseRef + "/stream/debug/v9.9.9",
},
"branch ref cli": {
ver: Version{
Ref: "foo",
Stream: "debug",
Version: "v9.9.9",
Kind: VersionKindCLI,
},
wantPath: constants.CDNAPIPrefix + "/ref/foo/stream/debug/v9.9.9",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
path := tc.ver.ArtifactPath(APIV1)
assert.Equal(tc.wantPath, path)
url := tc.ver.ArtifactsURL(APIV1)
assert.Equal(constants.CDNRepositoryURL+"/"+tc.wantPath, url)
})
}
}
func TestVersionKindUnMarshalJson(t *testing.T) {
testCases := map[string]VersionKind{
`"image"`: VersionKindImage,
`"cli"`: VersionKindCLI,
`"unknown"`: VersionKindUnknown,
}
for name, kind := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
data, err := kind.MarshalJSON()
assert.NoError(err)
assert.Equal(name, string(data))
var gotKind VersionKind
err = gotKind.UnmarshalJSON([]byte(name))
assert.NoError(err)
assert.Equal(kind, gotKind)
})
}
}
func TestVersionKindFromString(t *testing.T) {
testCases := map[string]VersionKind{
"image": VersionKindImage,
"cli": VersionKindCLI,
"unknown": VersionKindUnknown,
}
for name, kind := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
s := kind.String()
assert.Equal(name, s)
k := VersionKindFromString(name)
assert.Equal(kind, k)
})
}
}
func TestGranularityUnMarschalJSON(t *testing.T) {
testCases := map[string]Granularity{
`"major"`: GranularityMajor,
`"minor"`: GranularityMinor,
`"patch"`: GranularityPatch,
`"unknown"`: GranularityUnknown,
}
for name, gran := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
data, err := gran.MarshalJSON()
assert.NoError(err)
assert.Equal(name, string(data))
var gotGran Granularity
err = gotGran.UnmarshalJSON([]byte(name))
assert.NoError(err)
assert.Equal(gran, gotGran)
})
}
}
func TestGranularityFromString(t *testing.T) {
testCases := map[string]Granularity{
"major": GranularityMajor,
"minor": GranularityMinor,
"patch": GranularityPatch,
"unknown": GranularityUnknown,
}
for name, gran := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
s := gran.String()
assert.Equal(name, s)
g := GranularityFromString(name)
assert.Equal(gran, g)
})
}
}
func TestCanonicalRef(t *testing.T) {
testCases := map[string]string{
"feat/foo": "feat-foo",
"feat-foo": "feat-foo",
"feat$foo": "feat-foo",
"3234": "3234",
"feat foo": "feat-foo",
"/../": "----",
ReleaseRef: ReleaseRef,
".": "",
}
for ref, want := range testCases {
t.Run(ref, func(t *testing.T) {
assert := assert.New(t)
assert.Equal(want, CanonicalizeRef(ref))
})
}
}
func TestValidateRef(t *testing.T) {
testCases := map[string]bool{
"feat/foo": true,
"feat-foo": false,
"feat$foo": true,
"3234": false,
"feat foo": true,
"refs-heads-feat-foo": true,
"": true,
}
for ref, wantErr := range testCases {
t.Run(ref, func(t *testing.T) {
assert := assert.New(t)
err := ValidateRef(ref)
if !wantErr {
assert.NoError(err)
} else {
assert.Error(err)
}
})
}
}
func TestValidateStream(t *testing.T) {
testCases := []struct {
branch string
stream string
wantErr bool
}{
{branch: "-", stream: "stable", wantErr: false},
{branch: "-", stream: "debug", wantErr: false},
{branch: "-", stream: "nightly", wantErr: true},
{branch: "-", stream: "console", wantErr: false},
{branch: "main", stream: "stable", wantErr: true},
{branch: "main", stream: "debug", wantErr: false},
{branch: "main", stream: "nightly", wantErr: false},
{branch: "main", stream: "console", wantErr: false},
{branch: "foo-branch", stream: "nightly", wantErr: false},
{branch: "foo-branch", stream: "console", wantErr: false},
{branch: "foo-branch", stream: "debug", wantErr: false},
{branch: "foo-branch", stream: "stable", wantErr: true},
}
for _, tc := range testCases {
t.Run(tc.branch+"+"+tc.stream, func(t *testing.T) {
assert := assert.New(t)
err := ValidateStream(tc.branch, tc.stream)
if !tc.wantErr {
assert.NoError(err)
} else {
assert.Error(err)
}
})
}
}
func TestShortPath(t *testing.T) {
testCases := map[string]struct {
ref string
stream string
version string
}{
"v9.9.9": {
ref: ReleaseRef,
stream: "stable",
version: "v9.9.9",
},
"v9.9.9-foo": {
ref: ReleaseRef,
stream: "stable",
version: "v9.9.9-foo",
},
"stream/debug/v9.9.9": {
ref: ReleaseRef,
stream: "debug",
version: "v9.9.9",
},
"ref/foo/stream/debug/v9.9.9": {
ref: "foo",
stream: "debug",
version: "v9.9.9",
},
"ref/foo/stream/stable/v9.9.9": {
ref: "foo",
stream: "stable",
version: "v9.9.9",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
path := shortPath(tc.ref, tc.stream, tc.version)
assert.Equal(name, path)
})
}
}
func TestParseShortPath(t *testing.T) {
testCases := map[string]struct {
wantRef string
wantStream string
wantVersion string
wantErr bool
}{
"v9.9.9": {
wantRef: ReleaseRef,
wantStream: "stable",
wantVersion: "v9.9.9",
},
"v9.9.9-foo": {
wantRef: ReleaseRef,
wantStream: "stable",
wantVersion: "v9.9.9-foo",
},
"stream/debug/v9.9.9": {
wantRef: ReleaseRef,
wantStream: "debug",
wantVersion: "v9.9.9",
},
"ref/foo/stream/debug/v9.9.9": {
wantRef: "foo",
wantStream: "debug",
wantVersion: "v9.9.9",
},
"v9.9.9-foo/bar": {
wantErr: true,
},
"ref/foo/stream/debug/va.b.9": {
wantErr: true,
},
"stream/debug/va.b.9": {
wantErr: true,
},
"ref/foo/stream/bar/v9.9.9": {
wantErr: true,
},
"stream/bar/v9.9.9": {
wantErr: true,
},
"ref/refs-heads-bar/stream/debug/v9.9.9": {
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
ref, stream, version, err := parseShortPath(name)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
assert.Equal(tc.wantRef, ref)
assert.Equal(tc.wantStream, stream)
assert.Equal(tc.wantVersion, version)
}
})
}
}

View file

@ -0,0 +1,20 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
/*
# Versions API
The Versions API provides information about versions of Constellation components.
This package defines API types that represents objects of the versions API.
The types provide helper methods for validation and commonly used operations on the
information contained in the objects. Especially the paths used for the API are defined
in these helper methods.
The package also provides helper functions that can be used in context of the versions API,
e.g. to validate versions.
*/
package versionsapi