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

* rename to attestationconfigapi + put client and fetcher inside pkg

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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