mirror of
https://github.com/edgelesssys/constellation.git
synced 2025-08-06 14:04:17 -04:00
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:
parent
25037026e1
commit
4284f892ce
98 changed files with 385 additions and 490 deletions
46
internal/api/versionsapi/BUILD.bazel
Normal file
46
internal/api/versionsapi/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
22
internal/api/versionsapi/apiconstants.go
Normal file
22
internal/api/versionsapi/apiconstants.go
Normal 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
|
||||
}
|
38
internal/api/versionsapi/cli/BUILD.bazel
Normal file
38
internal/api/versionsapi/cli/BUILD.bazel
Normal 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__"],
|
||||
)
|
294
internal/api/versionsapi/cli/add.go
Normal file
294
internal/api/versionsapi/cli/add.go
Normal 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, ¬FoundErr) {
|
||||
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, ¬FoundErr) {
|
||||
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
|
||||
}
|
148
internal/api/versionsapi/cli/latest.go
Normal file
148
internal/api/versionsapi/cli/latest.go
Normal 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
|
||||
}
|
228
internal/api/versionsapi/cli/list.go
Normal file
228
internal/api/versionsapi/cli/list.go
Normal 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
|
||||
}
|
101
internal/api/versionsapi/cli/main.go
Normal file
101
internal/api/versionsapi/cli/main.go
Normal 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)
|
||||
}
|
||||
}
|
771
internal/api/versionsapi/cli/rm.go
Normal file
771
internal/api/versionsapi/cli/rm.go
Normal 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, ¬FoundErr) {
|
||||
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, ¬FoundErr) {
|
||||
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, ¬Found) {
|
||||
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
|
||||
}
|
254
internal/api/versionsapi/client.go
Normal file
254
internal/api/versionsapi/client.go
Normal 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, ¬FoundErr) {
|
||||
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, ¬FoundErr) {
|
||||
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
|
95
internal/api/versionsapi/cliinfo.go
Normal file
95
internal/api/versionsapi/cliinfo.go
Normal 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
|
||||
}
|
221
internal/api/versionsapi/cliinfo_test.go
Normal file
221
internal/api/versionsapi/cliinfo_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
43
internal/api/versionsapi/fetcher.go
Normal file
43
internal/api/versionsapi/fetcher.go
Normal 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)
|
||||
}
|
217
internal/api/versionsapi/fetcher_test.go
Normal file
217
internal/api/versionsapi/fetcher_test.go
Normal 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,
|
||||
}
|
||||
}
|
141
internal/api/versionsapi/imageinfo.go
Normal file
141
internal/api/versionsapi/imageinfo.go
Normal 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
|
||||
}
|
463
internal/api/versionsapi/imageinfo_test.go
Normal file
463
internal/api/versionsapi/imageinfo_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
93
internal/api/versionsapi/latest.go
Normal file
93
internal/api/versionsapi/latest.go
Normal 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)
|
||||
}
|
212
internal/api/versionsapi/latest_test.go
Normal file
212
internal/api/versionsapi/latest_test.go
Normal 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())
|
||||
}
|
173
internal/api/versionsapi/list.go
Normal file
173
internal/api/versionsapi/list.go
Normal 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
|
||||
}
|
354
internal/api/versionsapi/list_test.go
Normal file
354
internal/api/versionsapi/list_test.go
Normal 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)
|
||||
}
|
419
internal/api/versionsapi/version.go
Normal file
419
internal/api/versionsapi/version.go
Normal 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)
|
||||
}
|
843
internal/api/versionsapi/version_test.go
Normal file
843
internal/api/versionsapi/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
20
internal/api/versionsapi/versionsapi.go
Normal file
20
internal/api/versionsapi/versionsapi.go
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue