diff --git a/docs/stackit_beta_cdn_distribution.md b/docs/stackit_beta_cdn_distribution.md index 583780dca..c9c26a931 100644 --- a/docs/stackit_beta_cdn_distribution.md +++ b/docs/stackit_beta_cdn_distribution.md @@ -30,5 +30,9 @@ stackit beta cdn distribution [flags] ### SEE ALSO * [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources +* [stackit beta cdn distribution create](./stackit_beta_cdn_distribution_create.md) - Create a CDN distribution +* [stackit beta cdn distribution delete](./stackit_beta_cdn_distribution_delete.md) - Delete a CDN distribution +* [stackit beta cdn distribution describe](./stackit_beta_cdn_distribution_describe.md) - Describe a CDN distribution * [stackit beta cdn distribution list](./stackit_beta_cdn_distribution_list.md) - List CDN distributions +* [stackit beta cdn distribution update](./stackit_beta_cdn_distribution_update.md) - Update a CDN distribution diff --git a/docs/stackit_beta_cdn_distribution_create.md b/docs/stackit_beta_cdn_distribution_create.md new file mode 100644 index 000000000..6b04842ee --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_create.md @@ -0,0 +1,63 @@ +## stackit beta cdn distribution create + +Create a CDN distribution + +### Synopsis + +Create a CDN distribution for a given originUrl in multiple regions. + +``` +stackit beta cdn distribution create [flags] +``` + +### Examples + +``` + Create a CDN distribution with an HTTP backend + $ stackit beta cdn create --http --http-origin-url https://example.com \ +--regions AF,EU + + Create a CDN distribution with an Object Storage backend + $ stackit beta cdn create --bucket --bucket-url https://bucket.example.com \ +--bucket-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU +``` + +### Options + +``` + --blocked-countries strings Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR') + --blocked-ips strings Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1') + --bucket Use Object Storage backend + --bucket-credentials-access-key-id string Access Key ID for Object Storage backend + --bucket-region string Region for Object Storage backend + --bucket-url string Bucket URL for Object Storage backend + --default-cache-duration string ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes) + -h, --help Help for "stackit beta cdn distribution create" + --http Use HTTP backend + --http-geofencing stringArray Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable. + --http-origin-request-headers strings Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers! + --http-origin-url string Origin URL for HTTP backend + --loki Enable Loki log sink for the CDN distribution + --loki-push-url string Push URL for log sink + --loki-username string Username for log sink + --monthly-limit-bytes int Monthly limit in bytes for the CDN distribution + --optimizer Enable optimizer for the CDN distribution (paid feature). + --regions strings Regions in which content should be cached, multiple of: ["EU" "US" "AF" "SA" "ASIA"] (default []) +``` + +### 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_beta_cdn_distribution_delete.md b/docs/stackit_beta_cdn_distribution_delete.md new file mode 100644 index 000000000..7313b5a39 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_delete.md @@ -0,0 +1,40 @@ +## stackit beta cdn distribution delete + +Delete a CDN distribution + +### Synopsis + +Delete a CDN distribution by its ID. + +``` +stackit beta cdn distribution delete [flags] +``` + +### Examples + +``` + Delete a CDN distribution with ID "xxx" + $ stackit beta cdn distribution delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution delete" +``` + +### 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_beta_cdn_distribution_describe.md b/docs/stackit_beta_cdn_distribution_describe.md new file mode 100644 index 000000000..1e8f68a7e --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_describe.md @@ -0,0 +1,44 @@ +## stackit beta cdn distribution describe + +Describe a CDN distribution + +### Synopsis + +Describe a CDN distribution by its ID. + +``` +stackit beta cdn distribution describe [flags] +``` + +### Examples + +``` + Get details of a CDN distribution with ID "xxx" + $ stackit beta cdn distribution describe xxx + + Get details of a CDN, including WAF details, for ID "xxx" + $ stackit beta cdn distribution describe xxx --with-waf +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution describe" + --with-waf Include WAF details in the distribution description +``` + +### 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_beta_cdn_distribution_list.md b/docs/stackit_beta_cdn_distribution_list.md index 38873ae02..4fc5d2750 100644 --- a/docs/stackit_beta_cdn_distribution_list.md +++ b/docs/stackit_beta_cdn_distribution_list.md @@ -23,6 +23,7 @@ stackit beta cdn distribution list [flags] ### Options ``` + -- int Limit the output to the first n elements -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") ``` diff --git a/docs/stackit_beta_cdn_distribution_update.md b/docs/stackit_beta_cdn_distribution_update.md new file mode 100644 index 000000000..f8f26dec9 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_update.md @@ -0,0 +1,57 @@ +## stackit beta cdn distribution update + +Update a CDN distribution + +### Synopsis + +Update a CDN distribution by its ID, allowing replacement of its regions. + +``` +stackit beta cdn distribution update [flags] +``` + +### Examples + +``` + update a CDN distribution with ID "123e4567-e89b-12d3-a456-426614174000" to not use optimizer + $ stackit beta cdn update 123e4567-e89b-12d3-a456-426614174000 --optimizer=false +``` + +### Options + +``` + --blocked-countries strings Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR') + --blocked-ips strings Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1') + --bucket Use Object Storage backend + --bucket-credentials-access-key-id string Access Key ID for Object Storage backend + --bucket-region string Region for Object Storage backend + --bucket-url string Bucket URL for Object Storage backend + --default-cache-duration string ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes) + -h, --help Help for "stackit beta cdn distribution update" + --http Use HTTP backend + --http-geofencing stringArray Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable. + --http-origin-request-headers strings Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers! + --http-origin-url string Origin URL for HTTP backend + --loki Enable Loki log sink for the CDN distribution + --loki-push-url string Push URL for log sink + --loki-username string Username for log sink + --monthly-limit-bytes int Monthly limit in bytes for the CDN distribution + --optimizer Enable optimizer for the CDN distribution (paid feature). + --regions strings Regions in which content should be cached, multiple of: ["EU" "US" "AF" "SA" "ASIA"] (default []) +``` + +### 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/go.mod b/go.mod index 8918edfbe..d01af7966 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +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/cdn v1.8.1 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 @@ -36,6 +36,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/ske v1.4.0 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.3.2 github.com/zalando/go-keyring v0.2.6 + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 golang.org/x/mod v0.30.0 golang.org/x/oauth2 v0.33.0 golang.org/x/term v0.37.0 @@ -258,7 +259,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index ea8aa0408..9b1277165 100644 --- a/go.sum +++ b/go.sum @@ -567,8 +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/cdn v1.8.1 h1:CiOlfCsCDwHP0kas7qyhfp5XtL2kVmn9e4wjtc3LO10= +github.com/stackitcloud/stackit-sdk-go/services/cdn v1.8.1/go.mod h1:PyZ6g9JsGZZyeISAF+5E7L1lAlMnmbl2YbPj5Teu8to= 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/cdn/distribution/create/create.go b/internal/cmd/beta/cdn/distribution/create/create.go new file mode 100644 index 000000000..4f83829d3 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/create/create.go @@ -0,0 +1,369 @@ +package create + +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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "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" +) + +const ( + flagRegion = "regions" + flagHTTP = "http" + flagHTTPOriginURL = "http-origin-url" + flagHTTPGeofencing = "http-geofencing" + flagHTTPOriginRequestHeaders = "http-origin-request-headers" + flagBucket = "bucket" + flagBucketURL = "bucket-url" + flagBucketCredentialsAccessKeyID = "bucket-credentials-access-key-id" //nolint:gosec // linter false positive + flagBucketRegion = "bucket-region" + flagBlockedCountries = "blocked-countries" + flagBlockedIPs = "blocked-ips" + flagDefaultCacheDuration = "default-cache-duration" + flagLoki = "loki" + flagLokiUsername = "loki-username" + flagLokiPushURL = "loki-push-url" + flagMonthlyLimitBytes = "monthly-limit-bytes" + flagOptimizer = "optimizer" +) + +type httpInputModel struct { + OriginURL string + Geofencing *map[string][]string + OriginRequestHeaders *map[string]string +} + +type bucketInputModel struct { + URL string + AccessKeyID string + Password string + Region string +} + +type lokiInputModel struct { + Username string + Password string + PushURL string +} + +type inputModel struct { + *globalflags.GlobalFlagModel + Regions []cdn.Region + HTTP *httpInputModel + Bucket *bucketInputModel + BlockedCountries []string + BlockedIPs []string + DefaultCacheDuration string + MonthlyLimitBytes *int64 + Loki *lokiInputModel + Optimizer bool +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a CDN distribution", + Long: "Create a CDN distribution for a given originUrl in multiple regions.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a CDN distribution with an HTTP backend`, + `$ stackit beta cdn create --http --http-origin-url https://example.com \ +--regions AF,EU`, + ), + examples.NewExample( + `Create a CDN distribution with an Object Storage backend`, + `$ stackit beta cdn create --bucket --bucket-url https://bucket.example.com \ +--bucket-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU`, + ), + ), + PreRun: func(cmd *cobra.Command, _ []string) { + // either flagHTTP or flagBucket must be set, depending on which we mark other flags as required + if flags.FlagToBoolValue(params.Printer, cmd, flagHTTP) { + err := cmd.MarkFlagRequired(flagHTTPOriginURL) + cobra.CheckErr(err) + } else { + err := flags.MarkFlagsRequired(cmd, flagBucketURL, flagBucketCredentialsAccessKeyID, flagBucketRegion) + cobra.CheckErr(err) + } + // if user uses loki, mark related flags as required + if flags.FlagToBoolValue(params.Printer, cmd, flagLoki) { + err := flags.MarkFlagsRequired(cmd, flagLokiUsername, flagLokiPushURL) + cobra.CheckErr(err) + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + if model.Bucket != nil { + pw, err := params.Printer.PromptForPassword("enter your secret access key for the object storage bucket: ") + if err != nil { + return fmt.Errorf("reading secret access key: %w", err) + } + model.Bucket.Password = pw + } + if model.Loki != nil { + pw, err := params.Printer.PromptForPassword("enter your password for the loki log sink: ") + if err != nil { + return fmt.Errorf("reading loki password: %w", err) + } + model.Loki.Password = pw + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a CDN distribution for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create CDN distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.EnumSliceFlag(false, []string{}, sdkUtils.EnumSliceToStringSlice(cdn.AllowedRegionEnumValues)...), flagRegion, fmt.Sprintf("Regions in which content should be cached, multiple of: %q", cdn.AllowedRegionEnumValues)) + cmd.Flags().Bool(flagHTTP, false, "Use HTTP backend") + cmd.Flags().String(flagHTTPOriginURL, "", "Origin URL for HTTP backend") + cmd.Flags().StringSlice(flagHTTPOriginRequestHeaders, []string{}, "Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers!") + cmd.Flags().StringArray(flagHTTPGeofencing, []string{}, "Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable.") + cmd.Flags().Bool(flagBucket, false, "Use Object Storage backend") + cmd.Flags().String(flagBucketURL, "", "Bucket URL for Object Storage backend") + cmd.Flags().String(flagBucketCredentialsAccessKeyID, "", "Access Key ID for Object Storage backend") + cmd.Flags().String(flagBucketRegion, "", "Region for Object Storage backend") + cmd.Flags().StringSlice(flagBlockedCountries, []string{}, "Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR')") + cmd.Flags().StringSlice(flagBlockedIPs, []string{}, "Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1')") + cmd.Flags().String(flagDefaultCacheDuration, "", "ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes)") + cmd.Flags().Bool(flagLoki, false, "Enable Loki log sink for the CDN distribution") + cmd.Flags().String(flagLokiUsername, "", "Username for log sink") + cmd.Flags().String(flagLokiPushURL, "", "Push URL for log sink") + cmd.Flags().Int64(flagMonthlyLimitBytes, 0, "Monthly limit in bytes for the CDN distribution") + cmd.Flags().Bool(flagOptimizer, false, "Enable optimizer for the CDN distribution (paid feature).") + cmd.MarkFlagsMutuallyExclusive(flagHTTP, flagBucket) + cmd.MarkFlagsOneRequired(flagHTTP, flagBucket) + err := flags.MarkFlagsRequired(cmd, flagRegion) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + regionStrings := flags.FlagToStringSliceValue(p, cmd, flagRegion) + regions := make([]cdn.Region, 0, len(regionStrings)) + for _, regionStr := range regionStrings { + regions = append(regions, cdn.Region(regionStr)) + } + + var http *httpInputModel + if flags.FlagToBoolValue(p, cmd, flagHTTP) { + originURL := flags.FlagToStringValue(p, cmd, flagHTTPOriginURL) + + var geofencing *map[string][]string + geofencingInput := flags.FlagToStringArrayValue(p, cmd, flagHTTPGeofencing) + if geofencingInput != nil { + geofencing = parseGeofencing(p, geofencingInput) + } + + var originRequestHeaders *map[string]string + originRequestHeadersInput := flags.FlagToStringSliceValue(p, cmd, flagHTTPOriginRequestHeaders) + if originRequestHeadersInput != nil { + originRequestHeaders = parseOriginRequestHeaders(p, originRequestHeadersInput) + } + + http = &httpInputModel{ + OriginURL: originURL, + Geofencing: geofencing, + OriginRequestHeaders: originRequestHeaders, + } + } + + var bucket *bucketInputModel + if flags.FlagToBoolValue(p, cmd, flagBucket) { + bucketURL := flags.FlagToStringValue(p, cmd, flagBucketURL) + accessKeyID := flags.FlagToStringValue(p, cmd, flagBucketCredentialsAccessKeyID) + region := flags.FlagToStringValue(p, cmd, flagBucketRegion) + + bucket = &bucketInputModel{ + URL: bucketURL, + AccessKeyID: accessKeyID, + Password: "", + Region: region, + } + } + + blockedCountries := flags.FlagToStringSliceValue(p, cmd, flagBlockedCountries) + blockedIPs := flags.FlagToStringSliceValue(p, cmd, flagBlockedIPs) + cacheDuration := flags.FlagToStringValue(p, cmd, flagDefaultCacheDuration) + monthlyLimit := flags.FlagToInt64Pointer(p, cmd, flagMonthlyLimitBytes) + + var loki *lokiInputModel + if flags.FlagToBoolValue(p, cmd, flagLoki) { + loki = &lokiInputModel{ + Username: flags.FlagToStringValue(p, cmd, flagLokiUsername), + PushURL: flags.FlagToStringValue(p, cmd, flagLokiPushURL), + Password: "", + } + } + + optimizer := flags.FlagToBoolValue(p, cmd, flagOptimizer) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Regions: regions, + HTTP: http, + Bucket: bucket, + BlockedCountries: blockedCountries, + BlockedIPs: blockedIPs, + DefaultCacheDuration: cacheDuration, + MonthlyLimitBytes: monthlyLimit, + Loki: loki, + Optimizer: optimizer, + } + + return &model, nil +} + +func parseGeofencing(p *print.Printer, geofencingInput []string) *map[string][]string { //nolint:gocritic // ptrToRefParam is nice here because of awkward SDK API + geofencing := make(map[string][]string) + for _, in := range geofencingInput { + firstSpace := strings.IndexRune(in, ' ') + if firstSpace == -1 { + p.Debug(print.ErrorLevel, "invalid geofencing entry (no space found): %q", in) + continue + } + urlPart := in[:firstSpace] + countriesPart := in[firstSpace+1:] + geofencing[urlPart] = nil + countries := strings.Split(countriesPart, ",") + for _, country := range countries { + country = strings.TrimSpace(country) + geofencing[urlPart] = append(geofencing[urlPart], country) + } + } + return &geofencing +} + +func parseOriginRequestHeaders(p *print.Printer, originRequestHeadersInput []string) *map[string]string { //nolint:gocritic // ptrToRefParam is nice here because of awkward SDK API + originRequestHeaders := make(map[string]string) + for _, in := range originRequestHeadersInput { + parts := strings.Split(in, ":") + if len(parts) != 2 { + p.Debug(print.ErrorLevel, "invalid origin request header entry (no colon found): %q", in) + continue + } + originRequestHeaders[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return &originRequestHeaders +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiCreateDistributionRequest { + req := apiClient.CreateDistribution(ctx, model.ProjectId) + var backend cdn.CreateDistributionPayloadGetBackendArgType + if model.HTTP != nil { + backend = cdn.CreateDistributionPayloadGetBackendArgType{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Geofencing: model.HTTP.Geofencing, + OriginRequestHeaders: model.HTTP.OriginRequestHeaders, + OriginUrl: &model.HTTP.OriginURL, + Type: utils.Ptr("http"), + }, + } + } else { + backend = cdn.CreateDistributionPayloadGetBackendArgType{ + BucketBackendCreate: &cdn.BucketBackendCreate{ + BucketUrl: &model.Bucket.URL, + Credentials: cdn.NewBucketCredentials( + model.Bucket.AccessKeyID, + model.Bucket.Password, + ), + Region: &model.Bucket.Region, + Type: utils.Ptr("bucket"), + }, + } + } + + payload := cdn.NewCreateDistributionPayload( + backend, + model.Regions, + ) + if len(model.BlockedCountries) > 0 { + payload.BlockedCountries = &model.BlockedCountries + } + if len(model.BlockedIPs) > 0 { + payload.BlockedIps = &model.BlockedIPs + } + if model.DefaultCacheDuration != "" { + payload.DefaultCacheDuration = utils.Ptr(model.DefaultCacheDuration) + } + if model.Loki != nil { + payload.LogSink = &cdn.CreateDistributionPayloadGetLogSinkArgType{ + LokiLogSinkCreate: &cdn.LokiLogSinkCreate{ + Credentials: &cdn.LokiLogSinkCredentials{ + Password: &model.Loki.Password, + Username: &model.Loki.Username, + }, + PushUrl: &model.Loki.PushURL, + Type: utils.Ptr("loki"), + }, + } + } + payload.MonthlyLimitBytes = model.MonthlyLimitBytes + if model.Optimizer { + payload.Optimizer = &cdn.CreateDistributionPayloadGetOptimizerArgType{ + Enabled: utils.Ptr(true), + } + } + return req.CreateDistributionPayload(*payload) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *cdn.CreateDistributionResponse) error { + if resp == nil { + return fmt.Errorf("create distribution response is nil") + } + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Created CDN distribution for %q. Id: %s\n", projectLabel, utils.PtrString(resp.Distribution.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/cdn/distribution/create/create_test.go b/internal/cmd/beta/cdn/distribution/create/create_test.go new file mode 100644 index 000000000..8f78edd49 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/create/create_test.go @@ -0,0 +1,544 @@ +package create + +import ( + "bytes" + "context" + "fmt" + "strings" + "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" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + "k8s.io/utils/ptr" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &cdn.APIClient{} +var testProjectId = uuid.NewString() + +const testRegions = cdn.REGION_EU + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + flagRegion: string(testRegions), + } + flagsHTTPBackend()(flagValues) + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func flagsHTTPBackend() func(m map[string]string) { + return func(m map[string]string) { + delete(m, flagBucket) + m[flagHTTP] = "true" + m[flagHTTPOriginURL] = "https://http-backend.example.com" + } +} + +func flagsBucketBackend() func(m map[string]string) { + return func(m map[string]string) { + delete(m, flagHTTP) + m[flagBucket] = "true" + m[flagBucketURL] = "https://bucket-backend.example.com" + m[flagBucketCredentialsAccessKeyID] = "access-key-id" + m[flagBucketRegion] = "eu" + } +} + +func flagsLoki() func(m map[string]string) { + return func(m map[string]string) { + m[flagLoki] = "true" + m[flagLokiPushURL] = "https://loki.example.com" + m[flagLokiUsername] = "loki-user" + } +} + +func flagRegions(regions ...cdn.Region) func(flagValues map[string]string) { + return func(flagValues map[string]string) { + if len(regions) == 0 { + delete(flagValues, flagRegion) + return + } + stringRegions := sdkUtils.EnumSliceToStringSlice(regions) + flagValues[flagRegion] = strings.Join(stringRegions, ",") + } +} + +func fixtureModel(mods ...func(m *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Regions: []cdn.Region{testRegions}, + } + modelHTTPBackend()(model) + for _, mod := range mods { + mod(model) + } + return model +} + +func modelRegions(regions ...cdn.Region) func(m *inputModel) { + return func(m *inputModel) { + m.Regions = regions + } +} + +func modelHTTPBackend() func(m *inputModel) { + return func(m *inputModel) { + m.Bucket = nil + m.HTTP = &httpInputModel{ + OriginURL: "https://http-backend.example.com", + } + } +} + +func modelBucketBackend() func(m *inputModel) { + return func(m *inputModel) { + m.HTTP = nil + m.Bucket = &bucketInputModel{ + URL: "https://bucket-backend.example.com", + AccessKeyID: "access-key-id", + Region: "eu", + } + } +} + +func modelLoki() func(m *inputModel) { + return func(m *inputModel) { + m.Loki = &lokiInputModel{ + PushURL: "https://loki.example.com", + Username: "loki-user", + } + } +} + +func fixturePayload(mods ...func(p *cdn.CreateDistributionPayload)) cdn.CreateDistributionPayload { + p := *cdn.NewCreateDistributionPayload( + cdn.CreateDistributionPayloadGetBackendArgType{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Type: utils.Ptr("http"), + OriginUrl: utils.Ptr("https://http-backend.example.com"), + }, + }, + []cdn.Region{testRegions}, + ) + for _, mod := range mods { + mod(&p) + } + return p +} + +func payloadRegions(regions ...cdn.Region) func(p *cdn.CreateDistributionPayload) { + return func(p *cdn.CreateDistributionPayload) { + p.Regions = ®ions + } +} + +func payloadBucketBackend() func(p *cdn.CreateDistributionPayload) { + return func(p *cdn.CreateDistributionPayload) { + p.Backend = &cdn.CreateDistributionPayloadGetBackendArgType{ + BucketBackendCreate: &cdn.BucketBackendCreate{ + Type: utils.Ptr("bucket"), + BucketUrl: utils.Ptr("https://bucket-backend.example.com"), + Region: utils.Ptr("eu"), + Credentials: cdn.NewBucketCredentials( + "access-key-id", + "", + ), + }, + } + } +} + +func payloadLoki() func(p *cdn.CreateDistributionPayload) { + return func(p *cdn.CreateDistributionPayload) { + p.LogSink = &cdn.CreateDistributionPayloadGetLogSinkArgType{ + LokiLogSinkCreate: &cdn.LokiLogSinkCreate{ + Type: utils.Ptr("loki"), + PushUrl: utils.Ptr("https://loki.example.com"), + Credentials: cdn.NewLokiLogSinkCredentials("", "loki-user"), + }, + } + } +} + +func fixtureRequest(mods ...func(p *cdn.CreateDistributionPayload)) cdn.ApiCreateDistributionRequest { + req := testClient.CreateDistribution(testCtx, testProjectId) + req = req.CreateDistributionPayload(fixturePayload(mods...)) + return req +} + +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: fixtureModel(), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(m map[string]string) { + m[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(m map[string]string) { + m[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "regions missing", + flagValues: fixtureFlagValues(flagRegions()), + isValid: false, + }, + { + description: "multiple regions", + flagValues: fixtureFlagValues(flagRegions(cdn.REGION_EU, cdn.REGION_AF)), + isValid: true, + expected: fixtureModel(modelRegions(cdn.REGION_EU, cdn.REGION_AF)), + }, + { + description: "bucket backend", + flagValues: fixtureFlagValues(flagsBucketBackend()), + isValid: true, + expected: fixtureModel(modelBucketBackend()), + }, + { + description: "bucket backend missing url", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(m map[string]string) { + delete(m, flagBucketURL) + }, + ), + isValid: false, + }, + { + description: "bucket backend missing access key id", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(m map[string]string) { + delete(m, flagBucketCredentialsAccessKeyID) + }, + ), + isValid: false, + }, + { + description: "bucket backend missing region", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(m map[string]string) { + delete(m, flagBucketRegion) + }, + ), + isValid: false, + }, + { + description: "http backend missing url", + flagValues: fixtureFlagValues( + func(m map[string]string) { + delete(m, flagHTTPOriginURL) + }, + ), + isValid: false, + }, + { + description: "http backend with geofencing", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagHTTPGeofencing] = "https://dach.example.com DE,AT,CH" + }, + ), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.HTTP.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + }, + ), + }, + { + description: "http backend with origin request headers", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagHTTPOriginRequestHeaders] = "X-Custom-Header:Value1,X-Another-Header:Value2" + }, + ), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.HTTP.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + }, + { + description: "with blocked countries", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagBlockedCountries] = "DE,AT" + }), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.BlockedCountries = []string{"DE", "AT"} + }, + ), + }, + { + description: "with blocked IPs", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagBlockedIPs] = "127.0.0.1,10.0.0.8" + }), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + }), + }, + { + description: "with default cache duration", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagDefaultCacheDuration] = "PT1H30M" + }), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.DefaultCacheDuration = "PT1H30M" + }), + }, + { + description: "with optimizer", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagOptimizer] = "true" + }), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.Optimizer = true + }), + }, + { + description: "with loki", + flagValues: fixtureFlagValues( + flagsLoki(), + ), + isValid: true, + expected: fixtureModel( + modelLoki(), + ), + }, + { + description: "loki with missing username", + flagValues: fixtureFlagValues( + flagsLoki(), + func(m map[string]string) { + delete(m, flagLokiUsername) + }, + ), + isValid: false, + }, + { + description: "loki with missing push url", + flagValues: fixtureFlagValues( + flagsLoki(), + func(m map[string]string) { + delete(m, flagLokiPushURL) + }, + ), + isValid: false, + }, + { + description: "with monthly limit bytes", + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagMonthlyLimitBytes] = "1073741824" // 1 GiB + }), + isValid: true, + expected: fixtureModel( + func(m *inputModel) { + m.MonthlyLimitBytes = ptr.To[int64](1073741824) + }), + }, + } + 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 + model *inputModel + expected cdn.ApiCreateDistributionRequest + }{ + { + description: "base", + model: fixtureModel(), + expected: fixtureRequest(), + }, + { + description: "multiple regions", + model: fixtureModel(modelRegions(cdn.REGION_AF, cdn.REGION_EU)), + expected: fixtureRequest(payloadRegions(cdn.REGION_AF, cdn.REGION_EU)), + }, + { + description: "bucket backend", + model: fixtureModel(modelBucketBackend()), + expected: fixtureRequest(payloadBucketBackend()), + }, + { + description: "http backend with geofencing and origin request headers", + model: fixtureModel( + func(m *inputModel) { + m.HTTP.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + m.HTTP.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + expected: fixtureRequest( + func(p *cdn.CreateDistributionPayload) { + p.Backend.HttpBackendCreate.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + p.Backend.HttpBackendCreate.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + }, + { + description: "with full options", + model: fixtureModel( + func(m *inputModel) { + m.MonthlyLimitBytes = ptr.To[int64](5368709120) // 5 GiB + m.Optimizer = true + m.BlockedCountries = []string{"DE", "AT"} + m.BlockedIPs = []string{"127.0.0.1"} + m.DefaultCacheDuration = "PT2H" + }, + ), + expected: fixtureRequest( + func(p *cdn.CreateDistributionPayload) { + p.MonthlyLimitBytes = utils.Ptr[int64](5368709120) + p.Optimizer = &cdn.CreateDistributionPayloadGetOptimizerArgType{ + Enabled: utils.Ptr(true), + } + p.BlockedCountries = &[]string{"DE", "AT"} + p.BlockedIps = &[]string{"127.0.0.1"} + p.DefaultCacheDuration = utils.Ptr("PT2H") + }, + ), + }, + { + description: "loki", + model: fixtureModel( + modelLoki(), + ), + expected: fixtureRequest(payloadLoki()), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + response *cdn.CreateDistributionResponse + expected string + wantErr bool + }{ + { + description: "nil response", + outputFormat: "table", + response: nil, + wantErr: true, + }, + { + description: "table output", + outputFormat: "table", + response: &cdn.CreateDistributionResponse{ + Distribution: &cdn.Distribution{ + Id: ptr.To("dist-1234"), + }, + }, + expected: fmt.Sprintf("Created CDN distribution for %q. Id: dist-1234\n", testProjectId), + }, + } + + 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, testProjectId, tt.response); (err != nil) != tt.wantErr { + 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/beta/cdn/distribution/delete/delete.go b/internal/cmd/beta/cdn/distribution/delete/delete.go new file mode 100644 index 000000000..ddaf14843 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/delete/delete.go @@ -0,0 +1,94 @@ +package delete + +import ( + "context" + "fmt" + + "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/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +const argDistributionID = "DISTRIBUTION_ID" + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a CDN distribution", + Long: "Delete a CDN distribution by its ID.", + Args: args.SingleArg(argDistributionID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a CDN distribution with ID "xxx"`, + `$ stackit beta cdn distribution delete xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the CDN distribution %q for project %q?", model.DistributionID, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete loadbalancer: %w", err) + } + + params.Printer.Outputf("CDN distribution %q deleted.", model.DistributionID) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + distributionID := inputArgs[0] + model := inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: distributionID, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiDeleteDistributionRequest { + return apiClient.DeleteDistribution(ctx, model.ProjectId, model.DistributionID) +} diff --git a/internal/cmd/beta/cdn/distribution/delete/delete_test.go b/internal/cmd/beta/cdn/distribution/delete/delete_test.go new file mode 100644 index 000000000..03ec87f46 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/delete/delete_test.go @@ -0,0 +1,130 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testClient = &cdn.APIClient{} + testDistributionID = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argVales []string)) []string { + argVales := []string{ + testDistributionID, + } + for _, m := range mods { + m(argVales) + } + return argVales +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + DistributionID: testDistributionID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *cdn.ApiDeleteDistributionRequest)) cdn.ApiDeleteDistributionRequest { + request := testClient.DeleteDistribution(testCtx, testProjectId, testDistributionID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argsValues: []string{}, + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + }, + isValid: false, + }, + { + description: "no arg values", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult cdn.ApiDeleteDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/describe/describe.go b/internal/cmd/beta/cdn/distribution/describe/describe.go new file mode 100644 index 000000000..22b6443f5 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/describe/describe.go @@ -0,0 +1,214 @@ +package describe + +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" +) + +const distributionIDArg = "DISTRIBUTION_ID_ARG" +const flagWithWaf = "with-waf" + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string + WithWAF bool +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe a CDN distribution", + Long: "Describe a CDN distribution by its ID.", + Args: args.SingleArg(distributionIDArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a CDN distribution with ID "xxx"`, + `$ stackit beta cdn distribution describe xxx`, + ), + examples.NewExample( + `Get details of a CDN, including WAF details, for ID "xxx"`, + `$ stackit beta cdn distribution describe xxx --with-waf`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + 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 + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(flagWithWaf, false, "Include WAF details in the distribution description") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, args []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: args[0], + WithWAF: flags.FlagToBoolValue(p, cmd, flagWithWaf), + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiGetDistributionRequest { + return apiClient.GetDistribution(ctx, model.ProjectId, model.DistributionID).WithWafStatus(model.WithWAF) +} + +func outputResult(p *print.Printer, outputFormat string, distribution *cdn.GetDistributionResponse) error { + if distribution == nil { + return fmt.Errorf("distribution response is empty") + } + return p.OutputResult(outputFormat, distribution, func() error { + d := distribution.Distribution + var content []tables.Table + + content = append(content, buildDistributionTable(d)) + + if d.Waf != nil { + content = append(content, buildWAFTable(d)) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} + +func buildDistributionTable(d *cdn.Distribution) tables.Table { + regions := strings.Join(sdkUtils.EnumSliceToStringSlice(*d.Config.Regions), ", ") + defaultCacheDuration := "" + if d.Config.DefaultCacheDuration != nil && d.Config.DefaultCacheDuration.IsSet() { + defaultCacheDuration = *d.Config.DefaultCacheDuration.Get() + } + logSinkPushUrl := "" + if d.Config.LogSink != nil && d.Config.LogSink.LokiLogSink != nil { + logSinkPushUrl = *d.Config.LogSink.LokiLogSink.PushUrl + } + monthlyLimitBytes := "" + if d.Config.MonthlyLimitBytes != nil { + monthlyLimitBytes = fmt.Sprintf("%d", *d.Config.MonthlyLimitBytes) + } + optimizerEnabled := "" + if d.Config.Optimizer != nil { + optimizerEnabled = fmt.Sprintf("%t", *d.Config.Optimizer.Enabled) + } + table := tables.NewTable() + table.SetTitle("Distribution") + table.AddRow("ID", utils.PtrString(d.Id)) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(d.Status)) + table.AddSeparator() + table.AddRow("REGIONS", regions) + table.AddSeparator() + table.AddRow("CREATED AT", utils.PtrString(d.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.PtrString(d.UpdatedAt)) + table.AddSeparator() + table.AddRow("PROJECT ID", utils.PtrString(d.ProjectId)) + table.AddSeparator() + if d.Errors != nil && len(*d.Errors) > 0 { + var errorDescriptions []string + for _, err := range *d.Errors { + errorDescriptions = append(errorDescriptions, *err.En) + } + table.AddRow("ERRORS", strings.Join(errorDescriptions, "\n")) + table.AddSeparator() + } + if d.Config.Backend.BucketBackend != nil { + b := d.Config.Backend.BucketBackend + table.AddRow("BACKEND TYPE", "BUCKET") + table.AddSeparator() + table.AddRow("BUCKET URL", utils.PtrString(b.BucketUrl)) + table.AddSeparator() + table.AddRow("BUCKET REGION", utils.PtrString(b.Region)) + table.AddSeparator() + } else if d.Config.Backend.HttpBackend != nil { + h := d.Config.Backend.HttpBackend + var geofencing []string + for k, v := range *h.Geofencing { + geofencing = append(geofencing, fmt.Sprintf("%s: %s", k, strings.Join(v, ", "))) + } + table.AddRow("BACKEND TYPE", "HTTP") + table.AddSeparator() + table.AddRow("HTTP ORIGIN URL", utils.PtrString(h.OriginUrl)) + table.AddSeparator() + table.AddRow("HTTP ORIGIN REQUEST HEADERS", utils.JoinStringMap(*h.OriginRequestHeaders, ": ", ", ")) + table.AddSeparator() + table.AddRow("HTTP GEOFENCING PROPERTIES", strings.Join(geofencing, "\n")) + table.AddSeparator() + } + table.AddRow("BLOCKED COUNTRIES", strings.Join(*d.Config.BlockedCountries, ", ")) + table.AddSeparator() + table.AddRow("BLOCKED IPS", strings.Join(*d.Config.BlockedIps, ", ")) + table.AddSeparator() + table.AddRow("DEFAULT CACHE DURATION", defaultCacheDuration) + table.AddSeparator() + table.AddRow("LOG SINK PUSH URL", logSinkPushUrl) + table.AddSeparator() + table.AddRow("MONTHLY LIMIT (BYTES)", monthlyLimitBytes) + table.AddSeparator() + table.AddRow("OPTIMIZER ENABLED", optimizerEnabled) + table.AddSeparator() + // TODO config has yet another WAF block, left it out because the docs say to use the WAF block at the top level to determine enabled rules. There's also mode and type fields here, both left out. + return table +} + +func buildWAFTable(d *cdn.Distribution) tables.Table { + table := tables.NewTable() + table.SetTitle("WAF") + for _, disabled := range *d.Waf.DisabledRules { + table.AddRow("DISABLED RULE ID", utils.PtrString(disabled.Id)) + table.AddSeparator() + } + for _, enabled := range *d.Waf.EnabledRules { + table.AddRow("ENABLED RULE ID", utils.PtrString(enabled.Id)) + table.AddSeparator() + } + for _, logOnly := range *d.Waf.LogOnlyRules { + table.AddRow("LOG-ONLY RULE ID", utils.PtrString(logOnly.Id)) + table.AddSeparator() + } + return table +} diff --git a/internal/cmd/beta/cdn/distribution/describe/describe_test.go b/internal/cmd/beta/cdn/distribution/describe/describe_test.go new file mode 100644 index 000000000..176cff523 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/describe/describe_test.go @@ -0,0 +1,406 @@ +package describe + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "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" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectID = uuid.NewString() + testDistributionID = uuid.NewString() + testClient = &cdn.APIClient{} + testTime = time.Time{} +) + +func fixtureFlagValues(mods ...func(m map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(m *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Verbosity: globalflags.VerbosityDefault, + }, + DistributionID: testDistributionID, + WithWAF: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureResponse(mods ...func(r *cdn.GetDistributionResponse)) *cdn.GetDistributionResponse { + response := &cdn.GetDistributionResponse{ + Distribution: &cdn.Distribution{ + Config: &cdn.Config{ + Backend: &cdn.ConfigBackend{ + BucketBackend: &cdn.BucketBackend{ + BucketUrl: utils.Ptr("https://example.com"), + Region: utils.Ptr("eu"), + Type: utils.Ptr("bucket"), + }, + }, + BlockedCountries: utils.Ptr([]string{}), + BlockedIps: utils.Ptr([]string{}), + DefaultCacheDuration: nil, + LogSink: nil, + MonthlyLimitBytes: nil, + Optimizer: nil, + Regions: &[]cdn.Region{cdn.REGION_EU}, + Waf: nil, + }, + CreatedAt: utils.Ptr(testTime), + Domains: &[]cdn.Domain{}, + Errors: nil, + Id: utils.Ptr(testDistributionID), + ProjectId: utils.Ptr(testProjectID), + Status: utils.Ptr(cdn.DISTRIBUTIONSTATUS_ACTIVE), + UpdatedAt: utils.Ptr(testTime), + Waf: nil, + }, + } + for _, mod := range mods { + mod(response) + } + return response +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + args []string + flags map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + args: []string{testDistributionID}, + flags: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "no args", + args: []string{}, + flags: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid distribution id", + args: []string{"invalid-uuid"}, + flags: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing project id", + args: []string{testDistributionID}, + flags: map[string]string{}, + isValid: false, + }, + { + description: "invalid project id", + args: []string{testDistributionID}, + flags: map[string]string{ + globalflags.ProjectIdFlag: "invalid-uuid", + }, + isValid: false, + }, + { + description: "with WAF", + args: []string{testDistributionID}, + flags: fixtureFlagValues(func(m map[string]string) { + m[flagWithWaf] = "true" + }), + isValid: true, + expected: fixtureInputModel(func(m *inputModel) { + m.WithWAF = true + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.args, tt.flags, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected cdn.ApiGetDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expected: testClient.GetDistribution(testCtx, testProjectID, testDistributionID).WithWafStatus(false), + }, + { + description: "with WAF", + model: fixtureInputModel(func(m *inputModel) { + m.WithWAF = true + }), + expected: testClient.GetDistribution(testCtx, testProjectID, testDistributionID).WithWafStatus(true), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(got, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + format string + distribution *cdn.GetDistributionResponse + wantErr bool + expected string + }{ + { + description: "empty", + format: "table", + wantErr: true, + }, + { + description: "no errors", + format: "table", + distribution: fixtureResponse(), + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +────────────────────────┼────────────────────────────────────── + REGIONS │ EU +────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ BUCKET +────────────────────────┼────────────────────────────────────── + BUCKET URL │ https://example.com +────────────────────────┼────────────────────────────────────── + BUCKET REGION │ eu +────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ +────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ +────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ +────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ +────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ +────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ + +`, + testDistributionID, + testTime, + testTime, + testProjectID), + }, + { + description: "with errors", + format: "table", + distribution: fixtureResponse( + func(r *cdn.GetDistributionResponse) { + r.Distribution.Errors = &[]cdn.StatusError{ + { + En: utils.Ptr("First error message"), + }, + { + En: utils.Ptr("Second error message"), + }, + } + }, + ), + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +────────────────────────┼────────────────────────────────────── + REGIONS │ EU +────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +────────────────────────┼────────────────────────────────────── + ERRORS │ First error message + │ Second error message +────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ BUCKET +────────────────────────┼────────────────────────────────────── + BUCKET URL │ https://example.com +────────────────────────┼────────────────────────────────────── + BUCKET REGION │ eu +────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ +────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ +────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ +────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ +────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ +────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ + +`, testDistributionID, + testTime, + testTime, + testProjectID), + }, + { + description: "full", + format: "table", + distribution: fixtureResponse( + func(r *cdn.GetDistributionResponse) { + r.Distribution.Waf = &cdn.DistributionWaf{ + EnabledRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-1")}, + {Id: utils.Ptr("rule-id-2")}, + }, + DisabledRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-3")}, + {Id: utils.Ptr("rule-id-4")}, + }, + LogOnlyRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-5")}, + {Id: utils.Ptr("rule-id-6")}, + }, + } + r.Distribution.Config.Backend = &cdn.ConfigBackend{ + HttpBackend: &cdn.HttpBackend{ + OriginUrl: utils.Ptr("https://origin.example.com"), + OriginRequestHeaders: &map[string]string{ + "X-Custom-Header": "CustomValue", + }, + Geofencing: &map[string][]string{ + "origin1.example.com": {"US", "CA"}, + "origin2.example.com": {"FR", "DE"}, + }, + }, + } + r.Distribution.Config.BlockedCountries = &[]string{"US", "CN"} + r.Distribution.Config.BlockedIps = &[]string{"127.0.0.1"} + r.Distribution.Config.DefaultCacheDuration = cdn.NewNullableString(utils.Ptr("P1DT2H30M")) + r.Distribution.Config.LogSink = &cdn.ConfigLogSink{ + LokiLogSink: &cdn.LokiLogSink{ + PushUrl: utils.Ptr("https://logs.example.com"), + }, + } + r.Distribution.Config.MonthlyLimitBytes = utils.Ptr(int64(104857600)) + r.Distribution.Config.Optimizer = &cdn.Optimizer{ + Enabled: utils.Ptr(true), + } + }), + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +─────────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +─────────────────────────────┼────────────────────────────────────── + REGIONS │ EU +─────────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +─────────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +─────────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +─────────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ HTTP +─────────────────────────────┼────────────────────────────────────── + HTTP ORIGIN URL │ https://origin.example.com +─────────────────────────────┼────────────────────────────────────── + HTTP ORIGIN REQUEST HEADERS │ X-Custom-Header: CustomValue +─────────────────────────────┼────────────────────────────────────── + HTTP GEOFENCING PROPERTIES │ origin1.example.com: US, CA + │ origin2.example.com: FR, DE +─────────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ US, CN +─────────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ 127.0.0.1 +─────────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ P1DT2H30M +─────────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ https://logs.example.com +─────────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ 104857600 +─────────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ true + + + WAF  + DISABLED RULE ID │ rule-id-3 +──────────────────┼─────────── + DISABLED RULE ID │ rule-id-4 +──────────────────┼─────────── + ENABLED RULE ID │ rule-id-1 +──────────────────┼─────────── + ENABLED RULE ID │ rule-id-2 +──────────────────┼─────────── + LOG-ONLY RULE ID │ rule-id-5 +──────────────────┼─────────── + LOG-ONLY RULE ID │ rule-id-6 + +`, testDistributionID, testTime, testTime, testProjectID), + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var buf bytes.Buffer + p.Cmd.SetOut(&buf) + if err := outputResult(p, tt.format, tt.distribution); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(buf.String(), tt.expected) + if diff != "" { + t.Fatalf("outputResult() output mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/distribution.go b/internal/cmd/beta/cdn/distribution/distribution.go index 72d48c7fe..5812684c9 100644 --- a/internal/cmd/beta/cdn/distribution/distribution.go +++ b/internal/cmd/beta/cdn/distribution/distribution.go @@ -2,7 +2,11 @@ package distribution import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/update" "github.com/stackitcloud/stackit-cli/internal/cmd/params" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -21,4 +25,8 @@ func NewCommand(params *params.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) } diff --git a/internal/cmd/beta/cdn/distribution/update/update.go b/internal/cmd/beta/cdn/distribution/update/update.go new file mode 100644 index 000000000..c7c65ff3c --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/update/update.go @@ -0,0 +1,372 @@ +package update + +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/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "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" +) + +const ( + argDistributionID = "DISTRIBUTION_ID" + flagRegions = "regions" + flagHTTP = "http" + flagHTTPOriginURL = "http-origin-url" + flagHTTPGeofencing = "http-geofencing" + flagHTTPOriginRequestHeaders = "http-origin-request-headers" + flagBucket = "bucket" + flagBucketURL = "bucket-url" + flagBucketCredentialsAccessKeyID = "bucket-credentials-access-key-id" //nolint:gosec // linter false positive + flagBucketRegion = "bucket-region" + flagBlockedCountries = "blocked-countries" + flagBlockedIPs = "blocked-ips" + flagDefaultCacheDuration = "default-cache-duration" + flagLoki = "loki" + flagLokiUsername = "loki-username" + flagLokiPushURL = "loki-push-url" + flagMonthlyLimitBytes = "monthly-limit-bytes" + flagOptimizer = "optimizer" +) + +type bucketInputModel struct { + URL string + AccessKeyID string + Password string + Region string +} + +type httpInputModel struct { + Geofencing *map[string][]string + OriginRequestHeaders *map[string]string + OriginURL string +} + +type lokiInputModel struct { + Password string + Username string + PushURL string +} + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string + Regions []cdn.Region + Bucket *bucketInputModel + HTTP *httpInputModel + BlockedCountries []string + BlockedIPs []string + DefaultCacheDuration string + MonthlyLimitBytes *int64 + Loki *lokiInputModel + Optimizer *bool +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a CDN distribution", + Long: "Update a CDN distribution by its ID, allowing replacement of its regions.", + Args: args.SingleArg(argDistributionID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `update a CDN distribution with ID "123e4567-e89b-12d3-a456-426614174000" to not use optimizer`, + `$ stackit beta cdn update 123e4567-e89b-12d3-a456-426614174000 --optimizer=false`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + if model.Bucket != nil { + pw, err := params.Printer.PromptForPassword("enter your secret access key for the object storage bucket: ") + if err != nil { + return fmt.Errorf("reading secret access key: %w", err) + } + model.Bucket.Password = pw + } + if model.Loki != nil { + pw, err := params.Printer.PromptForPassword("enter your password for the loki log sink: ") + if err != nil { + return fmt.Errorf("reading loki password: %w", err) + } + model.Loki.Password = pw + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update a CDN distribution for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update CDN distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.EnumSliceFlag(false, []string{}, sdkUtils.EnumSliceToStringSlice(cdn.AllowedRegionEnumValues)...), flagRegions, fmt.Sprintf("Regions in which content should be cached, multiple of: %q", cdn.AllowedRegionEnumValues)) + cmd.Flags().Bool(flagHTTP, false, "Use HTTP backend") + cmd.Flags().String(flagHTTPOriginURL, "", "Origin URL for HTTP backend") + cmd.Flags().StringSlice(flagHTTPOriginRequestHeaders, []string{}, "Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers!") + cmd.Flags().StringArray(flagHTTPGeofencing, []string{}, "Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable.") + cmd.Flags().Bool(flagBucket, false, "Use Object Storage backend") + cmd.Flags().String(flagBucketURL, "", "Bucket URL for Object Storage backend") + cmd.Flags().String(flagBucketCredentialsAccessKeyID, "", "Access Key ID for Object Storage backend") + cmd.Flags().String(flagBucketRegion, "", "Region for Object Storage backend") + cmd.Flags().StringSlice(flagBlockedCountries, []string{}, "Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR')") + cmd.Flags().StringSlice(flagBlockedIPs, []string{}, "Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1')") + cmd.Flags().String(flagDefaultCacheDuration, "", "ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes)") + cmd.Flags().Bool(flagLoki, false, "Enable Loki log sink for the CDN distribution") + cmd.Flags().String(flagLokiUsername, "", "Username for log sink") + cmd.Flags().String(flagLokiPushURL, "", "Push URL for log sink") + cmd.Flags().Int64(flagMonthlyLimitBytes, 0, "Monthly limit in bytes for the CDN distribution") + cmd.Flags().Bool(flagOptimizer, false, "Enable optimizer for the CDN distribution (paid feature).") + cmd.MarkFlagsMutuallyExclusive(flagHTTP, flagBucket) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, args []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + distributionID := args[0] + + regionStrings := flags.FlagToStringSliceValue(p, cmd, flagRegions) + regions := make([]cdn.Region, 0, len(regionStrings)) + for _, regionStr := range regionStrings { + regions = append(regions, cdn.Region(regionStr)) + } + + var http *httpInputModel + if flags.FlagToBoolValue(p, cmd, flagHTTP) { + originURL := flags.FlagToStringValue(p, cmd, flagHTTPOriginURL) + + var geofencing *map[string][]string + geofencingInput := flags.FlagToStringArrayValue(p, cmd, flagHTTPGeofencing) + if geofencingInput != nil { + geofencing = parseGeofencing(p, geofencingInput) + } + + var originRequestHeaders *map[string]string + originRequestHeadersInput := flags.FlagToStringSliceValue(p, cmd, flagHTTPOriginRequestHeaders) + if originRequestHeadersInput != nil { + originRequestHeaders = parseOriginRequestHeaders(p, originRequestHeadersInput) + } + + http = &httpInputModel{ + OriginURL: originURL, + Geofencing: geofencing, + OriginRequestHeaders: originRequestHeaders, + } + } + + var bucket *bucketInputModel + if flags.FlagToBoolValue(p, cmd, flagBucket) { + bucketURL := flags.FlagToStringValue(p, cmd, flagBucketURL) + accessKeyID := flags.FlagToStringValue(p, cmd, flagBucketCredentialsAccessKeyID) + region := flags.FlagToStringValue(p, cmd, flagBucketRegion) + + bucket = &bucketInputModel{ + URL: bucketURL, + AccessKeyID: accessKeyID, + Password: "", + Region: region, + } + } + + blockedCountries := flags.FlagToStringSliceValue(p, cmd, flagBlockedCountries) + blockedIPs := flags.FlagToStringSliceValue(p, cmd, flagBlockedIPs) + cacheDuration := flags.FlagToStringValue(p, cmd, flagDefaultCacheDuration) + monthlyLimit := flags.FlagToInt64Pointer(p, cmd, flagMonthlyLimitBytes) + + var loki *lokiInputModel + if flags.FlagToBoolValue(p, cmd, flagLoki) { + loki = &lokiInputModel{ + Username: flags.FlagToStringValue(p, cmd, flagLokiUsername), + PushURL: flags.FlagToStringValue(p, cmd, flagLokiPushURL), + Password: "", + } + } + + var optimizer *bool + if cmd.Flags().Changed(flagOptimizer) { + o := flags.FlagToBoolValue(p, cmd, flagOptimizer) + optimizer = &o + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: distributionID, + Regions: regions, + HTTP: http, + Bucket: bucket, + BlockedCountries: blockedCountries, + BlockedIPs: blockedIPs, + DefaultCacheDuration: cacheDuration, + MonthlyLimitBytes: monthlyLimit, + Loki: loki, + Optimizer: optimizer, + } + + p.DebugInputModel(model) + return &model, nil +} + +// TODO both parseGeofencing and parseOriginRequestHeaders copied from create.go, move to another package and make public? +func parseGeofencing(p *print.Printer, geofencingInput []string) *map[string][]string { //nolint:gocritic // convenient for setting the SDK payload + geofencing := make(map[string][]string) + for _, in := range geofencingInput { + firstSpace := strings.IndexRune(in, ' ') + if firstSpace == -1 { + p.Debug(print.ErrorLevel, "invalid geofencing entry (no space found): %q", in) + continue + } + urlPart := in[:firstSpace] + countriesPart := in[firstSpace+1:] + geofencing[urlPart] = nil + countries := strings.Split(countriesPart, ",") + for _, country := range countries { + country = strings.TrimSpace(country) + geofencing[urlPart] = append(geofencing[urlPart], country) + } + } + return &geofencing +} + +func parseOriginRequestHeaders(p *print.Printer, originRequestHeadersInput []string) *map[string]string { //nolint:gocritic // convenient for setting the SDK payload + originRequestHeaders := make(map[string]string) + for _, in := range originRequestHeadersInput { + parts := strings.Split(in, ":") + if len(parts) != 2 { + p.Debug(print.ErrorLevel, "invalid origin request header entry (no colon found): %q", in) + continue + } + originRequestHeaders[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return &originRequestHeaders +} + +func buildRequest(ctx context.Context, apiClient *cdn.APIClient, model *inputModel) cdn.ApiPatchDistributionRequest { + req := apiClient.PatchDistribution(ctx, model.ProjectId, model.DistributionID) + payload := cdn.NewPatchDistributionPayload() + cfg := &cdn.ConfigPatch{} + payload.Config = cfg + if len(model.Regions) > 0 { + cfg.Regions = &model.Regions + } + if model.Bucket != nil { + bucket := &cdn.BucketBackendPatch{ + Type: utils.Ptr("bucket"), + } + cfg.Backend = &cdn.ConfigPatchBackend{ + BucketBackendPatch: bucket, + } + if model.Bucket.URL != "" { + bucket.BucketUrl = utils.Ptr(model.Bucket.URL) + } + if model.Bucket.AccessKeyID != "" { + bucket.Credentials = cdn.NewBucketCredentials( + model.Bucket.AccessKeyID, + model.Bucket.Password, + ) + } + if model.Bucket.Region != "" { + bucket.Region = utils.Ptr(model.Bucket.Region) + } + } else if model.HTTP != nil { + http := &cdn.HttpBackendPatch{ + Type: utils.Ptr("http"), + } + cfg.Backend = &cdn.ConfigPatchBackend{ + HttpBackendPatch: http, + } + if model.HTTP.OriginRequestHeaders != nil { + http.OriginRequestHeaders = model.HTTP.OriginRequestHeaders + } + if model.HTTP.Geofencing != nil { + http.Geofencing = model.HTTP.Geofencing + } + if model.HTTP.OriginURL != "" { + http.OriginUrl = utils.Ptr(model.HTTP.OriginURL) + } + } + if len(model.BlockedCountries) > 0 { + cfg.BlockedCountries = &model.BlockedCountries + } + if len(model.BlockedIPs) > 0 { + cfg.BlockedIps = &model.BlockedIPs + } + if model.DefaultCacheDuration != "" { + cfg.DefaultCacheDuration = cdn.NewNullableString(&model.DefaultCacheDuration) + } + if model.MonthlyLimitBytes != nil && *model.MonthlyLimitBytes > 0 { + cfg.MonthlyLimitBytes = model.MonthlyLimitBytes + } + if model.Loki != nil { + loki := &cdn.LokiLogSinkPatch{} + cfg.LogSink = cdn.NewNullableConfigPatchLogSink(&cdn.ConfigPatchLogSink{ + LokiLogSinkPatch: loki, + }) + if model.Loki.PushURL != "" { + loki.PushUrl = utils.Ptr(model.Loki.PushURL) + } + if model.Loki.Username != "" { + loki.Credentials = cdn.NewLokiLogSinkCredentials( + model.Loki.Password, + model.Loki.Username, + ) + } + } + if model.Optimizer != nil { + cfg.Optimizer = &cdn.OptimizerPatch{ + Enabled: model.Optimizer, + } + } + req = req.PatchDistributionPayload(*payload) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *cdn.PatchDistributionResponse) error { + if resp == nil { + return fmt.Errorf("update distribution response is empty") + } + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Updated CDN distribution for %q. ID: %s\n", projectLabel, utils.PtrString(resp.Distribution.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/cdn/distribution/update/update_test.go b/internal/cmd/beta/cdn/distribution/update/update_test.go new file mode 100644 index 000000000..c815cc284 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/update/update_test.go @@ -0,0 +1,365 @@ +package update + +import ( + "bytes" + "context" + "fmt" + "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" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + "k8s.io/utils/ptr" +) + +const testCacheDuration = "P1DT12H" + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &cdn.APIClient{} +var testProjectId = uuid.NewString() +var testDistributionID = uuid.NewString() + +const testMonthlyLimitBytes int64 = 1048576 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + DistributionID: testDistributionID, + Regions: []cdn.Region{}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(p *cdn.PatchDistributionPayload)) cdn.ApiPatchDistributionRequest { + req := testClient.PatchDistribution(testCtx, testProjectId, testDistributionID) + if p := fixturePayload(mods...); p != nil { + req = req.PatchDistributionPayload(*fixturePayload(mods...)) + } + return req +} + +func fixturePayload(mods ...func(p *cdn.PatchDistributionPayload)) *cdn.PatchDistributionPayload { + p := cdn.NewPatchDistributionPayload() + p.Config = &cdn.ConfigPatch{} + for _, m := range mods { + m(p) + } + return p +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "distribution id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid distribution id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "invalid distribution id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "both backends", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagHTTP] = "true" + m[flagBucket] = "true" + }, + ), + isValid: false, + }, + { + description: "max config without backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagRegions] = "EU,US" + m[flagBlockedCountries] = "DE,AT,CH" + m[flagBlockedIPs] = "127.0.0.1,10.0.0.8" + m[flagDefaultCacheDuration] = "P1DT12H" + m[flagLoki] = "true" + m[flagLokiUsername] = "loki-user" + m[flagLokiPushURL] = "https://loki.example.com" + m[flagMonthlyLimitBytes] = fmt.Sprintf("%d", testMonthlyLimitBytes) + m[flagOptimizer] = "true" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(m *inputModel) { + m.Regions = []cdn.Region{cdn.REGION_EU, cdn.REGION_US} + m.BlockedCountries = []string{"DE", "AT", "CH"} + m.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + m.DefaultCacheDuration = "P1DT12H" + m.Loki = &lokiInputModel{ + Username: "loki-user", + PushURL: "https://loki.example.com", + } + m.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + m.Optimizer = utils.Ptr(true) + }, + ), + }, + { + description: "max config http backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagHTTP] = "true" + m[flagHTTPOriginURL] = "https://origin.example.com" + m[flagHTTPOriginRequestHeaders] = "X-Example-Header: example-value, X-Another-Header: another-value" + m[flagHTTPGeofencing] = "https://dach.example.com DE,AT,CH" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(m *inputModel) { + m.HTTP = &httpInputModel{ + OriginURL: "https://origin.example.com", + OriginRequestHeaders: &map[string]string{ + "X-Example-Header": "example-value", + "X-Another-Header": "another-value", + }, + Geofencing: &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + }, + } + }, + ), + }, + { + description: "max config bucket backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(m map[string]string) { + m[flagBucket] = "true" + m[flagBucketURL] = "https://bucket.example.com" + m[flagBucketRegion] = "EU" + m[flagBucketCredentialsAccessKeyID] = "access-key-id" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(m *inputModel) { + m.Bucket = &bucketInputModel{ + URL: "https://bucket.example.com", + Region: "EU", + AccessKeyID: "access-key-id", + } + }, + ), + }, + } + 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 + model *inputModel + expected cdn.ApiPatchDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expected: fixtureRequest(), + }, + { + description: "max without backend", + model: fixtureInputModel( + func(m *inputModel) { + m.Regions = []cdn.Region{cdn.REGION_EU, cdn.REGION_US} + m.BlockedCountries = []string{"DE", "AT", "CH"} + m.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + m.DefaultCacheDuration = testCacheDuration + m.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + m.Loki = &lokiInputModel{ + Password: "loki-pass", + Username: "loki-user", + PushURL: "https://loki.example.com", + } + m.Optimizer = utils.Ptr(true) + }, + ), + expected: fixtureRequest( + func(p *cdn.PatchDistributionPayload) { + p.Config.Regions = &[]cdn.Region{cdn.REGION_EU, cdn.REGION_US} + p.Config.BlockedCountries = &[]string{"DE", "AT", "CH"} + p.Config.BlockedIps = &[]string{"127.0.0.1", "10.0.0.8"} + p.Config.DefaultCacheDuration = cdn.NewNullableString(utils.Ptr(testCacheDuration)) + p.Config.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + p.Config.LogSink = cdn.NewNullableConfigPatchLogSink(&cdn.ConfigPatchLogSink{ + LokiLogSinkPatch: &cdn.LokiLogSinkPatch{ + Credentials: cdn.NewLokiLogSinkCredentials("loki-pass", "loki-user"), + PushUrl: utils.Ptr("https://loki.example.com"), + }, + }) + p.Config.Optimizer = &cdn.OptimizerPatch{ + Enabled: utils.Ptr(true), + } + }, + ), + }, + { + description: "max http backend", + model: fixtureInputModel( + func(m *inputModel) { + m.HTTP = &httpInputModel{ + Geofencing: &map[string][]string{"https://dach.example.com": {"DE", "AT", "CH"}}, + OriginRequestHeaders: &map[string]string{"X-Example-Header": "example-value", "X-Another-Header": "another-value"}, + OriginURL: "https://http-backend.example.com", + } + }), + expected: fixtureRequest( + func(p *cdn.PatchDistributionPayload) { + p.Config.Backend = &cdn.ConfigPatchBackend{ + HttpBackendPatch: &cdn.HttpBackendPatch{ + Geofencing: &map[string][]string{"https://dach.example.com": {"DE", "AT", "CH"}}, + OriginRequestHeaders: &map[string]string{ + "X-Example-Header": "example-value", + "X-Another-Header": "another-value", + }, + OriginUrl: utils.Ptr("https://http-backend.example.com"), + Type: utils.Ptr("http"), + }, + } + }), + }, + { + description: "max bucket backend", + model: fixtureInputModel( + func(m *inputModel) { + m.Bucket = &bucketInputModel{ + URL: "https://bucket.example.com", + AccessKeyID: "bucket-access-key-id", + Password: "bucket-pass", + Region: "EU", + } + }), + expected: fixtureRequest( + func(p *cdn.PatchDistributionPayload) { + p.Config.Backend = &cdn.ConfigPatchBackend{ + BucketBackendPatch: &cdn.BucketBackendPatch{ + BucketUrl: utils.Ptr("https://bucket.example.com"), + Credentials: cdn.NewBucketCredentials("bucket-access-key-id", "bucket-pass"), + Region: utils.Ptr("EU"), + Type: utils.Ptr("bucket"), + }, + } + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.model) + + diff := cmp.Diff(request, tt.expected, + cmp.AllowUnexported(tt.expected, cdn.NullableString{}, cdn.NullableConfigPatchLogSink{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + response *cdn.PatchDistributionResponse + expected string + wantErr bool + }{ + { + description: "nil response", + outputFormat: "table", + response: nil, + wantErr: true, + }, + { + description: "table output", + outputFormat: "table", + response: &cdn.PatchDistributionResponse{ + Distribution: &cdn.Distribution{ + Id: ptr.To("dist-1234"), + }, + }, + expected: fmt.Sprintf("Updated CDN distribution for %q. ID: dist-1234\n", testProjectId), + }, + } + + 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, testProjectId, tt.response); (err != nil) != tt.wantErr { + 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/beta/cdn/domain/describe/describe.go b/internal/cmd/beta/cdn/domain/describe/describe.go new file mode 100644 index 000000000..cce268ec4 --- /dev/null +++ b/internal/cmd/beta/cdn/domain/describe/describe.go @@ -0,0 +1 @@ +package describe diff --git a/internal/pkg/flags/flag_to_value.go b/internal/pkg/flags/flag_to_value.go index 6385ba65a..f08904982 100644 --- a/internal/pkg/flags/flag_to_value.go +++ b/internal/pkg/flags/flag_to_value.go @@ -47,6 +47,20 @@ func FlagToStringSliceValue(p *print.Printer, cmd *cobra.Command, flag string) [ return nil } +// Returns the flag's value as a []string. +// Returns nil if flag is not set, if its value can not be converted to []string, or if the flag does not exist. +func FlagToStringArrayValue(p *print.Printer, cmd *cobra.Command, flag string) []string { + value, err := cmd.Flags().GetStringArray(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert flag to string array value: %v", err) + return nil + } + if !cmd.Flag(flag).Changed { + return nil + } + return value +} + // Returns a pointer to the flag's value. // Returns nil if the flag is not set, if its value can not be converted to map[string]string, or if the flag does not exist. func FlagToStringToStringPointer(p *print.Printer, cmd *cobra.Command, flag string) *map[string]string { //nolint:gocritic //convenient for setting the SDK payload @@ -75,6 +89,20 @@ func FlagToInt64Pointer(p *print.Printer, cmd *cobra.Command, flag string) *int6 return nil } +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to int64, or if the flag does not exist. +func FlagToInt32Pointer(p *print.Printer, cmd *cobra.Command, flag string) *int32 { + value, err := cmd.Flags().GetInt32(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert flag to Int pointer: %v", err) + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + // Returns a pointer to the flag's value. // Returns nil if the flag is not set, if its value can not be converted to string, or if the flag does not exist. func FlagToStringPointer(p *print.Printer, cmd *cobra.Command, flag string) *string { diff --git a/internal/pkg/testutils/testutils.go b/internal/pkg/testutils/testutils.go index 8f970fd0a..18730ef63 100644 --- a/internal/pkg/testutils/testutils.go +++ b/internal/pkg/testutils/testutils.go @@ -12,12 +12,14 @@ import ( // TestParseInput centralizes the logic to test a combination of inputs (arguments, flags) for a cobra command func TestParseInput[T any](t *testing.T, cmdFactory func(*params.CmdParams) *cobra.Command, parseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error), expectedModel T, argValues []string, flagValues map[string]string, isValid bool) { + t.Helper() TestParseInputWithAdditionalFlags(t, cmdFactory, parseInputFunc, expectedModel, argValues, flagValues, map[string][]string{}, isValid) } // TestParseInputWithAdditionalFlags centralizes the logic to test a combination of inputs (arguments, flags) for a cobra command. // It allows to pass multiple instances of a single flag to the cobra command using the `additionalFlagValues` parameter. func TestParseInputWithAdditionalFlags[T any](t *testing.T, cmdFactory func(*params.CmdParams) *cobra.Command, parseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error), expectedModel T, argValues []string, flagValues map[string]string, additionalFlagValues map[string][]string, isValid bool) { + t.Helper() p := print.NewPrinter() cmd := cmdFactory(¶ms.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) @@ -49,6 +51,21 @@ func TestParseInputWithAdditionalFlags[T any](t *testing.T, cmdFactory func(*par } } + if cmd.PreRun != nil { + // can be used for dynamic flag configuration + cmd.PreRun(cmd, argValues) + } + + if cmd.PreRunE != nil { + err := cmd.PreRunE(cmd, argValues) + if err != nil { + if !isValid { + return + } + t.Fatalf("error in PreRunE: %v", err) + } + } + err = cmd.ValidateArgs(argValues) if err != nil { if !isValid { diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go index 401287fa1..ef881bffd 100644 --- a/internal/pkg/utils/strings.go +++ b/internal/pkg/utils/strings.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "strings" "unicode/utf8" ) @@ -26,6 +27,18 @@ func JoinStringKeysPtr(m map[string]any, sep string) string { return JoinStringKeys(m, sep) } +// JoinStringMap concatenates the key-value pairs of a string map, key and value separated by kvSep, key value pairs separated by sep. +func JoinStringMap(m map[string]string, kvSep, sep string) string { + if m == nil { + return "" + } + parts := make([]string, 0, len(m)) + for k, v := range m { + parts = append(parts, fmt.Sprintf("%s%s%s", k, kvSep, v)) + } + return strings.Join(parts, sep) +} + // JoinStringPtr concatenates the strings of a string slice pointer, each separatore by the // [sep] string. func JoinStringPtr(vals *[]string, sep string) string { diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go index 862b92c8f..b37443e2b 100644 --- a/internal/pkg/utils/utils.go +++ b/internal/pkg/utils/utils.go @@ -14,6 +14,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/config" sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "golang.org/x/exp/constraints" ) // Ptr Returns the pointer to any type T @@ -259,3 +260,10 @@ func GetSliceFromPointer[T any](s *[]T) []T { } return *s } + +func Min[T constraints.Ordered](a, b T) T { + if a < b { + return a + } + return b +}