From 414df842b8c7784b091a8c1ee7ec111e51207d69 Mon Sep 17 00:00:00 2001 From: Guilherme Caulada Date: Tue, 22 Nov 2022 13:04:14 -0300 Subject: [PATCH] Build: Add command to publish to AWS Marketplace through the pipeline (#59068) * Remove generic variables from publish github action * Create publish aws cmd to automate aws releases * Add tests to publish aws cmd * Replace fmt with log for prints * Remove unnecessary type assertions * Readd mistakenly removed go package * Replace log with fmt for prints due to conflicts * Update github tests to conform with casing --- go.mod | 8 +- go.sum | 4 + pkg/build/cmd/main.go | 31 ++- pkg/build/cmd/publishaws.go | 307 ++++++++++++++++++++++++++++ pkg/build/cmd/publishaws_test.go | 205 +++++++++++++++++++ pkg/build/cmd/publishgithub.go | 12 +- pkg/build/cmd/publishgithub_test.go | 38 ++-- 7 files changed, 577 insertions(+), 28 deletions(-) create mode 100644 pkg/build/cmd/publishaws.go create mode 100644 pkg/build/cmd/publishaws_test.go diff --git a/go.mod b/go.mod index 50dbe4cff3c..8f29add2ffd 100644 --- a/go.mod +++ b/go.mod @@ -257,7 +257,6 @@ require ( github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f github.com/jmoiron/sqlx v1.3.5 github.com/matryer/is v1.4.0 - github.com/parca-dev/parca v0.12.1 github.com/urfave/cli v1.22.9 go.etcd.io/etcd/api/v3 v3.5.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.32.0 @@ -275,8 +274,11 @@ require ( github.com/armon/go-metrics v0.3.10 // indirect github.com/bmatcuk/doublestar v1.1.1 // indirect github.com/buildkite/yaml v2.1.0+incompatible // indirect + github.com/containerd/containerd v1.6.8 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/distribution v2.8.1+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect github.com/drone-runners/drone-runner-docker v1.8.2 // indirect github.com/drone/drone-go v1.7.1 // indirect github.com/drone/envsubst v1.0.3 // indirect @@ -296,6 +298,9 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect + github.com/parca-dev/parca v0.12.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.1.4 // indirect @@ -327,6 +332,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect + github.com/docker/docker v20.10.21+incompatible github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect diff --git a/go.sum b/go.sum index e5f5e30bf67..d41b9baea0d 100644 --- a/go.sum +++ b/go.sum @@ -579,6 +579,7 @@ github.com/containerd/containerd v1.5.0-rc.0/go.mod h1:V/IXoMqNGgBlabz3tHD2TWDoT github.com/containerd/containerd v1.5.1/go.mod h1:0DOxVqwDy2iZvrZp2JUx/E+hS0UNTVn7dJnIOwtYR4g= github.com/containerd/containerd v1.5.4/go.mod h1:sx18RgvW6ABJ4iYUw7Q5x7bgFOAB9B6G7+yO0XBc4zw= github.com/containerd/containerd v1.6.8 h1:h4dOFDwzHmqFEP754PgfgTeVXFnLiRc6kiqC7tplDJs= +github.com/containerd/containerd v1.6.8/go.mod h1:By6p5KqPK0/7/CgO/A6t/Gz+CUYUu2zf1hUaaymVXB0= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20190815185530-f2a389ac0a02/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20191127005431-f65d91d395eb/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= @@ -751,6 +752,7 @@ github.com/docker/distribution v2.7.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= +github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= @@ -2011,6 +2013,7 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 h1:rc3tiVYb5z54aKaDfakKn0dDjIyPpTtszkjuMzyt7ec= +github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= @@ -3483,6 +3486,7 @@ k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7/go.mod h1:wXW5VT87nVfh/iLV8FpR2uDvrFyomxbtb1KivDbvPTE= +k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e h1:KLHHjkdQFomZy8+06csTWZ0m1343QqxZhR2LJ1OxCYM= k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e/go.mod h1:vHXdDvt9+2spS2Rx9ql3I8tycm3H9FDfdUoIuKCefvw= k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 h1:Gii5eqf+GmIEwGNKQYQClCayuJCe2/4fZUvF7VG99sU= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index a75033a6ade..658cceb9011 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -225,7 +225,7 @@ func main() { { Name: "github", Usage: "Publish packages to GitHub releases", - Action: PublishGitHub, + Action: PublishGithub, Flags: []cli.Flag{ &dryRunFlag, &cli.StringFlag{ @@ -240,7 +240,7 @@ func main() { }, &cli.StringFlag{ Name: "tag", - Usage: "Release tag (default from metadata)ß", + Usage: "Release tag (default from metadata)", }, &cli.BoolFlag{ Name: "create", @@ -248,6 +248,33 @@ func main() { }, }, }, + { + Name: "aws", + Usage: "Publish image to AWS Marketplace releases", + Action: PublishAwsMarketplace, + Flags: []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "version", + Usage: "Release version (default from metadata)", + }, + &cli.StringFlag{ + Name: "image", + Required: true, + Usage: "Name of the image to be released", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "AWS Marketplace ECR repository", + }, + &cli.StringFlag{ + Name: "product", + Required: true, + Usage: "AWS Marketplace product identifier", + }, + }, + }, }, }, } diff --git a/pkg/build/cmd/publishaws.go b/pkg/build/cmd/publishaws.go new file mode 100644 index 00000000000..68ed5e695ac --- /dev/null +++ b/pkg/build/cmd/publishaws.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go/service/marketplacecatalog" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" + "github.com/urfave/cli/v2" +) + +const ( + marketplaceChangeSetName = "Add new version" + marketplaceCatalogId = "AWSMarketplace" + marketplaceRegistryId = "709825985650" + marketplaceRegistryRegion = "us-east-1" + marketplaceRegistryUrl = "709825985650.dkr.ecr.us-east-1.amazonaws.com" + marketplaceRequestsUrl = "https://aws.amazon.com/marketplace/management/requests/" + releaseNotesTemplateUrl = "https://grafana.com/docs/grafana/latest/release-notes/release-notes-${TAG}/" + helmChartsUrl = "https://grafana.github.io/helm-charts/" + docsUrl = "https://grafana.com/docs/grafana/latest/enterprise/license/" + imagePlatform = "linux/amd64" + + publishAwsMarketplaceTestKey publishAwsMarketplaceTestKeyType = "test-client" +) + +var ( + errEmptyVersion = errors.New(`failed to retrieve release version from metadata, use "--version" to set it manually`) +) + +type publishAwsMarketplaceTestKeyType string + +type publishAwsMarketplaceFlags struct { + dryRun bool + version string + repo string + image string + product string +} + +type AwsMarketplacePublishingService struct { + auth string + docker AwsMarketplaceDocker + ecr AwsMarketplaceRegistry + mkt AwsMarketplaceCatalog +} + +type AwsMarketplaceDocker interface { + ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) + ImageTag(ctx context.Context, source string, target string) error + ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error) +} + +type AwsMarketplaceRegistry interface { + GetAuthorizationTokenWithContext(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...request.Option) (*ecr.GetAuthorizationTokenOutput, error) +} + +type AwsMarketplaceCatalog interface { + DescribeEntityWithContext(ctx context.Context, input *marketplacecatalog.DescribeEntityInput, opts ...request.Option) (*marketplacecatalog.DescribeEntityOutput, error) + StartChangeSetWithContext(ctx context.Context, input *marketplacecatalog.StartChangeSetInput, opts ...request.Option) (*marketplacecatalog.StartChangeSetOutput, error) +} + +func PublishAwsMarketplace(ctx *cli.Context) error { + f, err := getPublishAwsMarketplaceFlags(ctx) + if err != nil { + return err + } + + if f.version == "" { + return errEmptyVersion + } + + svc, err := getAwsMarketplacePublishingService() + if err != nil { + return err + } + + if ctx.Context.Value(publishAwsMarketplaceTestKey) != nil { + svc = ctx.Context.Value(publishAwsMarketplaceTestKey).(*AwsMarketplacePublishingService) + } + + fmt.Println("Logging in to AWS Marketplace registry") + err = svc.Login(ctx.Context) + if err != nil { + return err + } + + fmt.Printf("Retrieving image '%s:%s' from Docker Hub\n", f.image, f.version) + err = svc.PullImage(ctx.Context, f.image, f.version) + if err != nil { + return err + } + + fmt.Printf("Renaming image '%s:%s' to '%s/%s:%s'\n", f.image, f.version, marketplaceRegistryUrl, f.repo, f.version) + err = svc.TagImage(ctx.Context, f.image, f.repo, f.version) + if err != nil { + return err + } + + if !f.dryRun { + fmt.Printf("Pushing image '%s/%s:%s' to the AWS Marketplace ECR\n", marketplaceRegistryUrl, f.repo, f.version) + err = svc.PushToMarketplace(ctx.Context, f.repo, f.version) + if err != nil { + return err + } + } else { + fmt.Printf("Dry-Run: Pushing image '%s/%s:%s' to the AWS Marketplace ECR\n", marketplaceRegistryUrl, f.repo, f.version) + } + + fmt.Printf("Retrieving product identifier for product '%s'\n", f.product) + pid, err := svc.GetProductIdentifier(ctx.Context, f.product) + if err != nil { + return err + } + + if !f.dryRun { + fmt.Printf("Releasing to product, you can view the progress of the release on %s\n", marketplaceRequestsUrl) + return svc.ReleaseToProduct(ctx.Context, pid, f.repo, f.version) + } else { + fmt.Printf("Dry-Run: Releasing to product, you can view the progress of the release on %s\n", marketplaceRequestsUrl) + } + + return nil +} + +func getAwsMarketplacePublishingService() (*AwsMarketplacePublishingService, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, err + } + + mySession := session.Must(session.NewSession()) + ecr := ecr.New(mySession, aws.NewConfig().WithRegion(marketplaceRegistryRegion)) + mkt := marketplacecatalog.New(mySession, aws.NewConfig().WithRegion(marketplaceRegistryRegion)) + return &AwsMarketplacePublishingService{ + docker: cli, + ecr: ecr, + mkt: mkt, + }, nil +} + +func (s *AwsMarketplacePublishingService) Login(ctx context.Context) error { + out, err := s.ecr.GetAuthorizationTokenWithContext(ctx, &ecr.GetAuthorizationTokenInput{}) + if err != nil { + return err + } + s.auth = *out.AuthorizationData[0].AuthorizationToken + authData, err := base64.StdEncoding.DecodeString(s.auth) + if err != nil { + return err + } + authString := strings.Split(string(authData), ":") + authData, err = json.Marshal(types.AuthConfig{ + Username: authString[0], + Password: authString[1], + }) + s.auth = base64.StdEncoding.EncodeToString(authData) + return err +} + +func (s *AwsMarketplacePublishingService) PullImage(ctx context.Context, image string, version string) error { + reader, err := s.docker.ImagePull(ctx, fmt.Sprintf("%s:%s", image, version), types.ImagePullOptions{ + Platform: imagePlatform, + }) + if err != nil { + return err + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + return err + } + + err = reader.Close() + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) TagImage(ctx context.Context, image string, repo string, version string) error { + err := s.docker.ImageTag(ctx, fmt.Sprintf("%s:%s", image, version), fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version)) + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) PushToMarketplace(ctx context.Context, repo string, version string) error { + reader, err := s.docker.ImagePush(ctx, fmt.Sprintf("%s/%s:%s", marketplaceRegistryUrl, repo, version), types.ImagePushOptions{ + RegistryAuth: s.auth, + }) + if err != nil { + return err + } + + _, err = io.Copy(os.Stdout, reader) + if err != nil { + return err + } + + err = reader.Close() + if err != nil { + return err + } + return nil +} + +func (s *AwsMarketplacePublishingService) GetProductIdentifier(ctx context.Context, product string) (string, error) { + out, err := s.mkt.DescribeEntityWithContext(ctx, &marketplacecatalog.DescribeEntityInput{ + EntityId: aws.String(product), + Catalog: aws.String(marketplaceCatalogId), + }) + if err != nil { + return "", err + } + return *out.EntityIdentifier, nil +} + +func (s *AwsMarketplacePublishingService) ReleaseToProduct(ctx context.Context, pid string, repo string, version string) error { + _, err := s.mkt.StartChangeSetWithContext(ctx, &marketplacecatalog.StartChangeSetInput{ + Catalog: aws.String(marketplaceCatalogId), + ChangeSetName: aws.String(marketplaceChangeSetName), + ChangeSet: []*marketplacecatalog.Change{ + buildAwsMarketplaceChangeSet(pid, repo, version), + }, + }) + return err +} + +func getPublishAwsMarketplaceFlags(ctx *cli.Context) (*publishAwsMarketplaceFlags, error) { + metadata, err := GenerateMetadata(ctx) + if err != nil { + return nil, err + } + version := ctx.String("version") + if version == "" && metadata.GrafanaVersion != "" { + version = metadata.GrafanaVersion + } + image := ctx.String("image") + repo := ctx.String("repo") + product := ctx.String("product") + dryRun := ctx.Bool("dry-run") + return &publishAwsMarketplaceFlags{ + dryRun: dryRun, + version: version, + image: image, + repo: repo, + product: product, + }, nil +} + +func buildAwsMarketplaceReleaseNotesUrl(version string) string { + sanitizedVersion := strings.ReplaceAll(version, ".", "-") + return strings.ReplaceAll(releaseNotesTemplateUrl, "${TAG}", sanitizedVersion) +} + +func buildAwsMarketplaceChangeSet(entityId string, repo string, version string) *marketplacecatalog.Change { + return &marketplacecatalog.Change{ + ChangeType: aws.String("AddDeliveryOptions"), + Entity: &marketplacecatalog.Entity{ + Type: aws.String("ContainerProduct@1.0"), + Identifier: aws.String(entityId), + }, + Details: aws.String(buildAwsMarketplaceVersionDetails(repo, version)), + } +} + +func buildAwsMarketplaceVersionDetails(repo string, version string) string { + releaseNotesUrl := buildAwsMarketplaceReleaseNotesUrl(version) + return fmt.Sprintf(`{ + "Version": { + "ReleaseNotes": "Release notes are available on the website %s", + "VersionTitle": "v%s" + }, + "DeliveryOptions": [ + { + "Details": { + "EcrDeliveryOptionDetails": { + "DeploymentResources": [ + { + "Name": "Helm Charts", + "Url": "%s" + } + ], + "CompatibleServices": ["EKS", "ECS", "ECS-Anywhere", "EKS-Anywhere"], + "ContainerImages": ["%s/%s:%s"], + "Description": "Grafana Enterprise can be installed using the official Grafana Helm chart repository. The repository is available on Github: %s", + "UsageInstructions": "You can apply your Grafana Enterprise license to a new or existing Grafana Enterprise deployment by updating a configuration setting or environment variable. Your Grafana instance must be deployed on AWS, or have network access to AWS. For more information, see %s" + } + }, + "DeliveryOptionTitle": "Helm Chart" + } + ] + }`, releaseNotesUrl, version, helmChartsUrl, marketplaceRegistryUrl, repo, version, helmChartsUrl, docsUrl) +} diff --git a/pkg/build/cmd/publishaws_test.go b/pkg/build/cmd/publishaws_test.go new file mode 100644 index 00000000000..34274aff92a --- /dev/null +++ b/pkg/build/cmd/publishaws_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "io" + "os" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go/service/marketplacecatalog" + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type awsPublishTestCase struct { + name string + args []string + expectedError error + errorContains string + expectedOutput string + mockedService *AwsMarketplacePublishingService +} + +func TestPublishAwsMarketplace(t *testing.T) { + t.Setenv("DRONE_BUILD_EVENT", "promote") + testApp := setupPublishAwsMarketplaceTests(t) + errShouldNotCallMock := errors.New("shouldn't call") + + testCases := []awsPublishTestCase{ + { + name: "try to publish without required flags", + errorContains: `Required flags "image, repo, product" not set`, + }, + { + name: "try to publish without credentials", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{ + GetAuthorizationTokenWithContextError: credentials.ErrNoValidProvidersFoundInChain, + }, + }, + expectedError: credentials.ErrNoValidProvidersFoundInChain, + }, + { + name: "try to publish with valid credentials and nonexisting version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{}, + mkt: &mockAwsMarketplaceCatalog{}, + }, + expectedOutput: "Releasing to product", + }, + { + name: "try to publish with valid credentials and existing version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{}, + mkt: &mockAwsMarketplaceCatalog{}, + }, + expectedOutput: "Releasing to product", + }, + { + name: "dry run with invalid credentials", + args: []string{"--dry-run", "--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{ + GetAuthorizationTokenWithContextError: credentials.ErrNoValidProvidersFoundInChain, + }, + }, + expectedError: credentials.ErrNoValidProvidersFoundInChain, + }, + { + name: "dry run with valid credentials", + args: []string{"--dry-run", "--image", "test/test", "--repo", "test/test", "--product", "test", "--version", "1.0.0"}, + mockedService: &AwsMarketplacePublishingService{ + ecr: &mockAwsMarketplaceRegistry{}, + docker: &mockAwsMarketplaceDocker{ + ImagePushError: errShouldNotCallMock, + }, + mkt: &mockAwsMarketplaceCatalog{ + StartChangeSetWithContextError: errShouldNotCallMock, + }, + }, + expectedOutput: "Dry-Run: Releasing to product", + }, + } + + if os.Getenv("DRONE_COMMIT") == "" { + // this test only works locally due to Drone environment + testCases = append(testCases, + awsPublishTestCase{ + name: "try to publish without version", + args: []string{"--image", "test/test", "--repo", "test/test", "--product", "test"}, + expectedError: errEmptyVersion, + }, + ) + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + ctx := context.WithValue(context.Background(), publishAwsMarketplaceTestKey, test.mockedService) + args := []string{"run"} + args = append(args, test.args...) + out, err := captureStdout(t, func() error { + return testApp.RunContext(ctx, args) + }) + if test.expectedOutput != "" { + assert.Contains(t, out, test.expectedOutput) + } + if test.expectedError != nil || test.errorContains != "" { + assert.Error(t, err) + if test.expectedError != nil { + assert.ErrorIs(t, err, test.expectedError) + } + if test.errorContains != "" { + assert.ErrorContains(t, err, test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func setupPublishAwsMarketplaceTests(t *testing.T) *cli.App { + t.Helper() + testApp := cli.NewApp() + testApp.Action = PublishAwsMarketplace + testApp.Flags = []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "version", + Usage: "Release version (default from metadata)", + }, + &cli.StringFlag{ + Name: "image", + Required: true, + Usage: "Name of the image to be released", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "AWS Marketplace ECR repository", + }, + &cli.StringFlag{ + Name: "product", + Required: true, + Usage: "AWS Marketplace product identifier", + }, + } + return testApp +} + +type mockAwsMarketplaceDocker struct { + ImagePullError error + ImageTagError error + ImagePushError error +} + +func (m *mockAwsMarketplaceDocker) ImagePull(ctx context.Context, refStr string, options types.ImagePullOptions) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePullError +} +func (m *mockAwsMarketplaceDocker) ImageTag(ctx context.Context, source string, target string) error { + return m.ImageTagError +} +func (m *mockAwsMarketplaceDocker) ImagePush(ctx context.Context, image string, options types.ImagePushOptions) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader([]byte(""))), m.ImagePushError +} + +type mockAwsMarketplaceRegistry struct { + GetAuthorizationTokenWithContextError error +} + +func (m *mockAwsMarketplaceRegistry) GetAuthorizationTokenWithContext(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...request.Option) (*ecr.GetAuthorizationTokenOutput, error) { + return &ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []*ecr.AuthorizationData{ + { + AuthorizationToken: aws.String(base64.StdEncoding.EncodeToString([]byte("username:password"))), + }, + }, + }, m.GetAuthorizationTokenWithContextError +} + +type mockAwsMarketplaceCatalog struct { + DescribeEntityWithContextError error + StartChangeSetWithContextError error +} + +func (m *mockAwsMarketplaceCatalog) DescribeEntityWithContext(ctx context.Context, input *marketplacecatalog.DescribeEntityInput, opts ...request.Option) (*marketplacecatalog.DescribeEntityOutput, error) { + return &marketplacecatalog.DescribeEntityOutput{ + EntityIdentifier: aws.String("productid"), + }, m.DescribeEntityWithContextError +} +func (m *mockAwsMarketplaceCatalog) StartChangeSetWithContext(ctx context.Context, input *marketplacecatalog.StartChangeSetInput, opts ...request.Option) (*marketplacecatalog.StartChangeSetOutput, error) { + return &marketplacecatalog.StartChangeSetOutput{}, m.StartChangeSetWithContextError +} diff --git a/pkg/build/cmd/publishgithub.go b/pkg/build/cmd/publishgithub.go index 02c06b3ab33..5217b16c98a 100644 --- a/pkg/build/cmd/publishgithub.go +++ b/pkg/build/cmd/publishgithub.go @@ -39,9 +39,9 @@ var ( errReleaseNotFound = errors.New(`release not found, use "--create" to create the release`) ) -func PublishGitHub(ctx *cli.Context) error { +func PublishGithub(ctx *cli.Context) error { token := os.Getenv("GH_TOKEN") - f, err := getFlags(ctx) + f, err := getPublishGithubFlags(ctx) if err != nil { return err } @@ -55,7 +55,7 @@ func PublishGitHub(ctx *cli.Context) error { } if f.dryRun { - return runDryRun(f, token, ctx) + return runPublishGithubDryRun(f, token, ctx) } client := newGithubClient(ctx.Context, token) @@ -99,7 +99,7 @@ func githubRepositoryClient(ctx context.Context, token string) githubRepositoryS return client.Repositories } -func getFlags(ctx *cli.Context) (*publishGithubFlags, error) { +func getPublishGithubFlags(ctx *cli.Context) (*publishGithubFlags, error) { metadata, err := GenerateMetadata(ctx) if err != nil { return nil, err @@ -126,12 +126,12 @@ func getFlags(ctx *cli.Context) (*publishGithubFlags, error) { }, nil } -func runDryRun(f *publishGithubFlags, token string, ctx *cli.Context) error { +func runPublishGithubDryRun(f *publishGithubFlags, token string, ctx *cli.Context) error { client := newGithubClient(ctx.Context, token) fmt.Println("Dry-Run: Retrieving release on repository by tag") release, res, err := client.GetReleaseByTag(ctx.Context, f.repo.owner, f.repo.name, f.tag) if err != nil && res.StatusCode != 404 { - fmt.Println("Dry-Run: GitHub communication error:\n", err) + fmt.Println("Dry-Run: Github communication error:\n", err) return nil } diff --git a/pkg/build/cmd/publishgithub_test.go b/pkg/build/cmd/publishgithub_test.go index 845e40a9af9..537669a3f1c 100644 --- a/pkg/build/cmd/publishgithub_test.go +++ b/pkg/build/cmd/publishgithub_test.go @@ -21,16 +21,16 @@ type githubPublishTestCases struct { expectedError error errorContains string expectedOutput string - mockedService *mockGitHubRepositoryServiceImpl + mockedService *mockGithubRepositoryServiceImpl } -var mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} +var mockGithubRepositoryService = &mockGithubRepositoryServiceImpl{} func mockGithubRepositoryClient(context.Context, string) githubRepositoryService { - return mockGitHubRepositoryService + return mockGithubRepositoryService } -func TestPublishGitHub(t *testing.T) { +func TestPublishGithub(t *testing.T) { t.Setenv("DRONE_BUILD_EVENT", "promote") testApp, testPath := setupPublishGithubTests(t) mockErrUnauthorized := errors.New("401") @@ -49,21 +49,21 @@ func TestPublishGitHub(t *testing.T) { name: "try to publish with invalid token", token: "invalid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, expectedError: mockErrUnauthorized, }, { name: "try to publish with valid token and nonexisting tag with create disabled", token: "valid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedError: errReleaseNotFound, }, { name: "try to publish with valid token and nonexisting tag with create enabled", token: "valid", args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, }, { name: "try to publish with valid token and existing tag", @@ -74,21 +74,21 @@ func TestPublishGitHub(t *testing.T) { name: "dry run with invalid token", token: "invalid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, - expectedOutput: "GitHub communication error", + mockedService: &mockGithubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + expectedOutput: "Github communication error", }, { name: "dry run with valid token and nonexisting tag with create disabled", token: "valid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedOutput: "Release doesn't exist", }, { name: "dry run with valid token and nonexisting tag with create enabled", token: "valid", args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, - mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + mockedService: &mockGithubRepositoryServiceImpl{tagErr: errReleaseNotFound}, expectedOutput: "Would upload asset", }, { @@ -116,9 +116,9 @@ func TestPublishGitHub(t *testing.T) { t.Setenv("GH_TOKEN", test.token) } if test.mockedService != nil { - mockGitHubRepositoryService = test.mockedService + mockGithubRepositoryService = test.mockedService } else { - mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} + mockGithubRepositoryService = &mockGithubRepositoryServiceImpl{} } args := []string{"run"} args = append(args, test.args...) @@ -154,7 +154,7 @@ func setupPublishGithubTests(t *testing.T) (*cli.App, string) { newGithubClient = mockGithubRepositoryClient testApp := cli.NewApp() - testApp.Action = PublishGitHub + testApp.Action = PublishGithub testApp.Flags = []cli.Flag{ &dryRunFlag, &cli.StringFlag{ @@ -165,7 +165,7 @@ func setupPublishGithubTests(t *testing.T) (*cli.App, string) { &cli.StringFlag{ Name: "repo", Required: true, - Usage: "GitHub repository", + Usage: "Github repository", }, &cli.StringFlag{ Name: "tag", @@ -194,13 +194,13 @@ func captureStdout(t *testing.T, fn func() error) (string, error) { return string(out), err } -type mockGitHubRepositoryServiceImpl struct { +type mockGithubRepositoryServiceImpl struct { tagErr error createErr error uploadErr error } -func (m *mockGitHubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) { var release *github.RepositoryRelease res := &github.Response{Response: &http.Response{}} if m.tagErr == nil { @@ -212,12 +212,12 @@ func (m *mockGitHubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, o return release, res, m.tagErr } -func (m *mockGitHubRepositoryServiceImpl) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { releaseID := int64(1) return &github.RepositoryRelease{ID: &releaseID}, &github.Response{}, m.createErr } -func (m *mockGitHubRepositoryServiceImpl) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { +func (m *mockGithubRepositoryServiceImpl) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { assetName := "test" assetUrl := "testurl.com.br" return &github.ReleaseAsset{Name: &assetName, BrowserDownloadURL: &assetUrl}, &github.Response{}, m.uploadErr