├── version ├── .gitignore ├── docs ├── images │ └── csp-api-token.png ├── README.md ├── PublishingVirtualMachineProducts.md ├── PublishingContainerImageProducts.md ├── PublishingChartProducts.md ├── Authentication.md └── ConcourseExample.md ├── cmd ├── constants.go ├── output │ ├── output_suite_test.go │ ├── size_formatter.go │ ├── output.go │ ├── size_formatter_test.go │ ├── encoded_output_test.go │ ├── encoded_output.go │ └── human_output_test.go ├── version.go ├── cmd_suite_test.go ├── config.go ├── auth.go ├── shared_test.go ├── auth_test.go ├── shared.go ├── curl.go ├── cmdfakes │ ├── fake_token_services_initializer.go │ └── fake_token_services.go └── download.go ├── main.go ├── .github └── dependabot.yml ├── internal ├── csp │ ├── csp_suite_test.go │ ├── claims.go │ ├── token_services.go │ ├── cspfakes │ │ └── fake_token_parser_fn.go │ ├── token_services_test.go │ └── claims_test.go ├── internal_suite_test.go ├── models │ ├── models_suite_test.go │ ├── certification.go │ ├── sku.go │ ├── addon.go │ ├── blueprints.go │ ├── product_test.go │ ├── metafile.go │ ├── compatibility.go │ ├── subscription.go │ ├── chart.go │ ├── version_test.go │ ├── product_deployment_file.go │ ├── version.go │ ├── container_image.go │ └── vsx.go ├── pagination.go ├── pagination_test.go ├── sorting_test.go ├── sorting.go ├── progress_bar.go ├── internalfakes │ ├── fake_progress_bar_maker.go │ └── fake_s3client.go └── uploader.go ├── test ├── external │ ├── external_suite_test.go │ ├── authentication_test.go │ ├── download_test.go │ ├── product_list_assets_test.go │ ├── retryrequest_test.go │ └── product_test.go ├── features │ ├── features_suite_test.go │ ├── version_test.go │ ├── debugging_test.go │ └── environment_test.go ├── test_products.go └── common_steps.go ├── ci └── tasks │ ├── download-asset.yaml │ ├── test.yaml │ ├── get-current-product.yaml │ ├── build.yaml │ ├── test-other-product.yaml │ ├── test-chart-product.yaml │ ├── set-osl-file.yaml │ ├── test-vm-product.yaml │ ├── attach-metafile.yaml │ ├── download-asset.sh │ └── test-container-image-product.yaml ├── NOTICE.txt ├── scripts ├── get-raw-product-json.sh └── set-env.sh ├── pkg ├── filter.go ├── other.go ├── hash.go ├── filter_test.go ├── pkg_suite_test.go ├── metafile.go ├── vm.go ├── marketplace_test.go ├── upload.go ├── container_image.go ├── marketplace.go ├── download.go ├── charts.go ├── pkgfakes │ ├── fake_chart_loader_func.go │ └── fake_perform_request_func.go ├── upload_test.go ├── other_test.go └── vm_test.go ├── Dockerfile ├── .golangci.yml ├── LICENSE.txt ├── CONTRIBUTING.md ├── README.md ├── go.mod └── Makefile /version: -------------------------------------------------------------------------------- 1 | 0.15.2-rc.8 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | release/ 3 | .idea/ -------------------------------------------------------------------------------- /docs/images/csp-api-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmware-labs/marketplace-cli/main/docs/images/csp-api-token.png -------------------------------------------------------------------------------- /cmd/constants.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | const AppName = "mkpcli" 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/vmware-labs/marketplace-cli/v2/cmd" 8 | ) 9 | 10 | func main() { 11 | cmd.Execute() 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - pacakge-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "docker" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | 12 | -------------------------------------------------------------------------------- /internal/csp/csp_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package csp_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestCmdSuite(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "CSP test suite") 16 | } 17 | -------------------------------------------------------------------------------- /cmd/output/output_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestOutputSuite(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Output test suite") 16 | } 17 | -------------------------------------------------------------------------------- /internal/internal_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestInternal(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Internal test suite") 16 | } 17 | -------------------------------------------------------------------------------- /internal/models/models_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestModels(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Models test suite") 16 | } 17 | -------------------------------------------------------------------------------- /test/external/external_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestExternal(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "External test suite") 16 | } 17 | -------------------------------------------------------------------------------- /test/features/features_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package features_test 5 | 6 | import ( 7 | "testing" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | func TestFeatures(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Features test suite") 16 | } 17 | -------------------------------------------------------------------------------- /ci/tasks/download-asset.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | ASSET_TYPE: 11 | PRODUCT_SLUG: 12 | PRODUCT_VERSION: 13 | MKPCLI_DEBUG: true 14 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 15 | 16 | inputs: 17 | - name: source 18 | 19 | run: 20 | path: source/ci/tasks/download-asset.sh 21 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Marketplace CLI 2 | Copyright 2023 VMware, Inc. 3 | 4 | This product is licensed to you under the BSD-2 license (the "License"). You may not use this product except in compliance with the BSD-2 License. 5 | 6 | This product may include a number of subcomponents with separate copyright notices and license terms. Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, as noted in the LICENSE file. 7 | 8 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Marketplace CLI Documentation 2 | 3 | ## Using the CLI 4 | 5 | * [Authentication](Authentication.md) 6 | * [Publishing chart-based products](PublishingChartProducts.md) 7 | * [Publishing container image-based products](PublishingContainerImageProducts.md) 8 | * [Publishing virtual machine-based products](PublishingVirtualMachineProducts.md) 9 | 10 | ## CI/CD and Automation Examples 11 | 12 | * [Updating a product using Concourse](ConcourseExample.md) -------------------------------------------------------------------------------- /cmd/output/size_formatter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output 5 | 6 | import "fmt" 7 | 8 | var units = []string{"B", "KB", "MB", "GB", "TB", "PB"} 9 | 10 | func FormatSize(size int64) string { 11 | var unit string 12 | newSize := float64(size) 13 | for _, unit = range units { 14 | if newSize < 1000 { 15 | break 16 | } 17 | newSize /= 1000 18 | } 19 | return fmt.Sprintf("%.3g %s", newSize, unit) 20 | } 21 | -------------------------------------------------------------------------------- /internal/models/certification.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type Certification struct { 7 | ID string `json:"certificationid"` 8 | DisplayName string `json:"displayname"` 9 | Logo string `json:"logo"` 10 | URL string `json:"url"` 11 | Description string `json:"description"` 12 | IsEnabled bool `json:"isenabled"` 13 | PartnerProgram string `json:"partnerprogram"` 14 | } 15 | -------------------------------------------------------------------------------- /scripts/get-raw-product-json.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script wraps around the CLI to get the JSON structure of a product directly from the Marketplace without 4 | # trying to unmarshal it into the structure. 5 | # This is helpful in diagnosing times when the Marketplace adds a field that the Marketplace CLI does not know about yet. 6 | 7 | if [[ -z "$1" ]]; then 8 | echo "USAGE: $0 " 9 | exit 1 10 | fi 11 | 12 | mkpcli curl "/api/v1/products/${1}?increaseViewCount=false&isSlug=true" 13 | -------------------------------------------------------------------------------- /scripts/set-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2023 VMware, Inc. 4 | # SPDX-License-Identifier: BSD-2-Clause 5 | 6 | echo -n CSP_API_TOKEN= 7 | CSP_API_TOKEN=$(vault read /runway_concourse/tanzu-isv-engineering/marketplace_api_token -format=json | jq -r .data.value) 8 | echo "" 9 | 10 | echo -n MARKETPLACE_ENV= 11 | MARKETPLACE_ENV="staging" 12 | if [ "$1" == "production" ] ; then 13 | MARKETPLACE_ENV="production" 14 | fi 15 | echo "${MARKETPLACE_ENV}" 16 | 17 | export CSP_API_TOKEN \ 18 | MARKETPLACE_ENV 19 | -------------------------------------------------------------------------------- /ci/tasks/test.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | image_resource: 8 | type: registry-image 9 | source: 10 | repository: harbor-repo.vmware.com/dockerhub-proxy-cache/library/golang 11 | tag: 1.19 12 | username: ((harbor.username)) 13 | password: ((harbor.token)) 14 | 15 | params: 16 | CSP_API_TOKEN: 17 | MARKETPLACE_ENV: 18 | 19 | inputs: 20 | - name: source 21 | 22 | run: 23 | path: make 24 | dir: source 25 | args: [test] 26 | -------------------------------------------------------------------------------- /pkg/filter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "encoding/json" 8 | "strings" 9 | ) 10 | 11 | type ListProductFilter struct { 12 | Text string `json:"search,omitempty"` 13 | AllOrgs bool `json:"-"` 14 | OrgIds []string `json:"Publishers,omitempty"` 15 | } 16 | 17 | func (f *ListProductFilter) QueryString() string { 18 | value, _ := json.Marshal(f) 19 | replacer := strings.NewReplacer(`"`, "%22") 20 | return "filters=" + replacer.Replace(string(value)) 21 | } 22 | -------------------------------------------------------------------------------- /test/test_products.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package test 5 | 6 | const ( 7 | Nginx = "nginx" 8 | NginxVersion = "1.22.0_150_r04" 9 | 10 | TKG = "tanzu-kubenetes-grid-1-111-1-1" 11 | TKGVersion = "1.5.4" 12 | 13 | ChartProductSlug = "nginx" 14 | ChartProductVersion = "1.21.1_0" 15 | 16 | ContainerImageProductSlug = "cloudian-s3-compatible-object-storage-for-tkgs0-1-1" 17 | ContainerImageProductVersion = "1.2.1" 18 | 19 | VMProductSlug = "nginxstack" 20 | VMProductVersion = "1.21.0_1" 21 | ) 22 | -------------------------------------------------------------------------------- /ci/tasks/get-current-product.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | MKPCLI_DEBUG: true 12 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 13 | 14 | outputs: 15 | - name: output 16 | 17 | run: 18 | path: bash 19 | dir: output 20 | args: 21 | - -exc 22 | - | 23 | mkpcli product get --product "${PRODUCT_SLUG}" --output json > product.json 24 | mkpcli product list-versions --product "${PRODUCT_SLUG}" --output json | jq -r .[0].versionnumber > version 25 | -------------------------------------------------------------------------------- /internal/pagination.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal 5 | 6 | import ( 7 | "encoding/json" 8 | "strings" 9 | ) 10 | 11 | type Pagination struct { 12 | Enable bool `json:"enable,omitempty"` // TODO: I see this when product list request returns. Maybe a bug? 13 | Enabled bool `json:"enabled,omitempty"` 14 | Page int32 `json:"page"` 15 | PageSize int32 `json:"pageSize"` 16 | } 17 | 18 | func (p *Pagination) QueryString() string { 19 | value, _ := json.Marshal(p) 20 | replacer := strings.NewReplacer(`"`, "%22") 21 | return "pagination=" + replacer.Replace(string(value)) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | func init() { 14 | rootCmd.AddCommand(versionCmd) 15 | versionCmd.SetOut(os.Stdout) 16 | } 17 | 18 | var version = "dev" 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: fmt.Sprintf("Print the version number of %s", AppName), 23 | Long: fmt.Sprintf("Print the version number of %s", AppName), 24 | Args: cobra.NoArgs, 25 | Run: func(cmd *cobra.Command, args []string) { 26 | cmd.Printf("%s version: %s\n", AppName, version) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /internal/pagination_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/internal" 10 | ) 11 | 12 | var _ = Describe("Pagination", func() { 13 | Describe("QueryString", func() { 14 | It("returns the specific string that works in the query parameter", func() { 15 | pagination := &internal.Pagination{ 16 | Page: 1, 17 | PageSize: 25, 18 | } 19 | queryString := pagination.QueryString() 20 | Expect(queryString).To(Equal("pagination={%22page%22:1,%22pageSize%22:25}")) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /cmd/cmd_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd_test 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "io" 10 | "net/http" 11 | "testing" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | func TestCmdSuite(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "Cmd test suite") 20 | } 21 | 22 | func ResponseWithPayload(payload interface{}) *http.Response { 23 | encoded, err := json.Marshal(payload) 24 | Expect(err).ToNot(HaveOccurred()) 25 | 26 | return &http.Response{ 27 | StatusCode: http.StatusOK, 28 | Body: io.NopCloser(bytes.NewReader(encoded)), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ci/tasks/build.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | image_resource: 8 | type: registry-image 9 | source: 10 | repository: harbor-repo.vmware.com/dockerhub-proxy-cache/library/golang 11 | tag: 1.19 12 | username: ((harbor.username)) 13 | password: ((harbor.token)) 14 | 15 | inputs: 16 | - name: source 17 | - name: version 18 | 19 | outputs: 20 | - name: build 21 | 22 | run: 23 | path: /bin/bash 24 | dir: source 25 | args: 26 | - -exc 27 | - | 28 | export VERSION=$(cat ../version/version) 29 | apt-get update && apt-get install -y zip 30 | make release 31 | cp release/* ../build 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | FROM harbor-repo.vmware.com/dockerhub-proxy-cache/library/golang:1.19 as builder 5 | ARG VERSION 6 | 7 | COPY . /marketplace-cli/ 8 | #ENV GOPATH 9 | ENV PATH="${PATH}:/root/go/bin" 10 | WORKDIR /marketplace-cli/ 11 | RUN make build/mkpcli-linux-amd64 12 | 13 | FROM harbor-repo.vmware.com/dockerhub-proxy-cache/library/photon:4.0 14 | LABEL description="VMware Marketplace CLI" 15 | LABEL maintainer="tanzu-isv-engineering@groups.vmware.com" 16 | 17 | RUN yum install jq -y && \ 18 | yum clean all 19 | 20 | COPY --from=builder /marketplace-cli/build/mkpcli-linux-amd64 /usr/local/bin/mkpcli 21 | ENTRYPOINT ["/usr/local/bin/mkpcli"] 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: true 3 | timeout: 10m 4 | 5 | linters: 6 | enable: 7 | - dupl 8 | - gofmt 9 | - goheader 10 | - goimports 11 | - revive 12 | - misspell 13 | - nakedret 14 | disable: 15 | - typecheck 16 | 17 | issues: 18 | # Excluding configuration per-path, per-linter, per-text and per-source 19 | exclude-rules: 20 | # Exclude some linters from running on tests files. 21 | - path: _test\.go 22 | linters: 23 | - dupl 24 | 25 | linters-settings: 26 | revive: 27 | rules: 28 | - name: dot-imports 29 | disabled: true 30 | 31 | goheader: 32 | template: |- 33 | Copyright {{YEAR}} VMware, Inc. 34 | SPDX-License-Identifier: BSD-2-Clause 35 | 36 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "encoding/json" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func init() { 14 | rootCmd.AddCommand(ConfigCmd) 15 | ConfigCmd.SetOut(ConfigCmd.OutOrStdout()) 16 | } 17 | 18 | var ConfigCmd = &cobra.Command{ 19 | Use: "config", 20 | Long: "Prints the current config", 21 | Hidden: true, 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | config := viper.AllSettings() 24 | formattedConfig, err := json.MarshalIndent(config, "", " ") 25 | if err != nil { 26 | return err 27 | } 28 | cmd.Println(string(formattedConfig)) 29 | return nil 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /docs/PublishingVirtualMachineProducts.md: -------------------------------------------------------------------------------- 1 | # Publishing Virtual Machine Products 2 | The Marketplace CLI can update products that host virtual machine images (both ISO and OVA format). 3 | The CLI will upload the image to the Marketplace, and then attach the reference to the product. 4 | 5 | ## Example 6 | To do this, you can use the `mkpcli attach vm` command: 7 | 8 | ```bash 9 | mkpcli attach vm --product hyperspace-database-vm --product-version 1.0.1 --file vm/hyperspace-db-1.0.1-1526e30ba.iso 10 | ``` 11 | 12 | If this version is a new version for the product, pass the `--create-version` flag: 13 | 14 | ```bash 15 | mkpcli attach vm --product hyperspace-database-vm --product-version 1.0.1 --create-version --file vm/hyperspace-db-1.0.1-1526e30ba.iso 16 | ``` 17 | -------------------------------------------------------------------------------- /internal/sorting_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/internal" 10 | ) 11 | 12 | var _ = Describe("Sorting", func() { 13 | Describe("QueryString", func() { 14 | It("returns the specific string that works in the query parameter", func() { 15 | sorting := &internal.Sorting{ 16 | Key: internal.SortKeyUpdateDate, 17 | Direction: internal.SortDirectionAscending, 18 | } 19 | queryString := sorting.QueryString() 20 | Expect(queryString).To(Equal("sortBy={%22key%22:%22updatedOn%22,%22direction%22:%22ASC%22}")) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /internal/sorting.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal 5 | 6 | import ( 7 | "encoding/json" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | SortKeyDisplayName = "displayName" 13 | SortKeyCreationDate = "createdOn" 14 | SortKeyUpdateDate = "updatedOn" 15 | SortDirectionAscending = "ASC" 16 | SortDirectionDescending = "DESC" 17 | ) 18 | 19 | type Sorting struct { 20 | Order int `json:"order,omitempty"` 21 | Key string `json:"key"` 22 | Direction string `json:"direction"` 23 | } 24 | 25 | func (s *Sorting) QueryString() string { 26 | value, _ := json.Marshal(s) 27 | replacer := strings.NewReplacer(`"`, "%22") 28 | return "sortBy=" + replacer.Replace(string(value)) 29 | } 30 | -------------------------------------------------------------------------------- /ci/tasks/test-other-product.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | MKPCLI_DEBUG: true 12 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 13 | 14 | inputs: 15 | - name: version 16 | - name: other 17 | 18 | run: 19 | path: bash 20 | args: 21 | - -exc 22 | - | 23 | set -ex 24 | VERSION=$(cat version/version) 25 | 26 | # Attach an other file 27 | mkpcli attach other --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --create-version --file other/file.tgz 28 | 29 | # Get the list of other files 30 | mkpcli product list-assets --type other --product "${PRODUCT_SLUG}" --product-version "${VERSION}" | grep file.tgz 31 | -------------------------------------------------------------------------------- /docs/PublishingContainerImageProducts.md: -------------------------------------------------------------------------------- 1 | # Publishing Container Image Products 2 | The Marketplace CLI can update products that host container images. 3 | The container image must be referenced by a publicly accessible repository, tag, and tag type. 4 | The tag type is either `FIXED` or `FLOATING` 5 | 6 | ## Example 7 | To do this, you can use the `mkpcli attach image` command: 8 | 9 | ```bash 10 | mkpcli attach image --product hyperspace-database --version 1.0.1 --image-repository astrowidgets/hyperspacedb --tag 1.0.1 --tag-type FIXED --instructions 'docker run astrowidgets/hyperspacedb:1.0.1' 11 | ``` 12 | 13 | If this version is a new version for the product, pass the `--create-version` flag: 14 | 15 | ```bash 16 | mkpcli attach image --product hyperspace-database --version 1.0.1 --create-version --image-repository astrowidgets/hyperspacedb --tag 1.0.1 --tag-type FIXED --instructions 'docker run astrowidgets/hyperspacedb:1.0.1' 17 | ``` 18 | -------------------------------------------------------------------------------- /internal/models/sku.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type SKUPublisherInfo struct { 7 | SKUNumber string `json:"skunumber"` 8 | Price string `json:"price"` 9 | Currency string `json:"currency"` 10 | BillFrequency string `json:"billfreq"` 11 | TermLength int32 `json:"termlength"` 12 | UnitOfMeasurement string `json:"unitofmeasurement"` 13 | PriceMeasurementUnit []string `json:"pricemeasurementunitlist"` 14 | IsMonthlySKUEnabled bool `json:"ismonthlyskuenabled"` 15 | MonthlySKUNumber string `json:"monthlyskunumber"` 16 | } 17 | 18 | type SKUPublisherView struct { 19 | SKUPublisherInfo *SKUPublisherInfo `json:"skupublisherinfo"` 20 | SKUID string `json:"skuid"` 21 | Description string `json:"description"` 22 | Status string `json:"status"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/other.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 8 | ) 9 | 10 | func (m *Marketplace) AttachOtherFile(file string, product *models.Product, version *models.Version) (*models.Product, error) { 11 | hashString, err := Hash(file, models.HashAlgoSHA1) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | uploader, err := m.GetUploader(product.PublisherDetails.OrgId) 17 | if err != nil { 18 | return nil, err 19 | } 20 | filename, fileUrl, err := uploader.UploadProductFile(file) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | product.PrepForUpdate() 26 | product.AddOnFiles = []*models.AddOnFile{{ 27 | Name: filename, 28 | URL: fileUrl, 29 | AppVersion: version.Number, 30 | HashDigest: hashString, 31 | HashAlgorithm: models.HashAlgoSHA1, 32 | }} 33 | 34 | return m.PutProduct(product, version.IsNewVersion) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/hash.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "crypto/sha1" 8 | "crypto/sha256" 9 | "encoding/hex" 10 | "fmt" 11 | "hash" 12 | "io" 13 | "os" 14 | 15 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 16 | ) 17 | 18 | func Hash(filePath, hashAlgorithm string) (string, error) { 19 | var hashAlgo hash.Hash 20 | if hashAlgorithm == models.HashAlgoSHA1 { 21 | hashAlgo = sha1.New() 22 | } else if hashAlgorithm == models.HashAlgoSHA256 { 23 | hashAlgo = sha256.New() 24 | } 25 | 26 | file, err := os.Open(filePath) 27 | if err != nil { 28 | return "", fmt.Errorf("failed to open %s: %w", filePath, err) 29 | } 30 | 31 | _, err = io.Copy(hashAlgo, file) 32 | if err != nil { 33 | return "", fmt.Errorf("failed to generate the hash for %s: %w", filePath, err) 34 | } 35 | 36 | err = file.Close() 37 | if err != nil { 38 | return "", fmt.Errorf("failed to close the file %s: %w", filePath, err) 39 | } 40 | 41 | return hex.EncodeToString(hashAlgo.Sum(nil)), nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/output/output.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output 5 | 6 | import ( 7 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 8 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 9 | ) 10 | 11 | const ( 12 | FormatHuman = "human" 13 | FormatJSON = "json" 14 | FormatYAML = "yaml" 15 | ) 16 | 17 | var SupportedOutputs = []string{FormatHuman, FormatJSON, FormatYAML} 18 | 19 | //go:generate counterfeiter . Format 20 | type Format interface { 21 | PrintHeader(message string) 22 | 23 | RenderProduct(product *models.Product, version *models.Version) error 24 | RenderProducts(products []*models.Product) error 25 | RenderVersions(product *models.Product) error 26 | RenderChart(chart *models.ChartVersion) error 27 | RenderCharts(charts []*models.ChartVersion) error 28 | RenderContainerImages(images []*models.DockerVersionList) error 29 | RenderFile(file *models.ProductDeploymentFile) error 30 | RenderFiles(files []*models.ProductDeploymentFile) error 31 | 32 | RenderAssets(assets []*pkg.Asset) error 33 | } 34 | -------------------------------------------------------------------------------- /docs/PublishingChartProducts.md: -------------------------------------------------------------------------------- 1 | # Publishing Chart Products 2 | The Marketplace CLI can update products that host chart images. 3 | The CLI can either upload a local chart (in a directory or tgz format), or attach a chart based on a public URL, then it 4 | will attach the reference to the product. 5 | 6 | ## Example 7 | To do this, you can use the `mkpcli attach chart` command: 8 | 9 | ### Upload a local chart 10 | 11 | ```bash 12 | mkpcli attach chart --product hyperspace-database-chart --product-version 1.0.1 --chart charts/hyperspace-db-1.0.1.tgz --instructions 'helm install it' 13 | ``` 14 | 15 | ### Attaching a remote chart 16 | 17 | ```bash 18 | mkpcli attach chart --product hyperspace-database-chart --product-version 1.0.1 --chart https://astro-widgets.example.com/charts/hyperspace-db-1.0.1.tgz --instructions 'helm install it' 19 | ``` 20 | 21 | If this version is a new version for the product, pass the `--create-version` flag: 22 | 23 | ```bash 24 | mkpcli attach chart --product hyperspace-database-chart --product-version 1.0.1 --create-version --chart charts/hyperspace-db-1.0.1.tgz --instructions 'helm install it' 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/Authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | The Marketplace CLI authenticates using a VMware Cloud Services API Token. 4 | 5 | ## Generating a token 6 | 7 | ![](images/csp-api-token.png) 8 | To generate a new API Token: 9 | 1. Go to the [API Tokens](https://console.cloud.vmware.com/csp/gateway/portal/#/user/tokens) page on [My Account](https://console.cloud.vmware.com/csp/gateway/portal/#/user/profile) 10 | 2. Click "Generate Token" 11 | 3. Set the token's name 12 | 4. Set the token's expiration 13 | 5. Add the "VMware Marketplace" service role 14 | 15 | If your org has access to the Staging Marketplace, add the "Cloud Stage Marketplace" service role 16 | 17 | 7. No organization role is required 18 | 8. Click Generate 19 | 9. Record the token somewhere (preferably a secure secret store) 20 | 21 | ## Using the token 22 | 23 | You have two options for passing the API Token to the Marketplace CLI: 24 | 25 | ### CLI argument 26 | 27 | ```bash 28 | $ mkpcli products list --csp-api-token 29 | ``` 30 | 31 | ### Environment variable 32 | 33 | ```bash 34 | $ export CSP_API_TOKEN= 35 | $ mkpcli products list 36 | ``` 37 | -------------------------------------------------------------------------------- /pkg/filter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 10 | ) 11 | 12 | var _ = Describe("Filter", func() { 13 | Describe("ListProductFilter", func() { 14 | Context("no filters", func() { 15 | It("returns an empty filter", func() { 16 | filter := pkg.ListProductFilter{} 17 | Expect(filter.QueryString()).To(Equal("filters={}")) 18 | }) 19 | }) 20 | 21 | Context("organization id provided", func() { 22 | It("adds a filter with the organization id", func() { 23 | filter := pkg.ListProductFilter{ 24 | OrgIds: []string{"my-org-id"}, 25 | } 26 | Expect(filter.QueryString()).To(Equal("filters={%22Publishers%22:[%22my-org-id%22]}")) 27 | }) 28 | }) 29 | 30 | Context("text filter", func() { 31 | It("adds a filter with the text", func() { 32 | filter := pkg.ListProductFilter{ 33 | Text: "tanzu", 34 | } 35 | Expect(filter.QueryString()).To(Equal("filters={%22search%22:%22tanzu%22}")) 36 | }) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/features/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package features_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gbytes" 11 | . "github.com/vmware-labs/marketplace-cli/v2/test" 12 | ) 13 | 14 | var _ = Describe("Report version", func() { 15 | steps := NewSteps() 16 | 17 | Scenario("version command reports version", func() { 18 | steps.When("running mkpcli version") 19 | steps.Then("the command exits without error") 20 | steps.And("the version is printed") 21 | }) 22 | 23 | Scenario("version command does not require CSP API token", func() { 24 | steps.Given("the environment variable CSP_API_TOKEN is not set") 25 | steps.When("running mkpcli version") 26 | steps.Then("the command exits without error") 27 | steps.And("the version is printed") 28 | }) 29 | 30 | steps.Define(func(define Definitions) { 31 | DefineCommonSteps(define) 32 | 33 | define.Then(`^the version is printed$`, func() { 34 | Eventually(CommandSession.Out).Should(Say("mkpcli version: 1.2.3")) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /pkg/pkg_suite_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | "testing" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 17 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 18 | ) 19 | 20 | func TestPkg(t *testing.T) { 21 | RegisterFailHandler(Fail) 22 | RunSpecs(t, "Pkg test suite") 23 | } 24 | 25 | func PutProductEchoResponse(requestURL *url.URL, content io.Reader, contentType string) (*http.Response, error) { 26 | Expect(contentType).To(Equal("application/json")) 27 | var product *models.Product 28 | productBytes, err := io.ReadAll(content) 29 | Expect(err).ToNot(HaveOccurred()) 30 | Expect(json.Unmarshal(productBytes, &product)).To(Succeed()) 31 | 32 | body, err := json.Marshal(&pkg.GetProductResponse{ 33 | Response: &pkg.GetProductResponsePayload{ 34 | Message: "", 35 | StatusCode: http.StatusOK, 36 | Data: product, 37 | }, 38 | }) 39 | Expect(err).ToNot(HaveOccurred()) 40 | 41 | return &http.Response{ 42 | StatusCode: http.StatusOK, 43 | Body: io.NopCloser(bytes.NewReader(body)), 44 | }, nil 45 | } 46 | -------------------------------------------------------------------------------- /cmd/output/size_formatter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | . "github.com/vmware-labs/marketplace-cli/v2/cmd/output" 10 | ) 11 | 12 | var _ = Describe("FormatSize", func() { 13 | It("prints the object as JSON", func() { 14 | Expect(FormatSize(123)).To(Equal("123 B")) 15 | Expect(FormatSize(1234)).To(Equal("1.23 KB")) 16 | Expect(FormatSize(12345)).To(Equal("12.3 KB")) 17 | Expect(FormatSize(123456)).To(Equal("123 KB")) 18 | Expect(FormatSize(1234567)).To(Equal("1.23 MB")) 19 | Expect(FormatSize(12345678)).To(Equal("12.3 MB")) 20 | Expect(FormatSize(123456789)).To(Equal("123 MB")) 21 | Expect(FormatSize(1234567890)).To(Equal("1.23 GB")) 22 | Expect(FormatSize(12345678901)).To(Equal("12.3 GB")) 23 | Expect(FormatSize(123456789012)).To(Equal("123 GB")) 24 | Expect(FormatSize(1234567890123)).To(Equal("1.23 TB")) 25 | Expect(FormatSize(12345678901234)).To(Equal("12.3 TB")) 26 | Expect(FormatSize(123456789012345)).To(Equal("123 TB")) 27 | Expect(FormatSize(1234567890123456)).To(Equal("1.23 PB")) 28 | Expect(FormatSize(12345678901234567)).To(Equal("12.3 PB")) 29 | Expect(FormatSize(123456789012345678)).To(Equal("123 PB")) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /pkg/metafile.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 8 | ) 9 | 10 | const ( 11 | MetaFileTypeCLI = "CLI" 12 | MetaFileTypeConfig = "CONFIG" 13 | MetaFileTypeOther = "MISC" 14 | ) 15 | 16 | func (m *Marketplace) AttachMetaFile(metafile, metafileType, metafileVersion string, product *models.Product, version *models.Version) (*models.Product, error) { 17 | hashString, err := Hash(metafile, models.HashAlgoSHA1) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | uploader, err := m.GetUploader(product.PublisherDetails.OrgId) 23 | if err != nil { 24 | return nil, err 25 | } 26 | filename, fileUrl, err := uploader.UploadMetaFile(metafile) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | product.PrepForUpdate() 32 | product.MetaFiles = append(product.MetaFiles, &models.MetaFile{ 33 | FileType: metafileType, 34 | Version: metafileVersion, 35 | AppVersion: version.Number, 36 | Objects: []*models.MetaFileObject{ 37 | { 38 | FileName: filename, 39 | TempURL: fileUrl, 40 | HashDigest: hashString, 41 | HashAlgorithm: models.HashAlgoSHA1, 42 | }, 43 | }, 44 | }) 45 | 46 | return m.PutProduct(product, version.IsNewVersion) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/vm.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 11 | ) 12 | 13 | func makeUniqueFileID() string { 14 | now := time.Now().UnixNano() / int64(time.Millisecond) 15 | return fmt.Sprintf("fileuploader%d.url", now) 16 | } 17 | 18 | func (m *Marketplace) UploadVM(vmFile string, product *models.Product, version *models.Version) (*models.Product, error) { 19 | hashString, err := Hash(vmFile, models.HashAlgoSHA1) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | uploader, err := m.GetUploader(product.PublisherDetails.OrgId) 25 | if err != nil { 26 | return nil, err 27 | } 28 | filename, fileUrl, err := uploader.UploadProductFile(vmFile) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | product.PrepForUpdate() 34 | product.ProductDeploymentFiles = []*models.ProductDeploymentFile{ 35 | { 36 | Name: filename, 37 | AppVersion: version.Number, 38 | Url: fileUrl, 39 | HashAlgo: models.HashAlgoSHA1, 40 | HashDigest: hashString, 41 | IsRedirectUrl: false, 42 | UniqueFileID: makeUniqueFileID(), 43 | VersionList: []string{}, 44 | }, 45 | } 46 | 47 | return m.PutProduct(product, version.IsNewVersion) 48 | } 49 | -------------------------------------------------------------------------------- /internal/models/addon.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type AddOnFile struct { 7 | ID string `json:"id"` 8 | Name string `json:"name"` 9 | URL string `json:"url"` 10 | ImageType string `json:"imagetype"` 11 | Status string `json:"status"` 12 | DeploymentStatus string `json:"deploymentstatus"` 13 | UploadedOn int32 `json:"uploadedon"` 14 | UploadedBy string `json:"uploadedby"` 15 | UpdatedOn int32 `json:"updatedon"` 16 | UpdatedBy string `json:"updatedby"` 17 | FileID string `json:"fileid"` 18 | AppVersion string `json:"appversion"` 19 | HashDigest string `json:"hashdigest"` 20 | HashAlgorithm string `json:"hashalgo"` 21 | DownloadCount int64 `json:"downloadcount"` 22 | IsRedirectURL bool `json:"isredirecturl"` 23 | IsThirdPartyURL bool `json:"isthirdpartyurl"` 24 | Size int64 `json:"size"` 25 | } 26 | 27 | func (product *Product) GetAddonFilesForVersion(version string) []*AddOnFile { 28 | var files []*AddOnFile 29 | versionObj := product.GetVersion(version) 30 | 31 | if versionObj != nil { 32 | for _, addonFile := range product.AddOnFiles { 33 | if addonFile.AppVersion == versionObj.Number { 34 | files = append(files, addonFile) 35 | } 36 | } 37 | } 38 | return files 39 | } 40 | -------------------------------------------------------------------------------- /ci/tasks/test-chart-product.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | MKPCLI_DEBUG: true 12 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 13 | 14 | inputs: 15 | - name: version 16 | - name: chart 17 | 18 | run: 19 | path: bash 20 | args: 21 | - -exc 22 | - | 23 | set -ex 24 | VERSION=$(cat version/version) 25 | 26 | # Upload a chart 27 | mkpcli attach chart --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --create-version \ 28 | --chart chart/*.tgz --instructions "helm install it" 29 | 30 | # Get the list of charts 31 | mkpcli product list-assets --type chart --product "${PRODUCT_SLUG}" --product-version "${VERSION}" | grep $(basename chart/*.tgz) 32 | 33 | # Wait until the chart is downloadable 34 | asset=$(mkpcli product list-assets --type chart --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq '.[0]') 35 | while [ "$(echo "${asset}" | jq .downloadable)" == "false" ] 36 | do 37 | if [ "$(echo "${asset}" | jq .error)" != "null" ]; then 38 | exit 1 39 | fi 40 | 41 | sleep 30 42 | asset=$(mkpcli product list-assets --type chart --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq '.[0]') 43 | done 44 | -------------------------------------------------------------------------------- /internal/models/blueprints.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type Image struct { 7 | URL string `json:"url"` 8 | DownloadURL string `json:"downloadurl"` 9 | HashType string `json:"hashtype"` 10 | HashValue string `json:"hashvalue"` 11 | } 12 | 13 | type File struct { 14 | ID string `json:"id"` 15 | Title string `json:"title"` 16 | URL string `json:"url"` 17 | DownloadURL string `json:"downloadurl"` 18 | HashType string `json:"hashtype"` 19 | HashValue string `json:"hashvalue"` 20 | } 21 | 22 | type BlueprintFile struct { 23 | ID string `json:"id"` 24 | FileID string `json:"fileid"` 25 | Title string `json:"string"` 26 | URL string `json:"url"` 27 | Status string `json:"status"` 28 | Metadata string `json:"metadata"` 29 | Images []Image `json:"imagesList"` 30 | Files []File `json:"filesList"` 31 | VRAVersion string `json:"vraversion"` 32 | DeploymentInstructions string `json:"deploymentinstructions"` 33 | } 34 | 35 | type ProductBlueprintDetails struct { 36 | Version string `json:"version"` 37 | Instructions string `json:"instructions"` 38 | BlueprintFiles []BlueprintFile `json:"blueprintfilesList"` 39 | Prerequisites []string `json:"prerequisitesList"` 40 | } 41 | -------------------------------------------------------------------------------- /ci/tasks/set-osl-file.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | MKPCLI_DEBUG: true 12 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 13 | 14 | inputs: 15 | - name: previous 16 | - name: version 17 | 18 | run: 19 | path: bash 20 | args: 21 | - -exc 22 | - | 23 | export VERSION=$(cat version/version) 24 | echo "OSL for ${PRODUCT_SLUG} ${VERSION}" > "osl-${VERSION}.txt" 25 | 26 | mkpcli product set --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --osl-file "osl-${VERSION}.txt" 27 | 28 | # Validate that the OSL was properly updated 29 | OSL_URL=$(mkpcli product get --product "${PRODUCT_SLUG}" --output json | jq -r .opensourcedisclosure.licensedisclosureurl) 30 | OSL_CONTENT=$(curl "${OSL_URL}") 31 | test "${OSL_CONTENT}" == "OSL for ${PRODUCT_SLUG} ${VERSION}" 32 | 33 | # Validate that the OSL URL didn't change for previous versions 34 | PREVIOUS_OSL_URL=$(mkpcli product get --product "${PRODUCT_SLUG}" --product-version "$(cat previous/version)" --output json | jq -r .opensourcedisclosure.licensedisclosureurl) 35 | test "${OSL_URL}" != "${PREVIOUS_OSL_URL}" 36 | 37 | # Validate that the OSL content didn't change for previous versions 38 | PREVIOUS_OSL_CONTENT=$(curl "${PREVIOUS_OSL_URL}") 39 | test "${OSL_CONTENT}" != "${PREVIOUS_OSL_CONTENT}" 40 | -------------------------------------------------------------------------------- /test/external/authentication_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | "fmt" 8 | 9 | . "github.com/bunniesandbeatings/goerkin" 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | . "github.com/onsi/gomega/gbytes" 13 | . "github.com/vmware-labs/marketplace-cli/v2/test" 14 | ) 15 | 16 | var _ = Describe("authentication", func() { 17 | steps := NewSteps() 18 | 19 | const ExpiredToken = "M_sfojHArrjx90lxUCmID2qhZw-I0WGlW5fThBuiQXwVtvy7UJq6XeKtAKzf8cFm" 20 | 21 | Scenario("Authentication works", func() { 22 | steps.Given("targeting the production environment") 23 | steps.When("running mkpcli auth") 24 | steps.Then("the command exits without error") 25 | steps.And("the token is printed") 26 | }) 27 | 28 | Scenario("Expired token", func() { 29 | steps.Given("targeting the production environment") 30 | steps.When(fmt.Sprintf("running mkpcli --csp-api-token %s auth", ExpiredToken)) 31 | steps.Then("the command exits with an error") 32 | steps.And("the expired token error message is printed") 33 | }) 34 | 35 | steps.Define(func(define Definitions) { 36 | DefineCommonSteps(define) 37 | define.Then(`^the token is printed$`, func() { 38 | Eventually(CommandSession.Out).Should(Say("[.-a-zA-Z]*")) 39 | }) 40 | define.Then(`^the expired token error message is printed$`, func() { 41 | Eventually(CommandSession.Err).Should(Say("the CSP API token is invalid or expired")) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Marketplace CLI 2 | Copyright 2023 VMware, Inc. 3 | 4 | The BSD-2 license (the "License") set forth below applies to all parts of the vSphere with Marketplace CLI project. You may not use this file except in compliance with the License. 5 | 6 | BSD-2 License 7 | 8 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 9 | 10 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 11 | 12 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/external/download_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gbytes" 11 | . "github.com/vmware-labs/marketplace-cli/v2/test" 12 | ) 13 | 14 | var _ = Describe("Download", func() { 15 | steps := NewSteps() 16 | 17 | Scenario("Downloading an asset", func() { 18 | steps.Given("targeting the staging environment") 19 | steps.When("running mkpcli download --product " + TKG + " --product-version " + TKGVersion + " --filter yq_linux_amd64 --filename yq --accept-eula") 20 | steps.Then("the command exits without error") 21 | steps.And("yq is downloaded") 22 | }) 23 | 24 | Scenario("Download fails when there are multiple files", func() { 25 | steps.Given("targeting the staging environment") 26 | steps.When("running mkpcli download --product " + TKG + " --product-version " + TKGVersion + " --accept-eula") 27 | steps.Then("the command exits with an error") 28 | steps.And("a message saying that there are multiple assets available to download") 29 | }) 30 | 31 | steps.Define(func(define Definitions) { 32 | DefineCommonSteps(define) 33 | 34 | define.Then(`^a message saying that there are multiple assets available to download$`, func() { 35 | Eventually(CommandSession.Err).Should(Say("product " + TKG + " " + TKGVersion + " has multiple downloadable assets, please use the --filter parameter")) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /pkg/marketplace_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | "strings" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 12 | ) 13 | 14 | var _ = Describe("Marketplace", func() { 15 | var marketplace *pkg.Marketplace 16 | 17 | BeforeEach(func() { 18 | marketplace = &pkg.Marketplace{} 19 | }) 20 | 21 | Describe("DecodeJson", func() { 22 | type AnObject struct { 23 | A string `json:"a"` 24 | B string `json:"b"` 25 | C string `json:"c"` 26 | } 27 | 28 | It("parses JSON", func() { 29 | input := "{\"a\": \"Apple\", \"b\": \"Butter\", \"c\": \"Croissant\"}" 30 | output := &AnObject{} 31 | err := marketplace.DecodeJson(strings.NewReader(input), output) 32 | 33 | Expect(err).ToNot(HaveOccurred()) 34 | Expect(output.A).To(Equal("Apple")) 35 | Expect(output.B).To(Equal("Butter")) 36 | Expect(output.C).To(Equal("Croissant")) 37 | }) 38 | 39 | Context("Strict decoding enabled", func() { 40 | BeforeEach(func() { 41 | marketplace.EnableStrictDecoding() 42 | }) 43 | 44 | It("throws an error on unknown fields", func() { 45 | input := "{\"extra\": \"How did this get here?\", \"a\": \"Apple\", \"b\": \"Butter\", \"c\": \"Croissant\"}" 46 | output := &AnObject{} 47 | err := marketplace.DecodeJson(strings.NewReader(input), output) 48 | 49 | Expect(err).To(HaveOccurred()) 50 | Expect(err.Error()).To(Equal("json: unknown field \"extra\"")) 51 | }) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /ci/tasks/test-vm-product.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | MKPCLI_DEBUG: true 12 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 13 | 14 | inputs: 15 | - name: version 16 | - name: vm 17 | 18 | run: 19 | path: bash 20 | args: 21 | - -exc 22 | - | 23 | set -ex 24 | VERSION=$(cat version/version) 25 | VM_FILE=$(find vm -type f -name '*.iso' -or -name '*.ova') 26 | 27 | # Attach a virtual machine file 28 | mkpcli attach vm --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --create-version \ 29 | --file "${VM_FILE}" 30 | 31 | # Get the list of vms 32 | mkpcli product list-assets --product "${PRODUCT_SLUG}" --product-version "${VERSION}" 33 | 34 | # Wait until the image is downloadable 35 | vmName=$(basename "${VM_FILE}") 36 | asset=$(mkpcli product list-assets --type vm --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq --arg name "${vmName}" '.[] | select(.displayname == $name)') 37 | while [ "$(echo "${asset}" | jq .downloadable)" == "false" ] 38 | do 39 | if [ "$(echo "${asset}" | jq .error)" != "null" ]; then 40 | exit 1 41 | fi 42 | 43 | sleep 30 44 | asset=$(mkpcli product list-assets --type vm --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq --arg name "${vmName}" '.[] | select(.displayname == $name)') 45 | done 46 | -------------------------------------------------------------------------------- /ci/tasks/attach-metafile.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | METAFILE_TYPE: 12 | MKPCLI_DEBUG: true 13 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 14 | 15 | inputs: 16 | - name: version 17 | 18 | run: 19 | path: bash 20 | args: 21 | - -exc 22 | - | 23 | set -ex 24 | VERSION=$(cat version/version) 25 | 26 | if [ "${METAFILE_TYPE}" == "config" ]; then 27 | METAFILE_NAME=config.json 28 | echo "{\"data\": \"totally a real config file\"}" > "${METAFILE_NAME}" 29 | mkpcli attach metafile --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --metafile "${METAFILE_NAME}" --metafile-type config 30 | elif [ "${METAFILE_TYPE}" == "cli" ]; then 31 | METAFILE_NAME=ls 32 | mkpcli attach metafile --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --metafile $(which "${METAFILE_NAME}") --metafile-type cli --metafile-version 1.0.0 33 | elif [ "${METAFILE_TYPE}" == "other" ]; then 34 | METAFILE_NAME=other-vm.iso 35 | echo "some other virtual machine" > other-vm.iso 36 | mkpcli attach metafile --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --metafile "${METAFILE_NAME}" --metafile-type other --metafile-version 1.0.0 37 | fi 38 | 39 | # Get the list of meta files 40 | mkpcli product list-assets --type metafile --product "${PRODUCT_SLUG}" --product-version "${VERSION}" | grep "${METAFILE_NAME}" 41 | -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/golang-jwt/jwt" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 13 | ) 14 | 15 | //go:generate counterfeiter . TokenServices 16 | type TokenServices interface { 17 | Redeem(refreshToken string) (*csp.Claims, error) 18 | } 19 | 20 | //go:generate counterfeiter . TokenServicesInitializer 21 | type TokenServicesInitializer func(cspHost string) TokenServices 22 | 23 | var InitializeTokenServices TokenServicesInitializer = func(cspHost string) TokenServices { 24 | return &csp.TokenServices{ 25 | CSPHost: cspHost, 26 | Client: Client, 27 | TokenParser: jwt.ParseWithClaims, 28 | } 29 | } 30 | 31 | func GetRefreshToken(cmd *cobra.Command, args []string) error { 32 | tokenServices := InitializeTokenServices(viper.GetString("csp.host")) 33 | 34 | apiToken := viper.GetString("csp.api-token") 35 | if apiToken == "" { 36 | return fmt.Errorf("missing CSP API token") 37 | } 38 | 39 | claims, err := tokenServices.Redeem(apiToken) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | viper.Set("csp.refresh-token", claims.Token) 45 | return nil 46 | } 47 | 48 | func init() { 49 | rootCmd.AddCommand(AuthCmd) 50 | } 51 | 52 | var AuthCmd = &cobra.Command{ 53 | Use: "auth", 54 | Long: "Fetch and return a valid CSP refresh token", 55 | Hidden: true, 56 | PreRunE: GetRefreshToken, 57 | Run: func(cmd *cobra.Command, args []string) { 58 | cmd.Println(viper.GetString("csp.refresh-token")) 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /internal/csp/claims.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package csp 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/golang-jwt/jwt" 11 | ) 12 | 13 | const ( 14 | RoleOrgOwner = "csp:org_owner" 15 | RolePlatformOperator = "csp:platform_operator" 16 | ClockSkewBuffer = int64(10) 17 | ) 18 | 19 | type Claims struct { 20 | jwt.StandardClaims 21 | 22 | ContextName string `json:"context_name,omitempty"` 23 | Domain string `json:"domain,omitempty"` 24 | Username string `json:"username,omitempty"` 25 | Perms []string `json:"perms,omitempty"` 26 | 27 | Context string `json:"context,omitempty"` 28 | AuthorizedParty string `json:"azp,omitempty"` 29 | 30 | // The token as a string, signed and ready to be put in an Authorization header 31 | Token string `json:"-"` 32 | } 33 | 34 | // Valid overloads the StandardClaims's Valid method to allow for additional room 35 | // to handle clock skew between the local machine's time and the CSP server 36 | func (claims *Claims) Valid() error { 37 | claims.IssuedAt -= ClockSkewBuffer 38 | valid := claims.StandardClaims.Valid() 39 | claims.StandardClaims.IssuedAt += ClockSkewBuffer 40 | return valid 41 | } 42 | 43 | func (claims *Claims) GetQualifiedUsername() string { 44 | if !strings.Contains(claims.Username, "@") { 45 | return fmt.Sprintf("%s@%s", claims.Username, claims.Domain) 46 | } 47 | return claims.Username 48 | } 49 | 50 | func (claims *Claims) IsOrgOwner() bool { 51 | for _, p := range claims.Perms { 52 | if p == RoleOrgOwner { 53 | return true 54 | } 55 | } 56 | 57 | return false 58 | } 59 | 60 | func (claims *Claims) IsPlatformOperator() bool { 61 | for _, p := range claims.Perms { 62 | if p == RolePlatformOperator { 63 | return true 64 | } 65 | } 66 | 67 | return false 68 | } 69 | -------------------------------------------------------------------------------- /internal/models/product_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 10 | "github.com/vmware-labs/marketplace-cli/v2/test" 11 | ) 12 | 13 | var _ = Describe("PrepForUpdate", func() { 14 | It("prepares the product for update", func() { 15 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeChart) 16 | test.AddVersions(product, "1.0.0") 17 | product.Versions = append(product.Versions, &models.Version{ 18 | Number: "2.0.0", 19 | Details: "Details for 2.0.0", 20 | Status: "ACTIVE", 21 | Instructions: "Instructions for 2.0.0", 22 | }) 23 | product.EncryptionDetails = &models.ProductEncryptionDetails{ 24 | List: []string{"alpha", "bravo", "delta"}, 25 | } 26 | 27 | product.PrepForUpdate() 28 | By("converting the encryption details list to the encryption hash", func() { 29 | Expect(product.Encryption).ToNot(BeNil()) 30 | Expect(product.Encryption.List).ToNot(BeNil()) 31 | Expect(product.Encryption.List).To(HaveKeyWithValue("alpha", true)) 32 | Expect(product.Encryption.List).To(HaveKeyWithValue("bravo", true)) 33 | Expect(product.Encryption.List).To(HaveKeyWithValue("delta", true)) 34 | }) 35 | 36 | By("Ensuring that both the versions list and the all versions list are in sync", func() { 37 | Expect(product.AllVersions).To(HaveLen(2)) 38 | Expect(product.AllVersions[0].Number).To(Equal("1.0.0")) 39 | Expect(product.AllVersions[1].Number).To(Equal("2.0.0")) 40 | Expect(product.Versions).To(HaveLen(2)) 41 | Expect(product.Versions[0].Number).To(Equal("1.0.0")) 42 | Expect(product.Versions[1].Number).To(Equal("2.0.0")) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /docs/ConcourseExample.md: -------------------------------------------------------------------------------- 1 | # Concourse Example 2 | 3 | Running the Marketplace CLI inside [Concourse](https://concourse-ci.org/) is simple. 4 | 5 | You will need the version of the product, which is typically handled with the [semver resource](https://github.com/concourse/semver-resource), and the actual asset to attach (container image, Helm chart, VM ISO or OVA). 6 | 7 | Your product slug and the [CSP API token](Authentication.md) can be passed via parameters. 8 | 9 | ## Example pipeline 10 | ```yaml 11 | resource: 12 | - name: hyperspace-database 13 | type: helm-chart 14 | 15 | - name: version 16 | type: semver 17 | 18 | - name: mkpcli 19 | type: registry-image 20 | source: 21 | repository: projects.registry.vmware.com/tanzu_isv_engineering/mkpcli 22 | 23 | jobs: 24 | - name: update-marketplace-product 25 | plan: 26 | - in_parallel: 27 | - get: hyperspace-db-chart 28 | - get: version 29 | - get: mkpcli 30 | - task: add-chart 31 | image: mkpcli 32 | config: 33 | params: 34 | CSP_API_TOKEN: ((csp.api_token)) 35 | PRODUCT_SLUG: hyperspace-database 36 | platform: linux 37 | inputs: 38 | - name: hyperspace-db-chart 39 | - name: version 40 | run: 41 | path: bash 42 | args: 43 | - -exc 44 | - | 45 | VERSION=$(cat version/version) 46 | 47 | mkpcli attach chart \ 48 | --product "${PRODUCT_SLUG}" \ 49 | --product-version "${VERSION}" --create-version \ 50 | --chart hyperspace-db-chart/*.tgz \ 51 | --instructions "helm install it" 52 | mkpcli product list-assets --type chart --product "${PRODUCT_SLUG}" --product-version "${VERSION}" 53 | ``` -------------------------------------------------------------------------------- /internal/progress_bar.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "time" 10 | 11 | "github.com/schollz/progressbar/v3" 12 | ) 13 | 14 | //go:generate counterfeiter . ProgressBar 15 | type ProgressBar interface { 16 | WrapWriter(source io.Writer) io.Writer 17 | WrapReader(source io.Reader) io.Reader 18 | } 19 | 20 | //go:generate counterfeiter . ProgressBarMaker 21 | type ProgressBarMaker func(description string, length int64, output io.Writer) ProgressBar 22 | 23 | var MakeProgressBar = NewProgressBar 24 | 25 | func NewProgressBar(description string, length int64, output io.Writer) ProgressBar { 26 | return &ProgressBarImpl{ 27 | description: description, 28 | length: length, 29 | output: output, 30 | } 31 | } 32 | 33 | type ProgressBarImpl struct { 34 | description string 35 | length int64 36 | output io.Writer 37 | } 38 | 39 | func (b *ProgressBarImpl) makeProgressBar() *progressbar.ProgressBar { 40 | bar := progressbar.NewOptions64( 41 | b.length, 42 | progressbar.OptionSetDescription(b.description), 43 | progressbar.OptionSetWriter(b.output), 44 | progressbar.OptionShowBytes(true), 45 | progressbar.OptionSetWidth(10), 46 | progressbar.OptionThrottle(65*time.Millisecond), 47 | progressbar.OptionShowCount(), 48 | progressbar.OptionOnCompletion(func() { 49 | _, _ = fmt.Fprintln(b.output, "") 50 | }), 51 | progressbar.OptionSpinnerType(14), 52 | progressbar.OptionFullWidth(), 53 | ) 54 | _ = bar.RenderBlank() 55 | return bar 56 | } 57 | 58 | func (b *ProgressBarImpl) WrapWriter(source io.Writer) io.Writer { 59 | return io.MultiWriter(source, b.makeProgressBar()) 60 | } 61 | 62 | func (b *ProgressBarImpl) WrapReader(source io.Reader) io.Reader { 63 | reader := progressbar.NewReader(source, b.makeProgressBar()) 64 | return &reader 65 | } 66 | -------------------------------------------------------------------------------- /ci/tasks/download-asset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2023 VMware, Inc. 4 | # SPDX-License-Identifier: BSD-2-Clause 5 | 6 | set -ex 7 | 8 | if [ -z "${ASSET_TYPE}" ]; then 9 | echo "ASSET_TYPE not defined. Should be one of: chart, image, metafile, other, vm" 10 | exit 1 11 | fi 12 | 13 | if [ -z "${PRODUCT_SLUG}" ] ; then 14 | echo "PRODUCT_SLUG not defined" 15 | exit 1 16 | fi 17 | 18 | if [ -z "${PRODUCT_VERSION}" ] ; then 19 | echo "PRODUCT_VERSION not defined, using latest version" >&2 20 | fi 21 | 22 | filename="" 23 | if [ "${ASSET_TYPE}" == "chart" ]; then 24 | filename="my-chart.tgz" 25 | elif [ "${ASSET_TYPE}" == "image" ]; then 26 | filename="my-container-image.tar" 27 | elif [ "${ASSET_TYPE}" == "metafile" ]; then 28 | filename="my-metafile" 29 | elif [ "${ASSET_TYPE}" == "other" ]; then 30 | filename="my-addon.vlcp" 31 | elif [ "${ASSET_TYPE}" == "vm" ]; then 32 | filename="my-vm-image" 33 | fi 34 | 35 | # Get the ID for the asset of type 36 | ASSETS=$(mkpcli product list-assets --type "${ASSET_TYPE}" --product "${PRODUCT_SLUG}" --product-version "${PRODUCT_VERSION}" --output json) 37 | NAME=$(echo "${ASSETS}" | jq -r .[0].displayname) 38 | DOWNLOADABLE=$(echo "${ASSETS}" | jq -r .[0].downloadable) 39 | ERROR=$(echo "${ASSETS}" | jq -r .[0].error) 40 | 41 | if [ "${DOWNLOADABLE}" == "true" ] ; then 42 | mkpcli download --product "${PRODUCT_SLUG}" --product-version "${PRODUCT_VERSION}" \ 43 | --filter "${NAME}" --filename "${filename}" --accept-eula 44 | 45 | test -f "${filename}" 46 | # if [ "${ASSET_TYPE}" == "addon" ]; then 47 | if [ "${ASSET_TYPE}" == "chart" ]; then 48 | tar tvf "${filename}" | grep Chart.yaml 49 | elif [ "${ASSET_TYPE}" == "image" ]; then 50 | tar tvf "${filename}" manifest.json 51 | # elif [ "${ASSET_TYPE}" == "metafile" ]; then 52 | # elif [ "${ASSET_TYPE}" == "vm" ]; then 53 | fi 54 | 55 | rm -f "${filename}" 56 | else 57 | echo "Asset is not downloadable: ${ERROR}" 58 | exit 1 59 | fi 60 | -------------------------------------------------------------------------------- /cmd/shared_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/cmd" 10 | ) 11 | 12 | var _ = Describe("ValidateAssetTypeFilter", func() { 13 | It("allows valid asset types", func() { 14 | cmd.AssetType = "chart" 15 | Expect(cmd.ValidateAssetTypeFilter(nil, nil)).To(Succeed()) 16 | cmd.AssetType = "image" 17 | Expect(cmd.ValidateAssetTypeFilter(nil, nil)).To(Succeed()) 18 | cmd.AssetType = "metafile" 19 | Expect(cmd.ValidateAssetTypeFilter(nil, nil)).To(Succeed()) 20 | cmd.AssetType = "other" 21 | Expect(cmd.ValidateAssetTypeFilter(nil, nil)).To(Succeed()) 22 | cmd.AssetType = "vm" 23 | Expect(cmd.ValidateAssetTypeFilter(nil, nil)).To(Succeed()) 24 | }) 25 | 26 | When("an invalid asset type is used", func() { 27 | It("returns an error", func() { 28 | cmd.AssetType = "dogfood" 29 | err := cmd.ValidateAssetTypeFilter(nil, nil) 30 | Expect(err).To(HaveOccurred()) 31 | Expect(err.Error()).To(Equal("Unknown asset type: dogfood\nPlease use one of chart, image, metafile, other, vm")) 32 | }) 33 | }) 34 | }) 35 | 36 | var _ = Describe("ValidateMetaFileType", func() { 37 | It("allows valid meta file types", func() { 38 | cmd.MetaFileType = "cli" 39 | Expect(cmd.ValidateMetaFileType(nil, nil)).To(Succeed()) 40 | cmd.MetaFileType = "config" 41 | Expect(cmd.ValidateMetaFileType(nil, nil)).To(Succeed()) 42 | cmd.MetaFileType = "other" 43 | Expect(cmd.ValidateMetaFileType(nil, nil)).To(Succeed()) 44 | }) 45 | 46 | When("an invalid meta file type is used", func() { 47 | It("returns an error", func() { 48 | cmd.MetaFileType = "dogfood" 49 | err := cmd.ValidateMetaFileType(nil, nil) 50 | Expect(err).To(HaveOccurred()) 51 | Expect(err.Error()).To(Equal("Unknown meta file type: dogfood\nPlease use one of cli, config, other")) 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /internal/models/metafile.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | const ( 7 | MetaFileTypeInvalid = "INVALID_FILE_TYPE" 8 | MetaFileTypeCLI = "CLI" 9 | MetaFileTypeConfig = "CONFIG" 10 | MetaFileTypeMisc = "MISC" 11 | ) 12 | 13 | type MetaFileObject struct { 14 | FileID string `json:"fileid"` 15 | FileName string `json:"filename"` 16 | TempURL string `json:"tempurl"` 17 | URL string `json:"url"` 18 | IsFileBackedUp bool `json:"isfilebackedup"` 19 | ProcessingError string `json:"processingerror"` 20 | HashDigest string `json:"hashdigest"` 21 | HashAlgorithm string `json:"hashalgo"` 22 | Size int64 `json:"size"` 23 | UploadedBy string `json:"uploadedby"` 24 | UploadedOn int32 `json:"uploadedon"` 25 | DownloadCount int64 `json:"downloadcount"` 26 | } 27 | 28 | type MetaFile struct { 29 | ID string `json:"metafileid"` 30 | GroupId string `json:"groupid"` 31 | GroupName string `json:"groupname"` 32 | FileType string `json:"filetype"` 33 | Version string `json:"version"` // Note: This is the version of this particular file... 34 | AppVersion string `json:"appversion"` // Note: and this is the associated Marketplace product version 35 | Status string `json:"status"` 36 | Objects []*MetaFileObject `json:"metafileobjectsList"` 37 | CreatedBy string `json:"createdby"` 38 | CreatedOn int32 `json:"createdon"` 39 | } 40 | 41 | func (product *Product) GetMetaFilesForVersion(version string) []*MetaFile { 42 | var metafiles []*MetaFile 43 | versionObj := product.GetVersion(version) 44 | 45 | if versionObj != nil { 46 | for _, metafile := range product.MetaFiles { 47 | if metafile.AppVersion == versionObj.Number { 48 | metafiles = append(metafiles, metafile) 49 | } 50 | } 51 | } 52 | 53 | return metafiles 54 | } 55 | -------------------------------------------------------------------------------- /cmd/auth_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd_test 5 | 6 | import ( 7 | "fmt" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | "github.com/spf13/viper" 12 | 13 | . "github.com/vmware-labs/marketplace-cli/v2/cmd" 14 | "github.com/vmware-labs/marketplace-cli/v2/cmd/cmdfakes" 15 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 16 | ) 17 | 18 | var _ = Describe("Auth", func() { 19 | Describe("GetRefreshToken", func() { 20 | var ( 21 | initializer *cmdfakes.FakeTokenServicesInitializer 22 | tokenServices *cmdfakes.FakeTokenServices 23 | ) 24 | 25 | BeforeEach(func() { 26 | tokenServices = &cmdfakes.FakeTokenServices{} 27 | 28 | initializer = &cmdfakes.FakeTokenServicesInitializer{} 29 | initializer.Returns(tokenServices) 30 | InitializeTokenServices = initializer.Spy 31 | }) 32 | 33 | BeforeEach(func() { 34 | viper.Set("csp.api-token", "my-csp-api-token") 35 | viper.Set("csp.host", "console.cloud.vmware.com.example") 36 | tokenServices.RedeemReturns(&csp.Claims{ 37 | Token: "my-refresh-token", 38 | }, nil) 39 | }) 40 | 41 | It("gets the refresh token and puts it into viper", func() { 42 | err := GetRefreshToken(nil, []string{}) 43 | Expect(err).ToNot(HaveOccurred()) 44 | Expect(viper.GetString("csp.refresh-token")).To(Equal("my-refresh-token")) 45 | 46 | Expect(initializer.CallCount()).To(Equal(1)) 47 | Expect(initializer.ArgsForCall(0)).To(Equal("console.cloud.vmware.com.example")) 48 | 49 | Expect(tokenServices.RedeemCallCount()).To(Equal(1)) 50 | Expect(tokenServices.RedeemArgsForCall(0)).To(Equal("my-csp-api-token")) 51 | }) 52 | 53 | Context("fails to exchange api token", func() { 54 | BeforeEach(func() { 55 | tokenServices.RedeemReturns(nil, fmt.Errorf("redeem failed")) 56 | }) 57 | 58 | It("returns an error", func() { 59 | err := GetRefreshToken(nil, []string{}) 60 | Expect(err).To(HaveOccurred()) 61 | Expect(err.Error()).To(Equal("redeem failed")) 62 | }) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /cmd/shared.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/vmware-labs/marketplace-cli/v2/cmd/output" 13 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 14 | ) 15 | 16 | var ( 17 | Client pkg.HTTPClient 18 | Marketplace pkg.MarketplaceInterface 19 | Output output.Format 20 | 21 | AssetType string 22 | assetTypeMapping = map[string]string{ 23 | "other": pkg.AssetTypeOther, 24 | "chart": pkg.AssetTypeChart, 25 | "image": pkg.AssetTypeContainerImage, 26 | "metafile": pkg.AssetTypeMetaFile, 27 | "vm": pkg.AssetTypeVM, 28 | } 29 | MetaFileType string 30 | metaFileTypeMapping = map[string]string{ 31 | "cli": pkg.MetaFileTypeCLI, 32 | "config": pkg.MetaFileTypeConfig, 33 | "other": pkg.MetaFileTypeOther, 34 | } 35 | ) 36 | 37 | func assetTypesList() []string { 38 | var assetTypes []string 39 | for assetType := range assetTypeMapping { 40 | assetTypes = append(assetTypes, assetType) 41 | } 42 | sort.Strings(assetTypes) 43 | return assetTypes 44 | } 45 | 46 | func ValidateAssetTypeFilter(cmd *cobra.Command, args []string) error { 47 | if AssetType == "" { 48 | return nil 49 | } 50 | if assetTypeMapping[AssetType] != "" { 51 | return nil 52 | } 53 | return fmt.Errorf("Unknown asset type: %s\nPlease use one of %s", AssetType, strings.Join(assetTypesList(), ", ")) 54 | } 55 | 56 | func metaFileTypesList() []string { 57 | var metaFileTypes []string 58 | for metaFileType := range metaFileTypeMapping { 59 | metaFileTypes = append(metaFileTypes, metaFileType) 60 | } 61 | sort.Strings(metaFileTypes) 62 | return metaFileTypes 63 | } 64 | 65 | func ValidateMetaFileType(cmd *cobra.Command, args []string) error { 66 | if MetaFileType == "" { 67 | return nil 68 | } 69 | if metaFileTypeMapping[MetaFileType] != "" { 70 | return nil 71 | } 72 | return fmt.Errorf("Unknown meta file type: %s\nPlease use one of %s", MetaFileType, strings.Join(metaFileTypesList(), ", ")) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/upload.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal" 14 | ) 15 | 16 | type CredentialsResponse struct { 17 | AccessID string `json:"accessId"` 18 | AccessKey string `json:"accessKey"` 19 | SessionToken string `json:"sessionToken"` 20 | Expiration time.Time `json:"expiration"` 21 | } 22 | 23 | func (c *CredentialsResponse) AWSCredentials() aws.Credentials { 24 | return aws.Credentials{ 25 | AccessKeyID: c.AccessID, 26 | SecretAccessKey: c.AccessKey, 27 | SessionToken: c.SessionToken, 28 | Expires: c.Expiration, 29 | } 30 | } 31 | 32 | func (m *Marketplace) GetUploadCredentials() (*CredentialsResponse, error) { 33 | requestURL := MakeURL(m.GetAPIHost(), "/aws/credentials/generate", nil) 34 | response, err := m.Client.Get(requestURL) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if response.StatusCode != http.StatusOK { 40 | return nil, fmt.Errorf("failed to fetch credentials: %d", response.StatusCode) 41 | } 42 | 43 | credsResponse := &CredentialsResponse{} 44 | d := json.NewDecoder(response.Body) 45 | if m.strictDecoding { 46 | d.DisallowUnknownFields() 47 | } 48 | err = d.Decode(credsResponse) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return credsResponse, nil 54 | } 55 | 56 | func (m *Marketplace) GetUploader(orgID string) (internal.Uploader, error) { 57 | if m.uploader == nil { 58 | credentials, err := m.GetUploadCredentials() 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to get upload credentials: %w", err) 61 | } 62 | client := internal.NewS3Client(m.StorageRegion, credentials.AWSCredentials()) 63 | return internal.NewS3Uploader(m.StorageBucket, m.StorageRegion, orgID, client, m.Output), nil 64 | } 65 | return m.uploader, nil 66 | } 67 | 68 | func (m *Marketplace) SetUploader(uploader internal.Uploader) { 69 | m.uploader = uploader 70 | } 71 | -------------------------------------------------------------------------------- /internal/models/compatibility.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type VmwareProduct struct { 7 | Id int64 `json:"id,omitempty"` // will be deprecated 8 | ShortName string `json:"shortname"` 9 | DisplayName string `json:"displayname"` 10 | Version string `json:"version,omitempty"` 11 | HideVmwareReadyLogo bool `json:"hidevmwarereadylogo"` 12 | LastUpdated int64 `json:"lastupdated"` 13 | EntitlementLevel string `json:"entitlementlevel"` 14 | Tiers []*Tiers `json:"tiersList"` 15 | VsxId string `json:"vsxid"` 16 | } 17 | 18 | type CompatibilityMatrix struct { 19 | ProductId string `json:"productid"` 20 | VmwareProductId int32 `json:"vmwareproductid"` // will be deprecated 21 | VmwareProductName string `json:"vmwareproductname"` 22 | IsPrimary bool `json:"isprimary"` 23 | PartnerProd string `json:"partnerprod"` 24 | PartnerProdVer string `json:"partnerprodver"` 25 | ThirdPartyCompany string `json:"thirdpartycompany"` 26 | ThirdPartyProd string `json:"thirdpartyprod"` 27 | ThirdPartyVer string `json:"thirdpartyver"` 28 | SupportStatement string `json:"supportstatement"` 29 | SupportStatementExternalLink string `json:"supportstatementexternallink"` 30 | IsVmwareReady bool `json:"isvmwareready"` 31 | CompId string `json:"compid"` 32 | VmwareProductDetails *VmwareProduct `json:"vmwareproductdetails"` 33 | VsxProductId string `json:"vsxproductid"` 34 | VersionNumber string `json:"versionnumber"` 35 | IsPartnerReady bool `json:"ispartnerready"` 36 | IsNone bool `json:"isnone"` 37 | CertificationName string `json:"certificationname"` 38 | CertificationDetail *Certification `json:"certificationdetail"` 39 | } 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to vm-provisioning-plugin-for-slurm 2 | 3 | The Marketplace CLI team welcomes contributions from the community. Before you start working with the Marketplace CLI, please read our [Developer Certificate of Origin](https://cla.vmware.com/dco). All contributions to this repository must be signed as described on that page. Your signature certifies that you wrote the patch or have the right to pass it on as an open-source patch. 4 | 5 | ## Contribution Flow 6 | 7 | This is a rough outline of what a contributor's workflow looks like: 8 | 9 | - Create a topic branch from where you want to base your work 10 | - Make commits of logical units 11 | - Make sure your commit messages are in the proper format (see below) 12 | - Push your changes to a topic branch in your fork of the repository 13 | - Submit a pull request 14 | 15 | ### Updating pull requests 16 | 17 | If your PR fails to pass CI or needs changes based on code review, you'll most likely want to squash these changes into 18 | existing commits. 19 | 20 | If your pull request contains a single commit or your changes are related to the most recent commit, you can simply 21 | amend the commit. 22 | 23 | ```bash 24 | git add . 25 | git commit --amend 26 | git push --force-with-lease origin my-new-feature 27 | ``` 28 | 29 | If you need to squash changes into an earlier commit, you can use: 30 | 31 | ```bash 32 | git add . 33 | git commit --fixup 34 | git rebase -i --autosquash master 35 | git push --force-with-lease origin my-new-feature 36 | ``` 37 | 38 | Be sure to add a comment to the PR indicating your new changes are ready to review, as GitHub does not generate a 39 | notification when you git push. 40 | 41 | ### Code Style 42 | 43 | ### Formatting Commit Messages 44 | 45 | We follow the conventions on [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/). 46 | 47 | Be sure to include any related GitHub issue references in the commit message. See 48 | [GFM syntax](https://guides.github.com/features/mastering-markdown/#GitHub-flavored-markdown) for referencing issues 49 | and commits. 50 | 51 | ## Reporting Bugs and Creating Issues 52 | 53 | When opening a new issue, try to roughly follow the commit message format conventions above. 54 | -------------------------------------------------------------------------------- /cmd/output/encoded_output_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output_test 5 | 6 | import ( 7 | "errors" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | . "github.com/onsi/gomega/gbytes" 12 | "github.com/vmware-labs/marketplace-cli/v2/cmd/output" 13 | ) 14 | 15 | var _ = Describe("EncodedOutput", func() { 16 | var writer *Buffer 17 | 18 | BeforeEach(func() { 19 | writer = NewBuffer() 20 | }) 21 | 22 | Describe("Print", func() { 23 | 24 | Context("JSON", func() { 25 | It("prints the object as JSON", func() { 26 | encodedOutput := output.NewJSONOutput(writer) 27 | data := struct { 28 | A string `json:"a"` 29 | Awesome bool `json:"awesome"` 30 | MisMatchedKey []int `json:"numbers"` 31 | }{ 32 | A: "some data", 33 | Awesome: true, 34 | MisMatchedKey: []int{1, 2, 3}, 35 | } 36 | 37 | err := encodedOutput.Print(data) 38 | Expect(err).ToNot(HaveOccurred()) 39 | Expect(writer).To(Say("{\"a\":\"some data\",\"awesome\":true,\"numbers\":\\[1,2,3]\\}")) 40 | }) 41 | }) 42 | 43 | Context("YAML", func() { 44 | It("prints the object as YAML", func() { 45 | encodedOutput := output.NewYAMLOutput(writer) 46 | data := struct { 47 | A string `json:"a"` 48 | Awesome bool `json:"awesome"` 49 | MisMatchedKey []int `json:"numbers"` 50 | }{ 51 | A: "some data", 52 | Awesome: true, 53 | MisMatchedKey: []int{1, 2, 3}, 54 | } 55 | 56 | err := encodedOutput.Print(data) 57 | Expect(err).ToNot(HaveOccurred()) 58 | Expect(writer).To(Say("a: some data\nawesome: true\nmismatchedkey:\n - 1\n - 2\n - 3\n")) 59 | }) 60 | }) 61 | 62 | Context("Encoding fails", func() { 63 | It("returns an error", func() { 64 | encodedOutput := output.NewJSONOutput(writer) 65 | encodedOutput.Marshall = func(v interface{}) ([]byte, error) { 66 | return nil, errors.New("encoding went bad") 67 | } 68 | 69 | err := encodedOutput.Print([]string{"a", "b"}) 70 | Expect(err).To(HaveOccurred()) 71 | Expect(err.Error()).To(Equal("encoding went bad")) 72 | }) 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /cmd/output/encoded_output.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output 5 | 6 | import ( 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | 11 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 12 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | type Encoder func(v interface{}) ([]byte, error) 17 | 18 | type EncodedOutput struct { 19 | Marshall Encoder 20 | writer io.Writer 21 | } 22 | 23 | func NewJSONOutput(writer io.Writer) *EncodedOutput { 24 | return &EncodedOutput{ 25 | Marshall: json.Marshal, 26 | writer: writer, 27 | } 28 | } 29 | 30 | func NewYAMLOutput(writer io.Writer) *EncodedOutput { 31 | return &EncodedOutput{ 32 | Marshall: yaml.Marshal, 33 | writer: writer, 34 | } 35 | } 36 | 37 | func (o *EncodedOutput) Print(object interface{}) error { 38 | data, err := o.Marshall(object) 39 | if err != nil { 40 | return err 41 | } 42 | _, err = fmt.Fprintln(o.writer, string(data)) 43 | return err 44 | } 45 | 46 | // PrintHeader is a no-op for encoded output. This output only prints the data 47 | func (o *EncodedOutput) PrintHeader(message string) {} 48 | 49 | func (o *EncodedOutput) RenderProduct(product *models.Product, _ *models.Version) error { 50 | return o.Print(product) 51 | } 52 | 53 | func (o *EncodedOutput) RenderProducts(products []*models.Product) error { 54 | return o.Print(products) 55 | } 56 | 57 | func (o *EncodedOutput) RenderVersions(product *models.Product) error { 58 | return o.Print(product.AllVersions) 59 | } 60 | 61 | func (o *EncodedOutput) RenderChart(chart *models.ChartVersion) error { 62 | return o.Print(chart) 63 | } 64 | 65 | func (o *EncodedOutput) RenderCharts(charts []*models.ChartVersion) error { 66 | return o.Print(charts) 67 | } 68 | 69 | func (o *EncodedOutput) RenderContainerImages(images []*models.DockerVersionList) error { 70 | return o.Print(images) 71 | } 72 | 73 | func (o *EncodedOutput) RenderFile(file *models.ProductDeploymentFile) error { 74 | return o.Print(file) 75 | } 76 | 77 | func (o *EncodedOutput) RenderFiles(files []*models.ProductDeploymentFile) error { 78 | return o.Print(files) 79 | } 80 | 81 | func (o *EncodedOutput) RenderAssets(assets []*pkg.Asset) error { 82 | return o.Print(assets) 83 | } 84 | -------------------------------------------------------------------------------- /cmd/curl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "net/url" 11 | "os" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 15 | ) 16 | 17 | var ( 18 | method = "GET" 19 | payload string 20 | useAPIHost = false 21 | ) 22 | 23 | func init() { 24 | rootCmd.AddCommand(curlCmd) 25 | curlCmd.SetOut(curlCmd.OutOrStdout()) 26 | curlCmd.Flags().StringVarP(&method, "method", "X", method, "HTTP verb to use") 27 | curlCmd.Flags().StringVar(&payload, "payload", "", "JSON file containing the payload to send as a request body") 28 | curlCmd.Flags().BoolVar(&useAPIHost, "use-api-host", false, "Send request to the API host, rather than the gateway host") 29 | } 30 | 31 | var curlCmd = &cobra.Command{ 32 | Use: "curl [/api/v1/path]", 33 | Long: "Sends an HTTP request to the Marketplace", 34 | Example: fmt.Sprintf("%s curl /api/v1/products", AppName), 35 | Hidden: true, 36 | PreRunE: GetRefreshToken, 37 | Args: cobra.ExactArgs(1), 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | cmd.SilenceUsage = true 40 | 41 | inputURL, err := url.Parse(args[0]) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | host := Marketplace.GetHost() 47 | if useAPIHost { 48 | host = Marketplace.GetAPIHost() 49 | } 50 | 51 | requestURL := pkg.MakeURL(host, inputURL.Path, inputURL.Query()) 52 | 53 | cmd.PrintErrf("Sending %s request to %s...\n", method, requestURL.String()) 54 | 55 | var content io.Reader 56 | headers := map[string]string{} 57 | if payload != "" { 58 | payloadBytes, err := os.ReadFile(payload) 59 | if err != nil { 60 | return fmt.Errorf("failed to read payload file: %w", err) 61 | } 62 | content = bytes.NewReader(payloadBytes) 63 | headers["Content-Type"] = "application/json" 64 | } 65 | 66 | resp, err := Client.SendRequest(method, requestURL, headers, content) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | cmd.PrintErrf("Response status %d\n", resp.StatusCode) 72 | 73 | body, err := io.ReadAll(resp.Body) 74 | if err != nil { 75 | return fmt.Errorf("failed to read response: %w", err) 76 | } 77 | 78 | cmd.PrintErrln("Body:") 79 | cmd.Println(string(body)) 80 | return nil 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /pkg/container_image.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 10 | ) 11 | 12 | func (m *Marketplace) AttachLocalContainerImage(imageFile, image, tag, tagType, instructions string, product *models.Product, version *models.Version) (*models.Product, error) { 13 | if product.HasContainerImage(version.Number, image, tag) { 14 | return nil, fmt.Errorf("%s %s already has the image %s:%s", product.Slug, version.Number, image, tag) 15 | } 16 | 17 | uploader, err := m.GetUploader(product.PublisherDetails.OrgId) 18 | if err != nil { 19 | return nil, err 20 | } 21 | _, fileUrl, err := uploader.UploadProductFile(imageFile) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | product.PrepForUpdate() 27 | product.DockerLinkVersions = append(product.DockerLinkVersions, &models.DockerVersionList{ 28 | AppVersion: version.Number, 29 | DockerURLs: []*models.DockerURLDetails{ 30 | { 31 | Url: image, 32 | ImageTags: []*models.DockerImageTag{ 33 | { 34 | Tag: tag, 35 | Type: tagType, 36 | MarketplaceS3Link: fileUrl, 37 | }, 38 | }, 39 | DeploymentInstruction: instructions, 40 | DockerType: models.DockerTypeUpload, 41 | }, 42 | }, 43 | }) 44 | 45 | return m.PutProduct(product, version.IsNewVersion) 46 | } 47 | 48 | func (m *Marketplace) AttachPublicContainerImage(image, tag, tagType, instructions string, product *models.Product, version *models.Version) (*models.Product, error) { 49 | if product.HasContainerImage(version.Number, image, tag) { 50 | return nil, fmt.Errorf("%s %s already has the image %s:%s", product.Slug, version.Number, image, tag) 51 | } 52 | 53 | product.PrepForUpdate() 54 | product.DockerLinkVersions = append(product.DockerLinkVersions, &models.DockerVersionList{ 55 | AppVersion: version.Number, 56 | DockerURLs: []*models.DockerURLDetails{ 57 | { 58 | Url: image, 59 | ImageTags: []*models.DockerImageTag{ 60 | { 61 | Tag: tag, 62 | Type: tagType, 63 | }, 64 | }, 65 | DeploymentInstruction: instructions, 66 | DockerType: models.DockerTypeRegistry, 67 | }, 68 | }, 69 | }) 70 | 71 | return m.PutProduct(product, version.IsNewVersion) 72 | } 73 | -------------------------------------------------------------------------------- /internal/models/subscription.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type Subscription struct { 7 | ID int `json:"id"` 8 | ConsumerID string `json:"consumerid"` 9 | ProductID string `json:"productid"` 10 | ProductName string `json:"productname"` 11 | ProductLogo string `json:"productlogo"` 12 | PublisherID string `json:"publisherid"` 13 | PublisherName string `json:"publishername"` 14 | DeploymentStatus string `json:"deploymentstatus"` 15 | DeployedOn int `json:"deployedon"` 16 | SDDCID string `json:"sddcid"` 17 | FolderID string `json:"folderid"` 18 | ResourcePoolID string `json:"resourcepoolid"` 19 | DatastoreID string `json:"datastoreid"` 20 | PowerStatus string `json:"powerstatus"` 21 | PoweredOn int `json:"poweredon"` 22 | PowerOn bool `json:"poweron"` 23 | StartedOn int `json:"startedon"` 24 | VMName string `json:"vmname"` 25 | SourceOrgID string `json:"sourceorgid"` 26 | TargetOrgID string `json:"targetorgid"` 27 | SDDCLocation struct { 28 | Latitude int `json:"latitude"` 29 | Longitude int `json:"longitude"` 30 | } `json:"sddclocation"` 31 | ProductVersion string `json:"productversion"` 32 | EULAAccepted bool `json:"eulaaccepted"` 33 | DeploymentPlatform string `json:"deploymentplatform"` 34 | SubscriptionUUID string `json:"subscriptionuuid"` 35 | SubscriptionURL string `json:"subscriptionurl"` 36 | PlatformRepoName string `json:"platformreponame"` 37 | ContainerSubscription struct { 38 | AppVersion string `json:"appversion"` 39 | ChartVersion string `json:"chartversion"` 40 | DeploymentType string `json:"deploymenttype"` 41 | } `json:"containersubscription"` 42 | StatusText string `json:"statustext"` 43 | SourceOrgName string `json:"sourceorgname"` 44 | PublisherOrgDisplayName string `json:"publisherorgdisplayname"` 45 | UpdatesAvailable bool `json:"updatesavailable"` 46 | AutoUpdate bool `json:"autoupdate"` 47 | ContentCatalogID string `json:"contentcatalogid"` 48 | PublisherOrgID string `json:"publisherorgid"` 49 | SourceOrgDisplayName string `json:"sourceorgdisplayname"` 50 | IsAlreadySubscribed bool `json:"isalreadysubscribed"` 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Marketplace CLI 2 | 3 | `mkpcli` enables a command-line interface to the [VMware Marketplace](http://marketplace.cloud.vmware.com/) for consumers and publishes. 4 | 5 | To learn how to get started with the Marketplace CLI, [watch this video](https://youtu.be/0oxFjnJCEBE) or follow the steps below: 6 | 7 | To install, grab the latest prebuilt binary from the [Releases](https://github.com/vmware-labs/marketplace-cli/releases) page, or [build from source](#building). 8 | 9 | Features: 10 | * Get details about a product 11 | * Manage products in your org 12 | * Add versions 13 | * Attach container images 14 | * Attach Helm charts 15 | * Attach virtual machine files (ISOs & OVAs) 16 | * Download assets from a product 17 | 18 | ## Example 19 | ```bash 20 | $ export CSP_API_TOKEN=... 21 | $ mkpcli chart attach --product hyperspace-database --version 1.0.1 --create-version --chart ./hyperspace-database-1.0.1.tgz 22 | ``` 23 | 24 | For more information, see the [documentation page](docs/README.md) 25 | 26 | 27 | ## Authentication 28 | 29 | `mkpcli` requires an API Token from [VMware Cloud Services](https://console.cloud.vmware.com/csp/gateway/portal/#/user/tokens). See [this doc](./docs/Authentication.md) for more information. 30 | 31 | The token can be set via CLI flag (i.e. `--csp-api-token`) or environment variable (i.e. `CSP_API_TOKEN`). 32 | 33 | For more information, see [Authentication](docs/Authentication.md) 34 | 35 | ## Building 36 | 37 | Building from source is simple with our Makefile: 38 | 39 | ```bash 40 | $ make build 41 | ... 42 | go build -o build/mkpcli -ldflags "-X github.com/vmware-labs/marketplace-cli/v2/cmd.version=dev" ./main.go 43 | $ file build/mkpcli 44 | build/mkpcli: Mach-O 64-bit executable x86_64 45 | $ ./build/mkpcli 46 | mkpcli is a CLI interface for the VMware Marketplace, 47 | enabling users to view, get, and manage their Marketplace entries. 48 | ... 49 | ``` 50 | 51 | ## Developing 52 | 53 | If you would like to build and contribute to this project, please fork and make pull requests. 54 | 55 | If you are internal to VMware, and you would like to run commands against the [Marketplace staging service](https://stg.market.csp.vmware.com/), ensure your CSP API token [has access](docs/Authentication.md) to the Staging Marketplace and then set this environment variable: 56 | ``` 57 | export MARKETPLACE_ENV=staging 58 | ``` 59 | 60 | Please see our [Code of Conduct](CODE-OF-CONDUCT.md) and [Contributors guide](CONTRIBUTING.md). 61 | 62 | -------------------------------------------------------------------------------- /internal/models/chart.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type Repo struct { 7 | Name string `json:"name,omitempty"` 8 | Url string `json:"url,omitempty"` 9 | } 10 | 11 | type ChartVersion struct { 12 | Id string `json:"id,omitempty"` 13 | Version string `json:"version,omitempty"` 14 | AppVersion string `json:"appversion"` 15 | Details string `json:"details,omitempty"` 16 | Readme string `json:"readme,omitempty"` 17 | Repo *Repo `json:"repo,omitempty"` 18 | Values string `json:"values,omitempty"` 19 | Digest string `json:"digest,omitempty"` 20 | Status string `json:"status,omitempty"` 21 | TarUrl string `json:"tarurl"` // to use during imgprocessor update & download from UI/API 22 | IsExternalUrl bool `json:"isexternalurl"` 23 | HelmTarUrl string `json:"helmtarurl"` // to use during UI/API create & update product 24 | IsUpdatedInMarketplaceRegistry bool `json:"isupdatedinmarketplaceregistry"` 25 | ProcessingError string `json:"processingerror"` 26 | DownloadCount int64 `json:"downloadcount"` 27 | ValidationStatus string `json:"validationstatus"` 28 | InstallOptions string `json:"installoptions"` 29 | HashDigest string `json:"hashdigest,omitempty"` 30 | HashAlgorithm string `json:"hashalgo,omitempty"` 31 | Size int64 `json:"size,omitempty"` 32 | Comment string `json:"comment"` 33 | MarketplaceRegistryURL string `json:"marketplaceregistryurl"` 34 | } 35 | 36 | func (product *Product) GetChartsForVersion(version string) []*ChartVersion { 37 | var charts []*ChartVersion 38 | versionObj := product.GetVersion(version) 39 | 40 | if versionObj != nil { 41 | for _, chart := range product.ChartVersions { 42 | if chart.AppVersion == versionObj.Number { 43 | charts = append(charts, chart) 44 | } 45 | } 46 | } 47 | return charts 48 | } 49 | 50 | func (product *Product) GetChart(chartId string) *ChartVersion { 51 | for _, chart := range product.ChartVersions { 52 | if chart.Id == chartId { 53 | return chart 54 | } 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/models/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models_test 5 | 6 | import ( 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 10 | "github.com/vmware-labs/marketplace-cli/v2/test" 11 | ) 12 | 13 | var _ = Describe("NewVersion", func() { 14 | var product *models.Product 15 | BeforeEach(func() { 16 | product = test.CreateFakeProduct("", "My Product", "my-product", models.SolutionTypeOVA) 17 | test.AddVersions(product, "1.2.3") 18 | }) 19 | It("creates a new version object and adds it to the product", func() { 20 | version := product.NewVersion("5.5.5") 21 | Expect(version.Number).To(Equal("5.5.5")) 22 | Expect(version.IsNewVersion).To(BeTrue()) 23 | Expect(product.CurrentVersion).To(Equal("5.5.5")) 24 | }) 25 | 26 | Context("version already exists", func() { 27 | It("returns that version", func() { 28 | version := product.NewVersion("1.2.3") 29 | Expect(version.Number).To(Equal("1.2.3")) 30 | Expect(version.IsNewVersion).To(BeFalse()) 31 | }) 32 | }) 33 | }) 34 | 35 | var _ = Describe("GetVersion", func() { 36 | It("gets the version object from the version number", func() { 37 | product := test.CreateFakeProduct("", "My Product", "my-product", models.SolutionTypeOVA) 38 | test.AddVersions(product, "1.2.3") 39 | 40 | version := product.GetVersion("1.2.3") 41 | Expect(version).ToNot(BeNil()) 42 | Expect(version.Number).To(Equal("1.2.3")) 43 | }) 44 | 45 | Context("version does not exist", func() { 46 | It("returns nil", func() { 47 | product := test.CreateFakeProduct("", "My Product", "my-product", models.SolutionTypeOVA) 48 | test.AddVersions(product, "1.2.3") 49 | 50 | version := product.GetVersion("9.9.9") 51 | Expect(version).To(BeNil()) 52 | }) 53 | }) 54 | 55 | Context("Argument is empty", func() { 56 | It("returns the latest version", func() { 57 | product := test.CreateFakeProduct("", "My Product", "my-product", models.SolutionTypeOVA) 58 | test.AddVersions(product, "1.2.3", "2.3.4", "0.0.1") 59 | 60 | version := product.GetVersion("") 61 | Expect(version).ToNot(BeNil()) 62 | Expect(version.Number).To(Equal("2.3.4")) 63 | }) 64 | 65 | Context("The product has no versions", func() { 66 | It("returns nil", func() { 67 | product := test.CreateFakeProduct("", "My Product", "my-product", models.SolutionTypeOVA) 68 | 69 | version := product.GetVersion("") 70 | Expect(version).To(BeNil()) 71 | }) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /ci/tasks/test-container-image-product.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | --- 5 | platform: linux 6 | 7 | params: 8 | CSP_API_TOKEN: ((marketplace_api_token)) 9 | MARKETPLACE_ENV: 10 | PRODUCT_SLUG: 11 | TEST_IMAGE_REPO: 12 | TEST_IMAGE_TAG: 13 | TEST_IMAGE_TAG_FILE: 14 | TEST_IMAGE_FILE: 15 | MKPCLI_DEBUG: true 16 | MKPCLI_DEBUG_REQUEST_PAYLOADS: true 17 | 18 | inputs: 19 | - name: version 20 | - name: image 21 | optional: true 22 | 23 | run: 24 | path: bash 25 | args: 26 | - -exc 27 | - | 28 | set -ex 29 | VERSION=$(cat version/version) 30 | 31 | if [ -z "${TEST_IMAGE_TAG}" ]; then 32 | if [ ! -z "${TEST_IMAGE_TAG_FILE}" ]; then 33 | TEST_IMAGE_TAG=$(cat "${TEST_IMAGE_TAG_FILE}") 34 | else 35 | echo "TEST_IMAGE_TAG or TEST_IMAGE_TAG_FILE must be defined" 36 | exit 1 37 | fi 38 | fi 39 | 40 | if [ -z "${TEST_IMAGE_FILE}" ]; then 41 | # Attach a public container image 42 | mkpcli attach image --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --create-version \ 43 | --image-repository "${TEST_IMAGE_REPO}" --tag "${TEST_IMAGE_TAG}" --tag-type FIXED \ 44 | --instructions "docker run ${TEST_IMAGE_REPO}:${TEST_IMAGE_TAG}" 45 | else 46 | # Attach a local container image 47 | mkpcli attach image --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --create-version \ 48 | --image-repository "${TEST_IMAGE_REPO}" --file "${TEST_IMAGE_FILE}" \ 49 | --tag "${TEST_IMAGE_TAG}" --tag-type FIXED \ 50 | --instructions "docker run ${TEST_IMAGE_REPO}:${TEST_IMAGE_TAG}" 51 | fi 52 | 53 | # Get the list of images 54 | mkpcli product list-assets --product "${PRODUCT_SLUG}" --product-version "${VERSION}" 55 | 56 | # Wait until the image is downloadable 57 | asset=$(mkpcli product list-assets --type image --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq --arg name "${TEST_IMAGE_REPO}:${TEST_IMAGE_TAG}" '.[] | select(.displayname == $name)') 58 | while [ "$(echo "${asset}" | jq .downloadable)" == "false" ] 59 | do 60 | if [ "$(echo "${asset}" | jq .error)" != "null" ]; then 61 | exit 1 62 | fi 63 | 64 | sleep 30 65 | asset=$(mkpcli product list-assets --type image --product "${PRODUCT_SLUG}" --product-version "${VERSION}" --output json | jq --arg name "${TEST_IMAGE_REPO}:${TEST_IMAGE_TAG}" '.[] | select(.displayname == $name)') 66 | done 67 | -------------------------------------------------------------------------------- /test/features/debugging_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package features_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gbytes" 11 | . "github.com/vmware-labs/marketplace-cli/v2/test" 12 | ) 13 | 14 | var _ = Describe("Debugging", func() { 15 | steps := NewSteps() 16 | 17 | Scenario("No debugging", func() { 18 | steps.When("running mkpcli config") 19 | steps.Then("the command exits without error") 20 | steps.And("the printed configuration has debugging.enabled with the value false") 21 | steps.And("the printed configuration has debugging.print-request-payloads with the value false") 22 | }) 23 | 24 | Scenario("Debug flag", func() { 25 | steps.When("running mkpcli config --debug") 26 | steps.Then("the command exits without error") 27 | steps.And("the printed configuration has debugging.enabled with the value true") 28 | steps.And("the printed configuration has debugging.print-request-payloads with the value false") 29 | }) 30 | 31 | Scenario("Debug environment variable", func() { 32 | steps.Given("the environment variable MKPCLI_DEBUG is set to true") 33 | steps.When("running mkpcli config") 34 | steps.Then("the command exits without error") 35 | steps.And("the printed configuration has debugging.enabled with the value true") 36 | steps.And("the printed configuration has debugging.print-request-payloads with the value false") 37 | }) 38 | 39 | Scenario("Debug flag", func() { 40 | steps.When("running mkpcli config --debug --debug-request-payloads") 41 | steps.Then("the command exits without error") 42 | steps.And("the printed configuration has debugging.enabled with the value true") 43 | steps.And("the printed configuration has debugging.print-request-payloads with the value true") 44 | }) 45 | 46 | Scenario("Debug environment variable", func() { 47 | steps.Given("the environment variable MKPCLI_DEBUG is set to true") 48 | steps.And("the environment variable MKPCLI_DEBUG_REQUEST_PAYLOADS is set to true") 49 | steps.When("running mkpcli config") 50 | steps.Then("the command exits without error") 51 | steps.And("the printed configuration has debugging.enabled with the value true") 52 | steps.And("the printed configuration has debugging.print-request-payloads with the value true") 53 | }) 54 | 55 | steps.Define(func(define Definitions) { 56 | DefineCommonSteps(define) 57 | 58 | define.Then(`^the version is printed$`, func() { 59 | Eventually(CommandSession.Out).Should(Say("mkpcli version: 1.2.3")) 60 | }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /internal/models/product_deployment_file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | import ( 7 | "encoding/json" 8 | ) 9 | 10 | type ProductDeploymentFile struct { 11 | Id string `json:"id,omitempty"` // uuid 12 | Name string `json:"name,omitempty"` 13 | Url string `json:"url,omitempty"` 14 | ImageType string `json:"imagetype,omitempty"` 15 | Status string `json:"status,omitempty"` 16 | UploadedOn int32 `json:"uploadedon,omitempty"` 17 | UploadedBy string `json:"uploadedby,omitempty"` 18 | UpdatedOn int32 `json:"updatedon,omitempty"` 19 | UpdatedBy string `json:"updatedby,omitempty"` 20 | ItemJson string `json:"itemjson,omitempty"` 21 | Itemkey string `json:"itemkey,omitempty"` 22 | FileID string `json:"fileid,omitempty"` 23 | IsSubscribed bool `json:"issubscribed,omitempty"` 24 | AppVersion string `json:"appversion"` // Mandatory 25 | HashDigest string `json:"hashdigest"` 26 | IsThirdPartyUrl bool `json:"isthirdpartyurl,omitempty"` 27 | ThirdPartyUrl string `json:"thirdpartyurl,omitempty"` 28 | IsRedirectUrl bool `json:"isredirecturl,omitempty"` 29 | Comment string `json:"comment,omitempty"` 30 | HashAlgo string `json:"hashalgo"` 31 | DownloadCount int64 `json:"downloadcount,omitempty"` 32 | UniqueFileID string `json:"uniqueFileId,omitempty"` 33 | VersionList []string `json:"versionList"` 34 | Size int64 `json:"size,omitempty"` 35 | } 36 | 37 | func (product *Product) GetFilesForVersion(version string) []*ProductDeploymentFile { 38 | var files []*ProductDeploymentFile 39 | versionObj := product.GetVersion(version) 40 | 41 | if versionObj != nil { 42 | for _, file := range product.ProductDeploymentFiles { 43 | if file.AppVersion == versionObj.Number { 44 | files = append(files, file) 45 | } 46 | } 47 | } 48 | return files 49 | } 50 | 51 | func (product *Product) GetFile(fileId string) *ProductDeploymentFile { 52 | for _, file := range product.ProductDeploymentFiles { 53 | if file.FileID == fileId { 54 | return file 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | func (f *ProductDeploymentFile) CalculateSize() int64 { 61 | if f.Size > 0 { 62 | return f.Size 63 | } 64 | 65 | details := &ProductItemDetails{} 66 | err := json.Unmarshal([]byte(f.ItemJson), details) 67 | if err != nil { 68 | return 0 69 | } 70 | 71 | var size int64 = 0 72 | for _, file := range details.Files { 73 | size += int64(file.Size) 74 | } 75 | return size 76 | } 77 | -------------------------------------------------------------------------------- /pkg/marketplace.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "encoding/json" 8 | "io" 9 | "net/url" 10 | 11 | "github.com/vmware-labs/marketplace-cli/v2/internal" 12 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 13 | ) 14 | 15 | //go:generate counterfeiter . MarketplaceInterface 16 | type MarketplaceInterface interface { 17 | EnableStrictDecoding() 18 | DecodeJson(input io.Reader, output interface{}) error 19 | 20 | GetHost() string 21 | GetAPIHost() string 22 | GetUIHost() string 23 | 24 | ListProducts(filter *ListProductFilter) ([]*models.Product, error) 25 | GetProduct(slug string) (*models.Product, error) 26 | GetProductWithVersion(slug, version string) (*models.Product, *models.Version, error) 27 | PutProduct(product *models.Product, versionUpdate bool) (*models.Product, error) 28 | 29 | GetUploader(orgID string) (internal.Uploader, error) 30 | SetUploader(uploader internal.Uploader) 31 | 32 | Download(filename string, payload *DownloadRequestPayload) error 33 | 34 | DownloadChart(chartURL *url.URL) (*models.ChartVersion, error) 35 | AttachLocalChart(chartPath, instructions string, product *models.Product, version *models.Version) (*models.Product, error) 36 | AttachPublicChart(chartPath *url.URL, instructions string, product *models.Product, version *models.Version) (*models.Product, error) 37 | 38 | AttachLocalContainerImage(imageFile, image, tag, tagType, instructions string, product *models.Product, version *models.Version) (*models.Product, error) 39 | AttachPublicContainerImage(image, tag, tagType, instructions string, product *models.Product, version *models.Version) (*models.Product, error) 40 | 41 | AttachMetaFile(metafile, metafileType, metafileVersion string, product *models.Product, version *models.Version) (*models.Product, error) 42 | 43 | AttachOtherFile(file string, product *models.Product, version *models.Version) (*models.Product, error) 44 | 45 | UploadVM(vmFile string, product *models.Product, version *models.Version) (*models.Product, error) 46 | } 47 | 48 | type Marketplace struct { 49 | Host string 50 | APIHost string 51 | UIHost string 52 | StorageBucket string 53 | StorageRegion string 54 | Client HTTPClient 55 | Output io.Writer 56 | uploader internal.Uploader 57 | strictDecoding bool 58 | } 59 | 60 | func (m *Marketplace) EnableStrictDecoding() { 61 | m.strictDecoding = true 62 | } 63 | 64 | func (m *Marketplace) GetHost() string { 65 | return m.Host 66 | } 67 | 68 | func (m *Marketplace) GetAPIHost() string { 69 | return m.APIHost 70 | } 71 | 72 | func (m *Marketplace) GetUIHost() string { 73 | return m.UIHost 74 | } 75 | 76 | func (m *Marketplace) DecodeJson(input io.Reader, output interface{}) error { 77 | d := json.NewDecoder(input) 78 | if m.strictDecoding { 79 | d.DisallowUnknownFields() 80 | } 81 | return d.Decode(output) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/download.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/vmware-labs/marketplace-cli/v2/internal" 13 | ) 14 | 15 | type DownloadRequestPayload struct { 16 | ProductId string `json:"productid,omitempty"` 17 | AppVersion string `json:"appVersion,omitempty"` 18 | EulaAccepted bool `json:"eulaAccepted"` 19 | DockerlinkVersionID string `json:"dockerlinkVersionId,omitempty"` 20 | DockerUrlId string `json:"dockerUrlId,omitempty"` 21 | ImageTagId string `json:"imageTagId,omitempty"` 22 | DeploymentFileId string `json:"deploymentFileId,omitempty"` 23 | ChartVersion string `json:"chartVersion,omitempty"` 24 | IsAddonFile bool `json:"isAddonFile,omitempty"` 25 | AddonFileId string `json:"addonFileId,omitempty"` 26 | MetaFileID string `json:"metafileid,omitempty"` 27 | MetaFileObjectID string `json:"metafileobjectid,omitempty"` 28 | } 29 | 30 | type DownloadResponseBody struct { 31 | PreSignedURL string `json:"presignedurl"` 32 | Message string `json:"message"` 33 | StatusCode int `json:"statuscode"` 34 | } 35 | type DownloadResponse struct { 36 | Response *DownloadResponseBody `json:"response"` 37 | } 38 | 39 | func (m *Marketplace) Download(filename string, payload *DownloadRequestPayload) error { 40 | requestURL := MakeURL(m.GetHost(), fmt.Sprintf("/api/v1/products/%s/download", payload.ProductId), nil) 41 | resp, err := m.Client.PostJSON(requestURL, payload) 42 | if err != nil { 43 | return fmt.Errorf("failed to get download link: %w", err) 44 | } 45 | 46 | if resp.StatusCode != http.StatusOK { 47 | body, err := io.ReadAll(resp.Body) 48 | if err == nil { 49 | return fmt.Errorf("failed to fetch download link: %s\n%s", resp.Status, string(body)) 50 | } 51 | return fmt.Errorf("failed to fetch download link: %s", resp.Status) 52 | } 53 | 54 | downloadResponse := &DownloadResponse{} 55 | err = m.DecodeJson(resp.Body, downloadResponse) 56 | if err != nil { 57 | return fmt.Errorf("failed to parse response: %w", err) 58 | } 59 | 60 | return m.downloadFile(filename, downloadResponse.Response.PreSignedURL) 61 | } 62 | 63 | func (m *Marketplace) downloadFile(filename string, fileDownloadURL string) error { 64 | file, err := os.Create(filename) 65 | if err != nil { 66 | return fmt.Errorf("failed to create file for download: %w", err) 67 | } 68 | defer file.Close() 69 | 70 | req, err := http.NewRequest("GET", fileDownloadURL, nil) 71 | if err != nil { 72 | return fmt.Errorf("failed to create download file request: %w", err) 73 | } 74 | resp, err := m.Client.Do(req) 75 | if err != nil { 76 | return fmt.Errorf("failed to download file: %w", err) 77 | } 78 | defer resp.Body.Close() 79 | 80 | progressBar := internal.MakeProgressBar(fmt.Sprintf("Downloading %s", filename), resp.ContentLength, m.Output) 81 | _, err = io.Copy(progressBar.WrapWriter(file), resp.Body) 82 | if err != nil { 83 | return fmt.Errorf("failed to download file to disk: %w", err) 84 | } 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /cmd/output/human_output_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package output_test 5 | 6 | import ( 7 | "regexp" 8 | 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | . "github.com/onsi/gomega/gbytes" 12 | "github.com/vmware-labs/marketplace-cli/v2/cmd/output" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 14 | ) 15 | 16 | var _ = Describe("HumanOutput", func() { 17 | var ( 18 | humanOutput *output.HumanOutput 19 | writer *Buffer 20 | ) 21 | 22 | BeforeEach(func() { 23 | writer = NewBuffer() 24 | humanOutput = output.NewHumanOutput(writer, "marketplace.example.com") 25 | }) 26 | 27 | Describe("RenderProduct", func() { 28 | var product *models.Product 29 | 30 | BeforeEach(func() { 31 | product = &models.Product{ 32 | ProductId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 33 | Slug: "hyperspace-database", 34 | DisplayName: "Hyperspace Database", 35 | Status: "theoretical", 36 | SolutionType: "HELMCHARTS", 37 | PublisherDetails: &models.Publisher{ 38 | OrgDisplayName: "Astronomical Widgets", 39 | }, 40 | Description: &models.Description{ 41 | Summary: "A database that's out of this world", 42 | Description: "Connecting to a database should be:
  • Instant
  • Robust
  • Break the laws of causality

Our database does just that!", 43 | }, 44 | AllVersions: []*models.Version{ 45 | {Number: "1.0.0"}, 46 | {Number: "1.2.3"}, 47 | {Number: "0.0.1"}, 48 | }, 49 | } 50 | }) 51 | 52 | It("renders the product", func() { 53 | err := humanOutput.RenderProduct(product, &models.Version{Number: "1.0.0"}) 54 | Expect(err).ToNot(HaveOccurred()) 55 | Expect(writer).To(Say("Name: Hyperspace Database")) 56 | Expect(writer).To(Say("Publisher: Astronomical Widgets")) 57 | Expect(writer).To(Say("A database that's out of this world")) 58 | Expect(writer).To(Say(regexp.QuoteMeta("https://marketplace.example.com/services/details/hyperspace-database?slug=true"))) 59 | Expect(writer).To(Say("Product Details:")) 60 | 61 | Expect(writer).To(Say("PRODUCT ID")) 62 | Expect(writer).To(Say("SLUG")) 63 | Expect(writer).To(Say("TYPE")) 64 | Expect(writer).To(Say("LATEST VERSION")) 65 | Expect(writer).To(Say("STATUS")) 66 | 67 | Expect(writer).To(Say("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")) 68 | Expect(writer).To(Say("hyperspace-database")) 69 | Expect(writer).To(Say("HELMCHARTS")) 70 | Expect(writer).To(Say("1.2.3")) 71 | Expect(writer).To(Say("theoretical")) 72 | 73 | Expect(writer).To(Say("Assets for 1.0.0:")) 74 | Expect(writer).To(Say("None")) 75 | 76 | Expect(writer).To(Say("Description:")) 77 | Expect(writer).To(Say(regexp.QuoteMeta("Connecting to a database should be:\n\n* Instant\n* Robust\n* Break the laws of causality\n\nOur database does just that!"))) 78 | }) 79 | 80 | Context("Nil version given", func() { 81 | It("does not print the list of assets", func() { 82 | err := humanOutput.RenderProduct(product, nil) 83 | Expect(err).ToNot(HaveOccurred()) 84 | Expect(writer).ToNot(Say("Assets for")) 85 | }) 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /pkg/charts.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | 13 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 14 | helmChart "helm.sh/helm/v3/pkg/chart" 15 | "helm.sh/helm/v3/pkg/chart/loader" 16 | ) 17 | 18 | //go:generate counterfeiter . ChartLoaderFunc 19 | type ChartLoaderFunc func(name string) (*helmChart.Chart, error) 20 | 21 | var ChartLoader ChartLoaderFunc = loader.Load 22 | 23 | func LoadChart(chartPath string) (*models.ChartVersion, error) { 24 | chart, err := ChartLoader(chartPath) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to read chart: %w", err) 27 | } 28 | 29 | return &models.ChartVersion{ 30 | Version: chart.Metadata.Version, 31 | Repo: &models.Repo{ 32 | Name: chart.Name(), 33 | }, 34 | IsExternalUrl: false, 35 | }, nil 36 | } 37 | 38 | func (m *Marketplace) DownloadChart(chartURL *url.URL) (*models.ChartVersion, error) { 39 | req, err := http.NewRequest("GET", chartURL.String(), nil) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to make request to download chart: %w", err) 42 | } 43 | 44 | resp, err := m.Client.Do(req) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to download chart: %w", err) 47 | } 48 | 49 | chartFile, err := os.CreateTemp("", "chart-*.tgz") 50 | if err != nil { 51 | return nil, fmt.Errorf("failed to create temporary local chart: %w", err) 52 | } 53 | 54 | _, err = io.Copy(chartFile, resp.Body) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to save local chart: %w", err) 57 | } 58 | 59 | _ = resp.Body.Close() 60 | _ = chartFile.Close() 61 | 62 | chart, err := LoadChart(chartFile.Name()) 63 | if err != nil { 64 | return nil, err 65 | } 66 | chart.IsExternalUrl = true 67 | chart.HelmTarUrl = chartURL.String() 68 | return chart, nil 69 | } 70 | 71 | func (m *Marketplace) AttachLocalChart(chartPath, instructions string, product *models.Product, version *models.Version) (*models.Product, error) { 72 | chart, err := LoadChart(chartPath) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | uploader, err := m.GetUploader(product.PublisherDetails.OrgId) 78 | if err != nil { 79 | return nil, err 80 | } 81 | _, uploadedChartUrl, err := uploader.UploadProductFile(chartPath) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | chart.HelmTarUrl = uploadedChartUrl 87 | chart.AppVersion = version.Number 88 | chart.Readme = instructions 89 | 90 | product.PrepForUpdate() 91 | product.ChartVersions = []*models.ChartVersion{chart} 92 | return m.PutProduct(product, version.IsNewVersion) 93 | } 94 | 95 | func (m *Marketplace) AttachPublicChart(chartPath *url.URL, instructions string, product *models.Product, version *models.Version) (*models.Product, error) { 96 | chart, err := m.DownloadChart(chartPath) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | chart.AppVersion = version.Number 102 | chart.Readme = instructions 103 | 104 | product.PrepForUpdate() 105 | product.ChartVersions = []*models.ChartVersion{chart} 106 | return m.PutProduct(product, version.IsNewVersion) 107 | } 108 | -------------------------------------------------------------------------------- /test/common_steps.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package test 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | 12 | . "github.com/bunniesandbeatings/goerkin" 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | "github.com/onsi/gomega/gexec" 16 | "github.com/tidwall/gjson" 17 | ) 18 | 19 | var ( 20 | CommandSession *gexec.Session 21 | EnvVars []string 22 | mkpcliPath string 23 | ) 24 | 25 | var _ = BeforeSuite(func() { 26 | var err error 27 | mkpcliPath, err = gexec.Build( 28 | "github.com/vmware-labs/marketplace-cli/v2", 29 | "-ldflags", 30 | "-X github.com/vmware-labs/marketplace-cli/v2/cmd.version=1.2.3", 31 | ) 32 | Expect(err).NotTo(HaveOccurred()) 33 | }) 34 | 35 | var _ = AfterSuite(func() { 36 | gexec.CleanupBuildArtifacts() 37 | gexec.KillAndWait() 38 | }) 39 | 40 | func unsetEnvVars(envVars []string, varsToUnset []string) []string { 41 | var filtered []string 42 | for _, envVar := range envVars { 43 | needsToBeUnset := false 44 | for _, varToUnset := range varsToUnset { 45 | if strings.HasPrefix(envVar, varToUnset) { 46 | needsToBeUnset = true 47 | } 48 | } 49 | if !needsToBeUnset { 50 | filtered = append(filtered, envVar) 51 | } 52 | } 53 | return filtered 54 | } 55 | 56 | func DefineCommonSteps(define Definitions) { 57 | var ( 58 | unsetVars []string 59 | downloadedFile string 60 | ) 61 | 62 | BeforeEach(func() { 63 | EnvVars = []string{} 64 | }) 65 | 66 | define.Given(`^targeting the (.*) environment$`, func(environment string) { 67 | EnvVars = append(EnvVars, "MARKETPLACE_ENV="+environment) 68 | }) 69 | 70 | define.Given(`^the environment variable ([_A-Z]*) is set to (.*)$`, func(key, value string) { 71 | EnvVars = append(EnvVars, key+"="+value) 72 | }) 73 | 74 | define.Given(`^the environment variable ([_A-Z]*) is not set$`, func(key string) { 75 | unsetVars = append(unsetVars, key) 76 | }) 77 | 78 | define.When(`^running mkpcli (.*)$`, func(argString string) { 79 | command := exec.Command(mkpcliPath, strings.Split(argString, " ")...) 80 | command.Env = unsetEnvVars(append(os.Environ(), EnvVars...), unsetVars) 81 | 82 | var err error 83 | CommandSession, err = gexec.Start(command, GinkgoWriter, GinkgoWriter) 84 | Expect(err).ToNot(HaveOccurred()) 85 | }) 86 | 87 | define.Then(`^the command exits without error$`, func() { 88 | Eventually(CommandSession, 5*time.Minute).Should(gexec.Exit(0)) 89 | }) 90 | 91 | define.Then(`^the command exits with an error$`, func() { 92 | Eventually(CommandSession, 5*time.Minute).Should(gexec.Exit(1)) 93 | }) 94 | 95 | define.Then(`^the printed configuration has (.*) with the value (.*)$`, func(keyPath, expectedValue string) { 96 | configOutput := string(CommandSession.Wait().Out.Contents()) 97 | value := gjson.Get(configOutput, keyPath) 98 | Expect(value.String()).To(Equal(expectedValue)) 99 | }) 100 | 101 | define.Then(`^(.*) is downloaded$`, func(filename string) { 102 | downloadedFile = filename 103 | _, err := os.Stat(filename) 104 | Expect(err).ToNot(HaveOccurred()) 105 | }, func() { 106 | if downloadedFile != "" { 107 | Expect(os.Remove(downloadedFile)).To(Succeed()) 108 | downloadedFile = "" 109 | } 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /cmd/cmdfakes/fake_token_services_initializer.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package cmdfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/vmware-labs/marketplace-cli/v2/cmd" 8 | ) 9 | 10 | type FakeTokenServicesInitializer struct { 11 | Stub func(string) cmd.TokenServices 12 | mutex sync.RWMutex 13 | argsForCall []struct { 14 | arg1 string 15 | } 16 | returns struct { 17 | result1 cmd.TokenServices 18 | } 19 | returnsOnCall map[int]struct { 20 | result1 cmd.TokenServices 21 | } 22 | invocations map[string][][]interface{} 23 | invocationsMutex sync.RWMutex 24 | } 25 | 26 | func (fake *FakeTokenServicesInitializer) Spy(arg1 string) cmd.TokenServices { 27 | fake.mutex.Lock() 28 | ret, specificReturn := fake.returnsOnCall[len(fake.argsForCall)] 29 | fake.argsForCall = append(fake.argsForCall, struct { 30 | arg1 string 31 | }{arg1}) 32 | stub := fake.Stub 33 | returns := fake.returns 34 | fake.recordInvocation("TokenServicesInitializer", []interface{}{arg1}) 35 | fake.mutex.Unlock() 36 | if stub != nil { 37 | return stub(arg1) 38 | } 39 | if specificReturn { 40 | return ret.result1 41 | } 42 | return returns.result1 43 | } 44 | 45 | func (fake *FakeTokenServicesInitializer) CallCount() int { 46 | fake.mutex.RLock() 47 | defer fake.mutex.RUnlock() 48 | return len(fake.argsForCall) 49 | } 50 | 51 | func (fake *FakeTokenServicesInitializer) Calls(stub func(string) cmd.TokenServices) { 52 | fake.mutex.Lock() 53 | defer fake.mutex.Unlock() 54 | fake.Stub = stub 55 | } 56 | 57 | func (fake *FakeTokenServicesInitializer) ArgsForCall(i int) string { 58 | fake.mutex.RLock() 59 | defer fake.mutex.RUnlock() 60 | return fake.argsForCall[i].arg1 61 | } 62 | 63 | func (fake *FakeTokenServicesInitializer) Returns(result1 cmd.TokenServices) { 64 | fake.mutex.Lock() 65 | defer fake.mutex.Unlock() 66 | fake.Stub = nil 67 | fake.returns = struct { 68 | result1 cmd.TokenServices 69 | }{result1} 70 | } 71 | 72 | func (fake *FakeTokenServicesInitializer) ReturnsOnCall(i int, result1 cmd.TokenServices) { 73 | fake.mutex.Lock() 74 | defer fake.mutex.Unlock() 75 | fake.Stub = nil 76 | if fake.returnsOnCall == nil { 77 | fake.returnsOnCall = make(map[int]struct { 78 | result1 cmd.TokenServices 79 | }) 80 | } 81 | fake.returnsOnCall[i] = struct { 82 | result1 cmd.TokenServices 83 | }{result1} 84 | } 85 | 86 | func (fake *FakeTokenServicesInitializer) Invocations() map[string][][]interface{} { 87 | fake.invocationsMutex.RLock() 88 | defer fake.invocationsMutex.RUnlock() 89 | fake.mutex.RLock() 90 | defer fake.mutex.RUnlock() 91 | copiedInvocations := map[string][][]interface{}{} 92 | for key, value := range fake.invocations { 93 | copiedInvocations[key] = value 94 | } 95 | return copiedInvocations 96 | } 97 | 98 | func (fake *FakeTokenServicesInitializer) recordInvocation(key string, args []interface{}) { 99 | fake.invocationsMutex.Lock() 100 | defer fake.invocationsMutex.Unlock() 101 | if fake.invocations == nil { 102 | fake.invocations = map[string][][]interface{}{} 103 | } 104 | if fake.invocations[key] == nil { 105 | fake.invocations[key] = [][]interface{}{} 106 | } 107 | fake.invocations[key] = append(fake.invocations[key], args) 108 | } 109 | 110 | var _ cmd.TokenServicesInitializer = new(FakeTokenServicesInitializer).Spy 111 | -------------------------------------------------------------------------------- /internal/models/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | import ( 7 | "sort" 8 | "strings" 9 | 10 | "github.com/coreos/go-semver/semver" 11 | ) 12 | 13 | type Version struct { 14 | Number string `json:"versionnumber"` 15 | Details string `json:"versiondetails"` 16 | Status string `json:"status,omitempty"` 17 | Instructions string `json:"versioninstruction"` 18 | CreatedOn int32 `json:"createdon,omitempty"` 19 | HasLimitedAccess bool `json:"haslimitedaccess,omitempty"` 20 | Tag string `json:"tag,omitempty"` 21 | IsNewVersion bool `json:"-"` // This is only for the CLI when adding a version 22 | } 23 | 24 | func (product *Product) NewVersion(number string) *Version { 25 | if product.HasVersion(number) { 26 | return product.GetVersion(number) 27 | } 28 | version := &Version{ 29 | Number: number, 30 | IsNewVersion: true, 31 | } 32 | product.CurrentVersion = number 33 | product.Versions = append(product.Versions, version) 34 | return version 35 | } 36 | 37 | func (product *Product) GetVersion(version string) *Version { 38 | if version == "" { 39 | return product.GetLatestVersion() 40 | } 41 | 42 | for _, v := range product.AllVersions { 43 | if v.Number == version { 44 | return v 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | func (product *Product) GetLatestVersion() *Version { 51 | if len(product.AllVersions) == 0 { 52 | return nil 53 | } 54 | 55 | version, err := product.getLatestVersionSemver() 56 | if err != nil { 57 | version = product.getLatestVersionAlphanumeric() 58 | } 59 | 60 | return version 61 | } 62 | 63 | func (product *Product) getLatestVersionSemver() (*Version, error) { 64 | latestVersion := product.AllVersions[0] 65 | version, err := semver.NewVersion(latestVersion.Number) 66 | if err != nil { 67 | return nil, err 68 | } 69 | for _, v := range product.AllVersions { 70 | otherVersion, err := semver.NewVersion(v.Number) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if version.LessThan(*otherVersion) { 75 | latestVersion = v 76 | version = otherVersion 77 | } 78 | } 79 | 80 | return latestVersion, nil 81 | } 82 | 83 | func (product *Product) getLatestVersionAlphanumeric() *Version { 84 | latestVersion := product.AllVersions[0] 85 | for _, v := range product.AllVersions { 86 | if strings.Compare(latestVersion.Number, v.Number) < 0 { 87 | latestVersion = v 88 | } 89 | } 90 | return latestVersion 91 | } 92 | 93 | type Versions []*Version 94 | 95 | func (v Versions) Len() int { 96 | return len(v) 97 | } 98 | 99 | func (v Versions) Swap(i, j int) { 100 | v[i], v[j] = v[j], v[i] 101 | } 102 | 103 | func (v Versions) Less(i, j int) bool { 104 | return v[i].LessThan(*v[j]) 105 | } 106 | 107 | func (a Version) LessThan(b Version) bool { 108 | semverA, errA := semver.NewVersion(a.Number) 109 | semverB, errB := semver.NewVersion(b.Number) 110 | 111 | if errA != nil || errB != nil { 112 | return strings.Compare(a.Number, b.Number) < 0 113 | } 114 | 115 | return semverA.LessThan(*semverB) 116 | } 117 | 118 | func Sort(versions []*Version) { 119 | sort.Sort(sort.Reverse(Versions(versions))) 120 | } 121 | 122 | func (product *Product) HasVersion(version string) bool { 123 | return product.GetVersion(version) != nil 124 | } 125 | -------------------------------------------------------------------------------- /pkg/pkgfakes/fake_chart_loader_func.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package pkgfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 8 | "helm.sh/helm/v3/pkg/chart" 9 | ) 10 | 11 | type FakeChartLoaderFunc struct { 12 | Stub func(string) (*chart.Chart, error) 13 | mutex sync.RWMutex 14 | argsForCall []struct { 15 | arg1 string 16 | } 17 | returns struct { 18 | result1 *chart.Chart 19 | result2 error 20 | } 21 | returnsOnCall map[int]struct { 22 | result1 *chart.Chart 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeChartLoaderFunc) Spy(arg1 string) (*chart.Chart, error) { 30 | fake.mutex.Lock() 31 | ret, specificReturn := fake.returnsOnCall[len(fake.argsForCall)] 32 | fake.argsForCall = append(fake.argsForCall, struct { 33 | arg1 string 34 | }{arg1}) 35 | stub := fake.Stub 36 | returns := fake.returns 37 | fake.recordInvocation("ChartLoaderFunc", []interface{}{arg1}) 38 | fake.mutex.Unlock() 39 | if stub != nil { 40 | return stub(arg1) 41 | } 42 | if specificReturn { 43 | return ret.result1, ret.result2 44 | } 45 | return returns.result1, returns.result2 46 | } 47 | 48 | func (fake *FakeChartLoaderFunc) CallCount() int { 49 | fake.mutex.RLock() 50 | defer fake.mutex.RUnlock() 51 | return len(fake.argsForCall) 52 | } 53 | 54 | func (fake *FakeChartLoaderFunc) Calls(stub func(string) (*chart.Chart, error)) { 55 | fake.mutex.Lock() 56 | defer fake.mutex.Unlock() 57 | fake.Stub = stub 58 | } 59 | 60 | func (fake *FakeChartLoaderFunc) ArgsForCall(i int) string { 61 | fake.mutex.RLock() 62 | defer fake.mutex.RUnlock() 63 | return fake.argsForCall[i].arg1 64 | } 65 | 66 | func (fake *FakeChartLoaderFunc) Returns(result1 *chart.Chart, result2 error) { 67 | fake.mutex.Lock() 68 | defer fake.mutex.Unlock() 69 | fake.Stub = nil 70 | fake.returns = struct { 71 | result1 *chart.Chart 72 | result2 error 73 | }{result1, result2} 74 | } 75 | 76 | func (fake *FakeChartLoaderFunc) ReturnsOnCall(i int, result1 *chart.Chart, result2 error) { 77 | fake.mutex.Lock() 78 | defer fake.mutex.Unlock() 79 | fake.Stub = nil 80 | if fake.returnsOnCall == nil { 81 | fake.returnsOnCall = make(map[int]struct { 82 | result1 *chart.Chart 83 | result2 error 84 | }) 85 | } 86 | fake.returnsOnCall[i] = struct { 87 | result1 *chart.Chart 88 | result2 error 89 | }{result1, result2} 90 | } 91 | 92 | func (fake *FakeChartLoaderFunc) Invocations() map[string][][]interface{} { 93 | fake.invocationsMutex.RLock() 94 | defer fake.invocationsMutex.RUnlock() 95 | fake.mutex.RLock() 96 | defer fake.mutex.RUnlock() 97 | copiedInvocations := map[string][][]interface{}{} 98 | for key, value := range fake.invocations { 99 | copiedInvocations[key] = value 100 | } 101 | return copiedInvocations 102 | } 103 | 104 | func (fake *FakeChartLoaderFunc) recordInvocation(key string, args []interface{}) { 105 | fake.invocationsMutex.Lock() 106 | defer fake.invocationsMutex.Unlock() 107 | if fake.invocations == nil { 108 | fake.invocations = map[string][][]interface{}{} 109 | } 110 | if fake.invocations[key] == nil { 111 | fake.invocations[key] = [][]interface{}{} 112 | } 113 | fake.invocations[key] = append(fake.invocations[key], args) 114 | } 115 | 116 | var _ pkg.ChartLoaderFunc = new(FakeChartLoaderFunc).Spy 117 | -------------------------------------------------------------------------------- /internal/csp/token_services.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package csp 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | 14 | "github.com/golang-jwt/jwt" 15 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 16 | ) 17 | 18 | //go:generate counterfeiter . TokenParserFn 19 | type TokenParserFn func(tokenString string, claims jwt.Claims, keyFunc jwt.Keyfunc) (*jwt.Token, error) 20 | 21 | type TokenServices struct { 22 | CSPHost string 23 | Client pkg.HTTPClient 24 | TokenParser TokenParserFn 25 | } 26 | 27 | type RedeemResponse struct { 28 | AccessToken string `json:"access_token"` 29 | StatusCode int `json:"statusCode,omitempty"` 30 | ModuleCode int `json:"moduleCode,omitempty"` 31 | Metadata interface{} `json:"metadata,omitempty"` // I don't know what the appropriate type for this field is 32 | TraceID string `json:"traceId,omitempty"` 33 | CSPErrorCode string `json:"cspErrorCode,omitempty"` 34 | Message string `json:"message,omitempty"` 35 | RequestID string `json:"requestId,omitempty"` 36 | } 37 | 38 | func (csp *TokenServices) Redeem(refreshToken string) (*Claims, error) { 39 | requestURL := pkg.MakeURL(csp.CSPHost, "/csp/gateway/am/api/auth/api-tokens/authorize", nil) 40 | formData := url.Values{ 41 | "refresh_token": []string{refreshToken}, 42 | } 43 | 44 | resp, err := csp.Client.PostForm(requestURL, formData) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to redeem token: %w", err) 47 | } 48 | 49 | var body RedeemResponse 50 | err = json.NewDecoder(resp.Body).Decode(&body) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to parse redeem response: %w", err) 53 | } 54 | 55 | if resp.StatusCode == http.StatusBadRequest && strings.Contains(body.Message, "invalid_grant: Invalid refresh token") { 56 | return nil, errors.New("the CSP API token is invalid or expired") 57 | } 58 | 59 | if resp.StatusCode != http.StatusOK { 60 | return nil, fmt.Errorf("failed to exchange refresh token for access token: %s: %s", resp.Status, body.Message) 61 | } 62 | 63 | claims := &Claims{} 64 | token, err := csp.TokenParser(body.AccessToken, claims, csp.GetPublicKey) 65 | if err != nil { 66 | return nil, fmt.Errorf("invalid token returned from CSP: %w", err) 67 | } 68 | 69 | claims.Token = token.Raw 70 | return claims, nil 71 | } 72 | 73 | func (csp *TokenServices) GetPublicKey(*jwt.Token) (interface{}, error) { 74 | resp, err := csp.Client.Get(pkg.MakeURL(csp.CSPHost, "/csp/gateway/am/api/auth/token-public-key", nil)) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to get CSP Public key: %w", err) 77 | } 78 | 79 | m := map[string]interface{}{} 80 | err = json.NewDecoder(resp.Body).Decode(&m) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to parse CSP Public key: %w", err) 83 | } 84 | 85 | pemData, ok := m["value"] 86 | if !ok { 87 | return nil, fmt.Errorf("public key does not contain value") 88 | } 89 | 90 | s, ok := pemData.(string) 91 | if !ok { 92 | return nil, fmt.Errorf("public key value was not in the expected format") 93 | } 94 | 95 | publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(s)) 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to parse CSP public key: %w", err) 98 | } 99 | 100 | return publicKey, nil 101 | } 102 | -------------------------------------------------------------------------------- /pkg/pkgfakes/fake_perform_request_func.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package pkgfakes 3 | 4 | import ( 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 9 | ) 10 | 11 | type FakePerformRequestFunc struct { 12 | Stub func(*http.Request) (*http.Response, error) 13 | mutex sync.RWMutex 14 | argsForCall []struct { 15 | arg1 *http.Request 16 | } 17 | returns struct { 18 | result1 *http.Response 19 | result2 error 20 | } 21 | returnsOnCall map[int]struct { 22 | result1 *http.Response 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakePerformRequestFunc) Spy(arg1 *http.Request) (*http.Response, error) { 30 | fake.mutex.Lock() 31 | ret, specificReturn := fake.returnsOnCall[len(fake.argsForCall)] 32 | fake.argsForCall = append(fake.argsForCall, struct { 33 | arg1 *http.Request 34 | }{arg1}) 35 | stub := fake.Stub 36 | returns := fake.returns 37 | fake.recordInvocation("PerformRequestFunc", []interface{}{arg1}) 38 | fake.mutex.Unlock() 39 | if stub != nil { 40 | return stub(arg1) 41 | } 42 | if specificReturn { 43 | return ret.result1, ret.result2 44 | } 45 | return returns.result1, returns.result2 46 | } 47 | 48 | func (fake *FakePerformRequestFunc) CallCount() int { 49 | fake.mutex.RLock() 50 | defer fake.mutex.RUnlock() 51 | return len(fake.argsForCall) 52 | } 53 | 54 | func (fake *FakePerformRequestFunc) Calls(stub func(*http.Request) (*http.Response, error)) { 55 | fake.mutex.Lock() 56 | defer fake.mutex.Unlock() 57 | fake.Stub = stub 58 | } 59 | 60 | func (fake *FakePerformRequestFunc) ArgsForCall(i int) *http.Request { 61 | fake.mutex.RLock() 62 | defer fake.mutex.RUnlock() 63 | return fake.argsForCall[i].arg1 64 | } 65 | 66 | func (fake *FakePerformRequestFunc) Returns(result1 *http.Response, result2 error) { 67 | fake.mutex.Lock() 68 | defer fake.mutex.Unlock() 69 | fake.Stub = nil 70 | fake.returns = struct { 71 | result1 *http.Response 72 | result2 error 73 | }{result1, result2} 74 | } 75 | 76 | func (fake *FakePerformRequestFunc) ReturnsOnCall(i int, result1 *http.Response, result2 error) { 77 | fake.mutex.Lock() 78 | defer fake.mutex.Unlock() 79 | fake.Stub = nil 80 | if fake.returnsOnCall == nil { 81 | fake.returnsOnCall = make(map[int]struct { 82 | result1 *http.Response 83 | result2 error 84 | }) 85 | } 86 | fake.returnsOnCall[i] = struct { 87 | result1 *http.Response 88 | result2 error 89 | }{result1, result2} 90 | } 91 | 92 | func (fake *FakePerformRequestFunc) Invocations() map[string][][]interface{} { 93 | fake.invocationsMutex.RLock() 94 | defer fake.invocationsMutex.RUnlock() 95 | fake.mutex.RLock() 96 | defer fake.mutex.RUnlock() 97 | copiedInvocations := map[string][][]interface{}{} 98 | for key, value := range fake.invocations { 99 | copiedInvocations[key] = value 100 | } 101 | return copiedInvocations 102 | } 103 | 104 | func (fake *FakePerformRequestFunc) recordInvocation(key string, args []interface{}) { 105 | fake.invocationsMutex.Lock() 106 | defer fake.invocationsMutex.Unlock() 107 | if fake.invocations == nil { 108 | fake.invocations = map[string][][]interface{}{} 109 | } 110 | if fake.invocations[key] == nil { 111 | fake.invocations[key] = [][]interface{}{} 112 | } 113 | fake.invocations[key] = append(fake.invocations[key], args) 114 | } 115 | 116 | var _ pkg.PerformRequestFunc = new(FakePerformRequestFunc).Spy 117 | -------------------------------------------------------------------------------- /internal/internalfakes/fake_progress_bar_maker.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package internalfakes 3 | 4 | import ( 5 | "io" 6 | "sync" 7 | 8 | "github.com/vmware-labs/marketplace-cli/v2/internal" 9 | ) 10 | 11 | type FakeProgressBarMaker struct { 12 | Stub func(string, int64, io.Writer) internal.ProgressBar 13 | mutex sync.RWMutex 14 | argsForCall []struct { 15 | arg1 string 16 | arg2 int64 17 | arg3 io.Writer 18 | } 19 | returns struct { 20 | result1 internal.ProgressBar 21 | } 22 | returnsOnCall map[int]struct { 23 | result1 internal.ProgressBar 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeProgressBarMaker) Spy(arg1 string, arg2 int64, arg3 io.Writer) internal.ProgressBar { 30 | fake.mutex.Lock() 31 | ret, specificReturn := fake.returnsOnCall[len(fake.argsForCall)] 32 | fake.argsForCall = append(fake.argsForCall, struct { 33 | arg1 string 34 | arg2 int64 35 | arg3 io.Writer 36 | }{arg1, arg2, arg3}) 37 | stub := fake.Stub 38 | returns := fake.returns 39 | fake.recordInvocation("ProgressBarMaker", []interface{}{arg1, arg2, arg3}) 40 | fake.mutex.Unlock() 41 | if stub != nil { 42 | return stub(arg1, arg2, arg3) 43 | } 44 | if specificReturn { 45 | return ret.result1 46 | } 47 | return returns.result1 48 | } 49 | 50 | func (fake *FakeProgressBarMaker) CallCount() int { 51 | fake.mutex.RLock() 52 | defer fake.mutex.RUnlock() 53 | return len(fake.argsForCall) 54 | } 55 | 56 | func (fake *FakeProgressBarMaker) Calls(stub func(string, int64, io.Writer) internal.ProgressBar) { 57 | fake.mutex.Lock() 58 | defer fake.mutex.Unlock() 59 | fake.Stub = stub 60 | } 61 | 62 | func (fake *FakeProgressBarMaker) ArgsForCall(i int) (string, int64, io.Writer) { 63 | fake.mutex.RLock() 64 | defer fake.mutex.RUnlock() 65 | return fake.argsForCall[i].arg1, fake.argsForCall[i].arg2, fake.argsForCall[i].arg3 66 | } 67 | 68 | func (fake *FakeProgressBarMaker) Returns(result1 internal.ProgressBar) { 69 | fake.mutex.Lock() 70 | defer fake.mutex.Unlock() 71 | fake.Stub = nil 72 | fake.returns = struct { 73 | result1 internal.ProgressBar 74 | }{result1} 75 | } 76 | 77 | func (fake *FakeProgressBarMaker) ReturnsOnCall(i int, result1 internal.ProgressBar) { 78 | fake.mutex.Lock() 79 | defer fake.mutex.Unlock() 80 | fake.Stub = nil 81 | if fake.returnsOnCall == nil { 82 | fake.returnsOnCall = make(map[int]struct { 83 | result1 internal.ProgressBar 84 | }) 85 | } 86 | fake.returnsOnCall[i] = struct { 87 | result1 internal.ProgressBar 88 | }{result1} 89 | } 90 | 91 | func (fake *FakeProgressBarMaker) Invocations() map[string][][]interface{} { 92 | fake.invocationsMutex.RLock() 93 | defer fake.invocationsMutex.RUnlock() 94 | fake.mutex.RLock() 95 | defer fake.mutex.RUnlock() 96 | copiedInvocations := map[string][][]interface{}{} 97 | for key, value := range fake.invocations { 98 | copiedInvocations[key] = value 99 | } 100 | return copiedInvocations 101 | } 102 | 103 | func (fake *FakeProgressBarMaker) recordInvocation(key string, args []interface{}) { 104 | fake.invocationsMutex.Lock() 105 | defer fake.invocationsMutex.Unlock() 106 | if fake.invocations == nil { 107 | fake.invocations = map[string][][]interface{}{} 108 | } 109 | if fake.invocations[key] == nil { 110 | fake.invocations[key] = [][]interface{}{} 111 | } 112 | fake.invocations[key] = append(fake.invocations[key], args) 113 | } 114 | 115 | var _ internal.ProgressBarMaker = new(FakeProgressBarMaker).Spy 116 | -------------------------------------------------------------------------------- /cmd/cmdfakes/fake_token_services.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package cmdfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/vmware-labs/marketplace-cli/v2/cmd" 8 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 9 | ) 10 | 11 | type FakeTokenServices struct { 12 | RedeemStub func(string) (*csp.Claims, error) 13 | redeemMutex sync.RWMutex 14 | redeemArgsForCall []struct { 15 | arg1 string 16 | } 17 | redeemReturns struct { 18 | result1 *csp.Claims 19 | result2 error 20 | } 21 | redeemReturnsOnCall map[int]struct { 22 | result1 *csp.Claims 23 | result2 error 24 | } 25 | invocations map[string][][]interface{} 26 | invocationsMutex sync.RWMutex 27 | } 28 | 29 | func (fake *FakeTokenServices) Redeem(arg1 string) (*csp.Claims, error) { 30 | fake.redeemMutex.Lock() 31 | ret, specificReturn := fake.redeemReturnsOnCall[len(fake.redeemArgsForCall)] 32 | fake.redeemArgsForCall = append(fake.redeemArgsForCall, struct { 33 | arg1 string 34 | }{arg1}) 35 | stub := fake.RedeemStub 36 | fakeReturns := fake.redeemReturns 37 | fake.recordInvocation("Redeem", []interface{}{arg1}) 38 | fake.redeemMutex.Unlock() 39 | if stub != nil { 40 | return stub(arg1) 41 | } 42 | if specificReturn { 43 | return ret.result1, ret.result2 44 | } 45 | return fakeReturns.result1, fakeReturns.result2 46 | } 47 | 48 | func (fake *FakeTokenServices) RedeemCallCount() int { 49 | fake.redeemMutex.RLock() 50 | defer fake.redeemMutex.RUnlock() 51 | return len(fake.redeemArgsForCall) 52 | } 53 | 54 | func (fake *FakeTokenServices) RedeemCalls(stub func(string) (*csp.Claims, error)) { 55 | fake.redeemMutex.Lock() 56 | defer fake.redeemMutex.Unlock() 57 | fake.RedeemStub = stub 58 | } 59 | 60 | func (fake *FakeTokenServices) RedeemArgsForCall(i int) string { 61 | fake.redeemMutex.RLock() 62 | defer fake.redeemMutex.RUnlock() 63 | argsForCall := fake.redeemArgsForCall[i] 64 | return argsForCall.arg1 65 | } 66 | 67 | func (fake *FakeTokenServices) RedeemReturns(result1 *csp.Claims, result2 error) { 68 | fake.redeemMutex.Lock() 69 | defer fake.redeemMutex.Unlock() 70 | fake.RedeemStub = nil 71 | fake.redeemReturns = struct { 72 | result1 *csp.Claims 73 | result2 error 74 | }{result1, result2} 75 | } 76 | 77 | func (fake *FakeTokenServices) RedeemReturnsOnCall(i int, result1 *csp.Claims, result2 error) { 78 | fake.redeemMutex.Lock() 79 | defer fake.redeemMutex.Unlock() 80 | fake.RedeemStub = nil 81 | if fake.redeemReturnsOnCall == nil { 82 | fake.redeemReturnsOnCall = make(map[int]struct { 83 | result1 *csp.Claims 84 | result2 error 85 | }) 86 | } 87 | fake.redeemReturnsOnCall[i] = struct { 88 | result1 *csp.Claims 89 | result2 error 90 | }{result1, result2} 91 | } 92 | 93 | func (fake *FakeTokenServices) Invocations() map[string][][]interface{} { 94 | fake.invocationsMutex.RLock() 95 | defer fake.invocationsMutex.RUnlock() 96 | fake.redeemMutex.RLock() 97 | defer fake.redeemMutex.RUnlock() 98 | copiedInvocations := map[string][][]interface{}{} 99 | for key, value := range fake.invocations { 100 | copiedInvocations[key] = value 101 | } 102 | return copiedInvocations 103 | } 104 | 105 | func (fake *FakeTokenServices) recordInvocation(key string, args []interface{}) { 106 | fake.invocationsMutex.Lock() 107 | defer fake.invocationsMutex.Unlock() 108 | if fake.invocations == nil { 109 | fake.invocations = map[string][][]interface{}{} 110 | } 111 | if fake.invocations[key] == nil { 112 | fake.invocations[key] = [][]interface{}{} 113 | } 114 | fake.invocations[key] = append(fake.invocations[key], args) 115 | } 116 | 117 | var _ cmd.TokenServices = new(FakeTokenServices) 118 | -------------------------------------------------------------------------------- /internal/csp/cspfakes/fake_token_parser_fn.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package cspfakes 3 | 4 | import ( 5 | "sync" 6 | 7 | "github.com/golang-jwt/jwt" 8 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 9 | ) 10 | 11 | type FakeTokenParserFn struct { 12 | Stub func(string, jwt.Claims, jwt.Keyfunc) (*jwt.Token, error) 13 | mutex sync.RWMutex 14 | argsForCall []struct { 15 | arg1 string 16 | arg2 jwt.Claims 17 | arg3 jwt.Keyfunc 18 | } 19 | returns struct { 20 | result1 *jwt.Token 21 | result2 error 22 | } 23 | returnsOnCall map[int]struct { 24 | result1 *jwt.Token 25 | result2 error 26 | } 27 | invocations map[string][][]interface{} 28 | invocationsMutex sync.RWMutex 29 | } 30 | 31 | func (fake *FakeTokenParserFn) Spy(arg1 string, arg2 jwt.Claims, arg3 jwt.Keyfunc) (*jwt.Token, error) { 32 | fake.mutex.Lock() 33 | ret, specificReturn := fake.returnsOnCall[len(fake.argsForCall)] 34 | fake.argsForCall = append(fake.argsForCall, struct { 35 | arg1 string 36 | arg2 jwt.Claims 37 | arg3 jwt.Keyfunc 38 | }{arg1, arg2, arg3}) 39 | stub := fake.Stub 40 | returns := fake.returns 41 | fake.recordInvocation("TokenParserFn", []interface{}{arg1, arg2, arg3}) 42 | fake.mutex.Unlock() 43 | if stub != nil { 44 | return stub(arg1, arg2, arg3) 45 | } 46 | if specificReturn { 47 | return ret.result1, ret.result2 48 | } 49 | return returns.result1, returns.result2 50 | } 51 | 52 | func (fake *FakeTokenParserFn) CallCount() int { 53 | fake.mutex.RLock() 54 | defer fake.mutex.RUnlock() 55 | return len(fake.argsForCall) 56 | } 57 | 58 | func (fake *FakeTokenParserFn) Calls(stub func(string, jwt.Claims, jwt.Keyfunc) (*jwt.Token, error)) { 59 | fake.mutex.Lock() 60 | defer fake.mutex.Unlock() 61 | fake.Stub = stub 62 | } 63 | 64 | func (fake *FakeTokenParserFn) ArgsForCall(i int) (string, jwt.Claims, jwt.Keyfunc) { 65 | fake.mutex.RLock() 66 | defer fake.mutex.RUnlock() 67 | return fake.argsForCall[i].arg1, fake.argsForCall[i].arg2, fake.argsForCall[i].arg3 68 | } 69 | 70 | func (fake *FakeTokenParserFn) Returns(result1 *jwt.Token, result2 error) { 71 | fake.mutex.Lock() 72 | defer fake.mutex.Unlock() 73 | fake.Stub = nil 74 | fake.returns = struct { 75 | result1 *jwt.Token 76 | result2 error 77 | }{result1, result2} 78 | } 79 | 80 | func (fake *FakeTokenParserFn) ReturnsOnCall(i int, result1 *jwt.Token, result2 error) { 81 | fake.mutex.Lock() 82 | defer fake.mutex.Unlock() 83 | fake.Stub = nil 84 | if fake.returnsOnCall == nil { 85 | fake.returnsOnCall = make(map[int]struct { 86 | result1 *jwt.Token 87 | result2 error 88 | }) 89 | } 90 | fake.returnsOnCall[i] = struct { 91 | result1 *jwt.Token 92 | result2 error 93 | }{result1, result2} 94 | } 95 | 96 | func (fake *FakeTokenParserFn) Invocations() map[string][][]interface{} { 97 | fake.invocationsMutex.RLock() 98 | defer fake.invocationsMutex.RUnlock() 99 | fake.mutex.RLock() 100 | defer fake.mutex.RUnlock() 101 | copiedInvocations := map[string][][]interface{}{} 102 | for key, value := range fake.invocations { 103 | copiedInvocations[key] = value 104 | } 105 | return copiedInvocations 106 | } 107 | 108 | func (fake *FakeTokenParserFn) recordInvocation(key string, args []interface{}) { 109 | fake.invocationsMutex.Lock() 110 | defer fake.invocationsMutex.Unlock() 111 | if fake.invocations == nil { 112 | fake.invocations = map[string][][]interface{}{} 113 | } 114 | if fake.invocations[key] == nil { 115 | fake.invocations[key] = [][]interface{}{} 116 | } 117 | fake.invocations[key] = append(fake.invocations[key], args) 118 | } 119 | 120 | var _ csp.TokenParserFn = new(FakeTokenParserFn).Spy 121 | -------------------------------------------------------------------------------- /test/features/environment_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package features_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/vmware-labs/marketplace-cli/v2/test" 10 | ) 11 | 12 | var _ = Describe("Marketplace environment variables", func() { 13 | steps := NewSteps() 14 | 15 | Scenario("Production environment", func() { 16 | steps.Given("targeting the production environment") 17 | steps.When("running mkpcli config") 18 | steps.Then("the command exits without error") 19 | steps.And("the printed configuration has marketplace.host with the value gtw.marketplace.cloud.vmware.com") 20 | steps.And("the printed configuration has marketplace.api-host with the value api.marketplace.cloud.vmware.com") 21 | steps.And("the printed configuration has marketplace.ui-host with the value marketplace.cloud.vmware.com") 22 | steps.And("the printed configuration has marketplace.storage.bucket with the value cspmarketplaceprd") 23 | steps.And("the printed configuration has marketplace.storage.region with the value us-west-2") 24 | }) 25 | 26 | Scenario("Staging environment", func() { 27 | steps.Given("targeting the staging environment") 28 | steps.When("running mkpcli config") 29 | steps.Then("the command exits without error") 30 | steps.And("the printed configuration has marketplace.host with the value gtwstg.market.csp.vmware.com") 31 | steps.And("the printed configuration has marketplace.api-host with the value apistg.market.csp.vmware.com") 32 | steps.And("the printed configuration has marketplace.ui-host with the value stg.market.csp.vmware.com") 33 | steps.And("the printed configuration has marketplace.storage.bucket with the value cspmarketplacestage") 34 | steps.And("the printed configuration has marketplace.storage.region with the value us-east-2") 35 | }) 36 | 37 | Scenario("Overriding marketplace gateway host", func() { 38 | steps.Given("the environment variable MKPCLI_HOST is set to gtw.marketplace.example.com") 39 | steps.When("running mkpcli config") 40 | steps.Then("the command exits without error") 41 | steps.And("the printed configuration has marketplace.host with the value gtw.marketplace.example.com") 42 | }) 43 | 44 | Scenario("Overriding marketplace API host", func() { 45 | steps.Given("the environment variable MKPCLI_API_HOST is set to api.marketplace.example.com") 46 | steps.When("running mkpcli config") 47 | steps.Then("the command exits without error") 48 | steps.And("the printed configuration has marketplace.api-host with the value api.marketplace.example.com") 49 | }) 50 | 51 | Scenario("Overriding marketplace UI host", func() { 52 | steps.Given("the environment variable MKPCLI_UI_HOST is set to marketplace.example.com") 53 | steps.When("running mkpcli config") 54 | steps.Then("the command exits without error") 55 | steps.And("the printed configuration has marketplace.ui-host with the value marketplace.example.com") 56 | }) 57 | 58 | Scenario("Overriding marketplace storage bucket", func() { 59 | steps.Given("the environment variable MKPCLI_STORAGE_BUCKET is set to exmaplestoragebucket") 60 | steps.When("running mkpcli config") 61 | steps.Then("the command exits without error") 62 | steps.And("the printed configuration has marketplace.storage.bucket with the value exmaplestoragebucket") 63 | }) 64 | 65 | Scenario("Overriding marketplace storage region", func() { 66 | steps.Given("the environment variable MKPCLI_STORAGE_REGION is set to us-central-1") 67 | steps.When("running mkpcli config") 68 | steps.Then("the command exits without error") 69 | steps.And("the printed configuration has marketplace.storage.region with the value us-central-1") 70 | }) 71 | 72 | steps.Define(func(define Definitions) { 73 | DefineCommonSteps(define) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/external/product_list_assets_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gbytes" 11 | . "github.com/vmware-labs/marketplace-cli/v2/test" 12 | ) 13 | 14 | var _ = Describe("product list-assets", func() { 15 | steps := NewSteps() 16 | 17 | Scenario("Listing charts", func() { 18 | steps.Given("targeting the production environment") 19 | steps.When("running mkpcli product list-assets --type chart --product " + ChartProductSlug + " --product-version " + ChartProductVersion) 20 | steps.Then("the command exits without error") 21 | steps.And("the table of charts is printed") 22 | }) 23 | 24 | Scenario("Listing container images", func() { 25 | steps.Given("targeting the production environment") 26 | steps.When("running mkpcli product list-assets --type image --product " + ContainerImageProductSlug + " --product-version " + ContainerImageProductVersion) 27 | steps.Then("the command exits without error") 28 | steps.And("the table of container images is printed") 29 | }) 30 | 31 | Scenario("Listing virtual machine files", func() { 32 | steps.Given("targeting the production environment") 33 | steps.When("running mkpcli product list-assets --type vm --product " + VMProductSlug + " --product-version " + VMProductVersion) 34 | steps.Then("the command exits without error") 35 | steps.And("the table of virtual machine files is printed") 36 | }) 37 | 38 | steps.Define(func(define Definitions) { 39 | DefineCommonSteps(define) 40 | 41 | define.Then(`^the table of charts is printed$`, func() { 42 | Eventually(CommandSession.Out).Should(Say("Chart assets for NGINX Open Source Helm Chart packaged by Bitnami 1.21.1_0:")) 43 | Eventually(CommandSession.Out).Should(Say("NAME")) 44 | Eventually(CommandSession.Out).Should(Say("TYPE")) 45 | Eventually(CommandSession.Out).Should(Say("VERSION")) 46 | Eventually(CommandSession.Out).Should(Say("SIZE")) 47 | Eventually(CommandSession.Out).Should(Say("DOWNLOADS")) 48 | 49 | Eventually(CommandSession.Out).Should(Say("https://charts.bitnami.com/bitnami/nginx-9.3.6.tgz")) 50 | Eventually(CommandSession.Out).Should(Say("Chart")) 51 | Eventually(CommandSession.Out).Should(Say("9.3.6")) 52 | Eventually(CommandSession.Out).Should(Say("0 B")) 53 | }) 54 | 55 | define.Then(`^the table of container images is printed$`, func() { 56 | Eventually(CommandSession.Out).Should(Say("Container Image assets for Cloudian S3 compatible object storage for Tanzu 1.2.1:")) 57 | Eventually(CommandSession.Out).Should(Say("NAME")) 58 | Eventually(CommandSession.Out).Should(Say("TYPE")) 59 | Eventually(CommandSession.Out).Should(Say("VERSION")) 60 | Eventually(CommandSession.Out).Should(Say("SIZE")) 61 | Eventually(CommandSession.Out).Should(Say("DOWNLOADS")) 62 | 63 | Eventually(CommandSession.Out).Should(Say("quay.io/cloudian/hyperstorec:v1.3.0rc1")) 64 | Eventually(CommandSession.Out).Should(Say("Container Image")) 65 | Eventually(CommandSession.Out).Should(Say("v1.3.0rc1")) 66 | Eventually(CommandSession.Out).Should(Say("2.38 GB")) 67 | }) 68 | 69 | define.Then(`^the table of virtual machine files is printed$`, func() { 70 | Eventually(CommandSession.Out).Should(Say("VM assets for NGINX Open Source Virtual Appliance packaged by Bitnami 1.21.0_1:")) 71 | Eventually(CommandSession.Out).Should(Say("NAME")) 72 | Eventually(CommandSession.Out).Should(Say("TYPE")) 73 | Eventually(CommandSession.Out).Should(Say("VERSION")) 74 | Eventually(CommandSession.Out).Should(Say("SIZE")) 75 | Eventually(CommandSession.Out).Should(Say("DOWNLOADS")) 76 | 77 | Eventually(CommandSession.Out).Should(Say("nginxstack")) 78 | Eventually(CommandSession.Out).Should(Say("VM")) 79 | Eventually(CommandSession.Out).Should(Say("1.28 GB")) 80 | }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /internal/models/container_image.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | const ( 7 | ImageTagTypeFixed = "FIXED" 8 | ImageTagTypeFloating = "FLOATING" 9 | ) 10 | 11 | type DockerImageTag struct { 12 | ID string `json:"id,omitempty"` 13 | Tag string `json:"tag"` 14 | Type string `json:"type"` 15 | IsUpdatedInMarketplaceRegistry bool `json:"isupdatedinmarketplaceregistry"` 16 | MarketplaceS3Link string `json:"marketplaces3link,omitempty"` 17 | AppCheckReportLink string `json:"appcheckreportlink,omitempty"` 18 | AppCheckSummaryPdfLink string `json:"appchecksummarypdflink,omitempty"` 19 | S3TarBackupUrl string `json:"s3tarbackupurl,omitempty"` 20 | ProcessingError string `json:"processingerror,omitempty"` 21 | DownloadCount int64 `json:"downloadcount,omitempty"` 22 | DownloadURL string `json:"downloadurl,omitempty"` 23 | HashAlgo string `json:"hashalgo,omitempty"` 24 | HashDigest string `json:"hashdigest,omitempty"` 25 | Size int64 `json:"size,omitempty"` 26 | } 27 | 28 | type DockerURLDetails struct { 29 | Key string `json:"key,omitempty"` 30 | Url string `json:"url,omitempty"` 31 | MarketplaceUpdatedUrl string `json:"marketplaceupdatedurl,omitempty"` 32 | ImageTags []*DockerImageTag `json:"imagetagsList"` 33 | ImageTagsAsJson string `json:"imagetagsasjson"` 34 | DockerType string `json:"dockertype,omitempty"` 35 | ID string `json:"id,omitempty"` 36 | DeploymentInstruction string `json:"deploymentinstruction"` 37 | Name string `json:"name"` 38 | IsMultiArch bool `json:"ismultiarch"` 39 | } 40 | 41 | const ( 42 | DockerTypeRegistry = "registry" 43 | DockerTypeUpload = "upload" 44 | ) 45 | 46 | func (d *DockerURLDetails) GetTag(tagName string) *DockerImageTag { 47 | for _, tag := range d.ImageTags { 48 | if tag.Tag == tagName { 49 | return tag 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func (d *DockerURLDetails) HasTag(tagName string) bool { 56 | return d.GetTag(tagName) != nil 57 | } 58 | 59 | type DockerVersionList struct { 60 | ID string `json:"id,omitempty"` 61 | AppVersion string `json:"appversion"` 62 | DeploymentInstruction string `json:"deploymentinstruction"` 63 | DockerURLs []*DockerURLDetails `json:"dockerurlsList"` 64 | Status string `json:"status,omitempty"` 65 | ImageTags []*DockerImageTag `json:"imagetagsList"` 66 | } 67 | 68 | func (product *Product) HasContainerImage(version, imageURL, tag string) bool { 69 | versionObj := product.GetVersion(version) 70 | 71 | if versionObj != nil { 72 | for _, dockerVersionLink := range product.DockerLinkVersions { 73 | if dockerVersionLink.AppVersion == versionObj.Number { 74 | for _, dockerUrl := range dockerVersionLink.DockerURLs { 75 | if dockerUrl.Url == imageURL { 76 | for _, imageTag := range dockerUrl.ImageTags { 77 | if imageTag.Tag == tag { 78 | return true 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | return false 87 | } 88 | 89 | func (product *Product) GetContainerImagesForVersion(version string) []*DockerVersionList { 90 | var images []*DockerVersionList 91 | versionObj := product.GetVersion(version) 92 | 93 | if versionObj != nil { 94 | for _, dockerVersionLink := range product.DockerLinkVersions { 95 | if dockerVersionLink.AppVersion == versionObj.Number { 96 | images = append(images, dockerVersionLink) 97 | } 98 | } 99 | } 100 | return images 101 | } 102 | -------------------------------------------------------------------------------- /test/external/retryrequest_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/http/httptest" 12 | "net/url" 13 | "strconv" 14 | 15 | . "github.com/bunniesandbeatings/goerkin" 16 | . "github.com/onsi/ginkgo" 17 | . "github.com/onsi/gomega" 18 | . "github.com/onsi/gomega/gbytes" 19 | . "github.com/vmware-labs/marketplace-cli/v2/test" 20 | ) 21 | 22 | var _ = Describe("retryable requests", func() { 23 | steps := NewSteps() 24 | 25 | Scenario("Does not fail", func() { 26 | steps.Given("targeting the staging environment") 27 | steps.And("using a csp proxy with 0 failures") 28 | steps.And("mkpcli will use the csp proxy instead of the default") 29 | steps.And("the environment variable MKPCLI_SKIP_SSL_VALIDATION is set to true") 30 | steps.When("running mkpcli --debug product list") 31 | steps.Then("the command exits without error") 32 | }) 33 | 34 | Scenario("Fails but retries will succeed", func() { 35 | steps.Given("targeting the staging environment") 36 | steps.And("using a csp proxy with 2 failures") 37 | steps.And("mkpcli will use the csp proxy instead of the default") 38 | steps.And("the environment variable MKPCLI_SKIP_SSL_VALIDATION is set to true") 39 | steps.When("running mkpcli --debug product list") 40 | steps.Then("the command exits without error") 41 | }) 42 | 43 | Scenario("Fails too many times", func() { 44 | steps.Given("targeting the staging environment") 45 | steps.And("using a csp proxy with 6 failures") 46 | steps.And("mkpcli will use the csp proxy instead of the default") 47 | steps.And("the environment variable MKPCLI_SKIP_SSL_VALIDATION is set to true") 48 | steps.When("running mkpcli product list") 49 | steps.Then("the command exits with an error") 50 | }) 51 | 52 | steps.Define(func(define Definitions) { 53 | var cspProxyServer *httptest.Server 54 | DefineCommonSteps(define) 55 | 56 | define.Given(`^using a csp proxy with (\d) failures$`, func(totalFailureCountString string) { 57 | failureCount := 0 58 | totalFailureCount, err := strconv.Atoi(totalFailureCountString) 59 | Expect(err).ToNot(HaveOccurred()) 60 | 61 | cspProxyServer = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 | if failureCount < totalFailureCount { 63 | w.WriteHeader(http.StatusServiceUnavailable) 64 | failureCount++ 65 | return 66 | } 67 | 68 | url := fmt.Sprintf("https://console.cloud.vmware.com/%s?%s", r.URL.Path, r.URL.RawQuery) 69 | body, err := io.ReadAll(r.Body) 70 | Expect(err).ToNot(HaveOccurred()) 71 | forwardedRequest, err := http.NewRequest(r.Method, url, io.NopCloser(bytes.NewReader(body))) 72 | Expect(err).ToNot(HaveOccurred()) 73 | copyHeaders(r.Header, forwardedRequest.Header) 74 | 75 | resp, err := http.DefaultClient.Do(forwardedRequest) 76 | Expect(err).ToNot(HaveOccurred()) 77 | 78 | // Prepare the response 79 | copyHeaders(resp.Header, w.Header()) 80 | w.WriteHeader(resp.StatusCode) 81 | 82 | _, err = io.Copy(w, resp.Body) 83 | Expect(err).ToNot(HaveOccurred()) 84 | Expect(resp.Body.Close()).To(Succeed()) 85 | })) 86 | cspProxyServer.StartTLS() 87 | }, func() { 88 | cspProxyServer.Close() 89 | }) 90 | 91 | define.Given(`^mkpcli will use the csp proxy instead of the default$`, func() { 92 | cpsProxyServerUrl, err := url.Parse(cspProxyServer.URL) 93 | Expect(err).ToNot(HaveOccurred()) 94 | EnvVars = append(EnvVars, "CSP_HOST="+cpsProxyServerUrl.Host) 95 | }) 96 | 97 | define.Then(`^the expired token error message is printed$`, func() { 98 | Eventually(CommandSession.Err).Should(Say("the CSP API token is invalid or expired")) 99 | }) 100 | }) 101 | }) 102 | 103 | func copyHeaders(source, dest http.Header) { 104 | for headerKey, headerValues := range source { 105 | for _, headerValue := range headerValues { 106 | dest.Add(headerKey, headerValue) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /test/external/product_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package external_test 5 | 6 | import ( 7 | . "github.com/bunniesandbeatings/goerkin" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | . "github.com/onsi/gomega/gbytes" 11 | . "github.com/vmware-labs/marketplace-cli/v2/test" 12 | ) 13 | 14 | var _ = Describe("Product", func() { 15 | steps := NewSteps() 16 | 17 | Scenario("Listing products", func() { 18 | steps.Given("targeting the production environment") 19 | steps.When("running mkpcli product list --all-orgs --search-text " + Nginx) 20 | steps.Then("the command exits without error") 21 | steps.And("the list of products is printed") 22 | }) 23 | 24 | Scenario("Listing product versions", func() { 25 | steps.Given("targeting the production environment") 26 | steps.When("running mkpcli product list-versions --product " + Nginx) 27 | steps.Then("the command exits without error") 28 | steps.And("the list of product versions is printed") 29 | }) 30 | 31 | Scenario("Getting product details", func() { 32 | steps.Given("targeting the production environment") 33 | steps.When("running mkpcli product get --product " + Nginx) 34 | steps.Then("the command exits without error") 35 | steps.And("the product details are printed") 36 | }) 37 | 38 | steps.Define(func(define Definitions) { 39 | DefineCommonSteps(define) 40 | 41 | define.Then(`^the list of products is printed$`, func() { 42 | Eventually(CommandSession.Out).Should(Say("All products from all organizations filtered by \"nginx\"")) 43 | Eventually(CommandSession.Out).Should(Say("SLUG")) 44 | Eventually(CommandSession.Out).Should(Say("NAME")) 45 | Eventually(CommandSession.Out).Should(Say("PUBLISHER")) 46 | Eventually(CommandSession.Out).Should(Say("TYPE")) 47 | Eventually(CommandSession.Out).Should(Say("LATEST VERSION")) 48 | 49 | Eventually(CommandSession.Out).Should(Say("nginx")) 50 | Eventually(CommandSession.Out).Should(Say("NGINX Open Source Helm Chart packaged by Bitnami")) 51 | Eventually(CommandSession.Out).Should(Say("Bitnami")) 52 | Eventually(CommandSession.Out).Should(Say("HELMCHARTS")) 53 | 54 | Eventually(CommandSession.Out).Should(Say(`Total count: \d`)) 55 | }) 56 | 57 | define.Then(`^the list of product versions is printed$`, func() { 58 | Eventually(CommandSession.Out).Should(Say("Versions for NGINX Open Source Helm Chart packaged by Bitnami:")) 59 | Eventually(CommandSession.Out).Should(Say("NUMBER")) 60 | Eventually(CommandSession.Out).Should(Say("STATUS")) 61 | 62 | Eventually(CommandSession.Out).Should(Say("1.21.3_0")) 63 | Eventually(CommandSession.Out).Should(Say("ACTIVE")) 64 | Eventually(CommandSession.Out).Should(Say("1.21.2_0")) 65 | Eventually(CommandSession.Out).Should(Say("ACTIVE")) 66 | Eventually(CommandSession.Out).Should(Say("1.21.1_0")) 67 | Eventually(CommandSession.Out).Should(Say("ACTIVE")) 68 | }) 69 | 70 | define.Then(`^the product details are printed$`, func() { 71 | Eventually(CommandSession.Out).Should(Say("Name:")) 72 | Eventually(CommandSession.Out).Should(Say("NGINX Open Source Helm Chart packaged by Bitnami")) 73 | Eventually(CommandSession.Out).Should(Say("Publisher:")) 74 | Eventually(CommandSession.Out).Should(Say("Bitnami")) 75 | 76 | Eventually(CommandSession.Out).Should(Say("Up-to-date, secure, and ready to deploy on Kubernetes.")) 77 | Eventually(CommandSession.Out).Should(Say(`https://marketplace.cloud.vmware.com/services/details/nginx\?slug=true`)) 78 | 79 | Eventually(CommandSession.Out).Should(Say("Product Details:")) 80 | Eventually(CommandSession.Out).Should(Say("PRODUCT ID")) 81 | Eventually(CommandSession.Out).Should(Say("SLUG")) 82 | Eventually(CommandSession.Out).Should(Say("TYPE")) 83 | Eventually(CommandSession.Out).Should(Say("LATEST VERSION")) 84 | Eventually(CommandSession.Out).Should(Say("89431c5d-ddb7-45df-a544-2c81a370e17b")) 85 | Eventually(CommandSession.Out).Should(Say("nginx")) 86 | Eventually(CommandSession.Out).Should(Say("HELMCHARTS")) 87 | 88 | Eventually(CommandSession.Out).Should(Say("Description:")) 89 | Eventually(CommandSession.Out).Should(Say("NGINX Open Source is a lightweight and high-performance server")) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /internal/internalfakes/fake_s3client.go: -------------------------------------------------------------------------------- 1 | // Code generated by counterfeiter. DO NOT EDIT. 2 | package internalfakes 3 | 4 | import ( 5 | "context" 6 | "sync" 7 | 8 | "github.com/aws/aws-sdk-go-v2/service/s3" 9 | "github.com/vmware-labs/marketplace-cli/v2/internal" 10 | ) 11 | 12 | type FakeS3Client struct { 13 | PutObjectStub func(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error) 14 | putObjectMutex sync.RWMutex 15 | putObjectArgsForCall []struct { 16 | arg1 context.Context 17 | arg2 *s3.PutObjectInput 18 | arg3 []func(*s3.Options) 19 | } 20 | putObjectReturns struct { 21 | result1 *s3.PutObjectOutput 22 | result2 error 23 | } 24 | putObjectReturnsOnCall map[int]struct { 25 | result1 *s3.PutObjectOutput 26 | result2 error 27 | } 28 | invocations map[string][][]interface{} 29 | invocationsMutex sync.RWMutex 30 | } 31 | 32 | func (fake *FakeS3Client) PutObject(arg1 context.Context, arg2 *s3.PutObjectInput, arg3 ...func(*s3.Options)) (*s3.PutObjectOutput, error) { 33 | fake.putObjectMutex.Lock() 34 | ret, specificReturn := fake.putObjectReturnsOnCall[len(fake.putObjectArgsForCall)] 35 | fake.putObjectArgsForCall = append(fake.putObjectArgsForCall, struct { 36 | arg1 context.Context 37 | arg2 *s3.PutObjectInput 38 | arg3 []func(*s3.Options) 39 | }{arg1, arg2, arg3}) 40 | stub := fake.PutObjectStub 41 | fakeReturns := fake.putObjectReturns 42 | fake.recordInvocation("PutObject", []interface{}{arg1, arg2, arg3}) 43 | fake.putObjectMutex.Unlock() 44 | if stub != nil { 45 | return stub(arg1, arg2, arg3...) 46 | } 47 | if specificReturn { 48 | return ret.result1, ret.result2 49 | } 50 | return fakeReturns.result1, fakeReturns.result2 51 | } 52 | 53 | func (fake *FakeS3Client) PutObjectCallCount() int { 54 | fake.putObjectMutex.RLock() 55 | defer fake.putObjectMutex.RUnlock() 56 | return len(fake.putObjectArgsForCall) 57 | } 58 | 59 | func (fake *FakeS3Client) PutObjectCalls(stub func(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error)) { 60 | fake.putObjectMutex.Lock() 61 | defer fake.putObjectMutex.Unlock() 62 | fake.PutObjectStub = stub 63 | } 64 | 65 | func (fake *FakeS3Client) PutObjectArgsForCall(i int) (context.Context, *s3.PutObjectInput, []func(*s3.Options)) { 66 | fake.putObjectMutex.RLock() 67 | defer fake.putObjectMutex.RUnlock() 68 | argsForCall := fake.putObjectArgsForCall[i] 69 | return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 70 | } 71 | 72 | func (fake *FakeS3Client) PutObjectReturns(result1 *s3.PutObjectOutput, result2 error) { 73 | fake.putObjectMutex.Lock() 74 | defer fake.putObjectMutex.Unlock() 75 | fake.PutObjectStub = nil 76 | fake.putObjectReturns = struct { 77 | result1 *s3.PutObjectOutput 78 | result2 error 79 | }{result1, result2} 80 | } 81 | 82 | func (fake *FakeS3Client) PutObjectReturnsOnCall(i int, result1 *s3.PutObjectOutput, result2 error) { 83 | fake.putObjectMutex.Lock() 84 | defer fake.putObjectMutex.Unlock() 85 | fake.PutObjectStub = nil 86 | if fake.putObjectReturnsOnCall == nil { 87 | fake.putObjectReturnsOnCall = make(map[int]struct { 88 | result1 *s3.PutObjectOutput 89 | result2 error 90 | }) 91 | } 92 | fake.putObjectReturnsOnCall[i] = struct { 93 | result1 *s3.PutObjectOutput 94 | result2 error 95 | }{result1, result2} 96 | } 97 | 98 | func (fake *FakeS3Client) Invocations() map[string][][]interface{} { 99 | fake.invocationsMutex.RLock() 100 | defer fake.invocationsMutex.RUnlock() 101 | fake.putObjectMutex.RLock() 102 | defer fake.putObjectMutex.RUnlock() 103 | copiedInvocations := map[string][][]interface{}{} 104 | for key, value := range fake.invocations { 105 | copiedInvocations[key] = value 106 | } 107 | return copiedInvocations 108 | } 109 | 110 | func (fake *FakeS3Client) recordInvocation(key string, args []interface{}) { 111 | fake.invocationsMutex.Lock() 112 | defer fake.invocationsMutex.Unlock() 113 | if fake.invocations == nil { 114 | fake.invocations = map[string][][]interface{}{} 115 | } 116 | if fake.invocations[key] == nil { 117 | fake.invocations[key] = [][]interface{}{} 118 | } 119 | fake.invocations[key] = append(fake.invocations[key], args) 120 | } 121 | 122 | var _ internal.S3Client = new(FakeS3Client) 123 | -------------------------------------------------------------------------------- /internal/uploader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package internal 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "strconv" 14 | "time" 15 | 16 | "github.com/aws/aws-sdk-go-v2/aws" 17 | "github.com/aws/aws-sdk-go-v2/config" 18 | "github.com/aws/aws-sdk-go-v2/credentials" 19 | "github.com/aws/aws-sdk-go-v2/service/s3" 20 | "github.com/aws/aws-sdk-go-v2/service/s3/types" 21 | ) 22 | 23 | const ( 24 | FolderMediaFiles = "media-files" 25 | FolderMetaFiles = "meta-files" 26 | FolderProductFiles = "marketplace-product-files" 27 | ) 28 | 29 | func now() string { 30 | return strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10) 31 | } 32 | 33 | //go:generate counterfeiter . Uploader 34 | type Uploader interface { 35 | UploadMediaFile(filePath string) (string, string, error) 36 | UploadMetaFile(filePath string) (string, string, error) 37 | UploadProductFile(filePath string) (string, string, error) 38 | } 39 | 40 | type S3Uploader struct { 41 | bucket string 42 | region string 43 | orgID string 44 | client S3Client 45 | output io.Writer 46 | } 47 | 48 | //go:generate counterfeiter . S3Client 49 | type S3Client interface { 50 | PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) 51 | } 52 | 53 | func NewS3Client(region string, creds aws.Credentials) S3Client { 54 | s3Config, err := config.LoadDefaultConfig(context.Background(), 55 | config.WithCredentialsProvider(credentials.StaticCredentialsProvider{ 56 | Value: creds, 57 | }), 58 | config.WithRegion(region), 59 | ) 60 | if err != nil { 61 | return nil 62 | } 63 | 64 | return s3.NewFromConfig(s3Config) 65 | } 66 | 67 | func NewS3Uploader(bucket, region, orgID string, client S3Client, output io.Writer) *S3Uploader { 68 | return &S3Uploader{ 69 | bucket: bucket, 70 | orgID: orgID, 71 | region: region, 72 | client: client, 73 | output: output, 74 | } 75 | } 76 | 77 | func (u *S3Uploader) UploadMediaFile(filePath string) (string, string, error) { 78 | filename := filepath.Base(filePath) 79 | key := path.Join(u.orgID, FolderMediaFiles, now(), filename) 80 | url := fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", u.bucket, u.region, key) 81 | err := u.upload(filePath, key, types.ObjectCannedACLPublicRead) 82 | return filename, url, err 83 | } 84 | 85 | func (u *S3Uploader) UploadMetaFile(filePath string) (string, string, error) { 86 | filename := filepath.Base(filePath) 87 | datestamp := now() 88 | key := path.Join(u.orgID, FolderMetaFiles, datestamp, filename) 89 | url := fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", u.bucket, u.region, key) 90 | err := u.upload(filePath, key, types.ObjectCannedACLPrivate) 91 | return filename, url, err 92 | } 93 | 94 | func (u *S3Uploader) UploadProductFile(filePath string) (string, string, error) { 95 | filename := filepath.Base(filePath) 96 | datestamp := now() 97 | key := path.Join(u.orgID, FolderProductFiles, datestamp, filename) 98 | url := fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", u.bucket, u.region, key) 99 | err := u.upload(filePath, key, types.ObjectCannedACLPrivate) 100 | return filename, url, err 101 | } 102 | 103 | func (u *S3Uploader) upload(filePath, key string, acl types.ObjectCannedACL) error { 104 | file, err := os.Open(filePath) 105 | if err != nil { 106 | return fmt.Errorf("failed to open %s: %w", filePath, err) 107 | } 108 | stat, err := file.Stat() 109 | if err != nil { 110 | return fmt.Errorf("failed to get info for %s: %w", filePath, err) 111 | } 112 | 113 | progressBar := MakeProgressBar(fmt.Sprintf("Uploading %s", path.Base(file.Name())), stat.Size(), u.output) 114 | _, err = u.client.PutObject(context.Background(), &s3.PutObjectInput{ 115 | ACL: acl, 116 | Bucket: aws.String(u.bucket), 117 | Key: aws.String(key), 118 | Body: progressBar.WrapReader(file), 119 | ContentLength: stat.Size(), 120 | }) 121 | if err != nil { 122 | return fmt.Errorf("failed to upload file: %w", err) 123 | } 124 | 125 | err = file.Close() 126 | if err != nil { 127 | return fmt.Errorf("failed to close file: %w", err) 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package cmd 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 12 | ) 13 | 14 | var ( 15 | DownloadProductSlug string 16 | DownloadProductVersion string 17 | DownloadFilter string 18 | DownloadFilename string 19 | DownloadAcceptEULA bool 20 | ) 21 | 22 | func init() { 23 | rootCmd.AddCommand(DownloadCmd) 24 | 25 | DownloadCmd.Flags().StringVarP(&DownloadProductSlug, "product", "p", "", "Product slug (required)") 26 | _ = DownloadCmd.MarkFlagRequired("product") 27 | DownloadCmd.Flags().StringVarP(&DownloadProductVersion, "product-version", "v", "", "Product version (default to latest version)") 28 | DownloadCmd.Flags().StringVar(&DownloadFilter, "filter", "", "Filter assets by display name") 29 | DownloadCmd.Flags().StringVarP(&AssetType, "type", "t", "", "Filter assets by type (one of "+strings.Join(assetTypesList(), ", ")+")") 30 | DownloadCmd.Flags().StringVarP(&DownloadFilename, "filename", "f", "", "Output file name") 31 | DownloadCmd.Flags().BoolVar(&DownloadAcceptEULA, "accept-eula", false, "Accept the product EULA") 32 | } 33 | 34 | func filterAssets(filter string, assets []*pkg.Asset) []*pkg.Asset { 35 | var filteredAssets []*pkg.Asset 36 | for _, asset := range assets { 37 | if strings.Contains(asset.DisplayName, filter) { 38 | filteredAssets = append(filteredAssets, asset) 39 | } 40 | } 41 | return filteredAssets 42 | } 43 | 44 | var DownloadCmd = &cobra.Command{ 45 | Use: "download", 46 | Short: "Download an asset from a product", 47 | Long: "Download an asset attached to a product in the VMware Marketplace", 48 | Example: fmt.Sprintf("%s download -p hyperspace-database-chart1 -v 1.2.3", AppName), 49 | Args: cobra.NoArgs, 50 | PreRunE: RunSerially(ValidateAssetTypeFilter, GetRefreshToken), 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | cmd.SilenceUsage = true 53 | product, version, err := Marketplace.GetProductWithVersion(DownloadProductSlug, DownloadProductVersion) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | assetType := "" 59 | if AssetType != "" { 60 | assetType = assetTypeMapping[AssetType] + " " 61 | } 62 | var asset *pkg.Asset 63 | assets := pkg.GetAssetsByType(assetTypeMapping[AssetType], product, version.Number) 64 | if len(assets) == 0 { 65 | return fmt.Errorf("product %s %s does not have any downloadable %sassets", product.Slug, version.Number, assetType) 66 | } 67 | 68 | if DownloadFilter == "" { 69 | asset = assets[0] 70 | if len(assets) > 1 { 71 | _ = Output.RenderAssets(assets) 72 | return fmt.Errorf("product %s %s has multiple downloadable %sassets, please use the --filter parameter", product.Slug, version.Number, assetType) 73 | } 74 | } else { 75 | filterAssets := filterAssets(DownloadFilter, assets) 76 | if len(filterAssets) == 0 { 77 | return fmt.Errorf("product %s %s does not have any downloadable %sassets that match the filter \"%s\", please adjust the --filter parameter", product.Slug, version.Number, assetType, DownloadFilter) 78 | } 79 | 80 | asset = filterAssets[0] 81 | if len(filterAssets) > 1 { 82 | _ = Output.RenderAssets(filterAssets) 83 | return fmt.Errorf("product %s %s has multiple downloadable %sassets that match the filter \"%s\", please adjust the --filter parameter", product.Slug, version.Number, assetType, DownloadFilter) 84 | } 85 | } 86 | 87 | filename := asset.Filename 88 | if DownloadFilename != "" { 89 | filename = DownloadFilename 90 | } 91 | 92 | if !DownloadAcceptEULA && !product.EulaDetails.Signed { 93 | cmd.PrintErrln("The EULA must be accepted before downloading") 94 | if product.EulaDetails.Text != "" { 95 | cmd.PrintErrf("EULA: %s\n\n", product.EulaDetails.Text) 96 | } else if product.EulaDetails.Url != "" { 97 | cmd.PrintErrf("EULA: %s\n\n", product.EulaDetails.Url) 98 | } 99 | return fmt.Errorf("please review the EULA and re-run with --accept-eula") 100 | } 101 | 102 | asset.DownloadRequestPayload.EulaAccepted = DownloadAcceptEULA 103 | return Marketplace.Download(filename, asset.DownloadRequestPayload) 104 | }, 105 | } 106 | -------------------------------------------------------------------------------- /internal/csp/token_services_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package csp_test 5 | 6 | import ( 7 | "errors" 8 | "net/http" 9 | 10 | "github.com/golang-jwt/jwt" 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 14 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp/cspfakes" 15 | "github.com/vmware-labs/marketplace-cli/v2/pkg/pkgfakes" 16 | "github.com/vmware-labs/marketplace-cli/v2/test" 17 | ) 18 | 19 | var _ = Describe("CSP Token Services", func() { 20 | var ( 21 | client *pkgfakes.FakeHTTPClient 22 | tokenParser *cspfakes.FakeTokenParserFn 23 | tokenServices *csp.TokenServices 24 | ) 25 | 26 | BeforeEach(func() { 27 | client = &pkgfakes.FakeHTTPClient{} 28 | tokenParser = &cspfakes.FakeTokenParserFn{} 29 | tokenServices = &csp.TokenServices{ 30 | CSPHost: "csp.example.com", 31 | Client: client, 32 | TokenParser: tokenParser.Spy, 33 | } 34 | }) 35 | 36 | Describe("Redeem", func() { 37 | BeforeEach(func() { 38 | responseBody := csp.RedeemResponse{ 39 | AccessToken: "my-access-token", 40 | StatusCode: http.StatusOK, 41 | } 42 | client.PostFormReturns(test.MakeJSONResponse(responseBody), nil) 43 | token := &jwt.Token{ 44 | Raw: "my-jwt-token", 45 | } 46 | tokenParser.Returns(token, nil) 47 | }) 48 | It("exchanges the token for the JWT token claims", func() { 49 | token, err := tokenServices.Redeem("my-csp-api-token") 50 | Expect(err).ToNot(HaveOccurred()) 51 | Expect(token.Token).To(Equal("my-jwt-token")) 52 | }) 53 | 54 | When("sending the request fails", func() { 55 | BeforeEach(func() { 56 | client.PostFormReturns(nil, errors.New("failed to send request")) 57 | }) 58 | It("returns an error", func() { 59 | _, err := tokenServices.Redeem("my-csp-api-token") 60 | Expect(err).To(HaveOccurred()) 61 | Expect(err.Error()).To(Equal("failed to redeem token: failed to send request")) 62 | }) 63 | }) 64 | 65 | When("the request returns an unparseable response", func() { 66 | BeforeEach(func() { 67 | client.PostFormReturns(test.MakeFailingBodyResponse("bad-response-body"), nil) 68 | }) 69 | It("returns an error", func() { 70 | _, err := tokenServices.Redeem("my-csp-api-token") 71 | Expect(err).To(HaveOccurred()) 72 | Expect(err.Error()).To(Equal("failed to parse redeem response: bad-response-body")) 73 | }) 74 | }) 75 | 76 | When("the response indicates that the token is invalid", func() { 77 | BeforeEach(func() { 78 | responseBody := csp.RedeemResponse{ 79 | StatusCode: http.StatusBadRequest, 80 | Message: "invalid_grant: Invalid refresh token: xxxxx-token", 81 | } 82 | response := test.MakeJSONResponse(responseBody) 83 | response.StatusCode = http.StatusBadRequest 84 | client.PostFormReturns(response, nil) 85 | }) 86 | It("returns an error", func() { 87 | _, err := tokenServices.Redeem("my-csp-api-token") 88 | Expect(err).To(HaveOccurred()) 89 | Expect(err.Error()).To(Equal("the CSP API token is invalid or expired")) 90 | }) 91 | }) 92 | 93 | When("the response shows some other error", func() { 94 | BeforeEach(func() { 95 | responseBody := csp.RedeemResponse{ 96 | StatusCode: http.StatusTeapot, 97 | Message: "teapots!", 98 | } 99 | response := test.MakeJSONResponse(responseBody) 100 | response.Status = http.StatusText(http.StatusTeapot) 101 | response.StatusCode = http.StatusTeapot 102 | client.PostFormReturns(response, nil) 103 | }) 104 | It("returns an error", func() { 105 | _, err := tokenServices.Redeem("my-csp-api-token") 106 | Expect(err).To(HaveOccurred()) 107 | Expect(err.Error()).To(Equal("failed to exchange refresh token for access token: I'm a teapot: teapots!")) 108 | }) 109 | }) 110 | 111 | When("the response is not a valid token", func() { 112 | BeforeEach(func() { 113 | tokenParser.Returns(nil, errors.New("token parser failed")) 114 | }) 115 | It("returns an error", func() { 116 | _, err := tokenServices.Redeem("my-csp-api-token") 117 | Expect(err).To(HaveOccurred()) 118 | Expect(err.Error()).To(Equal("invalid token returned from CSP: token parser failed")) 119 | }) 120 | }) 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /internal/csp/claims_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package csp_test 5 | 6 | import ( 7 | "github.com/golang-jwt/jwt" 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | "github.com/vmware-labs/marketplace-cli/v2/internal/csp" 11 | ) 12 | 13 | var _ = Describe("CSP Claims", func() { 14 | Describe("GetQualifiedUsername", func() { 15 | It("returns the username", func() { 16 | claims := &csp.Claims{ 17 | Username: "john@yahoo.com", 18 | } 19 | 20 | Expect(claims.GetQualifiedUsername()).To(Equal("john@yahoo.com")) 21 | }) 22 | 23 | Context("No domain in username", func() { 24 | It("returns a username using the domain field", func() { 25 | claims := &csp.Claims{ 26 | Domain: "example.com", 27 | Username: "john", 28 | } 29 | 30 | Expect(claims.GetQualifiedUsername()).To(Equal("john@example.com")) 31 | }) 32 | }) 33 | }) 34 | 35 | Describe("IsOrgOwner", func() { 36 | It("returns true if the right perm is set", func() { 37 | claims := &csp.Claims{ 38 | Perms: []string{ 39 | "csp:org_owner", 40 | "other:perm", 41 | }, 42 | } 43 | Expect(claims.IsOrgOwner()).To(BeTrue()) 44 | 45 | claims = &csp.Claims{ 46 | Perms: []string{ 47 | "other:perm", 48 | }, 49 | } 50 | Expect(claims.IsOrgOwner()).To(BeFalse()) 51 | }) 52 | }) 53 | 54 | Describe("IsOrgOwner", func() { 55 | It("returns true if the right perm is set", func() { 56 | claims := &csp.Claims{ 57 | Perms: []string{ 58 | "csp:org_owner", 59 | "other:perm", 60 | }, 61 | } 62 | Expect(claims.IsOrgOwner()).To(BeTrue()) 63 | 64 | claims = &csp.Claims{ 65 | Perms: []string{ 66 | "other:perm", 67 | }, 68 | } 69 | Expect(claims.IsOrgOwner()).To(BeFalse()) 70 | }) 71 | }) 72 | 73 | Describe("IsPlatformOperator", func() { 74 | It("returns true if the right perm is set", func() { 75 | claims := &csp.Claims{ 76 | Perms: []string{ 77 | "csp:platform_operator", 78 | "other:perm", 79 | }, 80 | } 81 | Expect(claims.IsPlatformOperator()).To(BeTrue()) 82 | 83 | claims = &csp.Claims{ 84 | Perms: []string{ 85 | "other:perm", 86 | }, 87 | } 88 | Expect(claims.IsPlatformOperator()).To(BeFalse()) 89 | }) 90 | }) 91 | 92 | Describe("ParseWithClaims", func() { 93 | It("parses successfully", func() { 94 | accessToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ2bXdhcmVpZDpiM2I4NDQwNS01Njg4LTQyN2QtOTllNS0zNTk3Njg1NmIzNTYiLCJhenAiOiJjc3BfZGV2X2ludGVybmFsX2NsaWVudF9pZCIsImRvbWFpbiI6InZtd2FyZWlkIiwiY29udGV4dCI6ImE2ODE1MzEyLTQwOTUtNGE5ZC05OGZkLTMwZTFjYjg3MzgxNCIsImlzcyI6Imh0dHBzOi8vZ2F6LWRldi5jc3AtdmlkbS1wcm9kLmNvbSIsInBlcm1zIjpbImNzcDpwbGF0Zm9ybV9vcGVyYXRvciJdLCJjb250ZXh0X25hbWUiOiJ2MjNoZzg3Ni05ODQzLTQxMmYtYTA3MS0zNzVpZzVoNjc0ZiIsImV4cCI6MTUzNjE3MTI2NSwiaWF0IjoxNTM2MTY5NDY1LCJqdGkiOiI1MjEzNzYyYy1mZDU0LTRiMDctYmZiZC1kMTEzMDJlZDA0MGMiLCJ1c2VybmFtZSI6InJtLmludGVncmF0aW9uLnRlc3QudXNlckBnbWFpbC5jb20ifQ.fPvEQ5WwlHhayPuFjMr2iNP8J2kndwp1YQ6EMAaHpUX3AoQ0OI5iXKGkYIDl7Gxm2cG5o3uiIoIiw00geNnd_-R6YoqnP7-S4H8oJjXFATG5ZH9RuvJah8RoNWtUFWKBpr6e_kynexF9bT0urPsNLHG43ALCZ_15EQZXZu0edUr0EojI8NeEBon0oiOm-lTB8Jsqnaj-XiHcHDqLF7_IBKy9ZeaGBqKWbimK-MIZzqxbQ6OmkWQoTGhGy4argGmmd-g1OptxOaQIP7CDShUJtTq4oixgkw4z473Cmhm6uQRvodeHb5JEBtr5m8-AAmgAoTX3tQRKGpO22eDxRdtcCg" 95 | 96 | // this is the decoded token from above 97 | // { 98 | // "sub": "vmwareid:b3b84405-5688-427d-99e5-35976856b356", 99 | // "azp": "csp_dev_internal_client_id", 100 | // "domain": "vmwareid", 101 | // "context": "a6815312-4095-4a9d-98fd-30e1cb873814", 102 | // "iss": "https://gaz-dev.csp-vidm-prod.com", 103 | // "perms": [ 104 | // "csp:platform_operator" 105 | // ], 106 | // "context_name": "v23hg876-9843-412f-a071-375ig5h674f", 107 | // "exp": 1536171265, 108 | // "iat": 1536169465, 109 | // "jti": "5213762c-fd54-4b07-bfbd-d11302ed040c", 110 | // "username": "rm.integration.test.user@gmail.com" 111 | // } 112 | claims := &csp.Claims{} 113 | _, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (interface{}, error) { 114 | // token was just retrieved, no need to validate 115 | return "not a valid key anyway", nil 116 | }) 117 | 118 | // I know the token has expired ok 119 | Expect(err).To(HaveOccurred()) 120 | 121 | Expect(claims.ContextName).To(Equal("v23hg876-9843-412f-a071-375ig5h674f")) 122 | Expect(claims.Context).To(Equal("a6815312-4095-4a9d-98fd-30e1cb873814")) 123 | Expect(claims.Username).To(Equal("rm.integration.test.user@gmail.com")) 124 | Expect(claims.Perms).To(ContainElements( 125 | "csp:platform_operator", 126 | )) 127 | }) 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | module github.com/vmware-labs/marketplace-cli/v2 5 | 6 | go 1.19 7 | 8 | require ( 9 | github.com/aws/aws-sdk-go-v2 v1.16.14 10 | github.com/aws/aws-sdk-go-v2/config v1.17.5 11 | github.com/aws/aws-sdk-go-v2/credentials v1.12.18 12 | github.com/aws/aws-sdk-go-v2/service/s3 v1.27.9 13 | github.com/bunniesandbeatings/goerkin v0.1.4-beta 14 | github.com/coreos/go-semver v0.3.1 15 | github.com/golang-jwt/jwt v3.2.2+incompatible 16 | github.com/google/uuid v1.3.0 17 | github.com/hashicorp/go-cleanhttp v0.5.2 18 | github.com/hashicorp/go-retryablehttp v0.7.1 19 | github.com/olekukonko/tablewriter v0.0.5 20 | github.com/onsi/ginkgo v1.16.5 21 | github.com/onsi/gomega v1.29.0 22 | github.com/schollz/progressbar/v3 v3.11.0 23 | github.com/spf13/cobra v1.8.0 24 | github.com/spf13/viper v1.13.0 25 | github.com/tidwall/gjson v1.14.3 26 | gopkg.in/yaml.v3 v3.0.1 27 | helm.sh/helm/v3 v3.14.2 28 | jaytaylor.com/html2text v0.0.0-20211105163654-bc68cce691ba 29 | ) 30 | 31 | require ( 32 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 33 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.7 // indirect 34 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.15 // indirect 35 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.21 // indirect 36 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.15 // indirect 37 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.22 // indirect 38 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.12 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.8 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.16 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.15 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.15 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/sso v1.11.21 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.3 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/sts v1.16.17 // indirect 46 | github.com/aws/smithy-go v1.13.2 // indirect 47 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 48 | github.com/fsnotify/fsnotify v1.7.0 // indirect 49 | github.com/go-logr/logr v1.3.0 // indirect 50 | github.com/gogo/protobuf v1.3.2 // indirect 51 | github.com/google/go-cmp v0.6.0 // indirect 52 | github.com/google/gofuzz v1.2.0 // indirect 53 | github.com/hashicorp/hcl v1.0.0 // indirect 54 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 55 | github.com/json-iterator/go v1.1.12 // indirect 56 | github.com/magiconair/properties v1.8.6 // indirect 57 | github.com/mattn/go-runewidth v0.0.13 // indirect 58 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 59 | github.com/mitchellh/copystructure v1.2.0 // indirect 60 | github.com/mitchellh/mapstructure v1.5.0 // indirect 61 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 62 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 63 | github.com/modern-go/reflect2 v1.0.2 // indirect 64 | github.com/nxadm/tail v1.4.8 // indirect 65 | github.com/pelletier/go-toml v1.9.5 // indirect 66 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 67 | github.com/pkg/errors v0.9.1 // indirect 68 | github.com/rivo/uniseg v0.4.2 // indirect 69 | github.com/spf13/afero v1.9.2 // indirect 70 | github.com/spf13/cast v1.5.0 // indirect 71 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 72 | github.com/spf13/pflag v1.0.5 // indirect 73 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 74 | github.com/subosito/gotenv v1.4.1 // indirect 75 | github.com/tidwall/match v1.1.1 // indirect 76 | github.com/tidwall/pretty v1.2.0 // indirect 77 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 78 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 79 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 80 | golang.org/x/net v0.17.0 // indirect 81 | golang.org/x/sys v0.15.0 // indirect 82 | golang.org/x/term v0.15.0 // indirect 83 | golang.org/x/text v0.14.0 // indirect 84 | gopkg.in/inf.v0 v0.9.1 // indirect 85 | gopkg.in/ini.v1 v1.67.0 // indirect 86 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 87 | gopkg.in/yaml.v2 v2.4.0 // indirect 88 | k8s.io/api v0.29.0 // indirect 89 | k8s.io/apiextensions-apiserver v0.29.0 // indirect 90 | k8s.io/apimachinery v0.29.0 // indirect 91 | k8s.io/client-go v0.29.0 // indirect 92 | k8s.io/klog/v2 v2.110.1 // indirect 93 | k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect 94 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 95 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 96 | sigs.k8s.io/yaml v1.3.0 // indirect 97 | ) 98 | -------------------------------------------------------------------------------- /pkg/upload_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | "errors" 8 | "net/http" 9 | "time" 10 | 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal" 14 | "github.com/vmware-labs/marketplace-cli/v2/internal/internalfakes" 15 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 16 | "github.com/vmware-labs/marketplace-cli/v2/pkg/pkgfakes" 17 | "github.com/vmware-labs/marketplace-cli/v2/test" 18 | ) 19 | 20 | var _ = Describe("Upload", func() { 21 | var ( 22 | httpClient *pkgfakes.FakeHTTPClient 23 | marketplace *pkg.Marketplace 24 | ) 25 | 26 | BeforeEach(func() { 27 | httpClient = &pkgfakes.FakeHTTPClient{} 28 | marketplace = &pkg.Marketplace{ 29 | APIHost: "marketplace.api.example.com", 30 | Host: "marketplace.example.com", 31 | Client: httpClient, 32 | } 33 | }) 34 | 35 | Describe("GetUploadCredentials", func() { 36 | BeforeEach(func() { 37 | response := &pkg.CredentialsResponse{ 38 | AccessID: "my-access-id", 39 | AccessKey: "my-access-key", 40 | SessionToken: "my-session-token", 41 | Expiration: time.Time{}, 42 | } 43 | httpClient.GetReturns(test.MakeJSONResponse(response), nil) 44 | }) 45 | 46 | It("gets the credentials", func() { 47 | creds, err := marketplace.GetUploadCredentials() 48 | Expect(err).ToNot(HaveOccurred()) 49 | Expect(creds.AccessID).To(Equal("my-access-id")) 50 | Expect(creds.AccessKey).To(Equal("my-access-key")) 51 | Expect(creds.SessionToken).To(Equal("my-session-token")) 52 | 53 | By("requesting the creds from the Marketplace", func() { 54 | Expect(httpClient.GetCallCount()).To(Equal(1)) 55 | url := httpClient.GetArgsForCall(0) 56 | Expect(url.String()).To(Equal("https://marketplace.api.example.com/aws/credentials/generate")) 57 | }) 58 | }) 59 | 60 | When("the credentials request fails", func() { 61 | BeforeEach(func() { 62 | httpClient.GetReturns(nil, errors.New("get credentials failed")) 63 | }) 64 | It("returns an error", func() { 65 | _, err := marketplace.GetUploadCredentials() 66 | Expect(err).To(HaveOccurred()) 67 | Expect(err.Error()).To(Equal("get credentials failed")) 68 | }) 69 | }) 70 | 71 | When("the credentials response is not 200 OK", func() { 72 | BeforeEach(func() { 73 | httpClient.GetReturns(&http.Response{ 74 | StatusCode: http.StatusTeapot, 75 | }, nil) 76 | }) 77 | 78 | It("returns an error", func() { 79 | _, err := marketplace.GetUploadCredentials() 80 | Expect(err).To(HaveOccurred()) 81 | Expect(err.Error()).To(Equal("failed to fetch credentials: 418")) 82 | }) 83 | }) 84 | 85 | When("the credentials response is invalid", func() { 86 | BeforeEach(func() { 87 | httpClient.GetReturns(test.MakeStringResponse("this is not valid json"), nil) 88 | }) 89 | It("returns an error", func() { 90 | _, err := marketplace.GetUploadCredentials() 91 | Expect(err).To(HaveOccurred()) 92 | Expect(err.Error()).To(Equal("invalid character 'h' in literal true (expecting 'r')")) 93 | }) 94 | }) 95 | }) 96 | 97 | Describe("GetUploader", func() { 98 | BeforeEach(func() { 99 | response := &pkg.CredentialsResponse{ 100 | AccessID: "my-access-id", 101 | AccessKey: "my-access-key", 102 | SessionToken: "my-session-token", 103 | Expiration: time.Time{}, 104 | } 105 | httpClient.GetReturns(test.MakeJSONResponse(response), nil) 106 | }) 107 | It("creates an uploader with upload credentials", func() { 108 | uploader, err := marketplace.GetUploader("my-org") 109 | Expect(err).ToNot(HaveOccurred()) 110 | 111 | By("requesting the upload credentials", func() { 112 | Expect(httpClient.GetCallCount()).To(Equal(1)) 113 | url := httpClient.GetArgsForCall(0) 114 | Expect(url.String()).To(Equal("https://marketplace.api.example.com/aws/credentials/generate")) 115 | }) 116 | Expect(uploader).ToNot(BeNil()) 117 | }) 118 | 119 | When("getting the credentials fails", func() { 120 | BeforeEach(func() { 121 | httpClient.GetReturns(nil, errors.New("get credentials failed")) 122 | }) 123 | It("returns an error", func() { 124 | _, err := marketplace.GetUploader("my-org") 125 | Expect(err).To(HaveOccurred()) 126 | Expect(err.Error()).To(Equal("failed to get upload credentials: get credentials failed")) 127 | }) 128 | }) 129 | 130 | When("the uploaded already exists", func() { 131 | var uploader internal.Uploader 132 | BeforeEach(func() { 133 | uploader = &internalfakes.FakeUploader{} 134 | marketplace.SetUploader(uploader) 135 | }) 136 | It("returns that uploader", func() { 137 | Expect(marketplace.GetUploader("doesn't matter")).To(Equal(uploader)) 138 | Expect(httpClient.GetCallCount()).To(Equal(0)) 139 | }) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 VMware, Inc. 2 | # SPDX-License-Identifier: BSD-2-Clause 3 | 4 | SHELL = /bin/bash 5 | 6 | default: build 7 | 8 | # #### GO Binary Management #### 9 | .PHONY: deps-go-binary deps-counterfeiter deps-ginkgo deps-golangci-lint 10 | 11 | GO_VERSION := $(shell go version) 12 | GO_VERSION_REQUIRED = go1.19 13 | GO_VERSION_MATCHED := $(shell go version | grep $(GO_VERSION_REQUIRED)) 14 | 15 | deps-go-binary: 16 | ifndef GO_VERSION 17 | $(error Go not installed) 18 | endif 19 | ifndef GO_VERSION_MATCHED 20 | $(error Required Go version is $(GO_VERSION_REQUIRED), but was $(GO_VERSION)) 21 | endif 22 | @: 23 | 24 | HAS_COUNTERFEITER := $(shell command -v counterfeiter;) 25 | HAS_GINKGO := $(shell command -v ginkgo;) 26 | HAS_GOLANGCI_LINT := $(shell command -v golangci-lint;) 27 | HAS_SHELLCHECK := $(shell command -v shellcheck;) 28 | PLATFORM := $(shell uname -s) 29 | 30 | # If go get is run from inside the project directory it will add the dependencies 31 | # to the go.mod file. To avoid that we import from another directory 32 | deps-counterfeiter: deps-go-binary 33 | ifndef HAS_COUNTERFEITER 34 | go install github.com/maxbrunsfeld/counterfeiter/v6@latest 35 | endif 36 | 37 | deps-ginkgo: deps-go-binary 38 | ifndef HAS_GINKGO 39 | go install github.com/onsi/ginkgo/ginkgo@latest 40 | endif 41 | 42 | deps-golangci-lint: deps-go-binary 43 | ifndef HAS_GOLANGCI_LINT 44 | ifeq ($(PLATFORM), Darwin) 45 | brew install golangci-lint 46 | endif 47 | ifeq ($(PLATFORM), Linux) 48 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin 49 | endif 50 | endif 51 | 52 | deps-shellcheck: 53 | ifndef HAS_SHELLCHECK 54 | ifeq ($(PLATFORM), Darwin) 55 | brew install shellcheck 56 | endif 57 | ifeq ($(PLATFORM), Linux) 58 | apt-get update && apt-get install -y shellcheck 59 | endif 60 | endif 61 | 62 | # #### CLEAN #### 63 | .PHONY: clean 64 | 65 | clean: deps-go-binary 66 | rm -rf build/* 67 | go clean --modcache 68 | 69 | 70 | # #### DEPS #### 71 | .PHONY: deps deps-counterfeiter deps-ginkgo deps-modules 72 | 73 | deps-modules: deps-go-binary 74 | go mod download 75 | 76 | deps: deps-modules deps-counterfeiter deps-ginkgo 77 | 78 | 79 | # #### BUILD #### 80 | .PHONY: build 81 | 82 | SRC = $(shell find . -name "*.go" | grep -v "_test\." ) 83 | VERSION := $(or $(VERSION), dev) 84 | LDFLAGS="-X github.com/vmware-labs/marketplace-cli/v2/cmd.version=$(VERSION)" 85 | 86 | build/mkpcli: $(SRC) 87 | go build -o build/mkpcli -ldflags ${LDFLAGS} ./main.go 88 | 89 | build/mkpcli-darwin-amd64: $(SRC) 90 | GOARCH=amd64 GOOS=darwin go build -o build/mkpcli-darwin-amd64 -ldflags ${LDFLAGS} ./main.go 91 | 92 | build/mkpcli-darwin-arm64: $(SRC) 93 | GOARCH=arm64 GOOS=darwin go build -o build/mkpcli-darwin-arm64 -ldflags ${LDFLAGS} ./main.go 94 | 95 | build/mkpcli-linux-amd64: $(SRC) 96 | GOARCH=amd64 GOOS=linux go build -o build/mkpcli-linux-amd64 -ldflags ${LDFLAGS} ./main.go 97 | 98 | build/mkpcli-windows-amd64.exe: $(SRC) 99 | GOARCH=amd64 GOOS=windows go build -o build/mkpcli-windows-amd64.exe -ldflags ${LDFLAGS} ./main.go 100 | 101 | build: deps build/mkpcli 102 | 103 | build-all: build/mkpcli-darwin-amd64 build/mkpcli-darwin-arm64 build/mkpcli-linux-amd64 build/mkpcli-windows-amd64 104 | 105 | release: build/mkpcli-darwin-amd64 build/mkpcli-darwin-arm64 build/mkpcli-linux-amd64 build/mkpcli-windows-amd64.exe 106 | mkdir -p release 107 | cp -f build/mkpcli-darwin-amd64 release/mkpcli && tar czvf release/mkpcli-darwin-amd64.tgz -C release mkpcli 108 | cp -f build/mkpcli-darwin-arm64 release/mkpcli && tar czvf release/mkpcli-darwin-arm64.tgz -C release mkpcli 109 | cp -f build/mkpcli-linux-amd64 release/mkpcli && tar czvf release/mkpcli-linux-amd64.tgz -C release mkpcli 110 | cp -f build/mkpcli-windows-amd64.exe release/mkpcli.exe && zip -j release/mkpcli-windows-amd64.zip release/mkpcli.exe 111 | rm release/mkpcli release/mkpcli.exe 112 | 113 | build-image: build/mkpcli-linux 114 | docker build . --tag harbor-repo.vmware.com/tanzu_isv_engineering/mkpcli:$(VERSION) 115 | 116 | # #### TESTS #### 117 | .PHONY: lint test test-features test-units 118 | 119 | test-units: deps 120 | ginkgo -r -skipPackage test . 121 | 122 | test-features: deps 123 | ginkgo -r test/features 124 | 125 | test-external: deps 126 | ifndef CSP_API_TOKEN 127 | $(error CSP_API_TOKEN must be defined to run external tests) 128 | else 129 | ginkgo -r test/external 130 | endif 131 | 132 | test-external-with-strict-decoding: deps 133 | ifndef CSP_API_TOKEN 134 | $(error CSP_API_TOKEN must be defined to run external tests) 135 | else 136 | MKPCLI_STRICT_DECODING=true ginkgo -r test/external 137 | endif 138 | 139 | test: deps lint test-units test-features test-external test-external-with-strict-decoding 140 | 141 | lint: lint-go lint-bash 142 | 143 | lint-go: deps-golangci-lint 144 | golangci-lint run 145 | 146 | BASH_SRC = $(shell find . -name "*.sh" ) 147 | lint-bash: $(BASH_SRC) deps-shellcheck 148 | shellcheck $(BASH_SRC) 149 | 150 | # #### DEVOPS #### 151 | .PHONY: set-pipeline 152 | set-pipeline: ci/pipeline.yaml 153 | fly -t tie set-pipeline --config ci/pipeline.yaml --pipeline marketplace-cli 154 | -------------------------------------------------------------------------------- /internal/models/vsx.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package models 5 | 6 | type VSXDeveloper struct { 7 | ID int64 `json:"id"` 8 | VSXID int64 `json:"vsxid"` 9 | Partner bool `json:"partner"` 10 | Slug string `json:"slug"` 11 | DisplayName string `json:"displayname"` 12 | Description string `json:"description"` 13 | Icon string `json:"icon"` 14 | URL string `json:"url"` 15 | SupportEmail string `json:"supportemail"` 16 | SupportURL string `json:"supporturl"` 17 | SupportPhone string `json:"supportphone"` 18 | TwitterUser string `json:"twitteruser"` 19 | FacebookURL string `json:"facebookurl"` 20 | LinkedInURL string `json:"linkedinurl"` 21 | YouTubeURL string `json:"youtubeurl"` 22 | State string `json:"state"` 23 | ReviewComment string `json:"reviewcomment"` 24 | CreatedOn int64 `json:"created"` 25 | UpdatedOn int64 `json:"lastupdated"` 26 | Count int64 `json:"count"` 27 | } 28 | 29 | type Category struct { 30 | ID int64 `json:"id"` 31 | Category *VSXCategory `json:"category"` 32 | } 33 | type VSXCategory struct { 34 | ID int64 `json:"id"` 35 | Type string `json:"type"` 36 | First string `json:"first"` 37 | Second string `json:"second"` 38 | Hidden bool `json:"hidden"` 39 | Description string `json:"description"` 40 | Icon string `json:"icon"` 41 | Link string `json:"link"` 42 | Count int64 `json:"count"` 43 | } 44 | type VSXContentType struct { 45 | ID int64 `json:"id"` 46 | DisplayName string `json:"displayname"` 47 | Count int64 `json:"count"` 48 | } 49 | 50 | type RelatedProduct struct { 51 | ID int64 `json:"id"` 52 | ShortName string `json:"shortname"` 53 | DisplayName string `json:"displayname"` 54 | Version string `json:"version"` 55 | EntitlementLevel string `json:"entitlementlevel"` 56 | ContentTypes string `json:"contenttypes"` 57 | Count int64 `json:"count"` 58 | ShortNameCount int64 `json:"shortnamecount"` 59 | EntitlementLevelCount int64 `json:"entitlementlevelcount"` 60 | LastUpdated int64 `json:"lastupdated"` 61 | } 62 | 63 | type VSXRelatedProducts struct { 64 | ID int64 `json:"id"` 65 | Product *RelatedProduct `json:"product"` 66 | VMwareReady bool `json:"vmwareready"` 67 | SupportStatement string `json:"supportstatement"` 68 | SupportStatementExternalLink bool `json:"supportstatementexternallink"` 69 | PartnerProduct string `json:"partnerproduct"` 70 | PartnerProductVersion string `json:"partnerproductversion"` 71 | ThirdPartyCompany string `json:"thirdpartycompany"` 72 | ThirdPartyProduct string `json:"thirdpartyproduct"` 73 | ThirdPartyProductVersion string `json:"thirdpartyproductversion"` 74 | PrimaryProduct bool `json:"primaryproduct"` 75 | } 76 | 77 | type OperatingSystem struct { 78 | ID int64 `json:"id"` 79 | Category *VSXCategory `json:"category"` 80 | } 81 | 82 | type SolutionArea struct { 83 | ID int64 `json:"id"` 84 | Category *VSXCategory `json:"category"` 85 | } 86 | 87 | type Technology struct { 88 | ID int64 `json:"id"` 89 | Category *VSXCategory `json:"category"` 90 | } 91 | 92 | type VSXDetails struct { 93 | ID int64 `json:"id"` 94 | UUID string `json:"uuid"` 95 | ShortName string `json:"shortname"` 96 | Version string `json:"version"` 97 | Revision int32 `json:"revision"` 98 | Featured bool `json:"featured"` 99 | Banner bool `json:"banner"` 100 | SupportsInProduct bool `json:"supportsinproduct"` 101 | Restricted bool `json:"restricted"` 102 | Developer *VSXDeveloper `json:"developer"` 103 | ContentType *VSXContentType `json:"contenttype"` 104 | VSGURL string `json:"vsgurl"` 105 | OtherMetadata string `json:"othermetadata"` 106 | NumViews int64 `json:"numviews"` 107 | NumInstalls int64 `json:"numinstalls"` 108 | NumReviews int64 `json:"numreviews"` 109 | AvgRating float32 `json:"avgrating"` 110 | Categories []*Category `json:"categoriesList"` 111 | SolutionAreas []*SolutionArea `json:"solutionareasList"` 112 | Technologies []*Technology `json:"technologiesList"` 113 | OperatingSystems []*OperatingSystem `json:"operatingsystemsList"` 114 | Tiers []*Tiers `json:"tiersList"` 115 | Products []*VSXRelatedProducts `json:"productsList"` 116 | ParentID int64 `json:"parentid"` 117 | } 118 | -------------------------------------------------------------------------------- /pkg/other_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | "github.com/spf13/viper" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal/internalfakes" 14 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 15 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 16 | "github.com/vmware-labs/marketplace-cli/v2/pkg/pkgfakes" 17 | "github.com/vmware-labs/marketplace-cli/v2/test" 18 | ) 19 | 20 | var _ = Describe("Other", func() { 21 | var ( 22 | httpClient *pkgfakes.FakeHTTPClient 23 | marketplace *pkg.Marketplace 24 | uploader *internalfakes.FakeUploader 25 | ) 26 | 27 | BeforeEach(func() { 28 | viper.Set("csp.refresh-token", "secrets") 29 | httpClient = &pkgfakes.FakeHTTPClient{} 30 | marketplace = &pkg.Marketplace{ 31 | Client: httpClient, 32 | Host: "marketplace.vmware.example", 33 | } 34 | uploader = &internalfakes.FakeUploader{} 35 | marketplace.SetUploader(uploader) 36 | }) 37 | 38 | Describe("AttachOtherFile", func() { 39 | var filePath string 40 | 41 | BeforeEach(func() { 42 | file, err := os.CreateTemp("", "mkpcli-attachotherfile-test-file.tgz") 43 | Expect(err).ToNot(HaveOccurred()) 44 | filePath = file.Name() 45 | uploader.UploadProductFileReturns("uploaded-file.tgz", "https://example.com/uploaded-file.tgz", err) 46 | 47 | httpClient.PutStub = PutProductEchoResponse 48 | }) 49 | 50 | AfterEach(func() { 51 | Expect(os.Remove(filePath)).To(Succeed()) 52 | }) 53 | 54 | It("uploads and attaches the file", func() { 55 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", "PENDING") 56 | test.AddVersions(product, "1.2.3") 57 | 58 | updatedProduct, err := marketplace.AttachOtherFile(filePath, product, &models.Version{Number: "1.2.3"}) 59 | Expect(err).ToNot(HaveOccurred()) 60 | 61 | By("uploading the file", func() { 62 | Expect(uploader.UploadProductFileCallCount()).To(Equal(1)) 63 | uploadedFilePath := uploader.UploadProductFileArgsForCall(0) 64 | Expect(uploadedFilePath).To(Equal(filePath)) 65 | }) 66 | 67 | By("updating the product in the marketplace", func() { 68 | Expect(updatedProduct.AddOnFiles).To(HaveLen(1)) 69 | uploadedFile := updatedProduct.AddOnFiles[0] 70 | Expect(uploadedFile.Name).To(Equal("uploaded-file.tgz")) 71 | Expect(uploadedFile.AppVersion).To(Equal("1.2.3")) 72 | Expect(uploadedFile.URL).To(Equal("https://example.com/uploaded-file.tgz")) 73 | Expect(uploadedFile.HashAlgorithm).To(Equal("SHA1")) 74 | Expect(uploadedFile.HashDigest).To(Equal("da39a3ee5e6b4b0d3255bfef95601890afd80709")) 75 | }) 76 | }) 77 | 78 | When("hashing fails", func() { 79 | It("returns an error", func() { 80 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", "PENDING") 81 | test.AddVersions(product, "1.2.3") 82 | 83 | _, err := marketplace.AttachOtherFile("this/file/does/not/exist", product, &models.Version{Number: "1.2.3"}) 84 | Expect(err).To(HaveOccurred()) 85 | Expect(err.Error()).To(Equal("failed to open this/file/does/not/exist: open this/file/does/not/exist: no such file or directory")) 86 | }) 87 | }) 88 | 89 | When("getting an uploader fails", func() { 90 | BeforeEach(func() { 91 | marketplace.SetUploader(nil) 92 | httpClient.GetReturns(nil, errors.New("get uploader failed")) 93 | }) 94 | 95 | It("returns an error", func() { 96 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", "PENDING") 97 | test.AddVersions(product, "1.2.3") 98 | 99 | _, err := marketplace.AttachOtherFile(filePath, product, &models.Version{Number: "1.2.3"}) 100 | Expect(err).To(HaveOccurred()) 101 | Expect(err.Error()).To(Equal("failed to get upload credentials: get uploader failed")) 102 | }) 103 | }) 104 | 105 | When("uploading the file fails", func() { 106 | BeforeEach(func() { 107 | uploader.UploadProductFileReturns("", "", errors.New("upload product file failed")) 108 | }) 109 | It("returns an error", func() { 110 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", "PENDING") 111 | test.AddVersions(product, "1.2.3") 112 | 113 | _, err := marketplace.AttachOtherFile(filePath, product, &models.Version{Number: "1.2.3"}) 114 | Expect(err).To(HaveOccurred()) 115 | Expect(err.Error()).To(Equal("upload product file failed")) 116 | }) 117 | }) 118 | 119 | When("updating the product fails", func() { 120 | BeforeEach(func() { 121 | httpClient.PutReturns(nil, errors.New("put product failed")) 122 | }) 123 | It("returns an error", func() { 124 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", "PENDING") 125 | test.AddVersions(product, "1.2.3") 126 | 127 | _, err := marketplace.AttachOtherFile(filePath, product, &models.Version{Number: "1.2.3"}) 128 | Expect(err).To(HaveOccurred()) 129 | Expect(err.Error()).To(Equal("sending the update for product \"hyperspace-database\" failed: put product failed")) 130 | }) 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /pkg/vm_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 VMware, Inc. 2 | // SPDX-License-Identifier: BSD-2-Clause 3 | 4 | package pkg_test 5 | 6 | import ( 7 | "errors" 8 | "os" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | "github.com/spf13/viper" 13 | "github.com/vmware-labs/marketplace-cli/v2/internal/internalfakes" 14 | "github.com/vmware-labs/marketplace-cli/v2/internal/models" 15 | "github.com/vmware-labs/marketplace-cli/v2/pkg" 16 | "github.com/vmware-labs/marketplace-cli/v2/pkg/pkgfakes" 17 | "github.com/vmware-labs/marketplace-cli/v2/test" 18 | ) 19 | 20 | var _ = Describe("VM", func() { 21 | var ( 22 | httpClient *pkgfakes.FakeHTTPClient 23 | marketplace *pkg.Marketplace 24 | uploader *internalfakes.FakeUploader 25 | ) 26 | 27 | BeforeEach(func() { 28 | viper.Set("csp.refresh-token", "secrets") 29 | httpClient = &pkgfakes.FakeHTTPClient{} 30 | marketplace = &pkg.Marketplace{ 31 | Client: httpClient, 32 | Host: "marketplace.vmware.example", 33 | } 34 | uploader = &internalfakes.FakeUploader{} 35 | marketplace.SetUploader(uploader) 36 | }) 37 | 38 | Describe("UploadVM", func() { 39 | var vmFilePath string 40 | 41 | BeforeEach(func() { 42 | vmFile, err := os.CreateTemp("", "mkpcli-uploadvm-test-vm.iso") 43 | Expect(err).ToNot(HaveOccurred()) 44 | vmFilePath = vmFile.Name() 45 | uploader.UploadProductFileReturns("uploaded-file.iso", "https://example.com/uploaded-file.iso", err) 46 | 47 | httpClient.PutStub = PutProductEchoResponse 48 | }) 49 | 50 | AfterEach(func() { 51 | Expect(os.Remove(vmFilePath)).To(Succeed()) 52 | }) 53 | 54 | It("uploads and attaches the vm file", func() { 55 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeISO) 56 | test.AddVersions(product, "1.2.3") 57 | 58 | updatedProduct, err := marketplace.UploadVM(vmFilePath, product, &models.Version{Number: "1.2.3"}) 59 | Expect(err).ToNot(HaveOccurred()) 60 | 61 | By("uploading the file", func() { 62 | Expect(uploader.UploadProductFileCallCount()).To(Equal(1)) 63 | uploadedFilePath := uploader.UploadProductFileArgsForCall(0) 64 | Expect(uploadedFilePath).To(Equal(vmFilePath)) 65 | }) 66 | 67 | By("updating the product in the marketplace", func() { 68 | Expect(updatedProduct.ProductDeploymentFiles).To(HaveLen(1)) 69 | uploadedFile := updatedProduct.ProductDeploymentFiles[0] 70 | Expect(uploadedFile.Name).To(Equal("uploaded-file.iso")) 71 | Expect(uploadedFile.AppVersion).To(Equal("1.2.3")) 72 | Expect(uploadedFile.Url).To(Equal("https://example.com/uploaded-file.iso")) 73 | Expect(uploadedFile.HashAlgo).To(Equal("SHA1")) 74 | Expect(uploadedFile.HashDigest).To(Equal("da39a3ee5e6b4b0d3255bfef95601890afd80709")) 75 | Expect(uploadedFile.IsRedirectUrl).To(BeFalse()) 76 | Expect(uploadedFile.UniqueFileID).To(MatchRegexp("fileuploader[0-9]+.url")) 77 | }) 78 | }) 79 | 80 | When("hashing fails", func() { 81 | It("returns an error", func() { 82 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeISO) 83 | test.AddVersions(product, "1.2.3") 84 | 85 | _, err := marketplace.UploadVM("this/file/does/not/exist", product, &models.Version{Number: "1.2.3"}) 86 | Expect(err).To(HaveOccurred()) 87 | Expect(err.Error()).To(Equal("failed to open this/file/does/not/exist: open this/file/does/not/exist: no such file or directory")) 88 | }) 89 | }) 90 | 91 | When("getting an uploader fails", func() { 92 | BeforeEach(func() { 93 | marketplace.SetUploader(nil) 94 | httpClient.GetReturns(nil, errors.New("get uploader failed")) 95 | }) 96 | 97 | It("returns an error", func() { 98 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeISO) 99 | test.AddVersions(product, "1.2.3") 100 | 101 | _, err := marketplace.UploadVM(vmFilePath, product, &models.Version{Number: "1.2.3"}) 102 | Expect(err).To(HaveOccurred()) 103 | Expect(err.Error()).To(Equal("failed to get upload credentials: get uploader failed")) 104 | }) 105 | }) 106 | 107 | When("uploading the VM image fails", func() { 108 | BeforeEach(func() { 109 | uploader.UploadProductFileReturns("", "", errors.New("upload product file failed")) 110 | }) 111 | It("returns an error", func() { 112 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeISO) 113 | test.AddVersions(product, "1.2.3") 114 | 115 | _, err := marketplace.UploadVM(vmFilePath, product, &models.Version{Number: "1.2.3"}) 116 | Expect(err).To(HaveOccurred()) 117 | Expect(err.Error()).To(Equal("upload product file failed")) 118 | }) 119 | }) 120 | 121 | When("updating the product fails", func() { 122 | BeforeEach(func() { 123 | httpClient.PutReturns(nil, errors.New("put product failed")) 124 | }) 125 | It("returns an error", func() { 126 | product := test.CreateFakeProduct("", "Hyperspace Database", "hyperspace-database", models.SolutionTypeISO) 127 | test.AddVersions(product, "1.2.3") 128 | 129 | _, err := marketplace.UploadVM(vmFilePath, product, &models.Version{Number: "1.2.3"}) 130 | Expect(err).To(HaveOccurred()) 131 | Expect(err.Error()).To(Equal("sending the update for product \"hyperspace-database\" failed: put product failed")) 132 | }) 133 | }) 134 | }) 135 | }) 136 | --------------------------------------------------------------------------------