diff --git a/CHANGELOG.md b/CHANGELOG.md index eded7a38..3140416f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- feat: add command `miactl marketplace list-versions` + ### Fixed - help text of version command diff --git a/docs/30_commands.md b/docs/30_commands.md index fcfbfaec..3c261610 100644 --- a/docs/30_commands.md +++ b/docs/30_commands.md @@ -399,3 +399,21 @@ miactl marketplace apply -f myFantasticGoTemplates -f, --file stringArray paths to JSON/YAML files or folder of files containing a Marketplace item definition -h, --help help for apply ``` + +### list-versions (ALPHA) + +:::warning + +This command is in ALPHA state. This means that it can be subject to breaking changes in the next versions of miactl. + +::: + +List all the available versions of a specific Marketplace item. + +#### Synopsis + +The flag `--item-id` or `-i` accepts the `itemId` of the Item. + +``` +miactl marketplace list-versions -i some-item +``` diff --git a/internal/clioptions/clioptions.go b/internal/clioptions/clioptions.go index 9bd1905a..f7d1f486 100644 --- a/internal/clioptions/clioptions.go +++ b/internal/clioptions/clioptions.go @@ -53,6 +53,7 @@ type CLIOptions struct { OutputPath string MarketplaceResourcePaths []string + MarketplaceItemID string FromCronJob string @@ -135,6 +136,12 @@ func (o *CLIOptions) AddMarketplaceApplyFlags(cmd *cobra.Command) { } } +func (o *CLIOptions) AddMarketplaceGetItemVersionsFlags(cmd *cobra.Command) string { + flagName := "item-id" + cmd.Flags().StringVarP(&o.MarketplaceItemID, flagName, "i", "", "The itemId of the item") + return flagName +} + func (o *CLIOptions) AddCreateJobFlags(flags *pflag.FlagSet) { flags.StringVar(&o.FromCronJob, "from", "", "name of the cronjob to create a Job from") } diff --git a/internal/cmd/marketplace.go b/internal/cmd/marketplace.go index e07a222b..fa87233b 100644 --- a/internal/cmd/marketplace.go +++ b/internal/cmd/marketplace.go @@ -39,6 +39,7 @@ func MarketplaceCmd(options *clioptions.CLIOptions) *cobra.Command { cmd.AddCommand(marketplace.GetCmd(options)) cmd.AddCommand(marketplace.DeleteCmd(options)) cmd.AddCommand(marketplace_apply.ApplyCmd(options)) + cmd.AddCommand(marketplace.ListVersionCmd(options)) return cmd } diff --git a/internal/cmd/marketplace/list_versions.go b/internal/cmd/marketplace/list_versions.go new file mode 100644 index 00000000..a1c6c4a4 --- /dev/null +++ b/internal/cmd/marketplace/list_versions.go @@ -0,0 +1,132 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package marketplace + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/mia-platform/miactl/internal/client" + "github.com/mia-platform/miactl/internal/clioptions" + "github.com/mia-platform/miactl/internal/resources/marketplace" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" +) + +const listItemVersionsEndpointTemplate = "/api/backend/marketplace/tenants/%s/resources/%s/versions" + +var ( + ErrItemNotFound = errors.New("item not found") + ErrGenericServerError = errors.New("server error while fetching item versions") + ErrMissingCompanyID = errors.New("companyID is required") +) + +// ListVersionCmd return a new cobra command for listing marketplace item versions +func ListVersionCmd(options *clioptions.CLIOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "list-versions", + Short: "List versions of a Marketplace item (ALPHA)", + Long: `List the currently available versions of a Marketplace item. +The command will output a table with each version of the item. + +This command is in ALPHA state. This means that it can be subject to breaking changes in the next versions of miactl.`, + Run: func(cmd *cobra.Command, args []string) { + restConfig, err := options.ToRESTConfig() + cobra.CheckErr(err) + client, err := client.APIClientForConfig(restConfig) + cobra.CheckErr(err) + + releases, err := getItemVersions( + client, + restConfig.CompanyID, + options.MarketplaceItemID, + ) + cobra.CheckErr(err) + + table := buildItemVersionListTable(releases) + + fmt.Println(table) + }, + } + + flagName := options.AddMarketplaceGetItemVersionsFlags(cmd) + err := cmd.MarkFlagRequired(flagName) + if err != nil { + // the error is only due to a programming error (missing command flag), hence panic + panic(err) + } + + return cmd +} + +func getItemVersions(client *client.APIClient, companyID, itemID string) (*[]marketplace.Release, error) { + if companyID == "" { + return nil, ErrMissingCompanyID + } + resp, err := client. + Get(). + APIPath( + fmt.Sprintf(listItemVersionsEndpointTemplate, companyID, itemID), + ). + Do(context.Background()) + + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + switch resp.StatusCode() { + case http.StatusOK: + releases := &[]marketplace.Release{} + err = resp.ParseResponse(releases) + if err != nil { + return nil, fmt.Errorf("error parsing response body: %w", err) + } + return releases, nil + case http.StatusNotFound: + return nil, fmt.Errorf("%w: %s", ErrItemNotFound, itemID) + } + return nil, ErrGenericServerError +} + +func buildItemVersionListTable(releases *[]marketplace.Release) string { + strBuilder := &strings.Builder{} + table := tablewriter.NewWriter(strBuilder) + table.SetBorders(tablewriter.Border{Left: false, Top: false, Right: false, Bottom: false}) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetAutoWrapText(false) + table.SetHeader([]string{"Version", "Name", "Description"}) + + for _, release := range *releases { + description := "-" + if release.Description != "" { + description = release.Description + } + table.Append([]string{ + release.Version, + release.Name, + description, + }) + } + table.Render() + + return strBuilder.String() +} diff --git a/internal/cmd/marketplace/list_versions_test.go b/internal/cmd/marketplace/list_versions_test.go new file mode 100644 index 00000000..335d0cd9 --- /dev/null +++ b/internal/cmd/marketplace/list_versions_test.go @@ -0,0 +1,222 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package marketplace + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mia-platform/miactl/internal/client" + "github.com/mia-platform/miactl/internal/clioptions" + "github.com/mia-platform/miactl/internal/resources/marketplace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + listVersionsMockResponseBody = `[ + { + "name": "Some Awesome Service", + "description": "The Awesome Service allows to do some amazing stuff.", + "version": "1.0.0", + "reference": "655342ce0f991db238fd73e4", + "security": false, + "releaseNote": "-", + "visibility": { + "public": true + } + }, + { + "name": "Some Awesome Service v2", + "description": "The Awesome Service allows to do some amazing stuff.", + "version": "2.0.0", + "reference": "655342ce0f991db238fd73e4", + "security": false, + "releaseNote": "-", + "visibility": { + "public": true + } + } +]` +) + +func TestNewListVersionsCmd(t *testing.T) { + t.Run("test command creation", func(t *testing.T) { + opts := clioptions.NewCLIOptions() + cmd := ListVersionCmd(opts) + require.NotNil(t, cmd) + }) +} + +func TestGetItemVersions(t *testing.T) { + testCases := []struct { + testName string + + companyID string + itemID string + + statusCode int + errorResponse map[string]string + + expected []marketplace.Release + expectedErr error + }{ + { + testName: "should return correct result when the endpoint answers 200 OK", + companyID: "some-company", + itemID: "some-item", + statusCode: http.StatusOK, + expected: []marketplace.Release{ + { + Name: "Some Awesome Service", + Description: "The Awesome Service allows to do some amazing stuff.", + Version: "1.0.0", + }, + { + Name: "Some Awesome Service v2", + Description: "The Awesome Service allows to do some amazing stuff.", + Version: "2.0.0", + }, + }, + expectedErr: nil, + }, + { + testName: "should return not found error if item is not found", + companyID: "some-company", + itemID: "some-item", + statusCode: http.StatusNotFound, + errorResponse: map[string]string{ + "error": "Not Found", + }, + expected: nil, + expectedErr: ErrItemNotFound, + }, + { + testName: "should return generic error if item is not found", + companyID: "some-company", + itemID: "some-item", + statusCode: http.StatusInternalServerError, + errorResponse: map[string]string{ + "error": "Internal Server Error", + }, + expected: nil, + expectedErr: ErrGenericServerError, + }, + { + testName: "should return error on missing companyID", + companyID: "", + itemID: "some-item", + expectedErr: ErrMissingCompanyID, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.testName, func(t *testing.T) { + var expectedBytes []byte + if testCase.expected != nil { + expectedBytes = []byte(listVersionsMockResponseBody) + } else { + var err error + expectedBytes, err = json.Marshal(testCase.errorResponse) + require.NoError(t, err) + } + + mockServer := buildMockListVersionServer( + t, + testCase.statusCode, + expectedBytes, + testCase.companyID, + testCase.itemID, + ) + defer mockServer.Close() + client, err := client.APIClientForConfig(&client.Config{ + Transport: http.DefaultTransport, + Host: mockServer.URL, + }) + require.NoError(t, err) + + found, err := getItemVersions(client, testCase.companyID, testCase.itemID) + if testCase.expectedErr != nil { + require.ErrorIs(t, err, testCase.expectedErr) + require.Nil(t, found) + } else { + require.NoError(t, err) + require.Equal(t, &testCase.expected, found) + } + }) + } +} + +func TestBuildMarketplaceItemVersionList(t *testing.T) { + testCases := map[string]struct { + releases []marketplace.Release + expectedContains []string + }{ + "should show all fields": { + releases: []marketplace.Release{ + { + Version: "1.0.0", + Name: "Some Awesome Service", + Description: "The Awesome Service allows to do some amazing stuff.", + }, + }, + expectedContains: []string{ + "VERSION", "NAME", "DESCRIPTION", + "1.0.0", "Some Awesome Service", "The Awesome Service allows to do some amazing stuff.", + }, + }, + "should show - on empty description": { + releases: []marketplace.Release{ + { + Version: "1.0.0", + Name: "Some Awesome Service", + }, + }, + expectedContains: []string{ + "VERSION", "NAME", "DESCRIPTION", + "1.0.0", "Some Awesome Service", "-", + }, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + found := buildItemVersionListTable(&testCase.releases) + assert.NotZero(t, found) + for _, expected := range testCase.expectedContains { + assert.Contains(t, found, expected) + } + }) + } +} + +func buildMockListVersionServer(t *testing.T, statusCode int, responseBody []byte, companyID, itemID string) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal( + t, + r.RequestURI, + fmt.Sprintf(listItemVersionsEndpointTemplate, companyID, itemID), + ) + w.WriteHeader(statusCode) + w.Write(responseBody) + })) +} diff --git a/internal/resources/marketplace/marketplace.go b/internal/resources/marketplace/marketplace.go index a4f14609..da8466a9 100644 --- a/internal/resources/marketplace/marketplace.go +++ b/internal/resources/marketplace/marketplace.go @@ -54,6 +54,12 @@ type UploadImageResponse struct { Location string `json:"location"` } +type Release struct { + Version string `json:"version"` + Name string `json:"name"` + Description string `json:"description"` +} + func (i *Item) MarshalItem(encodingFormat string) ([]byte, error) { return encoding.MarshalData(i, encodingFormat, encoding.MarshalOptions{Indent: true}) }