diff --git a/docs/stackit.md b/docs/stackit.md index eaa87cd07..7dacfde65 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -26,35 +26,22 @@ stackit [flags] ### SEE ALSO -* [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit auth](./stackit_auth.md) - Provides authentication functionality * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint * [stackit dns](./stackit_dns.md) - Provides functionality for DNS * [stackit logme](./stackit_logme.md) - Provides functionality for LogMe -* [stackit logme](./stackit_logme.md) - Provides functionality for LogMe * [stackit mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB -* [stackit mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB -* [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit object-storage](./stackit_object-storage.md) - Provides functionality regarding Object Storage -* [stackit object-storage](./stackit_object-storage.md) - Provides functionality regarding Object Storage -* [stackit opensearch](./stackit_opensearch.md) - Provides functionality for OpenSearch * [stackit opensearch](./stackit_opensearch.md) - Provides functionality for OpenSearch * [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations -* [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations -* [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex * [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex * [stackit project](./stackit_project.md) - Provides functionality regarding projects * [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ -* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ -* [stackit redis](./stackit_redis.md) - Provides functionality for Redis * [stackit redis](./stackit_redis.md) - Provides functionality for Redis * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager -* [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager -* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts * [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts * [stackit ske](./stackit_ske.md) - Provides functionality for SKE -* [stackit ske](./stackit_ske.md) - Provides functionality for SKE diff --git a/docs/stackit_argus.md b/docs/stackit_argus.md index 659b1dc36..eb22ea0fa 100644 --- a/docs/stackit_argus.md +++ b/docs/stackit_argus.md @@ -31,4 +31,5 @@ stackit argus [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances * [stackit argus plans](./stackit_argus_plans.md) - Lists all Argus service plans +* [stackit argus scrape-configs](./stackit_argus_scrape-configs.md) - Provides functionality for scrape configs in Argus. diff --git a/docs/stackit_argus_scrape-configs.md b/docs/stackit_argus_scrape-configs.md new file mode 100644 index 000000000..73d9455d8 --- /dev/null +++ b/docs/stackit_argus_scrape-configs.md @@ -0,0 +1,33 @@ +## stackit argus scrape-configs + +Provides functionality for scrape configs in Argus. + +### Synopsis + +Provides functionality for scrape configurations in Argus. + +``` +stackit argus scrape-configs [flags] +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-configs" +``` + +### 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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit argus](./stackit_argus.md) - Provides functionality for Argus +* [stackit argus scrape-configs generate-payload](./stackit_argus_scrape-configs_generate-payload.md) - Generates a payload to create/update Scrape Configurations for an Argus instance + diff --git a/docs/stackit_argus_scrape-configs_generate-payload.md b/docs/stackit_argus_scrape-configs_generate-payload.md new file mode 100644 index 000000000..fafe0a559 --- /dev/null +++ b/docs/stackit_argus_scrape-configs_generate-payload.md @@ -0,0 +1,54 @@ +## stackit argus scrape-configs generate-payload + +Generates a payload to create/update Scrape Configurations for an Argus instance + +### Synopsis + +Generates a JSON payload with values to be used as --payload input for Scrape Configurations creation or update. +This command can be used to generate a payload to update an existing Scrape Config job or to create a new Scrape Config job. +To update an existing Scrape Config job, provide the job name and the instance ID of the Argus instance. +To obtain a default payload to create a new Scrape Config job, run the command with no flags. +Note that the default values provided, particularly for the job name, the metrics path and URL of the targets, should be changed to your use case. +See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure. + + +``` +stackit argus scrape-configs generate-payload [flags] +``` + +### Examples + +``` + Generate a Create payload with default values, and adapt it with custom values for the different configuration options + $ stackit argus scrape-configs generate-payload > ./payload.json + + $ stackit argus scrape-configs create my-config --payload @./payload.json + + Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and adapt it with custom values for the different configuration options + $ stackit argus scrape-configs generate-payload --job-name my-config --instance-id xxx > ./payload.json + + $ stackit argus scrape-configs update my-config --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-configs generate-payload" + --instance-id string Instance ID + -n, --job-name string If set, generates an update payload with the current state of the given scrape config. If unset, generates a create payload with default values +``` + +### 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"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit argus scrape-configs](./stackit_argus_scrape-configs.md) - Provides functionality for scrape configs in Argus. + diff --git a/internal/cmd/argus/argus.go b/internal/cmd/argus/argus.go index 38dc77321..8e93e6c6f 100644 --- a/internal/cmd/argus/argus.go +++ b/internal/cmd/argus/argus.go @@ -3,6 +3,7 @@ package argus import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/plans" + scrapeconfigs "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-configs" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,4 +26,5 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(plans.NewCmd(p)) cmd.AddCommand(instance.NewCmd(p)) + cmd.AddCommand(scrapeconfigs.NewCmd(p)) } diff --git a/internal/cmd/argus/scrape-configs/generate-payload/generate_payload.go b/internal/cmd/argus/scrape-configs/generate-payload/generate_payload.go new file mode 100644 index 000000000..d79b27b9b --- /dev/null +++ b/internal/cmd/argus/scrape-configs/generate-payload/generate_payload.go @@ -0,0 +1,137 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "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/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + jobNameFlag = "job-name" + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + JobName *string + InstanceId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/update Scrape Configurations for an Argus instance ", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n", + "Generates a JSON payload with values to be used as --payload input for Scrape Configurations creation or update.", + "This command can be used to generate a payload to update an existing Scrape Config job or to create a new Scrape Config job.", + "To update an existing Scrape Config job, provide the job name and the instance ID of the Argus instance.", + "To obtain a default payload to create a new Scrape Config job, run the command with no flags.", + "Note that some of the default values provided, such as the job name, the metrics path and URL of the targets, should be adapted to your use case.", + "See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a Create payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit argus scrape-configs generate-payload > ./payload.json`, + ``, + `$ stackit argus scrape-configs create my-config --payload @./payload.json`), + examples.NewExample( + `Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and adapt it with custom values for the different configuration options`, + `$ stackit argus scrape-configs generate-payload --job-name my-config --instance-id xxx > ./payload.json`, + ``, + `$ stackit argus scrape-configs update my-config --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if model.JobName == nil { + createPayload := argusUtils.DefaultCreateScrapeConfigPayload + return outputCreateResult(p, &createPayload) + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read Argus Scrape Config: %w", err) + } + + payload, err := argusUtils.MapToUpdateScrapeConfigPayload(resp) + if err != nil { + return fmt.Errorf("map update scrape config payloads: %w", err) + } + + return outputUpdateResult(p, payload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().StringP(jobNameFlag, "n", "", "If set, generates an update payload with the current state of the given scrape config. If unset, generates a create payload with default values") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + jobName := flags.FlagToStringPointer(cmd, jobNameFlag) + instanceId := flags.FlagToStringValue(cmd, instanceIdFlag) + + if jobName != nil && (globalFlags.ProjectId == "" || instanceId == "") { + return nil, fmt.Errorf("if a job-name is provided then instance-id and project-id must to be provided") + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + JobName: jobName, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetScrapeConfigRequest { + req := apiClient.GetScrapeConfig(ctx, model.InstanceId, *model.JobName, model.ProjectId) + return req +} + +func outputCreateResult(p *print.Printer, payload *argus.CreateScrapeConfigPayload) error { + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + p.Outputln(string(payloadBytes)) + + return nil +} + +func outputUpdateResult(p *print.Printer, payload *argus.UpdateScrapeConfigPayload) error { + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + p.Outputln(string(payloadBytes)) + + return nil +} diff --git a/internal/cmd/argus/scrape-configs/generate-payload/generate_payload_test.go b/internal/cmd/argus/scrape-configs/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..afb663f08 --- /dev/null +++ b/internal/cmd/argus/scrape-configs/generate-payload/generate_payload_test.go @@ -0,0 +1,235 @@ +package generatepayload + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +const testJobName = "test-job-name" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + jobNameFlag: testJobName, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + JobName: utils.Ptr(testJobName), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiGetScrapeConfigRequest)) argus.ApiGetScrapeConfigRequest { + request := testClient.GetScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + }, + }, + { + description: "job name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, jobNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.JobName = nil + }), + }, + { + description: "job name missing, instance id provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, jobNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.JobName = nil + }), + }, + { + description: "instance id missing, job name provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "project id missing, job name provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id and instance id missing, job name provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiGetScrapeConfigRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/scrape-configs/scrape_configs.go b/internal/cmd/argus/scrape-configs/scrape_configs.go new file mode 100644 index 000000000..83c4fc634 --- /dev/null +++ b/internal/cmd/argus/scrape-configs/scrape_configs.go @@ -0,0 +1,26 @@ +package scrapeconfigs + +import ( + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-configs/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "scrape-configs", + Short: "Provides functionality for scrape configs in Argus.", + Long: "Provides functionality for scrape configurations in Argus.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(generatepayload.NewCmd(p)) +} diff --git a/internal/pkg/services/argus/utils/utils.go b/internal/pkg/services/argus/utils/utils.go index e530c187b..e26f66488 100644 --- a/internal/pkg/services/argus/utils/utils.go +++ b/internal/pkg/services/argus/utils/utils.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/argus" ) @@ -14,6 +15,24 @@ const ( service = "argus" ) +var ( + defaultStaticConfigs = []argus.CreateScrapeConfigPayloadStaticConfigsInner{ + { + Targets: utils.Ptr([]string{ + "url-target", + }), + }, + } + DefaultCreateScrapeConfigPayload = argus.CreateScrapeConfigPayload{ + JobName: utils.Ptr("default-name"), + MetricsPath: utils.Ptr("/metrics"), + Scheme: utils.Ptr("https"), + ScrapeInterval: utils.Ptr("5m"), + ScrapeTimeout: utils.Ptr("2m"), + StaticConfigs: utils.Ptr(defaultStaticConfigs), + } +) + func ValidatePlanId(planId string, resp *argus.PlansResponse) error { if resp == nil { return fmt.Errorf("no Argus plans provided") @@ -56,6 +75,123 @@ func LoadPlanId(planName string, resp *argus.PlansResponse) (*string, error) { } } +func MapToUpdateScrapeConfigPayload(resp *argus.GetScrapeConfigResponse) (*argus.UpdateScrapeConfigPayload, error) { + if resp == nil || resp.Data == nil { + return nil, fmt.Errorf("no Argus scrape config provided") + } + + data := resp.Data + + basicAuth := mapBasicAuth(data.BasicAuth) + staticConfigs := mapStaticConfig(data.StaticConfigs) + tlsConfig := mapTlsConfig(data.TlsConfig) + metricsRelabelConfigs := mapMetricsRelabelConfig(data.MetricsRelabelConfigs) + + var params *map[string]interface{} + if data.Params != nil { + params = utils.Ptr(mapParams(*data.Params)) + } + + payload := argus.UpdateScrapeConfigPayload{ + BasicAuth: basicAuth, + BearerToken: data.BearerToken, + HonorLabels: data.HonorLabels, + HonorTimeStamps: data.HonorTimeStamps, + MetricsPath: data.MetricsPath, + MetricsRelabelConfigs: metricsRelabelConfigs, + Params: params, + SampleLimit: utils.ConvertInt64PToFloat64P(data.SampleLimit), + Scheme: data.Scheme, + ScrapeInterval: data.ScrapeInterval, + ScrapeTimeout: data.ScrapeTimeout, + StaticConfigs: staticConfigs, + TlsConfig: tlsConfig, + } + + if payload == (argus.UpdateScrapeConfigPayload{}) { + return nil, fmt.Errorf("the provided Argus scrape config payload is empty") + } + + return &payload, nil +} + +func mapMetricsRelabelConfig(metricsRelabelConfigs *[]argus.MetricsRelabelConfig) *[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner { + if metricsRelabelConfigs == nil { + return nil + } + var mappedConfigs []argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner + for _, config := range *metricsRelabelConfigs { + mappedConfig := argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + Action: config.Action, + Modulus: utils.ConvertInt64PToFloat64P(config.Modulus), + Regex: config.Regex, + Replacement: config.Replacement, + Separator: config.Separator, + SourceLabels: config.SourceLabels, + TargetLabel: config.TargetLabel, + } + mappedConfigs = append(mappedConfigs, mappedConfig) + } + return &mappedConfigs +} + +func mapStaticConfig(staticConfigs *[]argus.StaticConfigs) *[]argus.UpdateScrapeConfigPayloadStaticConfigsInner { + if staticConfigs == nil { + return nil + } + var mappedConfigs []argus.UpdateScrapeConfigPayloadStaticConfigsInner + for _, config := range *staticConfigs { + var labels *map[string]interface{} + if config.Labels != nil { + labels = utils.Ptr(mapStaticConfigLabels(*config.Labels)) + } + mappedConfig := argus.UpdateScrapeConfigPayloadStaticConfigsInner{ + Labels: labels, + Targets: config.Targets, + } + mappedConfigs = append(mappedConfigs, mappedConfig) + } + + return &mappedConfigs +} + +func mapBasicAuth(basicAuth *argus.BasicAuth) *argus.CreateScrapeConfigPayloadBasicAuth { + if basicAuth == nil { + return nil + } + + return &argus.CreateScrapeConfigPayloadBasicAuth{ + Password: basicAuth.Password, + Username: basicAuth.Username, + } +} + +func mapTlsConfig(tlsConfig *argus.TLSConfig) *argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig { + if tlsConfig == nil { + return nil + } + + return &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + InsecureSkipVerify: tlsConfig.InsecureSkipVerify, + } +} + +func mapParams(params map[string][]string) map[string]interface{} { + paramsMap := make(map[string]interface{}) + for k, v := range params { + paramsMap[k] = v + } + return paramsMap +} + +func mapStaticConfigLabels(labels map[string]string) map[string]interface{} { + labelsMap := make(map[string]interface{}) + for k, v := range labels { + labelsMap[k] = v + } + return labelsMap +} + type ArgusClient interface { GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) } diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/argus/utils/utils_test.go index 8adf5de1b..453498669 100644 --- a/internal/pkg/services/argus/utils/utils_test.go +++ b/internal/pkg/services/argus/utils/utils_test.go @@ -8,6 +8,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/argus" ) @@ -32,6 +33,109 @@ var testPlansResponse = argus.PlansResponse{ }, } +func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)) *argus.GetScrapeConfigResponse { + number := int64(1) + resp := &argus.GetScrapeConfigResponse{ + Data: &argus.Job{ + BasicAuth: &argus.BasicAuth{ + Username: utils.Ptr("username"), + Password: utils.Ptr("password"), + }, + BearerToken: utils.Ptr("bearerToken"), + HonorLabels: utils.Ptr(true), + HonorTimeStamps: utils.Ptr(true), + MetricsPath: utils.Ptr("/metrics"), + MetricsRelabelConfigs: &[]argus.MetricsRelabelConfig{ + { + Action: utils.Ptr("replace"), + Modulus: &number, + Regex: utils.Ptr("regex"), + Replacement: utils.Ptr("replacement"), + Separator: utils.Ptr("separator"), + SourceLabels: &[]string{"sourceLabel"}, + TargetLabel: utils.Ptr("targetLabel"), + }, + }, + Params: &map[string][]string{ + "key": {"value1", "value2"}, + "key2": {}, + }, + SampleLimit: &number, + Scheme: utils.Ptr("scheme"), + ScrapeInterval: utils.Ptr("interval"), + ScrapeTimeout: utils.Ptr("timeout"), + StaticConfigs: &[]argus.StaticConfigs{ + { + Labels: &map[string]string{ + "label": "value", + "label2": "value2", + }, + Targets: &[]string{"target"}, + }, + }, + TlsConfig: &argus.TLSConfig{ + InsecureSkipVerify: utils.Ptr(true), + }, + }, + } + + for _, mod := range mods { + mod(resp) + } + + return resp +} + +func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayload)) *argus.UpdateScrapeConfigPayload { + payload := &argus.UpdateScrapeConfigPayload{ + BasicAuth: &argus.CreateScrapeConfigPayloadBasicAuth{ + Username: utils.Ptr("username"), + Password: utils.Ptr("password"), + }, + BearerToken: utils.Ptr("bearerToken"), + HonorLabels: utils.Ptr(true), + HonorTimeStamps: utils.Ptr(true), + MetricsPath: utils.Ptr("/metrics"), + MetricsRelabelConfigs: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + { + Action: utils.Ptr("replace"), + Modulus: utils.Ptr(1.0), + Regex: utils.Ptr("regex"), + Replacement: utils.Ptr("replacement"), + Separator: utils.Ptr("separator"), + SourceLabels: &[]string{"sourceLabel"}, + TargetLabel: utils.Ptr("targetLabel"), + }, + }, + Params: &map[string]interface{}{ + "key": []string{"value1", "value2"}, + "key2": []string{}, + }, + SampleLimit: utils.Ptr(1.0), + Scheme: utils.Ptr("scheme"), + ScrapeInterval: utils.Ptr("interval"), + ScrapeTimeout: utils.Ptr("timeout"), + StaticConfigs: &[]argus.UpdateScrapeConfigPayloadStaticConfigsInner{ + { + Labels: &map[string]interface{}{ + "label": "value", + "label2": "value2", + }, + Targets: &[]string{"target"}, + }, + }, + TlsConfig: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + InsecureSkipVerify: utils.Ptr(true), + }, + } + + for _, mod := range mods { + mod(payload) + } + + return payload +} + type argusClientMocked struct { getInstanceFails bool getInstanceResp *argus.GetInstanceResponse @@ -220,3 +324,339 @@ func TestValidatePlanId(t *testing.T) { }) } } + +func TestMapToUpdateScrapeConfigPayload(t *testing.T) { + tests := []struct { + description string + resp *argus.GetScrapeConfigResponse + expectedPayload *argus.UpdateScrapeConfigPayload + isValid bool + }{ + { + description: "base case", + resp: fixtureGetScrapeConfigResponse(), + expectedPayload: fixtureUpdateScrapeConfigPayload(), + isValid: true, + }, + { + description: "nil response", + resp: nil, + isValid: false, + }, + { + description: "nil data", + resp: &argus.GetScrapeConfigResponse{ + Data: nil, + }, + isValid: false, + }, + { + description: "empty data", + resp: &argus.GetScrapeConfigResponse{ + Data: &argus.Job{}, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + payload, err := MapToUpdateScrapeConfigPayload(tt.resp) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + diff := cmp.Diff(*payload, *tt.expectedPayload, + cmp.AllowUnexported(*tt.expectedPayload), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapMetricsRelabelConfig(t *testing.T) { + tests := []struct { + description string + config *[]argus.MetricsRelabelConfig + expected *[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner + }{ + { + description: "base case", + config: &[]argus.MetricsRelabelConfig{ + { + Action: utils.Ptr("replace"), + Modulus: utils.Int64Ptr(1), + Regex: utils.Ptr("regex"), + Replacement: utils.Ptr("replacement"), + Separator: utils.Ptr("separator"), + SourceLabels: utils.Ptr([]string{"sourceLabel", "sourceLabel2"}), + TargetLabel: utils.Ptr("targetLabel"), + }, + }, + expected: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + { + Action: utils.Ptr("replace"), + Modulus: utils.Float64Ptr(1.0), + Regex: utils.Ptr("regex"), + Replacement: utils.Ptr("replacement"), + Separator: utils.Ptr("separator"), + SourceLabels: utils.Ptr([]string{"sourceLabel", "sourceLabel2"}), + TargetLabel: utils.Ptr("targetLabel"), + }, + }, + }, + { + description: "empty data", + config: &[]argus.MetricsRelabelConfig{}, + expected: nil, + }, + { + description: "nil", + config: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapMetricsRelabelConfig(tt.config) + + if tt.expected == nil && output == nil || *output == nil { + return + } + + diff := cmp.Diff(*output, *tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapStaticConfig(t *testing.T) { + tests := []struct { + description string + config *[]argus.StaticConfigs + expected *[]argus.UpdateScrapeConfigPayloadStaticConfigsInner + }{ + { + description: "base case", + config: &[]argus.StaticConfigs{ + { + Labels: &map[string]string{ + "label": "value", + "label2": "value2", + }, + Targets: &[]string{"target", "target2"}, + }, + }, + expected: &[]argus.UpdateScrapeConfigPayloadStaticConfigsInner{ + { + Labels: utils.Ptr(map[string]interface{}{ + "label": "value", + "label2": "value2", + }), + Targets: utils.Ptr([]string{"target", "target2"}), + }, + }, + }, + { + description: "empty data", + config: &[]argus.StaticConfigs{}, + expected: nil, + }, + { + description: "nil", + config: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapStaticConfig(tt.config) + + if tt.expected == nil && (output == nil || *output == nil) { + return + } + + diff := cmp.Diff(*output, *tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapBasicAuth(t *testing.T) { + tests := []struct { + description string + auth *argus.BasicAuth + expected *argus.CreateScrapeConfigPayloadBasicAuth + }{ + { + description: "base case", + auth: &argus.BasicAuth{ + Username: utils.Ptr("username"), + Password: utils.Ptr("password"), + }, + expected: &argus.CreateScrapeConfigPayloadBasicAuth{ + Username: utils.Ptr("username"), + Password: utils.Ptr("password"), + }, + }, + { + description: "empty data", + auth: &argus.BasicAuth{}, + expected: &argus.CreateScrapeConfigPayloadBasicAuth{}, + }, + { + description: "nil", + auth: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapBasicAuth(tt.auth) + + if tt.expected == nil && output == nil && tt.auth == nil { + return + } + + diff := cmp.Diff(*output, *tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapTlsConfig(t *testing.T) { + tests := []struct { + description string + config *argus.TLSConfig + expected *argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig + }{ + { + description: "base case", + config: &argus.TLSConfig{ + InsecureSkipVerify: utils.Ptr(true), + }, + expected: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + InsecureSkipVerify: utils.Ptr(true), + }, + }, + { + description: "empty data", + config: &argus.TLSConfig{}, + expected: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{}, + }, + { + description: "nil", + config: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapTlsConfig(tt.config) + + if tt.expected == nil && output == nil && tt.config == nil { + return + } + + diff := cmp.Diff(*output, *tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapParams(t *testing.T) { + tests := []struct { + description string + params map[string][]string + expected map[string]interface{} + }{ + { + description: "base case", + params: map[string][]string{ + "key": {"value1", "value2"}, + "key2": {}, + }, + expected: map[string]interface{}{ + "key": []string{"value1", "value2"}, + "key2": []string{}, + }, + }, + { + description: "empty data", + params: map[string][]string{}, + expected: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapParams(tt.params) + + if tt.expected == nil && output == nil && tt.params == nil { + return + } + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestMapStaticConfigLabels(t *testing.T) { + tests := []struct { + description string + labels map[string]string + expected map[string]interface{} + }{ + { + description: "base case", + labels: map[string]string{ + "label": "value", + "label2": "value2", + }, + expected: map[string]interface{}{ + "label": "value", + "label2": "value2", + }, + }, + { + description: "empty data", + labels: map[string]string{}, + expected: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output := mapStaticConfigLabels(tt.labels) + + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go index 51678c654..f14ea7214 100644 --- a/internal/pkg/utils/utils.go +++ b/internal/pkg/utils/utils.go @@ -12,6 +12,18 @@ func Ptr[T any](v T) *T { return &v } +// Int64Ptr returns a pointer to an int64 +// Needed because the Ptr function only returns pointer to int +func Int64Ptr(i int64) *int64 { + return &i +} + +// Float64Ptr returns a pointer to a float64 +// Needed because the Ptr function only returns pointer to float +func Float64Ptr(f float64) *float64 { + return &f +} + // CmdHelp is used to explicitly set the Run function for non-leaf commands to the command help function, so that we can catch invalid commands // This is a workaround needed due to the open issue on the Cobra repo: https://github.com/spf13/cobra/issues/706 func CmdHelp(cmd *cobra.Command, _ []string) { @@ -26,3 +38,13 @@ func ValidateUUID(value string) error { } return nil } + +// ConvertInt64PToFloat64P converts an int64 pointer to a float64 pointer +// This function will return nil if the input is nil +func ConvertInt64PToFloat64P(i *int64) *float64 { + if i == nil { + return nil + } + f := float64(*i) + return &f +} diff --git a/internal/pkg/utils/utils_test.go b/internal/pkg/utils/utils_test.go new file mode 100644 index 000000000..3dc7b54a7 --- /dev/null +++ b/internal/pkg/utils/utils_test.go @@ -0,0 +1,45 @@ +package utils + +import "testing" + +func TestConvertInt64PToFloat64P(t *testing.T) { + tests := []struct { + name string + input *int64 + expected *float64 + }{ + { + name: "positive", + input: Int64Ptr(1), + expected: Float64Ptr(1.0), + }, + { + name: "negative", + input: Int64Ptr(-1), + expected: Float64Ptr(-1.0), + }, + { + name: "zero", + input: Int64Ptr(0), + expected: Float64Ptr(0.0), + }, + { + name: "nil", + input: nil, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expected := ConvertInt64PToFloat64P(tt.input) + + if expected == nil && tt.expected == nil && tt.input == nil { + return + } + + if *expected != *tt.expected { + t.Errorf("ConvertInt64ToFloat64() = %v, want %v", *expected, *tt.expected) + } + }) + } +}