diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index c099db5b3..992d371ef 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 intake](./stackit_beta_intake.md) - Provides functionality for intake * [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 1b4549617..07bc2be3e 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 cfe34ab0b..0dd6ce778 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 37dc9c476..8918edfbe 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.20.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.1 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.2.0 diff --git a/go.sum b/go.sum index 56bc40147..ea8aa0408 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.1 h1:RgWfaWDY8ZGZp5gEBe/A1r7s5NCRuLiYuHhscH6Ej9U= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index a60570613..6d377d455 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/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" @@ -42,4 +43,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(alb.NewCmd(params)) cmd.AddCommand(intake.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 7487f8ca3..7b8d499d8 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -48,6 +48,7 @@ const ( iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" intakeCustomEndpointFlag = "intake-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -163,6 +164,7 @@ func configureFlags(cmd *cobra.Command) { 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(intakeCustomEndpointFlag, "", "Intake API base URL, used in calls to this API") + 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) @@ -223,6 +225,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.IntakeCustomEndpointKey, cmd.Flags().Lookup(intakeCustomEndpointFlag)) 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 359248096..349197955 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -52,6 +52,7 @@ const ( iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" intakeCustomEndpointFlag = "intake-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -91,6 +92,7 @@ type inputModel struct { IaaSCustomEndpoint bool TokenCustomEndpoint bool IntakeCustomEndpoint bool + CDNCustomEndpoint bool } func NewCmd(params *params.CmdParams) *cobra.Command { @@ -217,6 +219,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { if model.IntakeCustomEndpoint { viper.Set(config.IntakeCustomEndpointKey, "") } + if model.CDNCustomEndpoint { + viper.Set(config.CDNCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -266,6 +271,7 @@ func configureFlags(cmd *cobra.Command) { 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(intakeCustomEndpointFlag, false, "Intake API base URL. If unset, uses the default base URL") + 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 { @@ -306,6 +312,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { IaaSCustomEndpoint: flags.FlagToBoolValue(p, cmd, iaasCustomEndpointFlag), TokenCustomEndpoint: flags.FlagToBoolValue(p, cmd, tokenCustomEndpointFlag), IntakeCustomEndpoint: flags.FlagToBoolValue(p, cmd, intakeCustomEndpointFlag), + 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 12eb2424f..e9a481767 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -45,6 +45,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool iaasCustomEndpointFlag: true, tokenCustomEndpointFlag: true, intakeCustomEndpointFlag: true, + cdnCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -86,6 +87,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { IaaSCustomEndpoint: true, TokenCustomEndpoint: true, IntakeCustomEndpoint: true, + CDNCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -143,6 +145,7 @@ func TestParseInput(t *testing.T) { model.IaaSCustomEndpoint = false model.TokenCustomEndpoint = false model.IntakeCustomEndpoint = false + model.CDNCustomEndpoint = false }), }, { @@ -305,6 +308,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 89cc4decb..26ea98c95 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -49,6 +49,7 @@ const ( TokenCustomEndpointKey = "token_custom_endpoint" GitCustomEndpointKey = "git_custom_endpoint" IntakeCustomEndpointKey = "intake_custom_endpoint" + CDNCustomEndpointKey = "cdn_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" @@ -111,6 +112,7 @@ var ConfigKeys = []string{ GitCustomEndpointKey, IntakeCustomEndpointKey, AlbCustomEndpoint, + CDNCustomEndpointKey, } var defaultConfigFolderPath string @@ -199,6 +201,7 @@ func setConfigDefaults() { viper.SetDefault(GitCustomEndpointKey, "") viper.SetDefault(IntakeCustomEndpointKey, "") 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) +}