diff --git a/docs/stackit_argus.md b/docs/stackit_argus.md index 213ae01be..5bb8bf420 100644 --- a/docs/stackit_argus.md +++ b/docs/stackit_argus.md @@ -32,4 +32,5 @@ stackit argus [flags] * [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances * [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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus diff --git a/docs/stackit_argus_scrape-config.md b/docs/stackit_argus_scrape-config.md new file mode 100644 index 000000000..15dea4a7f --- /dev/null +++ b/docs/stackit_argus_scrape-config.md @@ -0,0 +1,38 @@ +## stackit argus scrape-config + +Provides functionality for scrape configurations in Argus + +### Synopsis + +Provides functionality for scrape configurations in Argus. + +``` +stackit argus scrape-config [flags] +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config" +``` + +### 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"] + -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-config create](./stackit_argus_scrape-config_create.md) - Creates a scrape configuration for an Argus instance +* [stackit argus scrape-config delete](./stackit_argus_scrape-config_delete.md) - Deletes a scrape configuration from an Argus instance +* [stackit argus scrape-config describe](./stackit_argus_scrape-config_describe.md) - Shows details of a scrape configuration from an Argus instance +* [stackit argus scrape-config generate-payload](./stackit_argus_scrape-config_generate-payload.md) - Generates a payload to create/update scrape configurations for an Argus instance +* [stackit argus scrape-config list](./stackit_argus_scrape-config_list.md) - Lists all scrape configurations of an Argus instance +* [stackit argus scrape-config update](./stackit_argus_scrape-config_update.md) - Updates a scrape configuration of an Argus instance + diff --git a/docs/stackit_argus_scrape-config_create.md b/docs/stackit_argus_scrape-config_create.md new file mode 100644 index 000000000..cf1dfec34 --- /dev/null +++ b/docs/stackit_argus_scrape-config_create.md @@ -0,0 +1,55 @@ +## stackit argus scrape-config create + +Creates a scrape configuration for an Argus instance + +### Synopsis + +Creates a scrape configuration job for an Argus instance. +The payload can be provided as a JSON string or a file path prefixed with "@". +If no payload is provided, a default payload will be used. +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-config create [flags] +``` + +### Examples + +``` + Create a scrape configuration on Argus instance "xxx" using default configuration + $ stackit argus scrape-config create + + Create a scrape configuration on Argus instance "xxx" using an API payload sourced from the file "./payload.json" + $ stackit argus scrape-config create --payload @./payload.json --instance-id xxx + + Create a scrape configuration on Argus instance "xxx" using an API payload provided as a JSON string + $ stackit argus scrape-config create --payload "{...}" --instance-id xxx + + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit argus scrape-config generate-payload > ./payload.json + + $ stackit argus scrape-config create --payload @./payload.json --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config create" + --instance-id string Instance ID + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit argus scrape-config generate-payload") +``` + +### 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"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/docs/stackit_argus_scrape-config_delete.md b/docs/stackit_argus_scrape-config_delete.md new file mode 100644 index 000000000..8264d85fc --- /dev/null +++ b/docs/stackit_argus_scrape-config_delete.md @@ -0,0 +1,40 @@ +## stackit argus scrape-config delete + +Deletes a scrape configuration from an Argus instance + +### Synopsis + +Deletes a scrape configuration from an Argus instance. + +``` +stackit argus scrape-config delete JOB_NAME [flags] +``` + +### Examples + +``` + Delete a scrape configuration job with name "my-config" from Argus instance "xxx" + $ stackit argus scrape-config delete my-config --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config delete" + --instance-id string Instance ID +``` + +### 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"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/docs/stackit_argus_scrape-config_describe.md b/docs/stackit_argus_scrape-config_describe.md new file mode 100644 index 000000000..0a608a4d9 --- /dev/null +++ b/docs/stackit_argus_scrape-config_describe.md @@ -0,0 +1,43 @@ +## stackit argus scrape-config describe + +Shows details of a scrape configuration from an Argus instance + +### Synopsis + +Shows details of a scrape configuration from an Argus instance. + +``` +stackit argus scrape-config describe JOB_NAME [flags] +``` + +### Examples + +``` + Get details of a scrape configuration with name "my-config" from Argus instance "xxx" + $ stackit argus scrape-config describe my-config --instance-id xxx + + Get details of a scrape configuration with name "my-config" from Argus instance "xxx" in a table format + $ stackit argus scrape-config describe my-config --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config describe" + --instance-id string Instance ID +``` + +### 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"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/docs/stackit_argus_scrape-config_generate-payload.md b/docs/stackit_argus_scrape-config_generate-payload.md new file mode 100644 index 000000000..94da2ec8d --- /dev/null +++ b/docs/stackit_argus_scrape-config_generate-payload.md @@ -0,0 +1,54 @@ +## stackit argus scrape-config 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 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. + + +``` +stackit argus scrape-config 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-config generate-payload > ./payload.json + + $ stackit argus scrape-config 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-config generate-payload --job-name my-config --instance-id xxx > ./payload.json + + $ stackit argus scrape-config update my-config --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config 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" "none"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/docs/stackit_argus_scrape-config_list.md b/docs/stackit_argus_scrape-config_list.md new file mode 100644 index 000000000..45dc232d6 --- /dev/null +++ b/docs/stackit_argus_scrape-config_list.md @@ -0,0 +1,47 @@ +## stackit argus scrape-config list + +Lists all scrape configurations of an Argus instance + +### Synopsis + +Lists all scrape configurations of an Argus instance. + +``` +stackit argus scrape-config list [flags] +``` + +### Examples + +``` + List all scrape configurations of Argus instance "xxx" + $ stackit argus scrape-config list --instance-id xxx + + List all scrape configurations of Argus instance "xxx" in JSON format + $ stackit argus scrape-config list --instance-id xxx --output-format json + + List up to 10 scrape configurations of Argus instance "xxx" + $ stackit argus scrape-config list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config list" + --instance-id string Instance ID + --limit int Maximum number of entries to list +``` + +### 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"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/docs/stackit_argus_scrape-config_update.md b/docs/stackit_argus_scrape-config_update.md new file mode 100644 index 000000000..a3b523377 --- /dev/null +++ b/docs/stackit_argus_scrape-config_update.md @@ -0,0 +1,51 @@ +## stackit argus scrape-config update + +Updates a scrape configuration of an Argus instance + +### Synopsis + +Updates a scrape configuration of an Argus instance. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_update for information regarding the payload structure. + +``` +stackit argus scrape-config update JOB_NAME [flags] +``` + +### Examples + +``` + Update a scrape configuration with name "my-config" from Argus instance "xxx", using an API payload sourced from the file "./payload.json" + $ stackit argus scrape-config update my-config --payload @./payload.json --instance-id xxx + + Update an scrape configuration with name "my-config" from Argus instance "xxx", using an API payload provided as a JSON string + $ stackit argus scrape-config update my-config --payload "{...}" --instance-id xxx + + Generate a payload with the current values of a scrape configuration, and adapt it with custom values for the different configuration options + $ stackit argus scrape-config generate-payload --job-name my-config > ./payload.json + + $ stackit argus scrape-configs update my-config --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit argus scrape-config update" + --instance-id string Instance ID + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json +``` + +### 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"] + -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-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus + diff --git a/internal/cmd/argus/argus.go b/internal/cmd/argus/argus.go index 4454a1c02..c7bb2e05d 100644 --- a/internal/cmd/argus/argus.go +++ b/internal/cmd/argus/argus.go @@ -4,6 +4,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/argus/plans" + scrapeconfig "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -26,5 +27,6 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(grafana.NewCmd(p)) cmd.AddCommand(instance.NewCmd(p)) + cmd.AddCommand(scrapeconfig.NewCmd(p)) cmd.AddCommand(plans.NewCmd(p)) } diff --git a/internal/cmd/argus/scrape-config/create/create.go b/internal/cmd/argus/scrape-config/create/create.go new file mode 100644 index 000000000..a2c4d02e5 --- /dev/null +++ b/internal/cmd/argus/scrape-config/create/create.go @@ -0,0 +1,165 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "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/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + "github.com/stackitcloud/stackit-sdk-go/services/argus/wait" +) + +const ( + payloadFlag = "payload" + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Payload *argus.CreateScrapeConfigPayload +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a scrape configuration for an Argus instance", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Creates a scrape configuration job for an Argus instance.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "If no payload is provided, a default payload will be used.", + "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( + `Create a scrape configuration on Argus instance "xxx" using default configuration`, + "$ stackit argus scrape-config create"), + examples.NewExample( + `Create a scrape configuration on Argus instance "xxx" using an API payload sourced from the file "./payload.json"`, + "$ stackit argus scrape-config create --payload @./payload.json --instance-id xxx"), + examples.NewExample( + `Create a scrape configuration on Argus instance "xxx" using an API payload provided as a JSON string`, + `$ stackit argus scrape-config create --payload "{...}" --instance-id xxx`), + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit argus scrape-config generate-payload > ./payload.json`, + ``, + `$ stackit argus scrape-config create --payload @./payload.json --instance-id xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + // Fill in default payload, if needed + if model.Payload == nil { + defaultPayload := argusUtils.DefaultCreateScrapeConfigPayload + if err != nil { + return fmt.Errorf("get default payload: %w", err) + } + model.Payload = &defaultPayload + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create scrape configuration %q on Argus instance %q?", *model.Payload.JobName, instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("create scrape configuration: %w", err) + } + + jobName := model.Payload.JobName + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Creating scrape config") + _, err = wait.CreateScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, *jobName, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for scrape configuration creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s scrape configuration with name %q for Argus instance %q\n", operationState, *jobName, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit argus scrape-config generate-payload")`) + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *argus.CreateScrapeConfigPayload + if payloadValue != nil { + payload = &argus.CreateScrapeConfigPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Payload: payload, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiCreateScrapeConfigRequest { + req := apiClient.CreateScrapeConfig(ctx, model.InstanceId, model.ProjectId) + + req = req.CreateScrapeConfigPayload(*model.Payload) + return req +} diff --git a/internal/cmd/argus/scrape-config/create/create_test.go b/internal/cmd/argus/scrape-config/create/create_test.go new file mode 100644 index 000000000..a9ca561b4 --- /dev/null +++ b/internal/cmd/argus/scrape-config/create/create_test.go @@ -0,0 +1,312 @@ +package create + +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() + +var testPayload = &argus.CreateScrapeConfigPayload{ + 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"), + JobName: utils.Ptr("default-name"), + 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": []interface{}{string("value1"), string("value2")}, + "key2": []interface{}{}, + }, + SampleLimit: utils.Ptr(1.0), + Scheme: utils.Ptr("scheme"), + ScrapeInterval: utils.Ptr("interval"), + ScrapeTimeout: utils.Ptr("timeout"), + StaticConfigs: &[]argus.CreateScrapeConfigPayloadStaticConfigsInner{ + { + Labels: &map[string]interface{}{ + "label": "value", + "label2": "value2", + }, + Targets: &[]string{"target"}, + }, + }, + TlsConfig: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + InsecureSkipVerify: utils.Ptr(true), + }, +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + payloadFlag: `{ + "jobName": "default-name", + "basicAuth": { + "username": "username", + "password": "password" + }, + "bearerToken": "bearerToken", + "honorLabels": true, + "honorTimeStamps": true, + "metricsPath": "/metrics", + "metricsRelabelConfigs": [ + { + "action": "replace", + "modulus": 1.0, + "regex": "regex", + "replacement": "replacement", + "separator": "separator", + "sourceLabels": ["sourceLabel"], + "targetLabel": "targetLabel" + } + ], + "params": { + "key": ["value1", "value2"], + "key2": [] + }, + "sampleLimit": 1.0, + "scheme": "scheme", + "scrapeInterval": "interval", + "scrapeTimeout": "timeout", + "staticConfigs": [ + { + "labels": { + "label": "value", + "label2": "value2" + }, + "targets": ["target"] + } + ], + "tlsConfig": { + "insecureSkipVerify": true + } + }`, + } + 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, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiCreateScrapeConfigRequest)) argus.ApiCreateScrapeConfigRequest { + request := testClient.CreateScrapeConfig(testCtx, testInstanceId, testProjectId) + request = request.CreateScrapeConfigPayload(*testPayload) + 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: false, + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + 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, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + 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: "default config", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Payload = nil + }), + }, + { + description: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + 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(nil, 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, + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest argus.ApiCreateScrapeConfigRequest + 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-config/delete/delete.go b/internal/cmd/argus/scrape-config/delete/delete.go new file mode 100644 index 000000000..31cc6d855 --- /dev/null +++ b/internal/cmd/argus/scrape-config/delete/delete.go @@ -0,0 +1,127 @@ +package delete + +import ( + "context" + "fmt" + + "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/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + "github.com/stackitcloud/stackit-sdk-go/services/argus/wait" +) + +const ( + jobNameArg = "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: fmt.Sprintf("delete %s", jobNameArg), + Short: "Deletes a scrape configuration from an Argus instance", + Long: "Deletes a scrape configuration from an Argus instance.", + Args: args.SingleArg(jobNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete a scrape configuration job with name "my-config" from Argus instance "xxx"`, + "$ stackit argus scrape-config delete my-config --instance-id xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete scrape configuration %q on Argus instance %q? (This cannot be undone)", model.JobName, instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete scrape configuration: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(p) + s.Start("Deleting scrape config") + _, err = wait.DeleteScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, model.JobName, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for scrape config deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Info("%s scrape configuration with name %q for Argus instance %q\n", operationState, model.JobName, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + jobName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + JobName: jobName, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiDeleteScrapeConfigRequest { + req := apiClient.DeleteScrapeConfig(ctx, model.InstanceId, model.JobName, model.ProjectId) + return req +} diff --git a/internal/cmd/argus/scrape-config/delete/delete_test.go b/internal/cmd/argus/scrape-config/delete/delete_test.go new file mode 100644 index 000000000..21d1b79c9 --- /dev/null +++ b/internal/cmd/argus/scrape-config/delete/delete_test.go @@ -0,0 +1,233 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "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() +var testJobName = "my-config" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testJobName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + 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, + }, + JobName: testJobName, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiDeleteScrapeConfigRequest)) argus.ApiDeleteScrapeConfigRequest { + request := testClient.DeleteScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(nil, cmd, tt.argValues) + 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 + isValid bool + expectedRequest argus.ApiDeleteScrapeConfigRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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-config/describe/describe.go b/internal/cmd/argus/scrape-config/describe/describe.go new file mode 100644 index 000000000..5792f9986 --- /dev/null +++ b/internal/cmd/argus/scrape-config/describe/describe.go @@ -0,0 +1,176 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "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/argus/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-sdk-go/services/argus" + + "github.com/spf13/cobra" +) + +const ( + jobNameArg = "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: fmt.Sprintf("describe %s", jobNameArg), + Short: "Shows details of a scrape configuration from an Argus instance", + Long: "Shows details of a scrape configuration from an Argus instance.", + Args: args.SingleArg(jobNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of a scrape configuration with name "my-config" from Argus instance "xxx"`, + "$ stackit argus scrape-config describe my-config --instance-id xxx"), + examples.NewExample( + `Get details of a scrape configuration with name "my-config" from Argus instance "xxx" in a table format`, + "$ stackit argus scrape-config describe my-config --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read scrape configuration: %w", err) + } + + return outputResult(p, model.OutputFormat, resp.Data) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + jobName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + JobName: jobName, + InstanceId: flags.FlagToStringValue(p, 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 outputResult(p *print.Printer, outputFormat string, config *argus.Job) error { + switch outputFormat { + case print.PrettyOutputFormat: + + saml2Enabled := "Enabled" + if config.Params != nil { + saml2 := (*config.Params)["saml2"] + if len(saml2) > 0 && saml2[0] == "disabled" { + saml2Enabled = "Disabled" + } + } + + var targets []string + for _, target := range *config.StaticConfigs { + targetLabels := []string{} + targetLabelStr := "N/A" + if target.Labels != nil { + // make map prettier + for k, v := range *target.Labels { + targetLabels = append(targetLabels, fmt.Sprintf("%s:%s", k, v)) + } + if targetLabels != nil { + targetLabelStr = strings.Join(targetLabels, ",") + } + } + targetUrlsStr := "N/A" + if target.Targets != nil { + targetUrlsStr = strings.Join(*target.Targets, ",") + } + targets = append(targets, fmt.Sprintf("labels: %s\nurls: %s", targetLabelStr, targetUrlsStr)) + } + + table := tables.NewTable() + table.AddRow("NAME", *config.JobName) + table.AddSeparator() + table.AddRow("METRICS PATH", *config.MetricsPath) + table.AddSeparator() + table.AddRow("SCHEME", *config.Scheme) + table.AddSeparator() + table.AddRow("SCRAPE INTERVAL", *config.ScrapeInterval) + table.AddSeparator() + table.AddRow("SCRAPE TIMEOUT", *config.ScrapeTimeout) + table.AddSeparator() + table.AddRow("SAML2", saml2Enabled) + table.AddSeparator() + if config.BasicAuth == nil { + table.AddRow("AUTHENTICATION", "None") + } else { + table.AddRow("AUTHENTICATION", "Basic Auth") + table.AddSeparator() + table.AddRow("USERNAME", *config.BasicAuth.Username) + table.AddSeparator() + table.AddRow("PASSWORD", *config.BasicAuth.Password) + } + table.AddSeparator() + for i, target := range targets { + table.AddRow(fmt.Sprintf("TARGET #%d", i+1), target) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshal scrape configuration: %w", err) + } + p.Outputln(string(details)) + + return nil + } +} diff --git a/internal/cmd/argus/scrape-config/describe/describe_test.go b/internal/cmd/argus/scrape-config/describe/describe_test.go new file mode 100644 index 000000000..962d373a9 --- /dev/null +++ b/internal/cmd/argus/scrape-config/describe/describe_test.go @@ -0,0 +1,233 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "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() +var testJobName = "my-config" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testJobName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + 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, + }, + JobName: testJobName, + InstanceId: testInstanceId, + } + 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 + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(nil, cmd, tt.argValues) + 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 + isValid bool + expectedRequest argus.ApiGetScrapeConfigRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + 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-config/generate-payload/generate_payload.go b/internal/cmd/argus/scrape-config/generate-payload/generate_payload.go new file mode 100644 index 000000000..310ac67f3 --- /dev/null +++ b/internal/cmd/argus/scrape-config/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 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-config generate-payload > ./payload.json`, + ``, + `$ stackit argus scrape-config 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-config generate-payload --job-name my-config --instance-id xxx > ./payload.json`, + ``, + `$ stackit argus scrape-config update my-config --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, 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(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + jobName := flags.FlagToStringPointer(p, cmd, jobNameFlag) + instanceId := flags.FlagToStringValue(p, 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 be provided") + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + JobName: jobName, + InstanceId: flags.FlagToStringValue(p, 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-config/generate-payload/generate_payload_test.go b/internal/cmd/argus/scrape-config/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..3c1fafd38 --- /dev/null +++ b/internal/cmd/argus/scrape-config/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(nil, 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-config/list/list.go b/internal/cmd/argus/scrape-config/list/list.go new file mode 100644 index 000000000..c726b53aa --- /dev/null +++ b/internal/cmd/argus/scrape-config/list/list.go @@ -0,0 +1,163 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "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/argus/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + limitFlag = "limit" + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + InstanceId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all scrape configurations of an Argus instance", + Long: "Lists all scrape configurations of an Argus instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all scrape configurations of Argus instance "xxx"`, + "$ stackit argus scrape-config list --instance-id xxx"), + examples.NewExample( + `List all scrape configurations of Argus instance "xxx" in JSON format`, + "$ stackit argus scrape-config list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 scrape configurations of Argus instance "xxx"`, + "$ stackit argus scrape-config list --instance-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get scrape configurations: %w", err) + } + configs := *resp.Data + if len(configs) == 0 { + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + p.Info("No scrape configurations found for instance %q\n", instanceLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(configs) > int(*model.Limit) { + configs = configs[:*model.Limit] + } + + return outputResult(p, model.OutputFormat, configs) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiListScrapeConfigsRequest { + req := apiClient.ListScrapeConfigs(ctx, model.InstanceId, model.ProjectId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, configs []argus.Job) error { + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(configs, "", " ") + if err != nil { + return fmt.Errorf("marshal scrape configurations list: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("NAME", "TARGETS", "SCRAPE INTERVAL") + for i := range configs { + c := configs[i] + + targets := 0 + if c.StaticConfigs != nil { + for _, sc := range *c.StaticConfigs { + if sc.Targets == nil { + continue + } + targets += len(*sc.Targets) + } + } + + table.AddRow(*c.JobName, targets, *c.ScrapeInterval) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/argus/scrape-config/list/list_test.go b/internal/cmd/argus/scrape-config/list/list_test.go new file mode 100644 index 000000000..a62605da2 --- /dev/null +++ b/internal/cmd/argus/scrape-config/list/list_test.go @@ -0,0 +1,210 @@ +package list + +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/spf13/cobra" + "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() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + instanceIdFlag: testInstanceId, + } + 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, + }, + Limit: utils.Ptr(int64(10)), + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiListScrapeConfigsRequest)) argus.ApiListScrapeConfigsRequest { + request := testClient.ListScrapeConfigs(testCtx, testInstanceId, 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: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + 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, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + 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: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + 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) + } + + model, err := parseInput(nil, 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.ApiListScrapeConfigsRequest + }{ + { + 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-config/scrape_config.go b/internal/cmd/argus/scrape-config/scrape_config.go new file mode 100644 index 000000000..781569aa9 --- /dev/null +++ b/internal/cmd/argus/scrape-config/scrape_config.go @@ -0,0 +1,36 @@ +package scrapeconfig + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/update" + "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-config", + Short: "Provides functionality for scrape configurations 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)) + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(delete.NewCmd(p)) + cmd.AddCommand(update.NewCmd(p)) + cmd.AddCommand(list.NewCmd(p)) + cmd.AddCommand(describe.NewCmd(p)) +} diff --git a/internal/cmd/argus/scrape-config/update/update.go b/internal/cmd/argus/scrape-config/update/update.go new file mode 100644 index 000000000..ef92943ce --- /dev/null +++ b/internal/cmd/argus/scrape-config/update/update.go @@ -0,0 +1,130 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "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/argus/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + jobNameArg = "JOB_NAME" + + instanceIdFlag = "instance-id" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + JobName string + InstanceId string + Payload argus.UpdateScrapeConfigPayload +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", jobNameArg), + Short: "Updates a scrape configuration of an Argus instance", + Long: fmt.Sprintf("%s\n%s\n%s", + "Updates a scrape configuration of an Argus instance.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_update for information regarding the payload structure.", + ), + Args: args.SingleArg(jobNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Update a scrape configuration with name "my-config" from Argus instance "xxx", using an API payload sourced from the file "./payload.json"`, + "$ stackit argus scrape-config update my-config --payload @./payload.json --instance-id xxx"), + examples.NewExample( + `Update an scrape configuration with name "my-config" from Argus instance "xxx", using an API payload provided as a JSON string`, + `$ stackit argus scrape-config update my-config --payload "{...}" --instance-id xxx`), + examples.NewExample( + `Generate a payload with the current values of a scrape configuration, and adapt it with custom values for the different configuration options`, + `$ stackit argus scrape-config generate-payload --job-name my-config > ./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(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update scrape configuration %q?", model.JobName) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update scrape config: %w", err) + } + + // The API has no status to wait on, so async mode is default + p.Info("Updated Argus scrape configuration with name %q\n", model.JobName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json`) + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadString := flags.FlagToStringValue(p, cmd, payloadFlag) + var payload argus.UpdateScrapeConfigPayload + err := json.Unmarshal([]byte(payloadString), &payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + JobName: clusterName, + Payload: payload, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiUpdateScrapeConfigRequest { + req := apiClient.UpdateScrapeConfig(ctx, model.InstanceId, model.JobName, model.ProjectId) + + req = req.UpdateScrapeConfigPayload(model.Payload) + return req +} diff --git a/internal/cmd/argus/scrape-config/update/update_test.go b/internal/cmd/argus/scrape-config/update/update_test.go new file mode 100644 index 000000000..232d798c5 --- /dev/null +++ b/internal/cmd/argus/scrape-config/update/update_test.go @@ -0,0 +1,300 @@ +package update + +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() +var testJobName = "my-config" + +var testPayload = 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": []interface{}{string("value1"), string("value2")}, + "key2": []interface{}{}, + }, +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testJobName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + payloadFlag: `{ + "basicAuth": { + "username": "username", + "password": "password" + }, + "bearerToken": "bearerToken", + "honorLabels": true, + "honorTimestamps": true, + "metricsPath": "/metrics", + "metricsRelabelConfigs": [ + { + "action": "replace", + "modulus": 1.0, + "regex": "regex", + "replacement": "replacement", + "separator": "separator", + "sourceLabels": ["sourceLabel"], + "targetLabel": "targetLabel" + } + ], + "params": { + "key": ["value1", "value2"], + "key2": [] + } + }`, + } + 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, + }, + JobName: testJobName, + InstanceId: testInstanceId, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *argus.ApiUpdateScrapeConfigRequest)) argus.ApiUpdateScrapeConfigRequest { + request := testClient.UpdateScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId) + request = request.UpdateScrapeConfigPayload(testPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + }, + { + description: "payload missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(nil, cmd, tt.argValues) + 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.ApiUpdateScrapeConfigRequest + 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/pkg/services/argus/utils/utils.go b/internal/pkg/services/argus/utils/utils.go index 9024817d4..556c3b7d2 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" ) @@ -20,6 +21,24 @@ type ArgusClient interface { UpdateGrafanaConfigs(ctx context.Context, instanceId string, projectId string) argus.ApiUpdateGrafanaConfigsRequest } +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") @@ -62,6 +81,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 +} + func GetInstanceName(ctx context.Context, apiClient ArgusClient, instanceId, projectId string) (string, error) { resp, err := apiClient.GetInstanceExecute(ctx, instanceId, projectId) if err != nil { diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/argus/utils/utils_test.go index 66ca7fa2d..d36c572f3 100644 --- a/internal/pkg/services/argus/utils/utils_test.go +++ b/internal/pkg/services/argus/utils/utils_test.go @@ -34,6 +34,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 @@ -261,6 +364,342 @@ 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) + } + }) + } +} + func TestToPayloadGenericOAuth(t *testing.T) { tests := []struct { description string 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) + } + }) + } +}