From 8af9e54814dc112ba0d0b77f9ecd10e805ae7198 Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Fri, 11 Sep 2026 12:36:58 +0200 Subject: [PATCH] feat(telemetryrouter): onboard TelemetryRouter service STACKITCLI-382 Signed-off-by: Alexander Dahmen --- docs/stackit_beta.md | 1 + docs/stackit_beta_telemetryrouter.md | 36 ++ ...ackit_beta_telemetryrouter_access-token.md | 38 ++ ...eta_telemetryrouter_access-token_create.md | 50 +++ ...eta_telemetryrouter_access-token_delete.md | 41 ++ ...a_telemetryrouter_access-token_describe.md | 44 ++ ..._beta_telemetryrouter_access-token_list.md | 48 ++ ...eta_telemetryrouter_access-token_update.md | 46 ++ ...tackit_beta_telemetryrouter_destination.md | 39 ++ ...beta_telemetryrouter_destination_create.md | 56 +++ ...beta_telemetryrouter_destination_delete.md | 41 ++ ...ta_telemetryrouter_destination_describe.md | 44 ++ ...etryrouter_destination_generate-payload.md | 61 +++ ...t_beta_telemetryrouter_destination_list.md | 48 ++ ...beta_telemetryrouter_destination_update.md | 52 +++ docs/stackit_beta_telemetryrouter_instance.md | 39 ++ ...it_beta_telemetryrouter_instance_create.md | 53 +++ ...it_beta_telemetryrouter_instance_delete.md | 40 ++ ..._beta_telemetryrouter_instance_describe.md | 43 ++ ...elemetryrouter_instance_generate-filter.md | 54 +++ ...ckit_beta_telemetryrouter_instance_list.md | 44 ++ ...it_beta_telemetryrouter_instance_update.md | 53 +++ docs/stackit_config_set.md | 1 + docs/stackit_config_unset.md | 1 + go.mod | 1 + go.sum | 2 + internal/cmd/beta/beta.go | 2 + .../access_token/access_token.go | 34 ++ .../access_token/create/create.go | 175 ++++++++ .../access_token/create/create_test.go | 293 ++++++++++++ .../access_token/delete/delete.go | 133 ++++++ .../access_token/delete/delete_test.go | 207 +++++++++ .../access_token/describe/describe.go | 131 ++++++ .../access_token/describe/describe_test.go | 253 +++++++++++ .../telemetryrouter/access_token/list/list.go | 204 +++++++++ .../access_token/list/list_test.go | 366 +++++++++++++++ .../access_token/update/update.go | 174 ++++++++ .../access_token/update/update_test.go | 314 +++++++++++++ .../destination/create/create.go | 184 ++++++++ .../destination/create/create_test.go | 283 ++++++++++++ .../destination/delete/delete.go | 132 ++++++ .../destination/delete/delete_test.go | 198 +++++++++ .../destination/describe/describe.go | 132 ++++++ .../destination/describe/describe_test.go | 253 +++++++++++ .../destination/destination.go | 36 ++ .../generate-payload/generate_payload.go | 205 +++++++++ .../generate-payload/generate_payload_test.go | 287 ++++++++++++ .../telemetryrouter/destination/list/list.go | 201 +++++++++ .../destination/list/list_test.go | 355 +++++++++++++++ .../destination/update/update.go | 169 +++++++ .../destination/update/update_test.go | 258 +++++++++++ .../telemetryrouter/instance/create/create.go | 201 +++++++++ .../instance/create/create_test.go | 364 +++++++++++++++ .../telemetryrouter/instance/delete/delete.go | 133 ++++++ .../instance/delete/delete_test.go | 175 ++++++++ .../instance/describe/describe.go | 132 ++++++ .../instance/describe/describe_test.go | 271 +++++++++++ .../generate-filter/generate_filter.go | 148 +++++++ .../generate-filter/generate_filter_test.go | 225 ++++++++++ .../beta/telemetryrouter/instance/instance.go | 36 ++ .../telemetryrouter/instance/list/list.go | 192 ++++++++ .../instance/list/list_test.go | 327 ++++++++++++++ .../telemetryrouter/instance/update/update.go | 202 +++++++++ .../instance/update/update_test.go | 419 ++++++++++++++++++ .../beta/telemetryrouter/telemetryrouter.go | 30 ++ internal/cmd/config/set/set.go | 4 + internal/cmd/config/unset/unset.go | 7 + internal/cmd/config/unset/unset_test.go | 13 + internal/pkg/config/config.go | 3 + .../services/telemetryrouter/client/client.go | 15 + .../services/telemetryrouter/utils/utils.go | 276 ++++++++++++ .../telemetryrouter/utils/utils_test.go | 319 +++++++++++++ 72 files changed, 9447 insertions(+) create mode 100644 docs/stackit_beta_telemetryrouter.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token_create.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token_delete.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token_describe.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token_list.md create mode 100644 docs/stackit_beta_telemetryrouter_access-token_update.md create mode 100644 docs/stackit_beta_telemetryrouter_destination.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_create.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_delete.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_describe.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_generate-payload.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_list.md create mode 100644 docs/stackit_beta_telemetryrouter_destination_update.md create mode 100644 docs/stackit_beta_telemetryrouter_instance.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_create.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_delete.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_describe.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_generate-filter.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_list.md create mode 100644 docs/stackit_beta_telemetryrouter_instance_update.md create mode 100644 internal/cmd/beta/telemetryrouter/access_token/access_token.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/create/create.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/create/create_test.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/delete/delete.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/delete/delete_test.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/describe/describe.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/describe/describe_test.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/list/list.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/list/list_test.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/update/update.go create mode 100644 internal/cmd/beta/telemetryrouter/access_token/update/update_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/create/create.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/create/create_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/delete/delete.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/delete/delete_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/describe/describe.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/describe/describe_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/destination.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/list/list.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/list/list_test.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/update/update.go create mode 100644 internal/cmd/beta/telemetryrouter/destination/update/update_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/create/create.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/create/create_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/delete/delete.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/delete/delete_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/describe/describe.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/describe/describe_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/instance.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/list/list.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/list/list_test.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/update/update.go create mode 100644 internal/cmd/beta/telemetryrouter/instance/update/update_test.go create mode 100644 internal/cmd/beta/telemetryrouter/telemetryrouter.go create mode 100644 internal/pkg/services/telemetryrouter/client/client.go create mode 100644 internal/pkg/services/telemetryrouter/utils/utils.go create mode 100644 internal/pkg/services/telemetryrouter/utils/utils_test.go diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index 697e72855..6aa92bdda 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -47,5 +47,6 @@ stackit beta [flags] * [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for Edge Cloud services. * [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake * [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (STACKIT File Storage) +* [stackit beta telemetryrouter](./stackit_beta_telemetryrouter.md) - Provides functionality for TelemetryRouter * [stackit beta vpn](./stackit_beta_vpn.md) - Provides functionality for VPN diff --git a/docs/stackit_beta_telemetryrouter.md b/docs/stackit_beta_telemetryrouter.md new file mode 100644 index 000000000..3cd3c112f --- /dev/null +++ b/docs/stackit_beta_telemetryrouter.md @@ -0,0 +1,36 @@ +## stackit beta telemetryrouter + +Provides functionality for TelemetryRouter + +### Synopsis + +Provides functionality for TelemetryRouter. + +``` +stackit beta telemetryrouter [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_access-token.md b/docs/stackit_beta_telemetryrouter_access-token.md new file mode 100644 index 000000000..916d875eb --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token.md @@ -0,0 +1,38 @@ +## stackit beta telemetryrouter access-token + +Provides functionality for TelemetryRouter access tokens + +### Synopsis + +Provides functionality for TelemetryRouter access tokens. + +``` +stackit beta telemetryrouter access-token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter access-token" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter](./stackit_beta_telemetryrouter.md) - Provides functionality for TelemetryRouter +* [stackit beta telemetryrouter access-token create](./stackit_beta_telemetryrouter_access-token_create.md) - Creates a TelemetryRouter access token +* [stackit beta telemetryrouter access-token delete](./stackit_beta_telemetryrouter_access-token_delete.md) - Deletes a TelemetryRouter access token +* [stackit beta telemetryrouter access-token describe](./stackit_beta_telemetryrouter_access-token_describe.md) - Shows details of a TelemetryRouter access token +* [stackit beta telemetryrouter access-token list](./stackit_beta_telemetryrouter_access-token_list.md) - Lists all TelemetryRouter access tokens of an instance +* [stackit beta telemetryrouter access-token update](./stackit_beta_telemetryrouter_access-token_update.md) - Updates a TelemetryRouter access token + diff --git a/docs/stackit_beta_telemetryrouter_access-token_create.md b/docs/stackit_beta_telemetryrouter_access-token_create.md new file mode 100644 index 000000000..28b502968 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token_create.md @@ -0,0 +1,50 @@ +## stackit beta telemetryrouter access-token create + +Creates a TelemetryRouter access token + +### Synopsis + +Creates a TelemetryRouter access token. + +``` +stackit beta telemetryrouter access-token create [flags] +``` + +### Examples + +``` + Create an access token with the display name "access-token-1" for the TelemetryRouter instance "xxx" + $ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx + + Create an access token with a description + $ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx --description "Access token for service" + + Create an access token which expires in 30 days + $ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx --ttl 30 +``` + +### Options + +``` + --description string Description of the access token + --display-name string Display name for the access token + -h, --help Help for "stackit beta telemetryrouter access-token create" + --instance-id string ID of the TelemetryRouter instance + --ttl int32 Time-to-live (TTL) in days for the access token. If not set, the token will not expire +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens + diff --git a/docs/stackit_beta_telemetryrouter_access-token_delete.md b/docs/stackit_beta_telemetryrouter_access-token_delete.md new file mode 100644 index 000000000..3e00cfe3d --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token_delete.md @@ -0,0 +1,41 @@ +## stackit beta telemetryrouter access-token delete + +Deletes a TelemetryRouter access token + +### Synopsis + +Deletes a TelemetryRouter access token. + +``` +stackit beta telemetryrouter access-token delete ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Delete access token with ID "xxx" for the TelemetryRouter instance "yyy" + $ stackit beta telemetryrouter access-token delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter access-token delete" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens + diff --git a/docs/stackit_beta_telemetryrouter_access-token_describe.md b/docs/stackit_beta_telemetryrouter_access-token_describe.md new file mode 100644 index 000000000..8f233097c --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token_describe.md @@ -0,0 +1,44 @@ +## stackit beta telemetryrouter access-token describe + +Shows details of a TelemetryRouter access token + +### Synopsis + +Shows details of a TelemetryRouter access token. + +``` +stackit beta telemetryrouter access-token describe ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Show details of a TelemetryRouter access token with ID "xxx" + $ stackit beta telemetryrouter access-token describe xxx --instance-id yyy + + Show details of a TelemetryRouter access token with ID "xxx" in JSON format + $ stackit beta telemetryrouter access-token describe xxx --instance-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter access-token describe" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens + diff --git a/docs/stackit_beta_telemetryrouter_access-token_list.md b/docs/stackit_beta_telemetryrouter_access-token_list.md new file mode 100644 index 000000000..d20593e64 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token_list.md @@ -0,0 +1,48 @@ +## stackit beta telemetryrouter access-token list + +Lists all TelemetryRouter access tokens of an instance + +### Synopsis + +Lists all TelemetryRouter access tokens of an instance. + +``` +stackit beta telemetryrouter access-token list [flags] +``` + +### Examples + +``` + Lists all access tokens of the TelemetryRouter instance "xxx" + $ stackit beta telemetryrouter access-token list --instance-id xxx + + Lists all access tokens in JSON format + $ stackit beta telemetryrouter access-token list --instance-id xxx --output-format json + + Lists up to 10 access tokens + $ stackit beta telemetryrouter access-token list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter access-token list" + --instance-id string ID of the TelemetryRouter instance + --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, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens + diff --git a/docs/stackit_beta_telemetryrouter_access-token_update.md b/docs/stackit_beta_telemetryrouter_access-token_update.md new file mode 100644 index 000000000..bfaa8b2fe --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_access-token_update.md @@ -0,0 +1,46 @@ +## stackit beta telemetryrouter access-token update + +Updates a TelemetryRouter access token + +### Synopsis + +Updates a TelemetryRouter access token. + +``` +stackit beta telemetryrouter access-token update ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Update the display name of the access token with ID "xxx" + $ stackit beta telemetryrouter access-token update xxx --instance-id yyy --display-name access-token-1 + + Update the description of the access token with ID "xxx" + $ stackit beta telemetryrouter access-token update xxx --instance-id yyy --description "Access token for service" +``` + +### Options + +``` + --description string Description of the access token + --display-name string Display name for the access token + -h, --help Help for "stackit beta telemetryrouter access-token update" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter access-token](./stackit_beta_telemetryrouter_access-token.md) - Provides functionality for TelemetryRouter access tokens + diff --git a/docs/stackit_beta_telemetryrouter_destination.md b/docs/stackit_beta_telemetryrouter_destination.md new file mode 100644 index 000000000..c7eb3b65c --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination.md @@ -0,0 +1,39 @@ +## stackit beta telemetryrouter destination + +Provides functionality for TelemetryRouter destinations + +### Synopsis + +Provides functionality for TelemetryRouter destinations. + +``` +stackit beta telemetryrouter destination [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter](./stackit_beta_telemetryrouter.md) - Provides functionality for TelemetryRouter +* [stackit beta telemetryrouter destination create](./stackit_beta_telemetryrouter_destination_create.md) - Creates a TelemetryRouter destination +* [stackit beta telemetryrouter destination delete](./stackit_beta_telemetryrouter_destination_delete.md) - Deletes a TelemetryRouter destination +* [stackit beta telemetryrouter destination describe](./stackit_beta_telemetryrouter_destination_describe.md) - Shows details of a TelemetryRouter destination +* [stackit beta telemetryrouter destination generate-payload](./stackit_beta_telemetryrouter_destination_generate-payload.md) - Generates a payload to create/update TelemetryRouter destinations +* [stackit beta telemetryrouter destination list](./stackit_beta_telemetryrouter_destination_list.md) - Lists all TelemetryRouter destinations of an instance +* [stackit beta telemetryrouter destination update](./stackit_beta_telemetryrouter_destination_update.md) - Updates a TelemetryRouter destination + diff --git a/docs/stackit_beta_telemetryrouter_destination_create.md b/docs/stackit_beta_telemetryrouter_destination_create.md new file mode 100644 index 000000000..5284e9522 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_create.md @@ -0,0 +1,56 @@ +## stackit beta telemetryrouter destination create + +Creates a TelemetryRouter destination + +### Synopsis + +Creates a destination for a TelemetryRouter 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. +The "config" field is a discriminated union: exactly one of "openTelemetry" or "s3" must be set, matching the "configType". + +``` +stackit beta telemetryrouter destination create [flags] +``` + +### Examples + +``` + Create a destination on TelemetryRouter instance "xxx" using the default (OpenTelemetry) configuration + $ stackit beta telemetryrouter destination create --instance-id xxx + + Create a destination on TelemetryRouter instance "xxx" using an API payload sourced from the file "./payload.json" + $ stackit beta telemetryrouter destination create --payload @./payload.json --instance-id xxx + + Create a destination on TelemetryRouter instance "xxx" using an API payload provided as a JSON string + $ stackit beta telemetryrouter destination create --payload "{...}" --instance-id xxx + + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit beta telemetryrouter destination generate-payload --config-type s3 > ./payload.json + + $ stackit beta telemetryrouter destination create --payload @./payload.json --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination create" + --instance-id string ID of the TelemetryRouter instance + --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 beta telemetryrouter destination 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, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_destination_delete.md b/docs/stackit_beta_telemetryrouter_destination_delete.md new file mode 100644 index 000000000..7c8224b1b --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_delete.md @@ -0,0 +1,41 @@ +## stackit beta telemetryrouter destination delete + +Deletes a TelemetryRouter destination + +### Synopsis + +Deletes a TelemetryRouter destination. + +``` +stackit beta telemetryrouter destination delete DESTINATION_ID [flags] +``` + +### Examples + +``` + Delete destination with ID "xxx" for the TelemetryRouter instance "yyy" + $ stackit beta telemetryrouter destination delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination delete" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_destination_describe.md b/docs/stackit_beta_telemetryrouter_destination_describe.md new file mode 100644 index 000000000..6ece8e527 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_describe.md @@ -0,0 +1,44 @@ +## stackit beta telemetryrouter destination describe + +Shows details of a TelemetryRouter destination + +### Synopsis + +Shows details of a TelemetryRouter destination. + +``` +stackit beta telemetryrouter destination describe DESTINATION_ID [flags] +``` + +### Examples + +``` + Show details of a TelemetryRouter destination with ID "xxx" + $ stackit beta telemetryrouter destination describe xxx --instance-id yyy + + Show details of a TelemetryRouter destination with ID "xxx" in JSON format + $ stackit beta telemetryrouter destination describe xxx --instance-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination describe" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_destination_generate-payload.md b/docs/stackit_beta_telemetryrouter_destination_generate-payload.md new file mode 100644 index 000000000..d9b15ad35 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_generate-payload.md @@ -0,0 +1,61 @@ +## stackit beta telemetryrouter destination generate-payload + +Generates a payload to create/update TelemetryRouter destinations + +### Synopsis + +Generates a JSON payload with values to be used as --payload input for destination creation or update. +This command can be used to generate a payload to update an existing destination or to create a new destination. +To update an existing destination, provide the destination ID and the instance ID of the TelemetryRouter instance. +To obtain a default payload to create a new destination, run the command with the "--config-type" flag set to either "opentelemetry" (default) or "s3". +Note that the default values provided, such as the URI, bucket name or credentials, should be adapted to your use case. + +``` +stackit beta telemetryrouter destination generate-payload [flags] +``` + +### Examples + +``` + Generate a create payload with default values for an OpenTelemetry destination, and adapt it with custom values + $ stackit beta telemetryrouter destination generate-payload --file-path ./payload.json + + $ stackit beta telemetryrouter destination create --instance-id xxx --payload @./payload.json + + Generate a create payload with default values for an S3 destination + $ stackit beta telemetryrouter destination generate-payload --config-type s3 --file-path ./payload.json + + Generate an update payload with the values of an existing destination "yyy" for TelemetryRouter instance "xxx", and adapt it with custom values + $ stackit beta telemetryrouter destination generate-payload --destination-id yyy --instance-id xxx --file-path ./payload.json + + $ stackit beta telemetryrouter destination update yyy --instance-id xxx --payload @./payload.json + + Generate an update payload with the values of an existing destination "yyy" for TelemetryRouter instance "xxx", and preview it in the terminal + $ stackit beta telemetryrouter destination generate-payload --destination-id yyy --instance-id xxx +``` + +### Options + +``` + --config-type string Type of the default create payload to generate, one of "opentelemetry" or "s3". Only relevant if "--destination-id" is unset (default "opentelemetry") + --destination-id string If set, generates an update payload with the current state of the given destination. If unset, generates a create payload with default values + -f, --file-path string If set, writes the payload to the given file. If unset, writes the payload to the standard output + -h, --help Help for "stackit beta telemetryrouter destination generate-payload" + --instance-id string ID of the TelemetryRouter instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_destination_list.md b/docs/stackit_beta_telemetryrouter_destination_list.md new file mode 100644 index 000000000..8f73ecd80 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_list.md @@ -0,0 +1,48 @@ +## stackit beta telemetryrouter destination list + +Lists all TelemetryRouter destinations of an instance + +### Synopsis + +Lists all TelemetryRouter destinations of an instance. + +``` +stackit beta telemetryrouter destination list [flags] +``` + +### Examples + +``` + Lists all destinations of the TelemetryRouter instance "xxx" + $ stackit beta telemetryrouter destination list --instance-id xxx + + Lists all destinations in JSON format + $ stackit beta telemetryrouter destination list --instance-id xxx --output-format json + + Lists up to 10 destinations + $ stackit beta telemetryrouter destination list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination list" + --instance-id string ID of the TelemetryRouter instance + --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, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_destination_update.md b/docs/stackit_beta_telemetryrouter_destination_update.md new file mode 100644 index 000000000..6d1ca306c --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_destination_update.md @@ -0,0 +1,52 @@ +## stackit beta telemetryrouter destination update + +Updates a TelemetryRouter destination + +### Synopsis + +Updates a destination of a TelemetryRouter instance. +The payload can be provided as a JSON string or a file path prefixed with "@". +The "config" field is a discriminated union: exactly one of "openTelemetry" or "s3" must be set, matching the "configType". + +``` +stackit beta telemetryrouter destination update DESTINATION_ID [flags] +``` + +### Examples + +``` + Update a destination with ID "xxx" from TelemetryRouter instance "yyy", using an API payload sourced from the file "./payload.json" + $ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload @./payload.json + + Update a destination with ID "xxx" from TelemetryRouter instance "yyy", using an API payload provided as a JSON string + $ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload "{...}" + + Generate a payload with the current values of a destination, and adapt it with custom values for the different configuration options + $ stackit beta telemetryrouter destination generate-payload --destination-id xxx --instance-id yyy > ./payload.json + + $ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter destination update" + --instance-id string ID of the TelemetryRouter instance + --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, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter destination](./stackit_beta_telemetryrouter_destination.md) - Provides functionality for TelemetryRouter destinations + diff --git a/docs/stackit_beta_telemetryrouter_instance.md b/docs/stackit_beta_telemetryrouter_instance.md new file mode 100644 index 000000000..34d9dee20 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance.md @@ -0,0 +1,39 @@ +## stackit beta telemetryrouter instance + +Provides functionality for TelemetryRouter instances + +### Synopsis + +Provides functionality for TelemetryRouter instances. + +``` +stackit beta telemetryrouter instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter instance" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter](./stackit_beta_telemetryrouter.md) - Provides functionality for TelemetryRouter +* [stackit beta telemetryrouter instance create](./stackit_beta_telemetryrouter_instance_create.md) - Creates a TelemetryRouter instance +* [stackit beta telemetryrouter instance delete](./stackit_beta_telemetryrouter_instance_delete.md) - Deletes the given TelemetryRouter instance +* [stackit beta telemetryrouter instance describe](./stackit_beta_telemetryrouter_instance_describe.md) - Shows details of a TelemetryRouter instance +* [stackit beta telemetryrouter instance generate-filter](./stackit_beta_telemetryrouter_instance_generate-filter.md) - Generates a filter configuration to use as --filter input for TelemetryRouter instances +* [stackit beta telemetryrouter instance list](./stackit_beta_telemetryrouter_instance_list.md) - Lists TelemetryRouter instances +* [stackit beta telemetryrouter instance update](./stackit_beta_telemetryrouter_instance_update.md) - Updates a TelemetryRouter instance + diff --git a/docs/stackit_beta_telemetryrouter_instance_create.md b/docs/stackit_beta_telemetryrouter_instance_create.md new file mode 100644 index 000000000..ef5189e2b --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_create.md @@ -0,0 +1,53 @@ +## stackit beta telemetryrouter instance create + +Creates a TelemetryRouter instance + +### Synopsis + +Creates a TelemetryRouter instance. + +``` +stackit beta telemetryrouter instance create [flags] +``` + +### Examples + +``` + Create a TelemetryRouter instance with name "my-instance" + $ stackit beta telemetryrouter instance create --display-name "my-instance" + + Create a TelemetryRouter instance with name "my-instance" and a description + $ stackit beta telemetryrouter instance create --display-name "my-instance" --description "Description of the instance" + + Create a TelemetryRouter instance with name "my-instance" that only routes telemetry data for HTTP GET or HEAD requests, matched at the resource level + $ stackit beta telemetryrouter instance create --display-name "my-instance" --filter-attribute "key=http.method;level=resource;matcher==;values=GET,HEAD" + + Create a TelemetryRouter instance with name "my-instance" and many filter attributes sourced from a JSON file (recommended over repeating --filter-attribute for more than a handful of attributes); use "stackit beta telemetryrouter instance generate-filter" to generate a starting point for "./filter.json" + $ stackit beta telemetryrouter instance create --display-name "my-instance" --filter @./filter.json +``` + +### Options + +``` + --description string Description + --display-name string Display name + --filter string Filter configuration as JSON, of the form {"attributes": [{"key": ..., "level": ..., "matcher": ..., "values": [...]}, ...]}. Can be a JSON string or a file path, if prefixed with "@" (example: @./filter.json). Recommended over "--filter-attribute" when setting many filter attributes at once. Run "stackit beta telemetryrouter instance generate-filter" to generate a starting point. Mutually exclusive with "--filter-attribute". + --filter-attribute stringArray Adds a filter attribute that restricts which telemetry data is routed. Must be of the form "key=;level=;matcher=<=|!=>;values=,,...", e.g. "key=http.method;level=resource;matcher==;values=GET,HEAD" only routes data whose "http.method" resource attribute equals "GET" or "HEAD". Can be repeated to add multiple filter attributes, which are combined with AND. Impractical for many filter attributes at once; use "--filter" instead. Mutually exclusive with "--filter". + -h, --help Help for "stackit beta telemetryrouter instance create" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_instance_delete.md b/docs/stackit_beta_telemetryrouter_instance_delete.md new file mode 100644 index 000000000..3bb5fd1ef --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_delete.md @@ -0,0 +1,40 @@ +## stackit beta telemetryrouter instance delete + +Deletes the given TelemetryRouter instance + +### Synopsis + +Deletes the given TelemetryRouter instance. + +``` +stackit beta telemetryrouter instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a TelemetryRouter instance with ID "xxx" + $ stackit beta telemetryrouter instance delete "xxx" +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter instance delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_instance_describe.md b/docs/stackit_beta_telemetryrouter_instance_describe.md new file mode 100644 index 000000000..57b9eeeb8 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_describe.md @@ -0,0 +1,43 @@ +## stackit beta telemetryrouter instance describe + +Shows details of a TelemetryRouter instance + +### Synopsis + +Shows details of a TelemetryRouter instance + +``` +stackit beta telemetryrouter instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a TelemetryRouter instance with ID "xxx" + $ stackit beta telemetryrouter instance describe xxx + + Get details of a TelemetryRouter instance with ID "xxx" in JSON format + $ stackit beta telemetryrouter instance describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter instance describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_instance_generate-filter.md b/docs/stackit_beta_telemetryrouter_instance_generate-filter.md new file mode 100644 index 000000000..9fd18d4c0 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_generate-filter.md @@ -0,0 +1,54 @@ +## stackit beta telemetryrouter instance generate-filter + +Generates a filter configuration to use as --filter input for TelemetryRouter instances + +### Synopsis + +Generates a JSON filter configuration to be used as "--filter" input for "instance create" or "instance update". +If "--instance-id" is set, the current filter configuration of that instance is returned, to be adapted with custom values. +If unset, a default example filter configuration is returned. + +``` +stackit beta telemetryrouter instance generate-filter [flags] +``` + +### Examples + +``` + Generate a default example filter configuration, and adapt it with custom values + $ stackit beta telemetryrouter instance generate-filter --file-path ./filter.json + + $ stackit beta telemetryrouter instance create --display-name "my-instance" --filter @./filter.json + + Generate a filter configuration with the current values of TelemetryRouter instance "xxx", and adapt it with custom values + $ stackit beta telemetryrouter instance generate-filter --instance-id xxx --file-path ./filter.json + + $ stackit beta telemetryrouter instance update xxx --filter @./filter.json + + Generate a filter configuration with the current values of TelemetryRouter instance "xxx", and preview it in the terminal + $ stackit beta telemetryrouter instance generate-filter --instance-id xxx +``` + +### Options + +``` + -f, --file-path string If set, writes the filter configuration to the given file. If unset, writes it to the standard output + -h, --help Help for "stackit beta telemetryrouter instance generate-filter" + --instance-id string If set, generates a filter configuration with the current values of the given TelemetryRouter instance. If unset, generates a default example filter configuration +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_instance_list.md b/docs/stackit_beta_telemetryrouter_instance_list.md new file mode 100644 index 000000000..2fc0e9434 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_list.md @@ -0,0 +1,44 @@ +## stackit beta telemetryrouter instance list + +Lists TelemetryRouter instances + +### Synopsis + +Lists TelemetryRouter instances within the project. + +``` +stackit beta telemetryrouter instance list [flags] +``` + +### Examples + +``` + List all TelemetryRouter instances + $ stackit beta telemetryrouter instance list + + List the first 10 TelemetryRouter instances + $ stackit beta telemetryrouter instance list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit beta telemetryrouter instance list" + --limit int Limit the output to the first n elements +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_beta_telemetryrouter_instance_update.md b/docs/stackit_beta_telemetryrouter_instance_update.md new file mode 100644 index 000000000..879493721 --- /dev/null +++ b/docs/stackit_beta_telemetryrouter_instance_update.md @@ -0,0 +1,53 @@ +## stackit beta telemetryrouter instance update + +Updates a TelemetryRouter instance + +### Synopsis + +Updates a TelemetryRouter instance. + +``` +stackit beta telemetryrouter instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the display name of the TelemetryRouter instance with ID "xxx" + $ stackit beta telemetryrouter instance update xxx --display-name new-name + + Update the description of the TelemetryRouter instance with ID "xxx" + $ stackit beta telemetryrouter instance update xxx --description new-description + + Replace the filter attributes of the TelemetryRouter instance with ID "xxx" so it only routes telemetry data for HTTP GET or HEAD requests, matched at the resource level + $ stackit beta telemetryrouter instance update xxx --filter-attribute "key=http.method;level=resource;matcher==;values=GET,HEAD" + + Replace the filter attributes of the TelemetryRouter instance with ID "xxx" with many filter attributes sourced from a JSON file (recommended over repeating --filter-attribute for more than a handful of attributes); use "stackit beta telemetryrouter instance generate-filter --instance-id xxx" to generate a starting point from the instance's current filter + $ stackit beta telemetryrouter instance update xxx --filter @./filter.json +``` + +### Options + +``` + --description string Description + --display-name string Display name + --filter string Filter configuration as JSON, of the form {"attributes": [{"key": ..., "level": ..., "matcher": ..., "values": [...]}, ...]}. Can be a JSON string or a file path, if prefixed with "@" (example: @./filter.json). Replaces any existing filter attributes on the instance. Recommended over "--filter-attribute" when setting many filter attributes at once. Run "stackit beta telemetryrouter instance generate-filter --instance-id " to generate a starting point from the instance's current filter. Mutually exclusive with "--filter-attribute". The API does not support clearing all filter attributes on update; at least one filter attribute must remain set. + --filter-attribute stringArray Adds a filter attribute that restricts which telemetry data is routed. Must be of the form "key=;level=;matcher=<=|!=>;values=,,...", e.g. "key=http.method;level=resource;matcher==;values=GET,HEAD" only routes data whose "http.method" resource attribute equals "GET" or "HEAD". Can be repeated to add multiple filter attributes, which are combined with AND. Replaces any existing filter attributes on the instance. Impractical for many filter attributes at once; use "--filter" instead. Mutually exclusive with "--filter". The API does not support clearing all filter attributes on update; at least one filter attribute must remain set. + -h, --help Help for "stackit beta telemetryrouter instance update" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit beta telemetryrouter instance](./stackit_beta_telemetryrouter_instance.md) - Provides functionality for TelemetryRouter instances + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index 6d2d7d01b..f657fce3d 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -63,6 +63,7 @@ stackit config set [flags] --sfs-custom-endpoint string SFS API base URL, used in calls to this API --ske-custom-endpoint string SKE API base URL, used in calls to this API --sqlserverflex-custom-endpoint string SQLServer Flex API base URL, used in calls to this API + --telemetryrouter-custom-endpoint string TelemetryRouter API base URL, used in calls to this API --token-custom-endpoint string Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. --vpn-custom-endpoint string VPN API base URL, used in calls to this API ``` diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 255fd741f..f9444580a 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -65,6 +65,7 @@ stackit config unset [flags] --sfs-custom-endpoint SFS API base URL. If unset, uses the default base URL --ske-custom-endpoint SKE API base URL. If unset, uses the default base URL --sqlserverflex-custom-endpoint SQLServer Flex API base URL. If unset, uses the default base URL + --telemetryrouter-custom-endpoint TelemetryRouter API base URL. If unset, uses the default base URL --token-custom-endpoint Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. --verbosity Verbosity of the CLI --vpn-custom-endpoint VPN API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index 761c8b376..45515f102 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.7.1 github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1 github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 + github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.2 github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.1 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.2 github.com/zalando/go-keyring v0.2.8 diff --git a/go.sum b/go.sum index adad00e42..5028b9714 100644 --- a/go.sum +++ b/go.sum @@ -672,6 +672,8 @@ github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1 h1:dSkUoaMip0uA8tnRE github.com/stackitcloud/stackit-sdk-go/services/ske v1.21.1/go.mod h1:TbqmZhLMofmfl+HhVl6oHYcI3zvXTm1vRjN3A/fOkM4= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 h1:k4dgOFQvkBhSNTtmFOZwMbkKf6ZCEGB/D1WQgiO9gwc= github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0/go.mod h1:AiUoMAqQcOlMgDtkVJlqI7P/VGD5xjN3dYjERGnwN/M= +github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.2 h1:su5M+lv0+fsPjSY1KdC8sxoE8cB5kwNx8bSpEA6bxZE= +github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.2/go.mod h1:G1irvS3PFNNnf5t5ub1UUl2Iha+lv2RYu6l/rx79tcM= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.1 h1:zAdxoRrUB5sIz73xAyXXQlZZ46Yc1p3rW1PerOLHNOk= github.com/stackitcloud/stackit-sdk-go/services/valkey v0.3.1/go.mod h1:8znHnd2b6rdEHSmW/36LQa4dMMaLuN8dwo3tD6arEic= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.2 h1:82VZJaJPwcBKSj8WV8v0ujBvbkbMxFXIvvv0wU+0Be0= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 9b419a617..0484152d6 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -11,6 +11,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/vpn" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -49,4 +50,5 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(intake.NewCmd(params)) cmd.AddCommand(cdn.NewCmd(params)) cmd.AddCommand(vpn.NewCmd(params)) + cmd.AddCommand(telemetryrouter.NewCmd(params)) } diff --git a/internal/cmd/beta/telemetryrouter/access_token/access_token.go b/internal/cmd/beta/telemetryrouter/access_token/access_token.go new file mode 100644 index 000000000..a853dd7dd --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/access_token.go @@ -0,0 +1,34 @@ +package access_token + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "access-token", + Short: "Provides functionality for TelemetryRouter access tokens", + Long: "Provides functionality for TelemetryRouter access tokens.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/create/create.go b/internal/cmd/beta/telemetryrouter/access_token/create/create.go new file mode 100644 index 000000000..0b6cb61ff --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/create/create.go @@ -0,0 +1,175 @@ +package create + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "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" +) + +const ( + instanceIdFlag = "instance-id" + displayNameFlag = "display-name" + descriptionFlag = "description" + ttlFlag = "ttl" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + DisplayName string + Description *string + Ttl *int32 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a TelemetryRouter access token", + Long: "Creates a TelemetryRouter access token.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an access token with the display name "access-token-1" for the TelemetryRouter instance "xxx"`, + `$ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx`, + ), + examples.NewExample( + `Create an access token with a description`, + `$ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx --description "Access token for service"`, + ), + examples.NewExample( + `Create an access token which expires in 30 days`, + `$ stackit beta telemetryrouter access-token create --display-name access-token-1 --instance-id xxx --ttl 30`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to create an access token for the TelemetryRouter instance %q in project %q?", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create TelemetryRouter access token: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating access token", func() error { + _, err = wait.CreateAccessTokenWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, resp.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter access token creation: %w", err) + } + } + + return outputResult(params.Printer, model, instanceLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + cmd.Flags().String(displayNameFlag, "", "Display name for the access token") + cmd.Flags().String(descriptionFlag, "", "Description of the access token") + cmd.Flags().Int32(ttlFlag, 0, "Time-to-live (TTL) in days for the access token. If not set, the token will not expire") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, displayNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + DisplayName: flags.FlagToStringValue(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Ttl: flags.FlagToInt32Pointer(p, cmd, ttlFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiCreateAccessTokenRequest { + req := apiClient.DefaultAPI.CreateAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId) + return req.CreateAccessTokenPayload(telemetryrouter.CreateAccessTokenPayload{ + DisplayName: model.DisplayName, + Description: model.Description, + Ttl: *telemetryrouter.NewNullableInt32(model.Ttl), + }) +} + +func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *telemetryrouter.CreateAccessTokenResponse) error { + if resp == nil { + return fmt.Errorf("create TelemetryRouter access token response is empty") + } + if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s access token for TelemetryRouter instance %q.\n\nID: %s\nToken: %s\n", operationState, instanceLabel, resp.Id, resp.AccessToken) + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/create/create_test.go b/internal/cmd/beta/telemetryrouter/access_token/create/create_test.go new file mode 100644 index 000000000..3c4dabf05 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/create/create_test.go @@ -0,0 +1,293 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" + + testDisplayName = "display-name" + testDescription = "description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + displayNameFlag: testDisplayName, + descriptionFlag: testDescription, + ttlFlag: "30", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + DisplayName: testDisplayName, + Description: new(testDescription), + Ttl: new(int32(30)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiCreateAccessTokenRequest)) telemetryrouter.ApiCreateAccessTokenRequest { + request := testClient.DefaultAPI.CreateAccessToken(testCtx, testProjectId, testRegion, testInstanceId) + request = request.CreateAccessTokenPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *telemetryrouter.CreateAccessTokenPayload)) telemetryrouter.CreateAccessTokenPayload { + payload := telemetryrouter.CreateAccessTokenPayload{ + DisplayName: testDisplayName, + Description: new(testDescription), + Ttl: *telemetryrouter.NewNullableInt32(new(int32(30))), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ttlFlag) + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Ttl = nil + model.Description = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "ttl invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlFlag] = "invalid-integer" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiCreateAccessTokenRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optional values", + model: fixtureInputModel(func(model *inputModel) { + model.Ttl = nil + model.Description = nil + }), + expectedRequest: fixtureRequest(func(request *telemetryrouter.ApiCreateAccessTokenRequest) { + *request = request.CreateAccessTokenPayload(telemetryrouter.CreateAccessTokenPayload{ + DisplayName: testDisplayName, + Description: nil, + Ttl: *telemetryrouter.NewNullableInt32(nil), + }) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest, telemetryrouter.NullableInt32{}), + cmpopts.EquateComparable(testCtx, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + accessToken *telemetryrouter.CreateAccessTokenResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + accessToken: new(telemetryrouter.CreateAccessTokenResponse{ + Id: uuid.NewString(), + DisplayName: "Token", + AccessToken: "Secret access token", + CreatorId: uuid.NewString(), + Status: telemetryrouter.ACCESSTOKENBASERESPONSESTATUS_ACTIVE, + }), + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + accessToken: new(telemetryrouter.CreateAccessTokenResponse{}), + }, + wantErr: false, + }, + { + name: "empty response", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + accessToken: nil, + }, + wantErr: true, + }, + { + name: "nil model", + args: args{ + model: nil, + accessToken: new(telemetryrouter.CreateAccessTokenResponse{}), + }, + wantErr: true, + }, + { + name: "nil global flag model", + args: args{ + model: &inputModel{GlobalFlagModel: nil}, + accessToken: new(telemetryrouter.CreateAccessTokenResponse{}), + }, + wantErr: true, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, "label", tt.args.accessToken); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/delete/delete.go b/internal/cmd/beta/telemetryrouter/access_token/delete/delete.go new file mode 100644 index 000000000..047545b34 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/delete/delete.go @@ -0,0 +1,133 @@ +package delete + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", accessTokenIdArg), + Short: "Deletes a TelemetryRouter access token", + Long: "Deletes a TelemetryRouter access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete access token with ID "xxx" for the TelemetryRouter instance "yyy"`, + "$ stackit beta telemetryrouter access-token delete xxx --instance-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + accessTokenLabel, err := telemetryrouterUtils.GetAccessTokenName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get access token: %v", err) + } + if accessTokenLabel == "" { + accessTokenLabel = model.AccessTokenId + } + + prompt := fmt.Sprintf("Are you sure you want to delete access token %q? (This cannot be undone)", accessTokenLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete TelemetryRouter access token: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting access token", func() error { + _, err = wait.DeleteAccessTokenWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter access token deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s access token %q\n", operationState, accessTokenLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AccessTokenId: accessTokenId, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiDeleteAccessTokenRequest { + return apiClient.DefaultAPI.DeleteAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/delete/delete_test.go b/internal/cmd/beta/telemetryrouter/access_token/delete/delete_test.go new file mode 100644 index 000000000..a5a511767 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/delete/delete_test.go @@ -0,0 +1,207 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiDeleteAccessTokenRequest)) telemetryrouter.ApiDeleteAccessTokenRequest { + request := testClient.DefaultAPI.DeleteAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiDeleteAccessTokenRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/describe/describe.go b/internal/cmd/beta/telemetryrouter/access_token/describe/describe.go new file mode 100644 index 000000000..bcaff8847 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/describe/describe.go @@ -0,0 +1,131 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", accessTokenIdArg), + Short: "Shows details of a TelemetryRouter access token", + Long: "Shows details of a TelemetryRouter access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show details of a TelemetryRouter access token with ID "xxx"`, + "$ stackit beta telemetryrouter access-token describe xxx --instance-id yyy", + ), + examples.NewExample( + `Show details of a TelemetryRouter access token with ID "xxx" in JSON format`, + "$ stackit beta telemetryrouter access-token describe xxx --instance-id yyy --output-format json", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read TelemetryRouter access token: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + AccessTokenId: accessTokenId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiGetAccessTokenRequest { + return apiClient.DefaultAPI.GetAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) +} + +func outputResult(p *print.Printer, outputFormat string, token *telemetryrouter.GetAccessTokenResponse) error { + if token == nil { + return fmt.Errorf("access token cannot be nil") + } + return p.OutputResult(outputFormat, token, func() error { + table := tables.NewTable() + table.AddRow("ID", token.Id) + table.AddSeparator() + table.AddRow("DISPLAY NAME", token.DisplayName) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(token.Description)) + table.AddSeparator() + table.AddRow("CREATOR ID", token.CreatorId) + table.AddSeparator() + table.AddRow("STATUS", token.Status) + table.AddSeparator() + table.AddRow("EXPIRATION TIME", utils.PtrString(token.ExpirationTime.Get())) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/describe/describe_test.go b/internal/cmd/beta/telemetryrouter/access_token/describe/describe_test.go new file mode 100644 index 000000000..3c750c9e2 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/describe/describe_test.go @@ -0,0 +1,253 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiGetAccessTokenRequest)) telemetryrouter.ApiGetAccessTokenRequest { + request := testClient.DefaultAPI.GetAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiGetAccessTokenRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + accessToken *telemetryrouter.GetAccessTokenResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + accessToken: new(telemetryrouter.GetAccessTokenResponse{ + Id: uuid.NewString(), + DisplayName: "Token", + CreatorId: uuid.NewString(), + Status: telemetryrouter.ACCESSTOKENBASERESPONSESTATUS_ACTIVE, + }), + }, + wantErr: false, + }, + { + name: "set empty access token", + args: args{ + accessToken: new(telemetryrouter.GetAccessTokenResponse{}), + }, + wantErr: false, + }, + { + name: "empty", + args: args{}, + wantErr: true, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.accessToken); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/list/list.go b/internal/cmd/beta/telemetryrouter/access_token/list/list.go new file mode 100644 index 000000000..5eb732fdf --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/list/list.go @@ -0,0 +1,204 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/core/experimental/paginate" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + limitFlag = "limit" + instanceIdFlag = "instance-id" + + // maxPageSize is the maximum number of items the API returns per page. + maxPageSize = 100 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all TelemetryRouter access tokens of an instance", + Long: "Lists all TelemetryRouter access tokens of an instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all access tokens of the TelemetryRouter instance "xxx"`, + "$ stackit beta telemetryrouter access-token list --instance-id xxx", + ), + examples.NewExample( + `Lists all access tokens in JSON format`, + "$ stackit beta telemetryrouter access-token list --instance-id xxx --output-format json", + ), + examples.NewExample( + `Lists up to 10 access tokens`, + "$ stackit beta telemetryrouter access-token list --instance-id xxx --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + items, err := fetchAccessTokens(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, items, instanceLabel) + }, + } + 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, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiListAccessTokensRequest { + return apiClient.DefaultAPI.ListAccessTokens(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +// listAccessTokensResponse adapts telemetryrouter.ListAccessTokensResponse to paginate.Response, +// since the generated type names its list field after the resource (AIP-158) instead of "Items". +type listAccessTokensResponse struct { + *telemetryrouter.ListAccessTokensResponse +} + +func (r listAccessTokensResponse) GetItems() []telemetryrouter.GetAccessTokenResponse { + return r.GetAccessTokens() +} + +// listAccessTokensAdapter adapts telemetryrouter.ApiListAccessTokensRequest to paginate.Request. +type listAccessTokensAdapter struct { + request telemetryrouter.ApiListAccessTokensRequest +} + +func (a *listAccessTokensAdapter) PageSize(pageSize int32) *listAccessTokensAdapter { + a.request = a.request.PageSize(pageSize) + return a +} + +func (a *listAccessTokensAdapter) PageToken(pageToken string) *listAccessTokensAdapter { + a.request = a.request.PageToken(pageToken) + return a +} + +func (a *listAccessTokensAdapter) Execute() (listAccessTokensResponse, error) { + resp, err := a.request.Execute() + if err != nil { + return listAccessTokensResponse{}, err + } + return listAccessTokensResponse{resp}, nil +} + +func fetchAccessTokens(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) ([]telemetryrouter.GetAccessTokenResponse, error) { + opts := []paginate.Option{paginate.WithPageSize(maxPageSize)} + if model.Limit != nil { + opts = append(opts, paginate.WithLimit(int(*model.Limit))) // nolint:gosec // bounded by prior validation, cannot overflow int + } + + request := &listAccessTokensAdapter{request: buildRequest(ctx, model, apiClient)} + items, err := paginate.All(request, opts...) + if err != nil { + return nil, fmt.Errorf("list TelemetryRouter access tokens: %w", err) + } + if items == nil { + items = []telemetryrouter.GetAccessTokenResponse{} + } + return items, nil +} + +func outputResult(p *print.Printer, outputFormat string, tokens []telemetryrouter.GetAccessTokenResponse, instanceLabel string) error { + return p.OutputResult(outputFormat, tokens, func() error { + if len(tokens) == 0 { + p.Outputf("No access tokens found for TelemetryRouter instance %q\n", instanceLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DISPLAY NAME", "STATUS", "CREATOR ID", "EXPIRATION TIME") + + for _, token := range tokens { + table.AddRow( + token.Id, + token.DisplayName, + token.Status, + token.CreatorId, + utils.PtrString(token.ExpirationTime.Get()), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/list/list_test.go b/internal/cmd/beta/telemetryrouter/access_token/list/list_test.go new file mode 100644 index 000000000..ed5de7da0 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/list/list_test.go @@ -0,0 +1,366 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiListAccessTokensRequest)) telemetryrouter.ApiListAccessTokensRequest { + request := testClient.DefaultAPI.ListAccessTokens(testCtx, testProjectId, testRegion, testInstanceId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiListAccessTokensRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +type testResponse struct { + statusCode int + body telemetryrouter.ListAccessTokensResponse +} + +func fixtureTestResponse(mods ...func(resp *testResponse)) testResponse { + resp := testResponse{ + statusCode: 200, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureAccessTokens(count int) []telemetryrouter.GetAccessTokenResponse { + tokens := make([]telemetryrouter.GetAccessTokenResponse, count) + for i := 0; i < count; i++ { + tokens[i] = telemetryrouter.GetAccessTokenResponse{ + Id: fmt.Sprintf("token-%d", i+1), + DisplayName: fmt.Sprintf("token-%d", i+1), + CreatorId: testProjectId, + Status: telemetryrouter.ACCESSTOKENBASERESPONSESTATUS_ACTIVE, + ExpirationTime: *telemetryrouter.NewNullableTime(nil), + AdditionalProperties: map[string]interface{}{}, + } + } + return tokens +} + +func TestFetchAccessTokens(t *testing.T) { + tests := []struct { + description string + limit int64 + responses []testResponse + expected []telemetryrouter.GetAccessTokenResponse + fails bool + }{ + { + description: "no access tokens", + responses: []testResponse{ + fixtureTestResponse(), + }, + expected: []telemetryrouter.GetAccessTokenResponse{}, + }, + { + description: "access tokens returned", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.AccessTokens = fixtureAccessTokens(3) + }), + }, + expected: fixtureAccessTokens(3), + }, + { + description: "API error", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.statusCode = 500 + }), + }, + fails: true, + }, + { + description: "limit truncates within a single page", + limit: 2, + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.AccessTokens = fixtureAccessTokens(5) + }), + }, + expected: fixtureAccessTokens(2), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := tt.responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.statusCode) + bs, err := json.Marshal(resp.body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = w.Write(bs) + if err != nil { + t.Fatalf("write: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + client, err := telemetryrouter.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test client: %v", err) + } + model := fixtureInputModel(func(m *inputModel) { + if tt.limit > 0 { + m.Limit = utils.Ptr(tt.limit) + } else { + m.Limit = nil + } + }) + + got, err := fetchAccessTokens(testCtx, model, client) + if err != nil { + if !tt.fails { + t.Fatalf("fetchAccessTokens() unexpected error: %v", err) + } + return + } + if tt.fails { + t.Fatalf("fetchAccessTokens() expected error, got none") + } + if callCount != len(tt.responses) { + t.Errorf("fetchAccessTokens() expected %d calls, got %d", len(tt.responses), callCount) + } + diff := cmp.Diff(got, tt.expected, cmpopts.EquateComparable(telemetryrouter.NullableTime{})) + if diff != "" { + t.Errorf("fetchAccessTokens() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + accessTokens []telemetryrouter.GetAccessTokenResponse + instanceLabel string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + accessTokens: []telemetryrouter.GetAccessTokenResponse{ + { + Id: uuid.NewString(), + DisplayName: "Token", + CreatorId: uuid.NewString(), + Status: telemetryrouter.ACCESSTOKENBASERESPONSESTATUS_ACTIVE, + }, + }, + }, + wantErr: false, + }, + { + name: "set empty access token", + args: args{ + accessTokens: []telemetryrouter.GetAccessTokenResponse{ + {}, + }, + }, + wantErr: false, + }, + { + name: "empty", + args: args{}, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.accessTokens, tt.args.instanceLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/update/update.go b/internal/cmd/beta/telemetryrouter/access_token/update/update.go new file mode 100644 index 000000000..4286a0535 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/update/update.go @@ -0,0 +1,174 @@ +package update + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + displayNameFlag = "display-name" + descriptionFlag = "description" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string + DisplayName *string + Description *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", accessTokenIdArg), + Short: "Updates a TelemetryRouter access token", + Long: "Updates a TelemetryRouter access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of the access token with ID "xxx"`, + `$ stackit beta telemetryrouter access-token update xxx --instance-id yyy --display-name access-token-1`, + ), + examples.NewExample( + `Update the description of the access token with ID "xxx"`, + `$ stackit beta telemetryrouter access-token update xxx --instance-id yyy --description "Access token for service"`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + } + if instanceLabel == "" { + instanceLabel = model.InstanceId + } + + accessTokenLabel, err := telemetryrouterUtils.GetAccessTokenName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get access token: %v", err) + } + if accessTokenLabel == "" { + accessTokenLabel = model.AccessTokenId + } + + prompt := fmt.Sprintf("Are you sure you want to update access token %q for TelemetryRouter instance %q?", accessTokenLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update TelemetryRouter access token: %w", err) + } + if resp == nil { + return fmt.Errorf("update TelemetryRouter access token: empty response from API") + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating access token", func() error { + _, err = wait.UpdateAccessTokenWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter access token update: %w", err) + } + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + params.Printer.Outputf("%s access token %q\n", operationState, accessTokenLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + cmd.Flags().String(displayNameFlag, "", "Display name for the access token") + cmd.Flags().String(descriptionFlag, "", "Description of the access token") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + displayName := flags.FlagToStringPointer(p, cmd, displayNameFlag) + description := flags.FlagToStringPointer(p, cmd, descriptionFlag) + + if displayName == nil && description == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + AccessTokenId: accessTokenId, + DisplayName: displayName, + Description: description, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiUpdateAccessTokenRequest { + req := apiClient.DefaultAPI.UpdateAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + + payload := telemetryrouter.UpdateAccessTokenPayload{} + // Only set fields that were actually provided via flags to prevent that it is cleared on API side. + if model.DisplayName != nil { + payload.DisplayName = *telemetryrouter.NewNullableString(model.DisplayName) + } + if model.Description != nil { + payload.Description = *telemetryrouter.NewNullableString(model.Description) + } + + return req.UpdateAccessTokenPayload(payload) +} diff --git a/internal/cmd/beta/telemetryrouter/access_token/update/update_test.go b/internal/cmd/beta/telemetryrouter/access_token/update/update_test.go new file mode 100644 index 000000000..efcc825b6 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/access_token/update/update_test.go @@ -0,0 +1,314 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" + + testDisplayName = "display-name" + testDescription = "description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + displayNameFlag: testDisplayName, + descriptionFlag: testDescription, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + DisplayName: new(testDisplayName), + Description: new(testDescription), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiUpdateAccessTokenRequest)) telemetryrouter.ApiUpdateAccessTokenRequest { + request := testClient.DefaultAPI.UpdateAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + request = request.UpdateAccessTokenPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *telemetryrouter.UpdateAccessTokenPayload)) telemetryrouter.UpdateAccessTokenPayload { + payload := telemetryrouter.UpdateAccessTokenPayload{ + DisplayName: *telemetryrouter.NewNullableString(new(testDisplayName)), + Description: *telemetryrouter.NewNullableString(new(testDescription)), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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: "required only (no values to update)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + delete(flagValues, descriptionFlag) + }), + isValid: false, + }, + { + description: "display name only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + }, + { + description: "description only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + params := testparams.NewTestParams() + cmd := NewCmd(params.CmdParams) + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(params.Printer, 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 telemetryrouter.ApiUpdateAccessTokenRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "display name only leaves description unset (not explicit null)", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + expectedRequest: fixtureRequest(func(request *telemetryrouter.ApiUpdateAccessTokenRequest) { + *request = request.UpdateAccessTokenPayload(fixturePayload(func(payload *telemetryrouter.UpdateAccessTokenPayload) { + payload.Description = telemetryrouter.NullableString{} + })) + }), + }, + { + description: "description only leaves display name unset (not explicit null)", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + }), + expectedRequest: fixtureRequest(func(request *telemetryrouter.ApiUpdateAccessTokenRequest) { + *request = request.UpdateAccessTokenPayload(fixturePayload(func(payload *telemetryrouter.UpdateAccessTokenPayload) { + payload.DisplayName = telemetryrouter.NullableString{} + })) + }), + }, + } + + 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, telemetryrouter.NullableString{}), + cmpopts.EquateComparable(testCtx, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/create/create.go b/internal/cmd/beta/telemetryrouter/destination/create/create.go new file mode 100644 index 000000000..11831a64b --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/create/create.go @@ -0,0 +1,184 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Payload *telemetryrouter.CreateDestinationPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a TelemetryRouter destination", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Creates a destination for a TelemetryRouter 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.", + "The \"config\" field is a discriminated union: exactly one of \"openTelemetry\" or \"s3\" must be set, matching the \"configType\".", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a destination on TelemetryRouter instance "xxx" using the default (OpenTelemetry) configuration`, + "$ stackit beta telemetryrouter destination create --instance-id xxx"), + examples.NewExample( + `Create a destination on TelemetryRouter instance "xxx" using an API payload sourced from the file "./payload.json"`, + "$ stackit beta telemetryrouter destination create --payload @./payload.json --instance-id xxx"), + examples.NewExample( + `Create a destination on TelemetryRouter instance "xxx" using an API payload provided as a JSON string`, + `$ stackit beta telemetryrouter destination 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 beta telemetryrouter destination generate-payload --config-type s3 > ./payload.json`, + ``, + `$ stackit beta telemetryrouter destination create --payload @./payload.json --instance-id xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + // Fill in default payload, if needed + if model.Payload == nil { + defaultPayload := telemetryrouterUtils.DefaultCreateDestinationPayloadOpenTelemetry + model.Payload = &defaultPayload + } + + prompt := fmt.Sprintf("Are you sure you want to create destination %q for TelemetryRouter instance %q in project %q?", model.Payload.DisplayName, instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create TelemetryRouter destination: %w", err) + } + if resp == nil { + return fmt.Errorf("create TelemetryRouter destination: empty response from API") + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating destination", func() error { + _, err = wait.CreateDestinationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, resp.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter destination creation: %w", err) + } + } + + return outputResult(params.Printer, model, instanceLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + 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 beta telemetryrouter destination generate-payload")`) + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag) + var payload *telemetryrouter.CreateDestinationPayload + if payloadValue != nil { + payload = &telemetryrouter.CreateDestinationPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiCreateDestinationRequest { + req := apiClient.DefaultAPI.CreateDestination(ctx, model.ProjectId, model.Region, model.InstanceId) + req = req.CreateDestinationPayload(*model.Payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *telemetryrouter.DestinationResponse) error { + if resp == nil { + return fmt.Errorf("create TelemetryRouter destination response is empty") + } else if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s destination %q for TelemetryRouter instance %q. Destination ID: %s\n", operationState, resp.DisplayName, instanceLabel, resp.Id) + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/destination/create/create_test.go b/internal/cmd/beta/telemetryrouter/destination/create/create_test.go new file mode 100644 index 000000000..a18557942 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/create/create_test.go @@ -0,0 +1,283 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +var testPayload = &telemetryrouter.CreateDestinationPayload{ + DisplayName: "my-destination", + Description: nil, + Config: telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_OPEN_TELEMETRY, + OpenTelemetry: &telemetryrouter.DestinationConfigOpenTelemetry{ + Uri: "https://otel-collector.example.com:4317", + BasicAuth: &telemetryrouter.DestinationConfigOpenTelemetryBasicAuth{ + Username: "username", + Password: "password", + }, + }, + }, +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + payloadFlag: `{ + "displayName": "my-destination", + "config": { + "configType": "OpenTelemetry", + "openTelemetry": { + "uri": "https://otel-collector.example.com:4317", + "basicAuth": { + "username": "username", + "password": "password" + } + } + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + InstanceId: testInstanceId, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiCreateDestinationRequest)) telemetryrouter.ApiCreateDestinationRequest { + request := testClient.DefaultAPI.CreateDestination(testCtx, testProjectId, testRegion, testInstanceId) + request = request.CreateDestinationPayload(*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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, []testutils.TestingOption{ + testutils.WithCmpOptions(cmp.FilterPath(func(p cmp.Path) bool { + last := p.Last().String() + return last == ".AdditionalProperties" + }, cmp.Ignore())), + }) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiCreateDestinationRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + instanceLabel string + destination *telemetryrouter.DestinationResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + instanceLabel: "label", + destination: &telemetryrouter.DestinationResponse{ + Id: uuid.NewString(), + DisplayName: "my-destination", + Status: telemetryrouter.DESTINATIONRESPONSESTATUS_ACTIVE, + }, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + instanceLabel: "label", + destination: &telemetryrouter.DestinationResponse{}, + }, + wantErr: false, + }, + { + name: "empty response", + args: args{ + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + destination: nil, + }, + wantErr: true, + }, + { + name: "nil model", + args: args{ + model: nil, + destination: &telemetryrouter.DestinationResponse{}, + }, + wantErr: true, + }, + { + name: "nil global flag model", + args: args{ + model: &inputModel{GlobalFlagModel: nil}, + destination: &telemetryrouter.DestinationResponse{}, + }, + wantErr: true, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.instanceLabel, tt.args.destination); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/delete/delete.go b/internal/cmd/beta/telemetryrouter/destination/delete/delete.go new file mode 100644 index 000000000..0360cec80 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/delete/delete.go @@ -0,0 +1,132 @@ +package delete + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + destinationIdArg = "DESTINATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + DestinationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", destinationIdArg), + Short: "Deletes a TelemetryRouter destination", + Long: "Deletes a TelemetryRouter destination.", + Args: args.SingleArg(destinationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete destination with ID "xxx" for the TelemetryRouter instance "yyy"`, + "$ stackit beta telemetryrouter destination delete xxx --instance-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + destinationLabel, err := telemetryrouterUtils.GetDestinationName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.DestinationId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get destination name: %v", err) + } + if destinationLabel == "" { + destinationLabel = model.DestinationId + } + + prompt := fmt.Sprintf("Are you sure you want to delete destination %q? (This cannot be undone)", destinationLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete TelemetryRouter destination: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting destination", func() error { + _, err = wait.DeleteDestinationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.DestinationId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter destination deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s destination %q\n", operationState, destinationLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + destinationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DestinationId: destinationId, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiDeleteDestinationRequest { + return apiClient.DefaultAPI.DeleteDestination(ctx, model.ProjectId, model.Region, model.InstanceId, model.DestinationId) +} diff --git a/internal/cmd/beta/telemetryrouter/destination/delete/delete_test.go b/internal/cmd/beta/telemetryrouter/destination/delete/delete_test.go new file mode 100644 index 000000000..f5d8dfa0d --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/delete/delete_test.go @@ -0,0 +1,198 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testDestinationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testDestinationId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + DestinationId: testDestinationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiDeleteDestinationRequest)) telemetryrouter.ApiDeleteDestinationRequest { + request := testClient.DefaultAPI.DeleteDestination(testCtx, testProjectId, testRegion, testInstanceId, testDestinationId) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (destinationID)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "destination id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "destination id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiDeleteDestinationRequest + }{ + { + description: "base case", + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/describe/describe.go b/internal/cmd/beta/telemetryrouter/destination/describe/describe.go new file mode 100644 index 000000000..19dddc7ae --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/describe/describe.go @@ -0,0 +1,132 @@ +package describe + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + destinationIdArg = "DESTINATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + DestinationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", destinationIdArg), + Short: "Shows details of a TelemetryRouter destination", + Long: "Shows details of a TelemetryRouter destination.", + Args: args.SingleArg(destinationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show details of a TelemetryRouter destination with ID "xxx"`, + "$ stackit beta telemetryrouter destination describe xxx --instance-id yyy", + ), + examples.NewExample( + `Show details of a TelemetryRouter destination with ID "xxx" in JSON format`, + "$ stackit beta telemetryrouter destination describe xxx --instance-id yyy --output-format json", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read TelemetryRouter destination: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + destinationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + DestinationId: destinationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiGetDestinationRequest { + return apiClient.DefaultAPI.GetDestination(ctx, model.ProjectId, model.Region, model.InstanceId, model.DestinationId) +} + +func outputResult(p *print.Printer, outputFormat string, destination *telemetryrouter.DestinationResponse) error { + if destination == nil { + return fmt.Errorf("destination response is empty") + } + return p.OutputResult(outputFormat, destination, func() error { + table := tables.NewTable() + table.AddRow("ID", destination.Id) + table.AddSeparator() + table.AddRow("DISPLAY NAME", destination.DisplayName) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(destination.Description)) + table.AddSeparator() + table.AddRow("STATUS", destination.Status) + table.AddSeparator() + table.AddRow("CREDENTIAL TYPE", destination.CredentialType) + table.AddSeparator() + table.AddRow("CONFIG TYPE", destination.Config.ConfigType) + table.AddSeparator() + table.AddRow("CREATION TIME", destination.CreationTime) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/destination/describe/describe_test.go b/internal/cmd/beta/telemetryrouter/destination/describe/describe_test.go new file mode 100644 index 000000000..2a3b32d9f --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/describe/describe_test.go @@ -0,0 +1,253 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testDestinationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testDestinationId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + DestinationId: testDestinationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiGetDestinationRequest)) telemetryrouter.ApiGetDestinationRequest { + request := testClient.DefaultAPI.GetDestination(testCtx, testProjectId, testRegion, testInstanceId, testDestinationId) + 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 args (destinationID)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "destination id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "destination id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiGetDestinationRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + destination *telemetryrouter.DestinationResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + outputFormat: "", + destination: &telemetryrouter.DestinationResponse{ + Id: uuid.NewString(), + DisplayName: "my-destination", + Status: telemetryrouter.DESTINATIONRESPONSESTATUS_ACTIVE, + CredentialType: telemetryrouter.DESTINATIONRESPONSECREDENTIALTYPE_BASIC_AUTH, + Config: telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_OPEN_TELEMETRY, + }, + }, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + outputFormat: "json", + destination: &telemetryrouter.DestinationResponse{}, + }, + wantErr: false, + }, + { + name: "nil response", + args: args{ + destination: nil, + }, + wantErr: true, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.destination); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/destination.go b/internal/cmd/beta/telemetryrouter/destination/destination.go new file mode 100644 index 000000000..cef95b31e --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/destination.go @@ -0,0 +1,36 @@ +package destination + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/describe" + generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "destination", + Short: "Provides functionality for TelemetryRouter destinations", + Long: "Provides functionality for TelemetryRouter destinations.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(generatepayload.NewCmd(params)) +} diff --git a/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload.go b/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload.go new file mode 100644 index 000000000..ad54d703a --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload.go @@ -0,0 +1,205 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" + "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + destinationIdFlag = "destination-id" + configTypeFlag = "config-type" + filePathFlag = "file-path" + + configTypeOpenTelemetry = "opentelemetry" + configTypeS3 = "s3" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + DestinationId *string + ConfigType string + FilePath *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/update TelemetryRouter destinations", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Generates a JSON payload with values to be used as --payload input for destination creation or update.", + "This command can be used to generate a payload to update an existing destination or to create a new destination.", + "To update an existing destination, provide the destination ID and the instance ID of the TelemetryRouter instance.", + `To obtain a default payload to create a new destination, run the command with the "--config-type" flag set to either "opentelemetry" (default) or "s3".`, + "Note that the default values provided, such as the URI, bucket name or credentials, should be adapted to your use case.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a create payload with default values for an OpenTelemetry destination, and adapt it with custom values`, + `$ stackit beta telemetryrouter destination generate-payload --file-path ./payload.json`, + ``, + `$ stackit beta telemetryrouter destination create --instance-id xxx --payload @./payload.json`), + examples.NewExample( + `Generate a create payload with default values for an S3 destination`, + `$ stackit beta telemetryrouter destination generate-payload --config-type s3 --file-path ./payload.json`), + examples.NewExample( + `Generate an update payload with the values of an existing destination "yyy" for TelemetryRouter instance "xxx", and adapt it with custom values`, + `$ stackit beta telemetryrouter destination generate-payload --destination-id yyy --instance-id xxx --file-path ./payload.json`, + ``, + `$ stackit beta telemetryrouter destination update yyy --instance-id xxx --payload @./payload.json`), + examples.NewExample( + `Generate an update payload with the values of an existing destination "yyy" for TelemetryRouter instance "xxx", and preview it in the terminal`, + `$ stackit beta telemetryrouter destination generate-payload --destination-id yyy --instance-id xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if model.DestinationId == nil { + var createPayload telemetryrouter.CreateDestinationPayload + switch model.ConfigType { + case configTypeS3: + createPayload = telemetryrouterUtils.DefaultCreateDestinationPayloadS3 + default: + createPayload = telemetryrouterUtils.DefaultCreateDestinationPayloadOpenTelemetry + } + return outputCreateResult(params.Printer, model.FilePath, &createPayload) + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read TelemetryRouter destination: %w", err) + } + + payload, err := telemetryrouterUtils.MapToUpdateDestinationPayload(resp) + if err != nil { + return fmt.Errorf("map update destination payload: %w", err) + } + + return outputUpdateResult(params.Printer, model.FilePath, payload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + cmd.Flags().Var(flags.UUIDFlag(), destinationIdFlag, "If set, generates an update payload with the current state of the given destination. If unset, generates a create payload with default values") + cmd.Flags().String(configTypeFlag, configTypeOpenTelemetry, fmt.Sprintf(`Type of the default create payload to generate, one of %q or %q. Only relevant if "--destination-id" is unset`, configTypeOpenTelemetry, configTypeS3)) + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + destinationId := flags.FlagToStringPointer(p, cmd, destinationIdFlag) + instanceId := flags.FlagToStringValue(p, cmd, instanceIdFlag) + + if destinationId != nil { + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + if instanceId == "" { + return nil, &cliErr.FlagValidationError{ + Flag: instanceIdFlag, + Details: fmt.Sprintf("must be set when %q is provided", destinationIdFlag), + } + } + } + + configType := flags.FlagWithDefaultToStringValue(p, cmd, configTypeFlag) + if configType != configTypeOpenTelemetry && configType != configTypeS3 { + return nil, &cliErr.FlagValidationError{ + Flag: configTypeFlag, + Details: fmt.Sprintf("must be one of %q or %q", configTypeOpenTelemetry, configTypeS3), + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + DestinationId: destinationId, + ConfigType: configType, + FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiGetDestinationRequest { + return apiClient.DefaultAPI.GetDestination(ctx, model.ProjectId, model.Region, model.InstanceId, *model.DestinationId) +} + +func outputCreateResult(p *print.Printer, filePath *string, payload *telemetryrouter.CreateDestinationPayload) error { + if payload == nil { + return fmt.Errorf("payload is nil") + } + + payloadBytes, err := json.MarshalIndent(*payload, "", " ") // nolint:gosec // false positive + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(*filePath, string(payloadBytes)) + if err != nil { + return fmt.Errorf("write payload to the file: %w", err) + } + } else { + p.Outputln(string(payloadBytes)) + } + + return nil +} + +func outputUpdateResult(p *print.Printer, filePath *string, payload *telemetryrouter.UpdateDestinationPayload) error { + if payload == nil { + return fmt.Errorf("payload is nil") + } + + payloadBytes, err := json.MarshalIndent(*payload, "", " ") // nolint:gosec // false positive + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(*filePath, string(payloadBytes)) + if err != nil { + return fmt.Errorf("write payload to the file: %w", err) + } + } else { + p.Outputln(string(payloadBytes)) + } + + return nil +} diff --git a/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload_test.go b/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload_test.go new file mode 100644 index 000000000..200d4b192 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/generate-payload/generate_payload_test.go @@ -0,0 +1,287 @@ +package generatepayload + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testDestinationId = uuid.NewString() +) + +const ( + testFilePath = "example-file" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + destinationIdFlag: testDestinationId, + configTypeFlag: configTypeOpenTelemetry, + filePathFlag: testFilePath, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + DestinationId: new(testDestinationId), + ConfigType: configTypeOpenTelemetry, + FilePath: new(testFilePath), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiGetDestinationRequest)) telemetryrouter.ApiGetDestinationRequest { + request := testClient.DefaultAPI.GetDestination(testCtx, testProjectId, testRegion, testInstanceId, testDestinationId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + ConfigType: configTypeOpenTelemetry, + }, + }, + { + description: "file path missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, filePathFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FilePath = nil + }), + }, + { + description: "destination id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, destinationIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DestinationId = nil + }), + }, + { + description: "config type s3", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[configTypeFlag] = configTypeS3 + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ConfigType = configTypeS3 + }), + }, + { + description: "config type invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[configTypeFlag] = "invalid" + }), + isValid: false, + }, + { + description: "instance id missing, destination id provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "project id missing, destination id provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id and instance id missing, destination id provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.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[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiGetDestinationRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputCreateResult(t *testing.T) { + type args struct { + filePath *string + payload *telemetryrouter.CreateDestinationPayload + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty payload", + args: args{ + payload: &telemetryrouter.CreateDestinationPayload{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputCreateResult(params.Printer, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { + t.Errorf("outputCreateResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestOutputUpdateResult(t *testing.T) { + type args struct { + filePath *string + payload *telemetryrouter.UpdateDestinationPayload + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty payload", + args: args{ + payload: &telemetryrouter.UpdateDestinationPayload{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputUpdateResult(params.Printer, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { + t.Errorf("outputUpdateResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/list/list.go b/internal/cmd/beta/telemetryrouter/destination/list/list.go new file mode 100644 index 000000000..29851db94 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/list/list.go @@ -0,0 +1,201 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/core/experimental/paginate" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" +) + +const ( + limitFlag = "limit" + instanceIdFlag = "instance-id" + + // maxPageSize is the maximum number of items the API returns per page. + maxPageSize = 100 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all TelemetryRouter destinations of an instance", + Long: "Lists all TelemetryRouter destinations of an instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all destinations of the TelemetryRouter instance "xxx"`, + "$ stackit beta telemetryrouter destination list --instance-id xxx", + ), + examples.NewExample( + `Lists all destinations in JSON format`, + "$ stackit beta telemetryrouter destination list --instance-id xxx --output-format json", + ), + examples.NewExample( + `Lists up to 10 destinations`, + "$ stackit beta telemetryrouter destination list --instance-id xxx --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + items, err := fetchDestinations(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, items, instanceLabel) + }, + } + 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, "ID of the TelemetryRouter instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiListDestinationsRequest { + return apiClient.DefaultAPI.ListDestinations(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +// listDestinationsResponse adapts telemetryrouter.ListDestinationsResponse to paginate.Response, +// since the generated type names its list field after the resource (AIP-158) instead of "Items". +type listDestinationsResponse struct { + *telemetryrouter.ListDestinationsResponse +} + +func (r listDestinationsResponse) GetItems() []telemetryrouter.DestinationResponse { + return r.GetDestinations() +} + +// listDestinationsAdapter adapts telemetryrouter.ApiListDestinationsRequest to paginate.Request. +type listDestinationsAdapter struct { + request telemetryrouter.ApiListDestinationsRequest +} + +func (a *listDestinationsAdapter) PageSize(pageSize int32) *listDestinationsAdapter { + a.request = a.request.PageSize(pageSize) + return a +} + +func (a *listDestinationsAdapter) PageToken(pageToken string) *listDestinationsAdapter { + a.request = a.request.PageToken(pageToken) + return a +} + +func (a *listDestinationsAdapter) Execute() (listDestinationsResponse, error) { + resp, err := a.request.Execute() + if err != nil { + return listDestinationsResponse{}, err + } + return listDestinationsResponse{resp}, nil +} + +func fetchDestinations(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) ([]telemetryrouter.DestinationResponse, error) { + opts := []paginate.Option{paginate.WithPageSize(maxPageSize)} + if model.Limit != nil { + opts = append(opts, paginate.WithLimit(int(*model.Limit))) // nolint:gosec // bounded by prior validation, cannot overflow int + } + + request := &listDestinationsAdapter{request: buildRequest(ctx, model, apiClient)} + items, err := paginate.All(request, opts...) + if err != nil { + return nil, fmt.Errorf("list TelemetryRouter destinations: %w", err) + } + if items == nil { + items = []telemetryrouter.DestinationResponse{} + } + return items, nil +} + +func outputResult(p *print.Printer, outputFormat string, destinations []telemetryrouter.DestinationResponse, instanceLabel string) error { + return p.OutputResult(outputFormat, destinations, func() error { + if len(destinations) == 0 { + p.Outputf("No destinations found for TelemetryRouter instance %q\n", instanceLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DISPLAY NAME", "STATUS", "CREDENTIAL TYPE") + + for _, destination := range destinations { + table.AddRow( + destination.Id, + destination.DisplayName, + destination.Status, + destination.CredentialType, + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/destination/list/list_test.go b/internal/cmd/beta/telemetryrouter/destination/list/list_test.go new file mode 100644 index 000000000..8f7576dd9 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/list/list_test.go @@ -0,0 +1,355 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiListDestinationsRequest)) telemetryrouter.ApiListDestinationsRequest { + request := testClient.DefaultAPI.ListDestinations(testCtx, testProjectId, testRegion, testInstanceId) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiListDestinationsRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +type testResponse struct { + statusCode int + body telemetryrouter.ListDestinationsResponse +} + +func fixtureTestResponse(mods ...func(resp *testResponse)) testResponse { + resp := testResponse{ + statusCode: 200, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureDestinations(count int) []telemetryrouter.DestinationResponse { + destinations := make([]telemetryrouter.DestinationResponse, count) + for i := 0; i < count; i++ { + destinations[i] = telemetryrouter.DestinationResponse{ + Id: fmt.Sprintf("destination-%d", i+1), + DisplayName: fmt.Sprintf("destination-%d", i+1), + Config: telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_S3, + AdditionalProperties: map[string]interface{}{}, + }, + CredentialType: telemetryrouter.DESTINATIONRESPONSECREDENTIALTYPE_ACCESS_KEY, + Status: telemetryrouter.DESTINATIONRESPONSESTATUS_ACTIVE, + AdditionalProperties: map[string]interface{}{}, + } + } + return destinations +} + +func TestFetchDestinations(t *testing.T) { + tests := []struct { + description string + limit int64 + responses []testResponse + expected []telemetryrouter.DestinationResponse + fails bool + }{ + { + description: "no destinations", + responses: []testResponse{ + fixtureTestResponse(), + }, + expected: []telemetryrouter.DestinationResponse{}, + }, + { + description: "destinations returned", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.Destinations = fixtureDestinations(3) + }), + }, + expected: fixtureDestinations(3), + }, + { + description: "API error", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.statusCode = 500 + }), + }, + fails: true, + }, + { + description: "limit truncates within a single page", + limit: 2, + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.Destinations = fixtureDestinations(5) + }), + }, + expected: fixtureDestinations(2), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := tt.responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.statusCode) + bs, err := json.Marshal(resp.body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = w.Write(bs) + if err != nil { + t.Fatalf("write: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + client, err := telemetryrouter.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test client: %v", err) + } + model := fixtureInputModel(func(m *inputModel) { + if tt.limit > 0 { + m.Limit = utils.Ptr(tt.limit) + } else { + m.Limit = nil + } + }) + + got, err := fetchDestinations(testCtx, model, client) + if err != nil { + if !tt.fails { + t.Fatalf("fetchDestinations() unexpected error: %v", err) + } + return + } + if tt.fails { + t.Fatalf("fetchDestinations() expected error, got none") + } + if callCount != len(tt.responses) { + t.Errorf("fetchDestinations() expected %d calls, got %d", len(tt.responses), callCount) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Errorf("fetchDestinations() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instanceLabel string + destinations []telemetryrouter.DestinationResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty destinations slice", + args: args{ + destinations: []telemetryrouter.DestinationResponse{}, + }, + wantErr: false, + }, + { + name: "empty destination in destinations slice", + args: args{ + destinations: []telemetryrouter.DestinationResponse{{}}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.destinations, tt.args.instanceLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/destination/update/update.go b/internal/cmd/beta/telemetryrouter/destination/update/update.go new file mode 100644 index 000000000..fe16747b7 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/update/update.go @@ -0,0 +1,169 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + destinationIdArg = "DESTINATION_ID" + + instanceIdFlag = "instance-id" + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + DestinationId string + Payload telemetryrouter.UpdateDestinationPayload +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", destinationIdArg), + Short: "Updates a TelemetryRouter destination", + Long: fmt.Sprintf("%s\n%s\n%s", + "Updates a destination of a TelemetryRouter instance.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "The \"config\" field is a discriminated union: exactly one of \"openTelemetry\" or \"s3\" must be set, matching the \"configType\".", + ), + Args: args.SingleArg(destinationIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a destination with ID "xxx" from TelemetryRouter instance "yyy", using an API payload sourced from the file "./payload.json"`, + "$ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload @./payload.json"), + examples.NewExample( + `Update a destination with ID "xxx" from TelemetryRouter instance "yyy", using an API payload provided as a JSON string`, + `$ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload "{...}"`), + examples.NewExample( + `Generate a payload with the current values of a destination, and adapt it with custom values for the different configuration options`, + `$ stackit beta telemetryrouter destination generate-payload --destination-id xxx --instance-id yyy > ./payload.json`, + ``, + `$ stackit beta telemetryrouter destination update xxx --instance-id yyy --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + } + if instanceLabel == "" { + instanceLabel = model.InstanceId + } + + destinationLabel, err := telemetryrouterUtils.GetDestinationName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.DestinationId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get destination name: %v", err) + } + if destinationLabel == "" { + destinationLabel = model.DestinationId + } + + prompt := fmt.Sprintf("Are you sure you want to update destination %q for TelemetryRouter instance %q?", destinationLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update TelemetryRouter destination: %w", err) + } + if resp == nil { + return fmt.Errorf("update TelemetryRouter destination: empty response from API") + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating destination", func() error { + _, err = wait.UpdateDestinationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceId, model.DestinationId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter destination update: %w", err) + } + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + params.Printer.Outputf("%s destination %q for TelemetryRouter instance %q\n", operationState, destinationLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the TelemetryRouter instance") + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json`) + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + destinationId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + payloadString := flags.FlagToStringValue(p, cmd, payloadFlag) + var payload telemetryrouter.UpdateDestinationPayload + err := json.Unmarshal([]byte(payloadString), &payload) + if err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + DestinationId: destinationId, + Payload: payload, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiUpdateDestinationRequest { + req := apiClient.DefaultAPI.UpdateDestination(ctx, model.ProjectId, model.Region, model.InstanceId, model.DestinationId) + req = req.UpdateDestinationPayload(model.Payload) + return req +} diff --git a/internal/cmd/beta/telemetryrouter/destination/update/update_test.go b/internal/cmd/beta/telemetryrouter/destination/update/update_test.go new file mode 100644 index 000000000..5d1cf98b9 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/destination/update/update_test.go @@ -0,0 +1,258 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testDestinationId = uuid.NewString() +) + +var testPayload = telemetryrouter.UpdateDestinationPayload{ + DisplayName: new("my-destination"), + Description: new("description"), + Config: &telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_S3, + S3: &telemetryrouter.DestinationConfigS3{ + Bucket: "my-bucket", + Endpoint: "https://object.storage.eu01.onstackit.cloud", + AccessKey: &telemetryrouter.DestinationConfigS3AccessKey{ // nolint:gosec // false positive + Id: "access-key-id", + Secret: "access-key-secret", + }, + }, + }, +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testDestinationId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + payloadFlag: `{ + "displayName": "my-destination", + "description": "description", + "config": { + "configType": "S3", + "s3": { + "bucket": "my-bucket", + "endpoint": "https://object.storage.eu01.onstackit.cloud", + "accessKey": { + "id": "access-key-id", + "secret": "access-key-secret" + } + } + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + InstanceId: testInstanceId, + DestinationId: testDestinationId, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiUpdateDestinationRequest)) telemetryrouter.ApiUpdateDestinationRequest { + request := testClient.DefaultAPI.UpdateDestination(testCtx, testProjectId, testRegion, testInstanceId, testDestinationId) + request = request.UpdateDestinationPayload(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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "destination id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "destination id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid json", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + }, + { + description: "payload missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, []testutils.TestingOption{ + testutils.WithCmpOptions(cmp.FilterPath(func(p cmp.Path) bool { + last := p.Last().String() + return last == ".AdditionalProperties" + }, cmp.Ignore())), + }) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiUpdateDestinationRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/create/create.go b/internal/cmd/beta/telemetryrouter/instance/create/create.go new file mode 100644 index 000000000..d9ec07452 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/create/create.go @@ -0,0 +1,201 @@ +package create + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "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/utils" +) + +const ( + displayNameFlag = "display-name" + descriptionFlag = "description" + filterAttributeFlag = "filter-attribute" + filterFlag = "filter" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + DisplayName *string + Description *string + Filter *telemetryrouter.ConfigFilter +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a TelemetryRouter instance", + Long: "Creates a TelemetryRouter instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a TelemetryRouter instance with name "my-instance"`, + `$ stackit beta telemetryrouter instance create --display-name "my-instance"`), + examples.NewExample( + `Create a TelemetryRouter instance with name "my-instance" and a description`, + `$ stackit beta telemetryrouter instance create --display-name "my-instance" --description "Description of the instance"`), + examples.NewExample( + `Create a TelemetryRouter instance with name "my-instance" that only routes telemetry data for HTTP GET or HEAD requests, matched at the resource level`, + `$ stackit beta telemetryrouter instance create --display-name "my-instance" --filter-attribute "key=http.method;level=resource;matcher==;values=GET,HEAD"`), + examples.NewExample( + `Create a TelemetryRouter instance with name "my-instance" and many filter attributes sourced from a JSON file (recommended over repeating --filter-attribute for more than a handful of attributes); `+ + `use "stackit beta telemetryrouter instance generate-filter" to generate a starting point for "./filter.json"`, + `$ stackit beta telemetryrouter instance create --display-name "my-instance" --filter @./filter.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a TelemetryRouter instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create TelemetryRouter instance: %w", err) + } + if resp == nil { + return fmt.Errorf("create TelemetryRouter instance: empty response from API") + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateTelemetryRouterWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, resp.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter instance creation: %w", err) + } + } + + return outputResult(params.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringArray(filterAttributeFlag, nil, + `Adds a filter attribute that restricts which telemetry data is routed. `+ + `Must be of the form "key=;level=;matcher=<=|!=>;values=,,...", `+ + `e.g. "key=http.method;level=resource;matcher==;values=GET,HEAD" only routes data whose "http.method" resource `+ + `attribute equals "GET" or "HEAD". Can be repeated to add multiple filter attributes, which are combined with AND. `+ + `Impractical for many filter attributes at once; use "--filter" instead. Mutually exclusive with "--filter".`) + cmd.Flags().Var(flags.ReadFromFileFlag(), filterFlag, + `Filter configuration as JSON, of the form {"attributes": [{"key": ..., "level": ..., "matcher": ..., "values": [...]}, ...]}. `+ + `Can be a JSON string or a file path, if prefixed with "@" (example: @./filter.json). `+ + `Recommended over "--filter-attribute" when setting many filter attributes at once. `+ + `Run "stackit beta telemetryrouter instance generate-filter" to generate a starting point. Mutually exclusive with "--filter-attribute".`) + + cmd.MarkFlagsMutuallyExclusive(filterAttributeFlag, filterFlag) + + err := flags.MarkFlagsRequired(cmd, displayNameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + filter, err := parseFilter(p, cmd) + if err != nil { + return nil, err + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Filter: filter, + } + + p.DebugInputModel(model) + return &model, nil +} + +// parseFilter builds the Filter field from either --filter (JSON) or --filter-attribute +// (repeatable key=...;level=...;matcher=...;values=... syntax). The two flags are mutually +// exclusive (enforced by cobra), so at most one of them yields a non-nil result here. +func parseFilter(p *print.Printer, cmd *cobra.Command) (*telemetryrouter.ConfigFilter, error) { + if filterValue := flags.FlagToStringPointer(p, cmd, filterFlag); filterValue != nil { + return telemetryrouterUtils.ParseFilterJSON(*filterValue) + } + return telemetryrouterUtils.BuildConfigFilter(flags.FlagToStringArrayValue(p, cmd, filterAttributeFlag)) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiCreateTelemetryRouterRequest { + req := apiClient.DefaultAPI.CreateTelemetryRouter(ctx, model.ProjectId, model.Region) + req = req.CreateTelemetryRouterPayload(telemetryrouter.CreateTelemetryRouterPayload{ + DisplayName: utils.PtrString(model.DisplayName), + Description: model.Description, + Filter: model.Filter, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *telemetryrouter.TelemetryRouterResponse) error { + if resp == nil { + return fmt.Errorf("create TelemetryRouter instance response is empty") + } + if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, resp.Id) + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/instance/create/create_test.go b/internal/cmd/beta/telemetryrouter/instance/create/create_test.go new file mode 100644 index 000000000..fae1ce6c2 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/create/create_test.go @@ -0,0 +1,364 @@ +package create + +import ( + "context" + "testing" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" + testDisplayName = "my-telemetryrouter-instance" + testDescription = "my instance description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + descriptionFlag: testDescription, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: new(testDisplayName), + Description: new(testDescription), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *telemetryrouter.ApiCreateTelemetryRouterRequest)) telemetryrouter.ApiCreateTelemetryRouterRequest { + request := testClient.DefaultAPI.CreateTelemetryRouter(testCtx, testProjectId, testRegion) + request = request.CreateTelemetryRouterPayload(telemetryrouter.CreateTelemetryRouterPayload{ + DisplayName: testDisplayName, + Description: new(testDescription), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +const ( + testFilterAttributeRaw1 = "key=http.method;level=resource;matcher==;values=GET,HEAD" + testFilterAttributeRaw2 = "key=http.status;level=logRecord;matcher=!=;values=500" +) + +var ( + testConfigFilterAttribute1 = telemetryrouter.ConfigFilterAttributes{ + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + } + testConfigFilterAttribute2 = telemetryrouter.ConfigFilterAttributes{ + Key: "http.status", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"500"}, + } +) + +// addAdditionalProperties returns a copy of attr with a non-nil AdditionalProperties map, as +// produced by json.Unmarshal on the generated SDK type (used for --filter JSON test fixtures). +func addAdditionalProperties(attr *telemetryrouter.ConfigFilterAttributes) telemetryrouter.ConfigFilterAttributes { + withProperties := *attr + withProperties.AdditionalProperties = map[string]interface{}{} + return withProperties +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + additionalFlagValues map[string][]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "optional flags omitted", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + }, + { + description: "no values provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no filter-attribute at all still works, Filter stays nil", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = nil + }), + }, + { + description: "single filter-attribute produces expected ConfigFilter", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {testFilterAttributeRaw1}, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1}, + } + }), + }, + { + description: "multiple filter-attribute flags produce multiple attributes in order", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {testFilterAttributeRaw1, testFilterAttributeRaw2}, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + } + }), + }, + { + description: "invalid filter-attribute value produces a parseInput error", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {"key=http.method;level=bogus;matcher==;values=GET"}, + }, + isValid: false, + }, + { + description: "filter JSON produces expected ConfigFilter", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: { + `{"attributes": [` + + `{"key": "http.method", "level": "resource", "matcher": "=", "values": ["GET", "HEAD"]},` + + `{"key": "http.status", "level": "logRecord", "matcher": "!=", "values": ["500"]}` + + `]}`, + }, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + // json.Unmarshal on the generated SDK types always yields a non-nil + // (possibly empty) AdditionalProperties map, unlike BuildConfigFilter's + // hand-constructed struct literals used by the other filter-attribute cases. + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + addAdditionalProperties(&testConfigFilterAttribute1), + addAdditionalProperties(&testConfigFilterAttribute2), + }, + AdditionalProperties: map[string]interface{}{}, + } + }), + }, + { + description: "invalid filter JSON produces a parseInput error", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: {`{"attributes": [`}, + }, + isValid: false, + }, + { + description: "filter and filter-attribute together is rejected", + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: {`{"attributes": []}`}, + filterAttributeFlag: {testFilterAttributeRaw1}, + }, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "display name missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.additionalFlagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiCreateTelemetryRouterRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optional values", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + expectedRequest: fixtureRequest().CreateTelemetryRouterPayload(telemetryrouter.CreateTelemetryRouterPayload{ + DisplayName: testDisplayName, + Description: nil, + }), + }, + { + description: "with filter attributes", + model: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + } + }), + expectedRequest: fixtureRequest().CreateTelemetryRouterPayload(telemetryrouter.CreateTelemetryRouterPayload{ + DisplayName: testDisplayName, + Description: new(testDescription), + Filter: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + }, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + instance *telemetryrouter.TelemetryRouterResponse + wantErr bool + }{ + { + description: "nil response", + instance: nil, + wantErr: true, + }, + { + description: "model is nil", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: nil, + wantErr: true, + }, + { + description: "global flag nil", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: nil}, + wantErr: true, + }, + { + description: "default output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wantErr: false, + }, + { + description: "json output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wantErr: false, + }, + { + description: "yaml output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(params.Printer, tt.model, "label", tt.instance) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/delete/delete.go b/internal/cmd/beta/telemetryrouter/instance/delete/delete.go new file mode 100644 index 000000000..a850ee1f3 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/delete/delete.go @@ -0,0 +1,133 @@ +package delete + +import ( + "context" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" +) + +const ( + argInstanceID = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", argInstanceID), + Short: "Deletes the given TelemetryRouter instance", + Long: "Deletes the given TelemetryRouter instance.", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a TelemetryRouter instance with ID "xxx"`, + `$ stackit beta telemetryrouter instance delete "xxx"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceID + } + + prompt := fmt.Sprintf("Are you sure you want to delete instance %q from project %q? (This cannot be undone)", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete TelemetryRouter instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteTelemetryRouterWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceID).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for TelemetryRouter instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s instance %q\n", operationState, instanceLabel) + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiDeleteTelemetryRouterRequest { + req := apiClient.DefaultAPI.DeleteTelemetryRouter(ctx, model.ProjectId, model.Region, model.InstanceID) + return req +} diff --git a/internal/cmd/beta/telemetryrouter/instance/delete/delete_test.go b/internal/cmd/beta/telemetryrouter/instance/delete/delete_test.go new file mode 100644 index 000000000..0f827695a --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/delete/delete_test.go @@ -0,0 +1,175 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *telemetryrouter.ApiDeleteTelemetryRouterRequest)) telemetryrouter.ApiDeleteTelemetryRouterRequest { + request := testClient.DefaultAPI.DeleteTelemetryRouter(testCtx, testProjectId, testRegion, testInstanceId) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (instanceID)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiDeleteTelemetryRouterRequest + }{ + { + description: "base case", + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/describe/describe.go b/internal/cmd/beta/telemetryrouter/instance/describe/describe.go new file mode 100644 index 000000000..e45b027ac --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/describe/describe.go @@ -0,0 +1,132 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "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/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argInstanceID = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", argInstanceID), + Short: "Shows details of a TelemetryRouter instance", + Long: "Shows details of a TelemetryRouter instance", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a TelemetryRouter instance with ID "xxx"`, + `$ stackit beta telemetryrouter instance describe xxx`, + ), + examples.NewExample( + `Get details of a TelemetryRouter instance with ID "xxx" in JSON format`, + "$ stackit beta telemetryrouter instance describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get instance: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + model := &inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: inputArgs[0], + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiGetTelemetryRouterRequest { + return apiClient.DefaultAPI.GetTelemetryRouter(ctx, model.ProjectId, model.Region, model.InstanceID) +} + +func outputResult(p *print.Printer, outputFormat string, instance *telemetryrouter.TelemetryRouterResponse) error { + if instance == nil { + return fmt.Errorf("instance response is empty") + } + return p.OutputResult(outputFormat, instance, func() error { + table := tables.NewTable() + table.AddRow("ID", instance.Id) + table.AddSeparator() + table.AddRow("DISPLAY NAME", instance.DisplayName) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(instance.Description)) + table.AddSeparator() + table.AddRow("STATUS", instance.Status) + table.AddSeparator() + table.AddRow("URI", instance.Uri) + table.AddSeparator() + table.AddRow("CREATION TIME", instance.CreationTime) + table.AddSeparator() + table.AddRow("FILTER ATTRIBUTES", formatFilterAttributes(instance.Filter)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} + +// formatFilterAttributes renders a ConfigFilter's attributes for table output, using the same +// "key=...;level=...;matcher=...;values=..." syntax accepted by the --filter-attribute flag. +func formatFilterAttributes(filter *telemetryrouter.ConfigFilter) string { + if filter == nil || len(filter.Attributes) == 0 { + return "-" + } + + lines := make([]string, 0, len(filter.Attributes)) + for _, attr := range filter.Attributes { + lines = append(lines, fmt.Sprintf("key=%s;level=%s;matcher=%s;values=%s", + attr.Key, attr.Level, attr.Matcher, strings.Join(attr.Values, ","))) + } + return strings.Join(lines, "\n") +} diff --git a/internal/cmd/beta/telemetryrouter/instance/describe/describe_test.go b/internal/cmd/beta/telemetryrouter/instance/describe/describe_test.go new file mode 100644 index 000000000..602cbeb7c --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/describe/describe_test.go @@ -0,0 +1,271 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiGetTelemetryRouterRequest)) telemetryrouter.ApiGetTelemetryRouterRequest { + request := testClient.DefaultAPI.GetTelemetryRouter(testCtx, testProjectId, testRegion, testInstanceId) + 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: []string{testInstanceId}, + 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: "project id missing", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid instance id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiGetTelemetryRouterRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instance *telemetryrouter.TelemetryRouterResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", instance: &telemetryrouter.TelemetryRouterResponse{}}, + wantErr: false, + }, + { + name: "default output with filter", + args: args{outputFormat: "default", instance: &telemetryrouter.TelemetryRouterResponse{ + Filter: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + }, + }, + }}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, instance: &telemetryrouter.TelemetryRouterResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, instance: &telemetryrouter.TelemetryRouterResponse{}}, + wantErr: false, + }, + { + name: "nil instance", + args: args{instance: nil}, + wantErr: true, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatFilterAttributes(t *testing.T) { + tests := []struct { + description string + filter *telemetryrouter.ConfigFilter + want string + }{ + { + description: "nil filter", + filter: nil, + want: "-", + }, + { + description: "empty attributes", + filter: &telemetryrouter.ConfigFilter{Attributes: []telemetryrouter.ConfigFilterAttributes{}}, + want: "-", + }, + { + description: "single attribute", + filter: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + }, + }, + want: "key=http.method;level=resource;matcher==;values=GET,HEAD", + }, + { + description: "multiple attributes joined by newline", + filter: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + { + Key: "http.status_code", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"500", "503"}, + }, + }, + }, + want: "key=http.method;level=resource;matcher==;values=GET,HEAD\n" + + "key=http.status_code;level=logRecord;matcher=!=;values=500,503", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := formatFilterAttributes(tt.filter) + if got != tt.want { + t.Fatalf("formatFilterAttributes() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter.go b/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter.go new file mode 100644 index 000000000..1233843f1 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter.go @@ -0,0 +1,148 @@ +package generatefilter + +import ( + "context" + "encoding/json" + "fmt" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" + "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + filePathFlag = "file-path" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId *string + FilePath *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-filter", + Short: "Generates a filter configuration to use as --filter input for TelemetryRouter instances", + Long: fmt.Sprintf("%s\n%s\n%s", + `Generates a JSON filter configuration to be used as "--filter" input for "instance create" or "instance update".`, + `If "--instance-id" is set, the current filter configuration of that instance is returned, to be adapted with custom values.`, + `If unset, a default example filter configuration is returned.`, + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a default example filter configuration, and adapt it with custom values`, + `$ stackit beta telemetryrouter instance generate-filter --file-path ./filter.json`, + ``, + `$ stackit beta telemetryrouter instance create --display-name "my-instance" --filter @./filter.json`), + examples.NewExample( + `Generate a filter configuration with the current values of TelemetryRouter instance "xxx", and adapt it with custom values`, + `$ stackit beta telemetryrouter instance generate-filter --instance-id xxx --file-path ./filter.json`, + ``, + `$ stackit beta telemetryrouter instance update xxx --filter @./filter.json`), + examples.NewExample( + `Generate a filter configuration with the current values of TelemetryRouter instance "xxx", and preview it in the terminal`, + `$ stackit beta telemetryrouter instance generate-filter --instance-id xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + if model.InstanceId == nil { + defaultFilter := telemetryrouterUtils.DefaultConfigFilter + return outputResult(params.Printer, model.FilePath, &defaultFilter) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read TelemetryRouter instance: %w", err) + } + if resp == nil { + return fmt.Errorf("read TelemetryRouter instance: empty response from API") + } + + filter := resp.Filter + if filter == nil { + defaultFilter := telemetryrouterUtils.DefaultConfigFilter + filter = &defaultFilter + } + + return outputResult(params.Printer, model.FilePath, filter) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, + "If set, generates a filter configuration with the current values of the given TelemetryRouter instance. If unset, generates a default example filter configuration") + cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the filter configuration to the given file. If unset, writes it to the standard output") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + instanceId := flags.FlagToStringPointer(p, cmd, instanceIdFlag) + if instanceId != nil && globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiGetTelemetryRouterRequest { + return apiClient.DefaultAPI.GetTelemetryRouter(ctx, model.ProjectId, model.Region, *model.InstanceId) +} + +func outputResult(p *print.Printer, filePath *string, filter *telemetryrouter.ConfigFilter) error { + if filter == nil { + return fmt.Errorf("filter is nil") + } + + filterBytes, err := json.MarshalIndent(*filter, "", " ") // nolint:gosec // false positive + if err != nil { + return fmt.Errorf("marshal filter: %w", err) + } + + if filePath != nil { + err = fileutils.WriteToFile(*filePath, string(filterBytes)) + if err != nil { + return fmt.Errorf("write filter to the file: %w", err) + } + } else { + p.Outputln(string(filterBytes)) + } + + return nil +} diff --git a/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter_test.go b/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter_test.go new file mode 100644 index 000000000..aab44ec15 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/generate-filter/generate_filter_test.go @@ -0,0 +1,225 @@ +package generatefilter + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" + testFilePath = "example-file" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + filePathFlag: testFilePath, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: new(testInstanceId), + FilePath: new(testFilePath), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiGetTelemetryRouterRequest)) telemetryrouter.ApiGetTelemetryRouterRequest { + request := testClient.DefaultAPI.GetTelemetryRouter(testCtx, testProjectId, testRegion, testInstanceId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + }, + }, + { + description: "file path missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, filePathFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FilePath = nil + }), + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.InstanceId = nil + }), + }, + { + description: "project id missing, instance id provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id and instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + delete(flagValues, instanceIdFlag) + }), + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{Region: testRegion, Verbosity: globalflags.VerbosityDefault}, + FilePath: new(testFilePath), + }, + }, + { + 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[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiGetTelemetryRouterRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + filePath *string + filter *telemetryrouter.ConfigFilter + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil filter", + args: args{}, + wantErr: true, + }, + { + name: "empty filter to stdout", + args: args{ + filter: &telemetryrouter.ConfigFilter{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.filePath, tt.args.filter); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/instance.go b/internal/cmd/beta/telemetryrouter/instance/instance.go new file mode 100644 index 000000000..4325328f9 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/instance.go @@ -0,0 +1,36 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/describe" + generatefilter "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/generate-filter" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for TelemetryRouter instances", + Long: "Provides functionality for TelemetryRouter instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(generatefilter.NewCmd(params)) +} diff --git a/internal/cmd/beta/telemetryrouter/instance/list/list.go b/internal/cmd/beta/telemetryrouter/instance/list/list.go new file mode 100644 index 000000000..3a5da87b6 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/list/list.go @@ -0,0 +1,192 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/experimental/paginate" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const ( + limitFlag = "limit" + + // maxPageSize is the maximum number of items the API returns per page. + maxPageSize = 100 +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists TelemetryRouter instances", + Long: "Lists TelemetryRouter instances within the project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all TelemetryRouter instances`, + `$ stackit beta telemetryrouter instance list`, + ), + examples.NewExample( + `List the first 10 TelemetryRouter instances`, + `$ stackit beta telemetryrouter instance list --limit=10`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + if projectLabel == "" { + projectLabel = model.ProjectId + } + + items, err := fetchInstances(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*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", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiListTelemetryRoutersRequest { + return apiClient.DefaultAPI.ListTelemetryRouters(ctx, model.ProjectId, model.Region) +} + +// listTelemetryRoutersResponse adapts telemetryrouter.ListTelemetryRoutersResponse to +// paginate.Response, since the generated type names its list field after the resource +// (AIP-158) instead of "Items". +type listTelemetryRoutersResponse struct { + *telemetryrouter.ListTelemetryRoutersResponse +} + +func (r listTelemetryRoutersResponse) GetItems() []telemetryrouter.TelemetryRouterResponse { + return r.GetTelemetryRouters() +} + +// listTelemetryRoutersAdapter adapts telemetryrouter.ApiListTelemetryRoutersRequest to paginate.Request. +type listTelemetryRoutersAdapter struct { + request telemetryrouter.ApiListTelemetryRoutersRequest +} + +func (a *listTelemetryRoutersAdapter) PageSize(pageSize int32) *listTelemetryRoutersAdapter { + a.request = a.request.PageSize(pageSize) + return a +} + +func (a *listTelemetryRoutersAdapter) PageToken(pageToken string) *listTelemetryRoutersAdapter { + a.request = a.request.PageToken(pageToken) + return a +} + +func (a *listTelemetryRoutersAdapter) Execute() (listTelemetryRoutersResponse, error) { + resp, err := a.request.Execute() + if err != nil { + return listTelemetryRoutersResponse{}, err + } + return listTelemetryRoutersResponse{resp}, nil +} + +func fetchInstances(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) ([]telemetryrouter.TelemetryRouterResponse, error) { + opts := []paginate.Option{paginate.WithPageSize(maxPageSize)} + if model.Limit != nil { + opts = append(opts, paginate.WithLimit(int(*model.Limit))) // nolint:gosec // bounded by prior validation, cannot overflow int + } + + request := &listTelemetryRoutersAdapter{request: buildRequest(ctx, model, apiClient)} + items, err := paginate.All(request, opts...) + if err != nil { + return nil, fmt.Errorf("list TelemetryRouter instances: %w", err) + } + if items == nil { + items = []telemetryrouter.TelemetryRouterResponse{} + } + return items, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []telemetryrouter.TelemetryRouterResponse) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No TelemetryRouter instances found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DISPLAY NAME", "URI") + for _, instance := range instances { + table.AddRow( + instance.Id, + instance.DisplayName, + instance.Uri, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/instance/list/list_test.go b/internal/cmd/beta/telemetryrouter/instance/list/list_test.go new file mode 100644 index 000000000..63adf798b --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/list/list_test.go @@ -0,0 +1,327 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: new(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiListTelemetryRoutersRequest)) telemetryrouter.ApiListTelemetryRoutersRequest { + request := testClient.DefaultAPI.ListTelemetryRouters(testCtx, testProjectId, testRegion) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiListTelemetryRoutersRequest + }{ + { + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +type testResponse struct { + statusCode int + body telemetryrouter.ListTelemetryRoutersResponse +} + +func fixtureTestResponse(mods ...func(resp *testResponse)) testResponse { + resp := testResponse{ + statusCode: 200, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureInstances(count int) []telemetryrouter.TelemetryRouterResponse { + instances := make([]telemetryrouter.TelemetryRouterResponse, count) + for i := 0; i < count; i++ { + instances[i] = telemetryrouter.TelemetryRouterResponse{ + Id: fmt.Sprintf("instance-%d", i+1), + DisplayName: fmt.Sprintf("instance-%d", i+1), + Uri: fmt.Sprintf("https://instance-%d.telemetry-router.example.com", i+1), + Status: telemetryrouter.TELEMETRYROUTERRESPONSESTATUS_ACTIVE, + AdditionalProperties: map[string]interface{}{}, + } + } + return instances +} + +func TestFetchInstances(t *testing.T) { + tests := []struct { + description string + limit int64 + responses []testResponse + expected []telemetryrouter.TelemetryRouterResponse + fails bool + }{ + { + description: "no instances", + responses: []testResponse{ + fixtureTestResponse(), + }, + expected: []telemetryrouter.TelemetryRouterResponse{}, + }, + { + description: "instances returned", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.TelemetryRouters = fixtureInstances(3) + }), + }, + expected: fixtureInstances(3), + }, + { + description: "API error", + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.statusCode = 500 + }), + }, + fails: true, + }, + { + description: "limit truncates within a single page", + limit: 2, + responses: []testResponse{ + fixtureTestResponse(func(resp *testResponse) { + resp.body.TelemetryRouters = fixtureInstances(5) + }), + }, + expected: fixtureInstances(2), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := tt.responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.statusCode) + bs, err := json.Marshal(resp.body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = w.Write(bs) + if err != nil { + t.Fatalf("write: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + client, err := telemetryrouter.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test client: %v", err) + } + model := fixtureInputModel(func(m *inputModel) { + if tt.limit > 0 { + m.Limit = utils.Ptr(tt.limit) + } else { + m.Limit = nil + } + }) + + got, err := fetchInstances(testCtx, model, client) + if err != nil { + if !tt.fails { + t.Fatalf("fetchInstances() unexpected error: %v", err) + } + return + } + if tt.fails { + t.Fatalf("fetchInstances() expected error, got none") + } + if callCount != len(tt.responses) { + t.Errorf("fetchInstances() expected %d calls, got %d", len(tt.responses), callCount) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Errorf("fetchInstances() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + instances []telemetryrouter.TelemetryRouterResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty instances slice", + args: args{ + instances: []telemetryrouter.TelemetryRouterResponse{}, + }, + wantErr: false, + }, + { + name: "empty instance in instances slice", + args: args{ + instances: []telemetryrouter.TelemetryRouterResponse{{}}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/instance/update/update.go b/internal/cmd/beta/telemetryrouter/instance/update/update.go new file mode 100644 index 000000000..4c3588bab --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/update/update.go @@ -0,0 +1,202 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/telemetryrouter/client" + telemetryrouterUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/telemetryrouter/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +const ( + argInstanceID = "INSTANCE_ID" + + displayNameFlag = "display-name" + descriptionFlag = "description" + filterAttributeFlag = "filter-attribute" + filterFlag = "filter" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string + DisplayName *string + Description *string + Filter *telemetryrouter.ConfigFilter +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", argInstanceID), + Short: "Updates a TelemetryRouter instance", + Long: "Updates a TelemetryRouter instance.", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of the TelemetryRouter instance with ID "xxx"`, + "$ stackit beta telemetryrouter instance update xxx --display-name new-name"), + examples.NewExample( + `Update the description of the TelemetryRouter instance with ID "xxx"`, + "$ stackit beta telemetryrouter instance update xxx --description new-description"), + examples.NewExample( + `Replace the filter attributes of the TelemetryRouter instance with ID "xxx" so it only routes telemetry data for HTTP GET or HEAD requests, matched at the resource level`, + `$ stackit beta telemetryrouter instance update xxx --filter-attribute "key=http.method;level=resource;matcher==;values=GET,HEAD"`), + examples.NewExample( + `Replace the filter attributes of the TelemetryRouter instance with ID "xxx" with many filter attributes sourced from a JSON file (recommended over repeating --filter-attribute for more than a handful of attributes); `+ + `use "stackit beta telemetryrouter instance generate-filter --instance-id xxx" to generate a starting point from the instance's current filter`, + `$ stackit beta telemetryrouter instance update xxx --filter @./filter.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := telemetryrouterUtils.GetInstanceName(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.InstanceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceID + } + + prompt := fmt.Sprintf("Are you sure you want to update instance %s?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update TelemetryRouter instance: %w", err) + } + + return outputResult(params.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringArray(filterAttributeFlag, nil, + `Adds a filter attribute that restricts which telemetry data is routed. `+ + `Must be of the form "key=;level=;matcher=<=|!=>;values=,,...", `+ + `e.g. "key=http.method;level=resource;matcher==;values=GET,HEAD" only routes data whose "http.method" resource `+ + `attribute equals "GET" or "HEAD". Can be repeated to add multiple filter attributes, which are combined with AND. `+ + `Replaces any existing filter attributes on the instance. Impractical for many filter attributes at once; `+ + `use "--filter" instead. Mutually exclusive with "--filter". The API does not support clearing all filter `+ + `attributes on update; at least one filter attribute must remain set.`) + cmd.Flags().Var(flags.ReadFromFileFlag(), filterFlag, + `Filter configuration as JSON, of the form {"attributes": [{"key": ..., "level": ..., "matcher": ..., "values": [...]}, ...]}. `+ + `Can be a JSON string or a file path, if prefixed with "@" (example: @./filter.json). Replaces any existing filter `+ + `attributes on the instance. Recommended over "--filter-attribute" when setting many filter attributes at once. `+ + `Run "stackit beta telemetryrouter instance generate-filter --instance-id " to generate a starting point from the `+ + `instance's current filter. Mutually exclusive with "--filter-attribute". The API does not support clearing all `+ + `filter attributes on update; at least one filter attribute must remain set.`) + + cmd.MarkFlagsMutuallyExclusive(filterAttributeFlag, filterFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + displayName := flags.FlagToStringPointer(p, cmd, displayNameFlag) + description := flags.FlagToStringPointer(p, cmd, descriptionFlag) + filterValue := flags.FlagToStringPointer(p, cmd, filterFlag) + filterAttributes := flags.FlagToStringArrayValue(p, cmd, filterAttributeFlag) + + // --filter-attribute and --filter are mutually exclusive; this is enforced by + // cmd.MarkFlagsMutuallyExclusive in configureFlags (validated by cobra before parseInput + // runs), so both being set at once should not happen here in practice. + var filter *telemetryrouter.ConfigFilter + switch { + case filterValue != nil: + parsedFilter, err := telemetryrouterUtils.ParseFilterJSON(*filterValue) + if err != nil { + return nil, err + } + filter = parsedFilter + case len(filterAttributes) > 0: + builtFilter, err := telemetryrouterUtils.BuildConfigFilter(filterAttributes) + if err != nil { + return nil, err + } + filter = builtFilter + } + + if displayName == nil && description == nil && filter == nil { + return nil, &errors.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: instanceId, + DisplayName: displayName, + Description: description, + Filter: filter, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *telemetryrouter.APIClient) telemetryrouter.ApiUpdateTelemetryRouterRequest { + req := apiClient.DefaultAPI.UpdateTelemetryRouter(ctx, model.ProjectId, model.Region, model.InstanceID) + + req = req.UpdateTelemetryRouterPayload(telemetryrouter.UpdateTelemetryRouterPayload{ + DisplayName: model.DisplayName, + Description: model.Description, + Filter: model.Filter, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, instance *telemetryrouter.TelemetryRouterResponse) error { + if instance == nil { + return fmt.Errorf("instance is nil") + } + if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + return p.OutputResult(model.OutputFormat, instance, func() error { + p.Outputf("Updated instance %q for project %q.\n", instance.DisplayName, projectLabel) + return nil + }) +} diff --git a/internal/cmd/beta/telemetryrouter/instance/update/update_test.go b/internal/cmd/beta/telemetryrouter/instance/update/update_test.go new file mode 100644 index 000000000..bcbc3e282 --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/instance/update/update_test.go @@ -0,0 +1,419 @@ +package update + +import ( + "context" + "testing" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &telemetryrouter.APIClient{DefaultAPI: &telemetryrouter.DefaultAPIService{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: "name", + descriptionFlag: "Example", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + DisplayName: new("name"), + Description: new("Example"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *telemetryrouter.ApiUpdateTelemetryRouterRequest)) telemetryrouter.ApiUpdateTelemetryRouterRequest { + request := testClient.DefaultAPI.UpdateTelemetryRouter(testCtx, testProjectId, testRegion, testInstanceId) + request = request.UpdateTelemetryRouterPayload(telemetryrouter.UpdateTelemetryRouterPayload{ + DisplayName: new("name"), + Description: new("Example"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +const ( + testFilterAttributeRaw1 = "key=http.method;level=resource;matcher==;values=GET,HEAD" + testFilterAttributeRaw2 = "key=http.status;level=logRecord;matcher=!=;values=500" +) + +var ( + testConfigFilterAttribute1 = telemetryrouter.ConfigFilterAttributes{ + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + } + testConfigFilterAttribute2 = telemetryrouter.ConfigFilterAttributes{ + Key: "http.status", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"500"}, + } +) + +// addAdditionalProperties returns a copy of attr with a non-nil AdditionalProperties map, as +// produced by json.Unmarshal on the generated SDK type (used for --filter JSON test fixtures). +func addAdditionalProperties(attr *telemetryrouter.ConfigFilterAttributes) telemetryrouter.ConfigFilterAttributes { + withProperties := *attr + withProperties.AdditionalProperties = map[string]interface{}{} + return withProperties +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + primaryFlagValues []string + additionalFlagValues 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: "required flags only (no values to update)", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + }, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: "display-name", + descriptionFlag: "description", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + DisplayName: new("display-name"), + Description: new("description"), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "single filter-attribute produces expected ConfigFilter", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {testFilterAttributeRaw1}, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1}, + } + }), + }, + { + description: "multiple filter-attribute flags produce multiple attributes in order", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {testFilterAttributeRaw1, testFilterAttributeRaw2}, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + } + }), + }, + { + description: "invalid filter-attribute value produces a parseInput error", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterAttributeFlag: {"key=http.method;level=bogus;matcher==;values=GET"}, + }, + isValid: false, + }, + { + description: "filter JSON produces expected ConfigFilter", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: { + `{"attributes": [` + + `{"key": "http.method", "level": "resource", "matcher": "=", "values": ["GET", "HEAD"]},` + + `{"key": "http.status", "level": "logRecord", "matcher": "!=", "values": ["500"]}` + + `]}`, + }, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + addAdditionalProperties(&testConfigFilterAttribute1), + addAdditionalProperties(&testConfigFilterAttribute2), + }, + AdditionalProperties: map[string]interface{}{}, + } + }), + }, + { + description: "invalid filter JSON produces a parseInput error", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: {`{"attributes": [`}, + }, + isValid: false, + }, + { + description: "filter and filter-attribute together is rejected", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + additionalFlagValues: map[string][]string{ + filterFlag: {`{"attributes": []}`}, + filterAttributeFlag: {testFilterAttributeRaw1}, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.additionalFlagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest telemetryrouter.ApiUpdateTelemetryRouterRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + }, + expectedRequest: testClient.DefaultAPI.UpdateTelemetryRouter(testCtx, testProjectId, testRegion, testInstanceId). + UpdateTelemetryRouterPayload(telemetryrouter.UpdateTelemetryRouterPayload{}), + }, + { + description: "with filter attributes", + model: fixtureInputModel(func(model *inputModel) { + model.Filter = &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + } + }), + expectedRequest: fixtureRequest().UpdateTelemetryRouterPayload(telemetryrouter.UpdateTelemetryRouterPayload{ + DisplayName: new("name"), + Description: new("Example"), + Filter: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{testConfigFilterAttribute1, testConfigFilterAttribute2}, + }, + }), + }, + } + + 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, telemetryrouter.DefaultAPIService{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + instance *telemetryrouter.TelemetryRouterResponse + wantErr bool + }{ + { + description: "nil response", + instance: nil, + wantErr: true, + }, + { + description: "default output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wantErr: false, + }, + { + description: "model is nil", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: nil, + wantErr: true, + }, + { + description: "global flag nil", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: nil}, + wantErr: true, + }, + { + description: "json output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wantErr: false, + }, + { + description: "yaml output", + instance: &telemetryrouter.TelemetryRouterResponse{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(params.Printer, tt.model, "label", tt.instance) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/telemetryrouter/telemetryrouter.go b/internal/cmd/beta/telemetryrouter/telemetryrouter.go new file mode 100644 index 000000000..28c5eccec --- /dev/null +++ b/internal/cmd/beta/telemetryrouter/telemetryrouter.go @@ -0,0 +1,30 @@ +package telemetryrouter + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/access_token" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/destination" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/telemetryrouter/instance" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "telemetryrouter", + Short: "Provides functionality for TelemetryRouter", + Long: "Provides functionality for TelemetryRouter.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(destination.NewCmd(params)) + cmd.AddCommand(access_token.NewCmd(params)) +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 5401e4aa6..ba68eb621 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -52,6 +52,7 @@ const ( tokenCustomEndpointFlag = "token-custom-endpoint" intakeCustomEndpointFlag = "intake-custom-endpoint" logsCustomEndpointFlag = "logs-custom-endpoint" + telemetryRouterCustomEndpointFlag = "telemetryrouter-custom-endpoint" sfsCustomEndpointFlag = "sfs-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" @@ -173,6 +174,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(tokenCustomEndpointFlag, "", "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") cmd.Flags().String(intakeCustomEndpointFlag, "", "Intake API base URL, used in calls to this API") cmd.Flags().String(logsCustomEndpointFlag, "", "Logs API base URL, used in calls to this API") + cmd.Flags().String(telemetryRouterCustomEndpointFlag, "", "TelemetryRouter API base URL, used in calls to this API") cmd.Flags().String(sfsCustomEndpointFlag, "", "SFS API base URL, used in calls to this API") cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") cmd.Flags().String(vpnCustomEndpointFlag, "", "VPN API base URL, used in calls to this API") @@ -242,6 +244,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.LogsCustomEndpointKey, cmd.Flags().Lookup(logsCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.TelemetryRouterCustomEndpointKey, cmd.Flags().Lookup(telemetryRouterCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.SfsCustomEndpointKey, cmd.Flags().Lookup(sfsCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.CDNCustomEndpointKey, cmd.Flags().Lookup(cdnCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 110204f64..7f6a5bd09 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -56,6 +56,7 @@ const ( tokenCustomEndpointFlag = "token-custom-endpoint" intakeCustomEndpointFlag = "intake-custom-endpoint" logsCustomEndpointFlag = "logs-custom-endpoint" + telemetryRouterCustomEndpointFlag = "telemetryrouter-custom-endpoint" cdnCustomEndpointFlag = "cdn-custom-endpoint" vpnCustomEndpointFlag = "vpn-custom-endpoint" ) @@ -107,6 +108,7 @@ type inputModel struct { TokenCustomEndpoint bool IntakeCustomEndpoint bool LogsCustomEndpoint bool + TelemetryRouterCustomEndpoint bool CDNCustomEndpoint bool VpnCustomEndpoint bool } @@ -247,6 +249,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.LogsCustomEndpoint { viper.Set(config.LogsCustomEndpointKey, "") } + if model.TelemetryRouterCustomEndpoint { + viper.Set(config.TelemetryRouterCustomEndpointKey, "") + } if model.SfsCustomEndpoint { viper.Set(config.SfsCustomEndpointKey, "") } @@ -309,6 +314,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(tokenCustomEndpointFlag, false, "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") cmd.Flags().Bool(intakeCustomEndpointFlag, false, "Intake API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logsCustomEndpointFlag, false, "Logs API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(telemetryRouterCustomEndpointFlag, false, "TelemetryRouter API base URL. If unset, uses the default base URL") cmd.Flags().Bool(sfsCustomEndpointFlag, false, "SFS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") cmd.Flags().Bool(vpnCustomEndpointFlag, false, "VPN API base URL. If unset, uses the default base URL") @@ -357,6 +363,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { TokenCustomEndpoint: flags.FlagToBoolValue(p, cmd, tokenCustomEndpointFlag), IntakeCustomEndpoint: flags.FlagToBoolValue(p, cmd, intakeCustomEndpointFlag), LogsCustomEndpoint: flags.FlagToBoolValue(p, cmd, logsCustomEndpointFlag), + TelemetryRouterCustomEndpoint: flags.FlagToBoolValue(p, cmd, telemetryRouterCustomEndpointFlag), CDNCustomEndpoint: flags.FlagToBoolValue(p, cmd, cdnCustomEndpointFlag), VpnCustomEndpoint: flags.FlagToBoolValue(p, cmd, vpnCustomEndpointFlag), } diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 75965ec31..d4c3c8187 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -48,6 +48,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool tokenCustomEndpointFlag: true, intakeCustomEndpointFlag: true, logsCustomEndpointFlag: true, + telemetryRouterCustomEndpointFlag: true, cdnCustomEndpointFlag: true, vpnCustomEndpointFlag: true, } @@ -96,6 +97,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { TokenCustomEndpoint: true, IntakeCustomEndpoint: true, LogsCustomEndpoint: true, + TelemetryRouterCustomEndpoint: true, CDNCustomEndpoint: true, VpnCustomEndpoint: true, } @@ -160,6 +162,7 @@ func TestParseInput(t *testing.T) { model.TokenCustomEndpoint = false model.IntakeCustomEndpoint = false model.LogsCustomEndpoint = false + model.TelemetryRouterCustomEndpoint = false model.CDNCustomEndpoint = false model.VpnCustomEndpoint = false }), @@ -364,6 +367,16 @@ func TestParseInput(t *testing.T) { model.LogsCustomEndpoint = false }), }, + { + description: "telemetryrouter custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[telemetryRouterCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.TelemetryRouterCustomEndpoint = false + }), + }, { description: "cdn custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 2365ed8b4..c2e48de55 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -56,6 +56,7 @@ const ( CDNCustomEndpointKey = "cdn_custom_endpoint" IntakeCustomEndpointKey = "intake_custom_endpoint" LogsCustomEndpointKey = "logs_custom_endpoint" + TelemetryRouterCustomEndpointKey = "telemetryrouter_custom_endpoint" ValkeyCustomEndpointKey = "valkey_custom_endpoint" VPNCustomEndpointKey = "vpn_custom_endpoint" @@ -125,6 +126,7 @@ var ConfigKeys = []string{ ServiceAccountCustomEndpointKey, ServiceEnablementCustomEndpointKey, SfsCustomEndpointKey, + TelemetryRouterCustomEndpointKey, TokenCustomEndpointKey, ValkeyCustomEndpointKey, VPNCustomEndpointKey, @@ -219,6 +221,7 @@ func setConfigDefaults() { viper.SetDefault(AlbCustomEndpoint, "") viper.SetDefault(AlbWafCustomEndpointKey, "") viper.SetDefault(LogsCustomEndpointKey, "") + viper.SetDefault(TelemetryRouterCustomEndpointKey, "") viper.SetDefault(CDNCustomEndpointKey, "") viper.SetDefault(ValkeyCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") diff --git a/internal/pkg/services/telemetryrouter/client/client.go b/internal/pkg/services/telemetryrouter/client/client.go new file mode 100644 index 000000000..2c79eb227 --- /dev/null +++ b/internal/pkg/services/telemetryrouter/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*telemetryrouter.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.TelemetryRouterCustomEndpointKey), false, telemetryrouter.NewAPIClient) +} diff --git a/internal/pkg/services/telemetryrouter/utils/utils.go b/internal/pkg/services/telemetryrouter/utils/utils.go new file mode 100644 index 000000000..8eba9b20a --- /dev/null +++ b/internal/pkg/services/telemetryrouter/utils/utils.go @@ -0,0 +1,276 @@ +package utils + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" +) + +var ( + ErrResponseNil = errors.New("response is nil") +) + +func GetInstanceName(ctx context.Context, apiClient telemetryrouter.DefaultAPI, projectId, regionId, instanceId string) (string, error) { + resp, err := apiClient.GetTelemetryRouter(ctx, projectId, regionId, instanceId).Execute() + if err != nil { + return "", fmt.Errorf("get TelemetryRouter instance: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } + return resp.DisplayName, nil +} + +func GetDestinationName(ctx context.Context, apiClient telemetryrouter.DefaultAPI, projectId, regionId, instanceId, destinationId string) (string, error) { + resp, err := apiClient.GetDestination(ctx, projectId, regionId, instanceId, destinationId).Execute() + if err != nil { + return "", fmt.Errorf("get TelemetryRouter destination: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } + return resp.DisplayName, nil +} + +func GetAccessTokenName(ctx context.Context, apiClient telemetryrouter.DefaultAPI, projectId, regionId, instanceId, accessTokenId string) (string, error) { + resp, err := apiClient.GetAccessToken(ctx, projectId, regionId, instanceId, accessTokenId).Execute() + if err != nil { + return "", fmt.Errorf("get TelemetryRouter access token: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } + return resp.DisplayName, nil +} + +// DefaultCreateDestinationPayloadOpenTelemetry is a default payload for creating a TelemetryRouter destination +// that routes to an OpenTelemetry (OTLP) endpoint, secured with basic auth. +var DefaultCreateDestinationPayloadOpenTelemetry = telemetryrouter.CreateDestinationPayload{ + DisplayName: "default-name", + Config: telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_OPEN_TELEMETRY, + OpenTelemetry: &telemetryrouter.DestinationConfigOpenTelemetry{ + Uri: "https://otel-collector.example.com:4317", + BasicAuth: &telemetryrouter.DestinationConfigOpenTelemetryBasicAuth{ + Username: "username", + Password: "password", + }, + }, + }, +} + +// DefaultCreateDestinationPayloadS3 is a default payload for creating a TelemetryRouter destination +// that routes to an S3-compatible bucket, secured with an access key. +var DefaultCreateDestinationPayloadS3 = telemetryrouter.CreateDestinationPayload{ + DisplayName: "default-name", + Config: telemetryrouter.DestinationConfig{ + ConfigType: telemetryrouter.DESTINATIONCONFIGTYPE_S3, + S3: &telemetryrouter.DestinationConfigS3{ + Bucket: "my-bucket", + Endpoint: "https://object.storage.eu01.onstackit.cloud", + AccessKey: &telemetryrouter.DestinationConfigS3AccessKey{ // nolint:gosec // false positive + Id: "access-key-id", + Secret: "access-key-secret", + }, + }, + }, +} + +// DefaultConfigFilter is a default example filter configuration for a TelemetryRouter instance, +// used as a starting point for the --filter flag of "instance create"/"instance update" when no +// existing instance is used as a seed. +var DefaultConfigFilter = telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + }, +} + +// MapToUpdateDestinationPayload maps a DestinationResponse (as returned by the API) to an +// UpdateDestinationPayload, so it can be used as a seed payload for the "update" command. +func MapToUpdateDestinationPayload(resp *telemetryrouter.DestinationResponse) (*telemetryrouter.UpdateDestinationPayload, error) { + if resp == nil { + return nil, fmt.Errorf("no TelemetryRouter destination provided") + } + + payload := &telemetryrouter.UpdateDestinationPayload{ + Config: &resp.Config, + Description: resp.Description, + DisplayName: &resp.DisplayName, + } + + return payload, nil +} + +const ( + filterAttributeFieldKey = "key" + filterAttributeFieldLevel = "level" + filterAttributeFieldMatcher = "matcher" + filterAttributeFieldValues = "values" +) + +// ParseFilterAttribute parses a single --filter-attribute flag value in the form +// +// "key=;level=;matcher=<=|!=>;values=,,..." +// +// into a telemetryrouter.ConfigFilterAttributes. +func ParseFilterAttribute(raw string) (telemetryrouter.ConfigFilterAttributes, error) { + fields := strings.Split(raw, ";") + + found := make(map[string]string, len(fields)) + var unknownFields []string + var duplicateFields []string + + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + + // Split on the first "=" only, in case a value itself contains "=". + parts := strings.SplitN(field, "=", 2) + if len(parts) != 2 { + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + "invalid --filter-attribute value %q: field %q must be of the form \"name=value\"", raw, field) + } + name := strings.TrimSpace(parts[0]) + value := parts[1] + + switch name { + case filterAttributeFieldKey, filterAttributeFieldLevel, filterAttributeFieldMatcher, filterAttributeFieldValues: + if _, exists := found[name]; exists { + duplicateFields = append(duplicateFields, name) + continue + } + found[name] = value + default: + unknownFields = append(unknownFields, name) + } + } + + var missingFields []string + for _, required := range []string{filterAttributeFieldKey, filterAttributeFieldLevel, filterAttributeFieldMatcher, filterAttributeFieldValues} { + if _, ok := found[required]; !ok { + missingFields = append(missingFields, required) + } + } + + if len(missingFields) > 0 || len(unknownFields) > 0 || len(duplicateFields) > 0 { + var problems []string + if len(missingFields) > 0 { + problems = append(problems, fmt.Sprintf("missing field(s): %s", strings.Join(missingFields, ", "))) + } + if len(unknownFields) > 0 { + problems = append(problems, fmt.Sprintf("unrecognized field(s): %s", strings.Join(unknownFields, ", "))) + } + if len(duplicateFields) > 0 { + problems = append(problems, fmt.Sprintf("duplicate field(s): %s", strings.Join(duplicateFields, ", "))) + } + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + `invalid --filter-attribute value %q: %s (expected exactly the fields "key", "level", "matcher" and "values", each specified once)`, + raw, strings.Join(problems, "; ")) + } + + key := strings.TrimSpace(found[filterAttributeFieldKey]) + if key == "" { + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + `invalid --filter-attribute value %q: "key" must not be empty`, raw) + } + + level := strings.TrimSpace(found[filterAttributeFieldLevel]) + if !isValidConfigFilterLevel(level) { + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + `invalid --filter-attribute value %q: "level" must be one of resource, scope, logRecord (got %q)`, raw, level) + } + + matcher := strings.TrimSpace(found[filterAttributeFieldMatcher]) + if !isValidConfigFilterMatcher(matcher) { + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + `invalid --filter-attribute value %q: "matcher" must be one of =, != (got %q)`, raw, matcher) + } + + var values []string + for _, v := range strings.Split(found[filterAttributeFieldValues], ",") { + v = strings.TrimSpace(v) + if v == "" { + continue + } + values = append(values, v) + } + if len(values) == 0 { + return telemetryrouter.ConfigFilterAttributes{}, fmt.Errorf( + `invalid --filter-attribute value %q: "values" must contain at least one non-empty, comma-separated value`, raw) + } + + return telemetryrouter.ConfigFilterAttributes{ + Key: key, + Level: telemetryrouter.ConfigFilterLevel(level), + Matcher: telemetryrouter.ConfigFilterMatcher(matcher), + Values: values, + }, nil +} + +// isValidConfigFilterLevel reports whether level is one of the SDK's allowed +// ConfigFilterLevel enum values, excluding the "unknown_default_open_api" sentinel. +func isValidConfigFilterLevel(level string) bool { + for _, allowed := range telemetryrouter.AllowedConfigFilterLevelEnumValues { + if allowed == telemetryrouter.CONFIGFILTERLEVEL_UNKNOWN_DEFAULT_OPEN_API { + continue + } + if string(allowed) == level { + return true + } + } + return false +} + +// isValidConfigFilterMatcher reports whether matcher is one of the SDK's allowed +// ConfigFilterMatcher enum values, excluding the "unknown_default_open_api" sentinel. +func isValidConfigFilterMatcher(matcher string) bool { + for _, allowed := range telemetryrouter.AllowedConfigFilterMatcherEnumValues { + if allowed == telemetryrouter.CONFIGFILTERMATCHER_UNKNOWN_DEFAULT_OPEN_API { + continue + } + if string(allowed) == matcher { + return true + } + } + return false +} + +// BuildConfigFilter maps a slice of raw --filter-attribute flag values (as collected by +// pflag.StringArray) into a *ConfigFilter. Returns nil (not an error) if raw is empty, +// so the Filter field stays unset for API calls where no filtering was requested. +func BuildConfigFilter(raw []string) (*telemetryrouter.ConfigFilter, error) { + if len(raw) == 0 { + return nil, nil + } + + attrs := make([]telemetryrouter.ConfigFilterAttributes, 0, len(raw)) + for i, r := range raw { + attr, err := ParseFilterAttribute(r) + if err != nil { + return nil, fmt.Errorf("parse --filter-attribute value #%d (%q): %w", i+1, r, err) + } + attrs = append(attrs, attr) + } + + return &telemetryrouter.ConfigFilter{Attributes: attrs}, nil +} + +// ParseFilterJSON parses the raw JSON value of a --filter flag (a JSON object of the form +// {"attributes": [{"key": ..., "level": ..., "matcher": ..., "values": [...]}, ...]}, matching +// the API's ConfigFilter schema) into a *ConfigFilter. This is the practical alternative to +// repeating --filter-attribute when many filter attributes need to be set at once. +func ParseFilterJSON(raw string) (*telemetryrouter.ConfigFilter, error) { + var filter telemetryrouter.ConfigFilter + if err := json.Unmarshal([]byte(raw), &filter); err != nil { + return nil, fmt.Errorf("decode filter: %w", err) + } + return &filter, nil +} diff --git a/internal/pkg/services/telemetryrouter/utils/utils_test.go b/internal/pkg/services/telemetryrouter/utils/utils_test.go new file mode 100644 index 000000000..e21b73f83 --- /dev/null +++ b/internal/pkg/services/telemetryrouter/utils/utils_test.go @@ -0,0 +1,319 @@ +package utils + +import ( + "strings" + "testing" + + telemetryrouter "github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter/v1api" + + "github.com/google/go-cmp/cmp" +) + +func TestParseFilterAttribute(t *testing.T) { + tests := []struct { + description string + raw string + want telemetryrouter.ConfigFilterAttributes + wantErr bool + errContains string + }{ + { + description: "valid single attribute", + raw: "key=http.method;level=resource;matcher=!=;values=GET", + want: telemetryrouter.ConfigFilterAttributes{ + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"GET"}, + }, + }, + { + description: "valid equal matcher", + raw: "key=http.method;level=resource;matcher==;values=GET,HEAD", + want: telemetryrouter.ConfigFilterAttributes{ + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + }, + { + description: "whitespace trimming in values", + raw: "key=http.method;level=scope;matcher=!=;values= GET , HEAD ,,POST ", + want: telemetryrouter.ConfigFilterAttributes{ + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_SCOPE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"GET", "HEAD", "POST"}, + }, + }, + { + description: "logRecord level", + raw: "key=body;level=logRecord;matcher==;values=foo", + want: telemetryrouter.ConfigFilterAttributes{ + Key: "body", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"foo"}, + }, + }, + { + description: "missing field", + raw: "key=http.method;level=resource;values=GET", + wantErr: true, + errContains: "missing field(s): matcher", + }, + { + description: "unknown field", + raw: "key=http.method;level=resource;matcher==;values=GET;unknown=foo", + wantErr: true, + errContains: "unrecognized field(s): unknown", + }, + { + description: "duplicate field", + raw: "key=http.method;key=http.status;level=resource;matcher==;values=GET", + wantErr: true, + errContains: "duplicate field(s): key", + }, + { + description: "invalid level", + raw: "key=http.method;level=bogus;matcher==;values=GET", + wantErr: true, + errContains: `"level" must be one of resource, scope, logRecord`, + }, + { + description: "invalid matcher", + raw: "key=http.method;level=resource;matcher=~;values=GET", + wantErr: true, + errContains: `"matcher" must be one of =, !=`, + }, + { + description: "empty values", + raw: "key=http.method;level=resource;matcher==;values= , ", + wantErr: true, + errContains: `"values" must contain at least one non-empty`, + }, + { + description: "empty key", + raw: "key= ;level=resource;matcher==;values=GET", + wantErr: true, + errContains: `"key" must not be empty`, + }, + { + description: "field with no equals sign", + raw: "key=http.method;bogusfield;level=resource;matcher==;values=GET", + wantErr: true, + errContains: `must be of the form "name=value"`, + }, + { + description: "unknown_default_open_api not accepted as level", + raw: "key=http.method;level=unknown_default_open_api;matcher==;values=GET", + wantErr: true, + errContains: `"level" must be one of resource, scope, logRecord`, + }, + { + description: "unknown_default_open_api not accepted as matcher", + raw: "key=http.method;level=resource;matcher=unknown_default_open_api;values=GET", + wantErr: true, + errContains: `"matcher" must be one of =, !=`, + }, + { + description: "empty raw string", + raw: "", + wantErr: true, + errContains: "missing field(s): key, level, matcher, values", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := ParseFilterAttribute(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseFilterAttribute() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if tt.errContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errContains)) { + t.Fatalf("expected error to contain %q, got %v", tt.errContains, err) + } + return + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("ParseFilterAttribute() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildConfigFilter(t *testing.T) { + tests := []struct { + description string + raw []string + want *telemetryrouter.ConfigFilter + wantErr bool + errContains string + }{ + { + description: "empty raw slice returns nil, nil", + raw: nil, + want: nil, + }, + { + description: "empty (non-nil) raw slice returns nil, nil", + raw: []string{}, + want: nil, + }, + { + description: "single attribute", + raw: []string{ + "key=http.method;level=resource;matcher==;values=GET,HEAD", + }, + want: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + }, + }, + }, + { + description: "multiple attributes preserve order", + raw: []string{ + "key=http.method;level=resource;matcher==;values=GET,HEAD", + "key=http.status;level=logRecord;matcher=!=;values=500", + }, + want: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + }, + { + Key: "http.status", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"500"}, + }, + }, + }, + }, + { + description: "invalid entry propagates error with context", + raw: []string{ + "key=http.method;level=resource;matcher==;values=GET", + "key=http.status;level=bogus;matcher==;values=500", + }, + wantErr: true, + errContains: "parse --filter-attribute value #2", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := BuildConfigFilter(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("BuildConfigFilter() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if tt.errContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errContains)) { + t.Fatalf("expected error to contain %q, got %v", tt.errContains, err) + } + return + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("BuildConfigFilter() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestParseFilterJSON(t *testing.T) { + tests := []struct { + description string + raw string + want *telemetryrouter.ConfigFilter + wantErr bool + errContains string + }{ + { + // json.Unmarshal on the generated SDK types always yields a non-nil (possibly + // empty) AdditionalProperties map, unlike hand-constructed struct literals. + description: "single attribute", + raw: `{"attributes": [{"key": "http.method", "level": "resource", "matcher": "=", "values": ["GET", "HEAD"]}]}`, + want: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + AdditionalProperties: map[string]interface{}{}, + }, + }, + AdditionalProperties: map[string]interface{}{}, + }, + }, + { + description: "many attributes", + raw: `{"attributes": [` + + `{"key": "http.method", "level": "resource", "matcher": "=", "values": ["GET", "HEAD"]},` + + `{"key": "http.status", "level": "logRecord", "matcher": "!=", "values": ["500"]}` + + `]}`, + want: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{ + { + Key: "http.method", + Level: telemetryrouter.CONFIGFILTERLEVEL_RESOURCE, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_EQUAL, + Values: []string{"GET", "HEAD"}, + AdditionalProperties: map[string]interface{}{}, + }, + { + Key: "http.status", + Level: telemetryrouter.CONFIGFILTERLEVEL_LOG_RECORD, + Matcher: telemetryrouter.CONFIGFILTERMATCHER_NOT_EQUAL, + Values: []string{"500"}, + AdditionalProperties: map[string]interface{}{}, + }, + }, + AdditionalProperties: map[string]interface{}{}, + }, + }, + { + description: "empty attributes list", + raw: `{"attributes": []}`, + want: &telemetryrouter.ConfigFilter{ + Attributes: []telemetryrouter.ConfigFilterAttributes{}, + AdditionalProperties: map[string]interface{}{}, + }, + }, + { + description: "invalid json", + raw: `{"attributes": [`, + wantErr: true, + errContains: "decode filter", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := ParseFilterJSON(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseFilterJSON() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + if tt.errContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errContains)) { + t.Fatalf("expected error to contain %q, got %v", tt.errContains, err) + } + return + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("ParseFilterJSON() mismatch (-want +got):\n%s", diff) + } + }) + } +}