diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 23097f8ca..fce0031cb 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,6 +42,7 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources * [stackit beta kms](./stackit_beta_kms.md) - Provides functionality for KMS * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_cdn.md b/docs/stackit_beta_cdn.md new file mode 100644 index 000000000..b0a99f688 --- /dev/null +++ b/docs/stackit_beta_cdn.md @@ -0,0 +1,34 @@ +## stackit beta cdn + +Manage CDN resources + +### Synopsis + +Manage the lifecycle of CDN resources. + +``` +stackit beta cdn [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution.md b/docs/stackit_beta_cdn_distribution.md new file mode 100644 index 000000000..583780dca --- /dev/null +++ b/docs/stackit_beta_cdn_distribution.md @@ -0,0 +1,34 @@ +## stackit beta cdn distribution + +Manage CDN distributions + +### Synopsis + +Manage the lifecycle of CDN distributions. + +``` +stackit beta cdn distribution [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources +* [stackit beta cdn distribution list](./stackit_beta_cdn_distribution_list.md) - List CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution_list.md b/docs/stackit_beta_cdn_distribution_list.md new file mode 100644 index 000000000..38873ae02 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_list.md @@ -0,0 +1,44 @@ +## stackit beta cdn distribution list + +List CDN distributions + +### Synopsis + +List all CDN distributions in your account. + +``` +stackit beta cdn distribution list [flags] +``` + +### Examples + +``` + List all CDN distributions + $ stackit beta cdn distribution list + + List all CDN distributions sorted by id + $ stackit beta cdn distribution list --sort-by=id +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution list" + --sort-by string Sort entries by a specific field, one of ["id" "createdAt" "updatedAt" "originUrl" "status" "originUrlRelated"] (default "createdAt") +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 9b07e46b5..70382292f 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -31,6 +31,7 @@ stackit config set [flags] ``` --allowed-url-domain string Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command --authorization-custom-endpoint string Authorization API base URL, used in calls to this API + --cdn-custom-endpoint string CDN API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API -h, --help Help for "stackit config set" --iaas-custom-endpoint string IaaS API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 9d4c83088..0e85f4e9f 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -29,6 +29,7 @@ stackit config unset [flags] --allowed-url-domain Domain name, used for the verification of the URLs that are given in the IDP endpoint and curl commands. If unset, defaults to stackit.cloud --async Configuration option to run commands asynchronously --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL + --cdn-custom-endpoint Custom CDN endpoint URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL -h, --help Help for "stackit config unset" --iaas-custom-endpoint IaaS API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index 9fc4511c3..c6736283d 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.19.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.1 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 + github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 github.com/stackitcloud/stackit-sdk-go/services/git v0.9.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.2.0 diff --git a/go.sum b/go.sum index 65d126b27..3830937c3 100644 --- a/go.sum +++ b/go.sum @@ -567,6 +567,8 @@ github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.1 h1:DaJkEN/6l+AJEQ3Dr+ github.com/stackitcloud/stackit-sdk-go/services/alb v0.7.1/go.mod h1:SzA+UsSNv4D9IvNT7hwYPewgAvUgj5WXIU2tZ0XaMBI= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 h1:7ZKd3b+E/R4TEVShLTXxx5FrsuDuJBOyuVOuKTMa4mo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0/go.mod h1:/FoXa6hF77Gv8brrvLBCKa5ie1Xy9xn39yfHwaln9Tw= +github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 h1:Q+qIdejeMsYMkbtVoI9BpGlKGdSVFRBhH/zj44SP8TM= +github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0/go.mod h1:YGadfhuy8yoseczTxF7vN4t9ES2WxGQr0Pug14ii7y4= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 h1:CnhAMLql0MNmAeq4roQKN8OpSKX4FSgTU6Eu6detB4I= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1/go.mod h1:7Bx85knfNSBxulPdJUFuBePXNee3cO+sOTYnUG6M+iQ= github.com/stackitcloud/stackit-sdk-go/services/git v0.9.0 h1:zuoJnsLnjxdQcQbs7gUXYzrN0Ip5NXj+6LFBp1EO6cg= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index b026da770..8b50b53ca 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/params" @@ -40,4 +41,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) cmd.AddCommand(kms.NewCmd(params)) + cmd.AddCommand(cdn.NewCmd(params)) } diff --git a/internal/cmd/beta/cdn/cdn.go b/internal/cmd/beta/cdn/cdn.go new file mode 100644 index 000000000..794c81bd2 --- /dev/null +++ b/internal/cmd/beta/cdn/cdn.go @@ -0,0 +1,25 @@ +package cdn + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "cdn", + Short: "Manage CDN resources", + Long: "Manage the lifecycle of CDN resources.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(distribution.NewCommand(params)) +} diff --git a/internal/cmd/beta/cdn/distribution/distribution.go b/internal/cmd/beta/cdn/distribution/distribution.go new file mode 100644 index 000000000..72d48c7fe --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/distribution.go @@ -0,0 +1,24 @@ +package distribution + +import ( + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCommand(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "distribution", + Short: "Manage CDN distributions", + Long: "Manage the lifecycle of CDN distributions.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/cdn/distribution/list/list.go b/internal/cmd/beta/cdn/distribution/list/list.go new file mode 100644 index 000000000..22dc326dd --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/list/list.go @@ -0,0 +1,153 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SortBy string +} + +const ( + sortByFlag = "sort-by" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List CDN distributions", + Long: "List all CDN distributions in your account.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all CDN distributions`, + `$ stackit beta cdn distribution list`, + ), + examples.NewExample( + `List all CDN distributions sorted by id`, + `$ stackit beta cdn distribution list --sort-by=id`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() // should this be cancellable? + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + distributions, err := fetchDistributions(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("fetch distributions: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, distributions) + }, + } + + configureFlags(cmd) + return cmd +} + +var sortByFlagOptions = []string{"id", "createdAt", "updatedAt", "originUrl", "status", "originUrlRelated"} + +func configureFlags(cmd *cobra.Command) { + // same default as apiClient + cmd.Flags().Var(flags.EnumFlag(false, "createdAt", sortByFlagOptions...), sortByFlag, fmt.Sprintf("Sort entries by a specific field, one of %q", sortByFlagOptions)) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SortBy: flags.FlagWithDefaultToStringValue(p, cmd, sortByFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient, nextPageID cdn.ListDistributionsResponseGetNextPageIdentifierAttributeType) cdn.ApiListDistributionsRequest { + req := apiClient.ListDistributions(ctx, model.GlobalFlagModel.ProjectId) + req = req.SortBy(model.SortBy) + req = req.PageSize(100) + if nextPageID != nil { + req = req.PageIdentifier(*nextPageID) + } + return req +} + +func outputResult(p *print.Printer, outputFormat string, distributions []cdn.Distribution) error { + if distributions == nil { + distributions = make([]cdn.Distribution, 0) // otherwise prints null in json output + } + return p.OutputResult(outputFormat, distributions, func() error { + if len(distributions) == 0 { + p.Outputln("No CDN distributions found") + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "REGIONS", "STATUS") + for i := range distributions { + d := &distributions[i] + joinedRegions := strings.Join(sdkUtils.EnumSliceToStringSlice(*d.Config.Regions), ", ") + table.AddRow( + utils.PtrString(d.Id), + joinedRegions, + utils.PtrString(d.Status), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} + +func fetchDistributions(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) ([]cdn.Distribution, error) { + var nextPageID cdn.ListDistributionsResponseGetNextPageIdentifierAttributeType + var distributions []cdn.Distribution + for { + request := buildRequest(ctx, model, apiClient, nextPageID) + response, err := request.Execute() + if err != nil { + return nil, fmt.Errorf("list distributions: %w", err) + } + nextPageID = response.NextPageIdentifier + if response.Distributions != nil { + distributions = append(distributions, *response.Distributions...) + } + if nextPageID == nil { + break + } + } + return distributions, nil +} diff --git a/internal/cmd/beta/cdn/distribution/list/list_test.go b/internal/cmd/beta/cdn/distribution/list/list_test.go new file mode 100644 index 000000000..287be0a58 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/list/list_test.go @@ -0,0 +1,434 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +type testCtxKey struct{} + +var testProjectId = uuid.NewString() +var testClient = &cdn.APIClient{} +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + +const ( + testNextPageID = "next-page-id-123" + testID = "dist-1" + testStatus = cdn.DISTRIBUTIONSTATUS_ACTIVE +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func flagSortBy(sortBy string) func(m map[string]string) { + return func(m map[string]string) { + m[sortByFlag] = sortBy + } +} + +func flagProjectId(id *string) func(m map[string]string) { + return func(m map[string]string) { + if id == nil { + delete(m, globalflags.ProjectIdFlag) + } else { + m[globalflags.ProjectIdFlag] = *id + } + } +} + +func fixtureInputModel(mods ...func(m *inputModel)) *inputModel { + m := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SortBy: "createdAt", + } + for _, mod := range mods { + mod(m) + } + return m +} + +func inputSortBy(sortBy string) func(m *inputModel) { + return func(m *inputModel) { + m.SortBy = sortBy + } +} + +func fixtureRequest(mods ...func(r cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + r := testClient.ListDistributions(testCtx, testProjectId) + r = r.PageSize(100) + r = r.SortBy("createdAt") + for _, mod := range mods { + r = mod(r) + } + return r +} + +func requestSortBy(sortBy string) func(r cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return func(r cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return r.SortBy(sortBy) + } +} + +func requestNextPageID(nextPageID string) func(r cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return func(r cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return r.PageIdentifier(nextPageID) + } +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "no project id", + flagValues: fixtureFlagValues(flagProjectId(nil)), + isValid: false, + }, + { + description: "sort by id", + flagValues: fixtureFlagValues(flagSortBy("id")), + isValid: true, + expected: fixtureInputModel(inputSortBy("id")), + }, + { + description: "sort by origin-url", + flagValues: fixtureFlagValues(flagSortBy("originUrl")), + isValid: true, + expected: fixtureInputModel(inputSortBy("originUrl")), + }, + { + description: "sort by status", + flagValues: fixtureFlagValues(flagSortBy("status")), + isValid: true, + expected: fixtureInputModel(inputSortBy("status")), + }, + { + description: "sort by created", + flagValues: fixtureFlagValues(flagSortBy("createdAt")), + isValid: true, + expected: fixtureInputModel(inputSortBy("createdAt")), + }, + { + description: "sort by updated", + flagValues: fixtureFlagValues(flagSortBy("updatedAt")), + isValid: true, + expected: fixtureInputModel(inputSortBy("updatedAt")), + }, + { + description: "sort by originUrlRelated", + flagValues: fixtureFlagValues(flagSortBy("originUrlRelated")), + isValid: true, + expected: fixtureInputModel(inputSortBy("originUrlRelated")), + }, + { + description: "invalid sort by", + flagValues: fixtureFlagValues(flagSortBy("invalid")), + isValid: false, + }, + { + description: "missing sort by uses default", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, sortByFlag) + }, + ), + isValid: true, + expected: fixtureInputModel(inputSortBy("createdAt")), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + nextPageID *string + expected cdn.ApiListDistributionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expected: fixtureRequest(), + }, + { + description: "sort by updatedAt", + inputModel: fixtureInputModel(inputSortBy("updatedAt")), + expected: fixtureRequest(requestSortBy("updatedAt")), + }, + { + description: "with next page id", + inputModel: fixtureInputModel(), + nextPageID: utils.Ptr(testNextPageID), + expected: fixtureRequest(requestNextPageID(testNextPageID)), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + req := buildRequest(testCtx, tt.inputModel, testClient, tt.nextPageID) + diff := cmp.Diff(req, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type testResponse struct { + statusCode int + body cdn.ListDistributionsResponse +} + +func fixtureTestResponse(mods ...func(r *testResponse)) testResponse { + r := testResponse{ + statusCode: 200, + } + for _, mod := range mods { + mod(&r) + } + return r +} + +func responseStatus(statusCode int) func(r *testResponse) { + return func(r *testResponse) { + r.statusCode = statusCode + } +} + +func responseNextPageID(nextPageID *string) func(r *testResponse) { + return func(r *testResponse) { + r.body.NextPageIdentifier = nextPageID + } +} + +func responseDistributions(distributions ...cdn.Distribution) func(r *testResponse) { + return func(r *testResponse) { + r.body.Distributions = &distributions + } +} + +func fixtureDistribution(id string) cdn.Distribution { + return cdn.Distribution{ + Id: &id, + } +} + +func TestFetchDistributions(t *testing.T) { + tests := []struct { + description string + responses []testResponse + expected []cdn.Distribution + fails bool + }{ + { + description: "no distributions", + responses: []testResponse{ + fixtureTestResponse(), + }, + expected: nil, + }, + { + description: "single distribution, single page", + responses: []testResponse{ + fixtureTestResponse( + responseDistributions(fixtureDistribution("dist-1")), + ), + }, + expected: []cdn.Distribution{ + fixtureDistribution("dist-1"), + }, + }, + { + description: "multiple distributions, multiple pages", + responses: []testResponse{ + fixtureTestResponse( + responseNextPageID(utils.Ptr(testNextPageID)), + responseDistributions( + fixtureDistribution("dist-1"), + ), + ), + fixtureTestResponse( + responseDistributions( + fixtureDistribution("dist-2"), + ), + ), + }, + expected: []cdn.Distribution{ + fixtureDistribution("dist-1"), + fixtureDistribution("dist-2"), + }, + }, + { + description: "API error", + responses: []testResponse{ + fixtureTestResponse( + responseStatus(500), + ), + }, + fails: true, + }, + { + description: "API error on second page", + responses: []testResponse{ + fixtureTestResponse( + responseNextPageID(utils.Ptr(testNextPageID)), + responseDistributions( + fixtureDistribution("dist-1"), + ), + ), + fixtureTestResponse(responseStatus(500)), + }, + fails: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := tt.responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.statusCode) + bs, err := json.Marshal(resp.body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = w.Write(bs) + if err != nil { + t.Fatalf("write: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + client, err := cdn.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test client: %v", err) + } + got, err := fetchDistributions(testCtx, fixtureInputModel(), client) + if err != nil { + if !tt.fails { + t.Fatalf("fetchDistributions() unexpected error: %v", err) + } + return + } + if callCount != len(tt.responses) { + t.Errorf("fetchDistributions() expected %d calls, got %d", len(tt.responses), callCount) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Errorf("fetchDistributions() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + distributions []cdn.Distribution + expected string + }{ + { + description: "no distributions", + outputFormat: "json", + distributions: []cdn.Distribution{}, + expected: `[] +`, + }, + { + description: "no distributions nil slice", + outputFormat: "json", + expected: `[] +`, + }, + { + description: "single distribution", + outputFormat: "table", + distributions: []cdn.Distribution{ + { + Id: utils.Ptr(testID), + Config: &cdn.Config{ + Regions: &[]cdn.Region{ + cdn.REGION_EU, + cdn.REGION_AF, + }, + }, + Status: utils.Ptr(testStatus), + }, + }, + expected: ` + ID │ REGIONS │ STATUS +────────┼─────────┼──────── + dist-1 │ EU, AF │ ACTIVE + +`, + }, + { + description: "no distributions, table format", + outputFormat: "table", + expected: "No CDN distributions found\n", + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + buffer := &bytes.Buffer{} + p.Cmd.SetOut(buffer) + if err := outputResult(p, tt.outputFormat, tt.distributions); err != nil { + t.Fatalf("outputResult: %v", err) + } + if buffer.String() != tt.expected { + t.Errorf("want:\n%s\ngot:\n%s", tt.expected, buffer.String()) + } + }) + } +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index e694e8901..93548fa10 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -47,6 +47,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -161,6 +162,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sqlServerFlexCustomEndpointFlag, "", "SQLServer Flex API base URL, used in calls to this API") cmd.Flags().String(iaasCustomEndpointFlag, "", "IaaS API base URL, used in calls to this API") cmd.Flags().String(tokenCustomEndpointFlag, "", "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") + cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -219,6 +221,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.TokenCustomEndpointKey, cmd.Flags().Lookup(tokenCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.CDNCustomEndpointKey, cmd.Flags().Lookup(cdnCustomEndpointFlag)) + cobra.CheckErr(err) } func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 28ed935b9..33a2fa5c5 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -51,6 +51,7 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -89,6 +90,7 @@ type inputModel struct { SQLServerFlexCustomEndpoint bool IaaSCustomEndpoint bool TokenCustomEndpoint bool + CDNCustomEndpoint bool } func NewCmd(params *params.CmdParams) *cobra.Command { @@ -212,6 +214,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { if model.TokenCustomEndpoint { viper.Set(config.TokenCustomEndpointKey, "") } + if model.CDNCustomEndpoint { + viper.Set(config.CDNCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -260,6 +265,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(sqlServerFlexCustomEndpointFlag, false, "SQLServer Flex API base URL. If unset, uses the default base URL") cmd.Flags().Bool(iaasCustomEndpointFlag, false, "IaaS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(tokenCustomEndpointFlag, false, "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") + cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -299,6 +305,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { SQLServerFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, sqlServerFlexCustomEndpointFlag), IaaSCustomEndpoint: flags.FlagToBoolValue(p, cmd, iaasCustomEndpointFlag), TokenCustomEndpoint: flags.FlagToBoolValue(p, cmd, tokenCustomEndpointFlag), + CDNCustomEndpoint: flags.FlagToBoolValue(p, cmd, cdnCustomEndpointFlag), } p.DebugInputModel(model) diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index e48dff384..1a35587e9 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -44,6 +44,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool sqlServerFlexCustomEndpointFlag: true, iaasCustomEndpointFlag: true, tokenCustomEndpointFlag: true, + cdnCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -84,6 +85,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { SQLServerFlexCustomEndpoint: true, IaaSCustomEndpoint: true, TokenCustomEndpoint: true, + CDNCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -140,6 +142,7 @@ func TestParseInput(t *testing.T) { model.SQLServerFlexCustomEndpoint = false model.IaaSCustomEndpoint = false model.TokenCustomEndpoint = false + model.CDNCustomEndpoint = false }), }, { @@ -302,6 +305,16 @@ func TestParseInput(t *testing.T) { model.TokenCustomEndpoint = false }), }, + { + description: "cdn custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[cdnCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.CDNCustomEndpoint = false + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 9a03ac3b8..0c91034f0 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -48,6 +48,7 @@ const ( IaaSCustomEndpointKey = "iaas_custom_endpoint" TokenCustomEndpointKey = "token_custom_endpoint" GitCustomEndpointKey = "git_custom_endpoint" + CDNCustomEndpointKey = "cdn_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -109,6 +110,7 @@ var ConfigKeys = []string{ TokenCustomEndpointKey, GitCustomEndpointKey, AlbCustomEndpoint, + CDNCustomEndpointKey, } var defaultConfigFolderPath string @@ -196,6 +198,7 @@ func setConfigDefaults() { viper.SetDefault(TokenCustomEndpointKey, "") viper.SetDefault(GitCustomEndpointKey, "") viper.SetDefault(AlbCustomEndpoint, "") + viper.SetDefault(CDNCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/cdn/client/client.go b/internal/pkg/services/cdn/client/client.go new file mode 100644 index 000000000..afefb7a92 --- /dev/null +++ b/internal/pkg/services/cdn/client/client.go @@ -0,0 +1,13 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*cdn.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.CDNCustomEndpointKey), true, cdn.NewAPIClient) +}