diff --git a/docs/stackit.md b/docs/stackit.md index 91fa48bed..faa15e7cb 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -41,6 +41,7 @@ stackit [flags] * [stackit logme](./stackit_logme.md) - Provides functionality for LogMe * [stackit logs](./stackit_logs.md) - Provides functionality for Logs * [stackit mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB +* [stackit model-experiments](./stackit_model-experiments.md) - Provides functionality for AI Model Experiments * [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit network](./stackit_network.md) - Provides functionality for networks * [stackit network-area](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA) diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index e53de9c0e..98f1ac7ef 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -47,6 +47,7 @@ stackit config set [flags] --logme-custom-endpoint string LogMe API base URL, used in calls to this API --logs-custom-endpoint string Logs API base URL, used in calls to this API --mariadb-custom-endpoint string MariaDB API base URL, used in calls to this API + --model-experiments-custom-endpoint string Model Experiments API base URL, used in calls to this API --mongodbflex-custom-endpoint string MongoDB Flex API base URL, used in calls to this API --object-storage-custom-endpoint string Object Storage API base URL, used in calls to this API --observability-custom-endpoint string Observability API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index b2ed52a05..c8c4719c5 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -46,6 +46,7 @@ stackit config unset [flags] --logme-custom-endpoint LogMe API base URL. If unset, uses the default base URL --logs-custom-endpoint Logs API base URL. If unset, uses the default base URL --mariadb-custom-endpoint MariaDB API base URL. If unset, uses the default base URL + --model-experiments-custom-endpoint Model Experiments API base URL. If unset, uses the default base URL --mongodbflex-custom-endpoint MongoDB Flex API base URL. If unset, uses the default base URL --object-storage-custom-endpoint Object Storage API base URL. If unset, uses the default base URL --observability-custom-endpoint Observability API base URL. If unset, uses the default base URL diff --git a/docs/stackit_model-experiments.md b/docs/stackit_model-experiments.md new file mode 100644 index 000000000..0032ec8fd --- /dev/null +++ b/docs/stackit_model-experiments.md @@ -0,0 +1,35 @@ +## stackit model-experiments + +Provides functionality for AI Model Experiments + +### Synopsis + +Provides functionality for AI Model Experiments. + +``` +stackit model-experiments [flags] +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments" +``` + +### 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](./stackit.md) - Manage STACKIT resources using the command line +* [stackit model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances +* [stackit model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/docs/stackit_model-experiments_instance.md b/docs/stackit_model-experiments_instance.md new file mode 100644 index 000000000..9b6b762bb --- /dev/null +++ b/docs/stackit_model-experiments_instance.md @@ -0,0 +1,38 @@ +## stackit model-experiments instance + +Provides functionality for AI Model Experiments instances + +### Synopsis + +Provides functionality for AI Model Experiments instances. + +``` +stackit model-experiments instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments 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 model-experiments](./stackit_model-experiments.md) - Provides functionality for AI Model Experiments +* [stackit model-experiments instance create](./stackit_model-experiments_instance_create.md) - Creates an AI Model Experiments instance +* [stackit model-experiments instance delete](./stackit_model-experiments_instance_delete.md) - Deletes an AI Model Experiments instance +* [stackit model-experiments instance describe](./stackit_model-experiments_instance_describe.md) - Shows details of an AI Model Experiments instance +* [stackit model-experiments instance list](./stackit_model-experiments_instance_list.md) - Lists all AI Model Experiments instances +* [stackit model-experiments instance update](./stackit_model-experiments_instance_update.md) - Updates an AI Model Experiments instance + diff --git a/docs/stackit_model-experiments_instance_create.md b/docs/stackit_model-experiments_instance_create.md new file mode 100644 index 000000000..611da7c29 --- /dev/null +++ b/docs/stackit_model-experiments_instance_create.md @@ -0,0 +1,47 @@ +## stackit model-experiments instance create + +Creates an AI Model Experiments instance + +### Synopsis + +Creates an AI Model Experiments (MLflow) instance in your STACKIT project. + +``` +stackit model-experiments instance create [flags] +``` + +### Examples + +``` + Create an AI Model Experiments instance with name "my-tracking" + $ stackit model-experiments instance create --name my-tracking + + Create an instance with a description and labels + $ stackit model-experiments instance create --name my-tracking --description "team tracking server" --labels env=prod,team=ml +``` + +### Options + +``` + --deleted-experiment-retention string Retention period for deleted experiments before permanent purge, e.g. "30d" (min 1d, max 90d) + --description string Instance description + -h, --help Help for "stackit model-experiments instance create" + --labels stringToString Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml" (default []) + -n, --name string Instance name +``` + +### 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 model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances + diff --git a/docs/stackit_model-experiments_instance_delete.md b/docs/stackit_model-experiments_instance_delete.md new file mode 100644 index 000000000..b5139f838 --- /dev/null +++ b/docs/stackit_model-experiments_instance_delete.md @@ -0,0 +1,40 @@ +## stackit model-experiments instance delete + +Deletes an AI Model Experiments instance + +### Synopsis + +Deletes an AI Model Experiments instance from a STACKIT project. + +``` +stackit model-experiments instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete an AI Model Experiments instance with ID "xxx" + $ stackit model-experiments instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments 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 model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances + diff --git a/docs/stackit_model-experiments_instance_describe.md b/docs/stackit_model-experiments_instance_describe.md new file mode 100644 index 000000000..c70200822 --- /dev/null +++ b/docs/stackit_model-experiments_instance_describe.md @@ -0,0 +1,43 @@ +## stackit model-experiments instance describe + +Shows details of an AI Model Experiments instance + +### Synopsis + +Shows details of an AI Model Experiments instance in a STACKIT project. + +``` +stackit model-experiments instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Show details of an AI Model Experiments instance with ID "xxx" + $ stackit model-experiments instance describe xxx + + Get details of an AI Model Experiments instance with ID "xxx" in JSON format + $ stackit model-experiments instance describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments 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 model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances + diff --git a/docs/stackit_model-experiments_instance_list.md b/docs/stackit_model-experiments_instance_list.md new file mode 100644 index 000000000..6b22709fa --- /dev/null +++ b/docs/stackit_model-experiments_instance_list.md @@ -0,0 +1,50 @@ +## stackit model-experiments instance list + +Lists all AI Model Experiments instances + +### Synopsis + +Lists all AI Model Experiments instances in a STACKIT project. + +``` +stackit model-experiments instance list [flags] +``` + +### Examples + +``` + List AI Model Experiments instances in a project + $ stackit model-experiments instance list + + List AI Model Experiments instances in a project in region eu02 + $ stackit model-experiments instance list --region eu02 + + List AI Model Experiments instances in JSON format + $ stackit model-experiments instance list --output-format json + + List up to 10 AI Model Experiments instances + $ stackit model-experiments instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments instance list" + --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 model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances + diff --git a/docs/stackit_model-experiments_instance_update.md b/docs/stackit_model-experiments_instance_update.md new file mode 100644 index 000000000..8ad59a051 --- /dev/null +++ b/docs/stackit_model-experiments_instance_update.md @@ -0,0 +1,53 @@ +## stackit model-experiments instance update + +Updates an AI Model Experiments instance + +### Synopsis + +Partially updates an AI Model Experiments instance in a STACKIT project. + +``` +stackit model-experiments instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the name of an AI Model Experiments instance with ID "xxx" + $ stackit model-experiments instance update xxx --name my-new-name + + Update the description of an AI Model Experiments instance + $ stackit model-experiments instance update xxx --description "team tracking server" + + Update labels on an AI Model Experiments instance + $ stackit model-experiments instance update xxx --labels env=prod,team=ml + + Update multiple fields of an AI Model Experiments instance + $ stackit model-experiments instance update xxx --name my-new-name --description "team tracking server" --labels env=prod,team=ml --deleted-experiment-retention 30d +``` + +### Options + +``` + --deleted-experiment-retention string Retention period for deleted experiments before permanent purge, e.g. "30d" (min 1d, max 90d) + --description string Instance description + -h, --help Help for "stackit model-experiments instance update" + --labels stringToString Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml" (default []) + -n, --name string Instance name +``` + +### 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 model-experiments instance](./stackit_model-experiments_instance.md) - Provides functionality for AI Model Experiments instances + diff --git a/docs/stackit_model-experiments_token.md b/docs/stackit_model-experiments_token.md new file mode 100644 index 000000000..f5156d738 --- /dev/null +++ b/docs/stackit_model-experiments_token.md @@ -0,0 +1,38 @@ +## stackit model-experiments token + +Provides functionality for AI Model Experiments instance tokens + +### Synopsis + +Provides functionality for AI Model Experiments instance tokens. + +``` +stackit model-experiments token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments 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 model-experiments](./stackit_model-experiments.md) - Provides functionality for AI Model Experiments +* [stackit model-experiments token create](./stackit_model-experiments_token_create.md) - Creates an instance token +* [stackit model-experiments token delete](./stackit_model-experiments_token_delete.md) - Deletes an instance token +* [stackit model-experiments token describe](./stackit_model-experiments_token_describe.md) - Shows details of an instance token +* [stackit model-experiments token list](./stackit_model-experiments_token_list.md) - Lists instance tokens +* [stackit model-experiments token update](./stackit_model-experiments_token_update.md) - Updates an instance token + diff --git a/docs/stackit_model-experiments_token_create.md b/docs/stackit_model-experiments_token_create.md new file mode 100644 index 000000000..db95d6c5e --- /dev/null +++ b/docs/stackit_model-experiments_token_create.md @@ -0,0 +1,45 @@ +## stackit model-experiments token create + +Creates an instance token + +### Synopsis + +Creates an auth token for an AI Model Experiments instance. + +``` +stackit model-experiments token create [flags] +``` + +### Examples + +``` + Create an auth token + $ stackit model-experiments token create --instance-id xxx --name my-token +``` + +### Options + +``` + --description string Token description + -h, --help Help for "stackit model-experiments token create" + --instance-id string ID of the instance + --labels stringToString Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml" (default []) + --name string Token name + --ttl-duration string Token time to live duration +``` + +### 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 model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/docs/stackit_model-experiments_token_delete.md b/docs/stackit_model-experiments_token_delete.md new file mode 100644 index 000000000..616a4914c --- /dev/null +++ b/docs/stackit_model-experiments_token_delete.md @@ -0,0 +1,41 @@ +## stackit model-experiments token delete + +Deletes an instance token + +### Synopsis + +Deletes an auth token from an AI Model Experiments instance. + +``` +stackit model-experiments token delete TOKEN_ID [flags] +``` + +### Examples + +``` + Delete an auth token with ID "xxx" + $ stackit model-experiments token delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments token delete" + --instance-id string ID of the 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 model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/docs/stackit_model-experiments_token_describe.md b/docs/stackit_model-experiments_token_describe.md new file mode 100644 index 000000000..081005b63 --- /dev/null +++ b/docs/stackit_model-experiments_token_describe.md @@ -0,0 +1,41 @@ +## stackit model-experiments token describe + +Shows details of an instance token + +### Synopsis + +Shows details of an auth token for an AI Model Experiments instance. + +``` +stackit model-experiments token describe TOKEN_ID [flags] +``` + +### Examples + +``` + Show details of an auth token with ID "xxx" + $ stackit model-experiments token describe xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments token describe" + --instance-id string ID of the 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 model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/docs/stackit_model-experiments_token_list.md b/docs/stackit_model-experiments_token_list.md new file mode 100644 index 000000000..661b55428 --- /dev/null +++ b/docs/stackit_model-experiments_token_list.md @@ -0,0 +1,41 @@ +## stackit model-experiments token list + +Lists instance tokens + +### Synopsis + +Lists all auth tokens for an AI Model Experiments instance. + +``` +stackit model-experiments token list [flags] +``` + +### Examples + +``` + List all tokens for an instance + $ stackit model-experiments token list --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit model-experiments token list" + --instance-id string ID of the 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 model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/docs/stackit_model-experiments_token_update.md b/docs/stackit_model-experiments_token_update.md new file mode 100644 index 000000000..311bef63c --- /dev/null +++ b/docs/stackit_model-experiments_token_update.md @@ -0,0 +1,44 @@ +## stackit model-experiments token update + +Updates an instance token + +### Synopsis + +Partially updates an auth token for an AI Model Experiments instance. + +``` +stackit model-experiments token update TOKEN_ID [flags] +``` + +### Examples + +``` + Update an auth token with ID "xxx" + $ stackit model-experiments token update xxx --instance-id yyy --name updated-token +``` + +### Options + +``` + --description string Token description + -h, --help Help for "stackit model-experiments token update" + --instance-id string ID of the instance + --labels stringToString Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml" (default []) + --name string Token name +``` + +### 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 model-experiments token](./stackit_model-experiments_token.md) - Provides functionality for AI Model Experiments instance tokens + diff --git a/go.mod b/go.mod index 72159163f..f94a465aa 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/iaas v1.15.1 github.com/stackitcloud/stackit-sdk-go/services/intake v0.12.0 github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.2 + github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.13.0 github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.5.0 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.15.0 diff --git a/go.sum b/go.sum index 0c366ccf5..174ee8ab0 100644 --- a/go.sum +++ b/go.sum @@ -640,6 +640,8 @@ github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.2 h1:ZVOAS95S445ZUvXL github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.2/go.mod h1:euyZx4EqA2N7lVrC/wWxdCKalZmnY5zjfrjouNkGycA= github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.4.0 h1:LFpdvASJnTv5Y/PEfwgK0pn7ixXLgcUmLt2cRTg979g= github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.4.0/go.mod h1:GpFHOEVEq+Pog9dWPMvGeF4mEvB+MVLIRh3/jB1isUI= +github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0 h1:g8AX62NqDLJy2dNPPTzfeaBoaR2K+EIwxF8sRiAESL0= +github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.3.0/go.mod h1:TW2PYG0kSrfAos3yY8wUxDem0J9ZYSXulnbzBZ4BTaE= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.13.0 h1:+7cw7kFNw2dZ3opWDusuW+GC+3lwf8mjpHtIvi2+wqs= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.13.0/go.mod h1:yNxGU6mwDzXX82/QfgxRzg2M3+ktVNKS1qPzT01QIi0= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.12.0 h1:IIvEF3DBLuPF38vvBfKWKaOTWKH9NjTpybd1pfAymb0= diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 477695495..d8a4e4d48 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -32,6 +32,7 @@ const ( loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" + modelExperimentsCustomEndpointFlag = "model-experiments-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" objectStorageCustomEndpointFlag = "object-storage-custom-endpoint" observabilityCustomEndpointFlag = "observability-custom-endpoint" @@ -157,6 +158,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(loadBalancerCustomEndpointFlag, "", "Load Balancer API base URL, used in calls to this API") cmd.Flags().String(logMeCustomEndpointFlag, "", "LogMe API base URL, used in calls to this API") cmd.Flags().String(mariaDBCustomEndpointFlag, "", "MariaDB API base URL, used in calls to this API") + cmd.Flags().String(modelExperimentsCustomEndpointFlag, "", "Model Experiments API base URL, used in calls to this API") cmd.Flags().String(mongoDBFlexCustomEndpointFlag, "", "MongoDB Flex API base URL, used in calls to this API") cmd.Flags().String(objectStorageCustomEndpointFlag, "", "Object Storage API base URL, used in calls to this API") cmd.Flags().String(openSearchCustomEndpointFlag, "", "OpenSearch API base URL, used in calls to this API") @@ -210,6 +212,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.MariaDBCustomEndpointKey, cmd.Flags().Lookup(mariaDBCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.ModelExperimentsCustomEndpointKey, cmd.Flags().Lookup(modelExperimentsCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.MongoDBFlexCustomEndpointKey, cmd.Flags().Lookup(mongoDBFlexCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.ObjectStorageCustomEndpointKey, cmd.Flags().Lookup(objectStorageCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 57600ecea..29e3a274b 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -35,6 +35,7 @@ const ( loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" + modelExperimentsCustomEndpointFlag = "model-experiments-custom-endpoint" mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" objectStorageCustomEndpointFlag = "object-storage-custom-endpoint" observabilityCustomEndpointFlag = "observability-custom-endpoint" @@ -89,6 +90,7 @@ type inputModel struct { LoadBalancerCustomEndpoint bool LogMeCustomEndpoint bool MariaDBCustomEndpoint bool + ModelExperimentsCustomEndpoint bool MongoDBFlexCustomEndpoint bool ObjectStorageCustomEndpoint bool ObservabilityCustomEndpoint bool @@ -196,6 +198,9 @@ func NewCmd(params *types.CmdParams) *cobra.Command { if model.MariaDBCustomEndpoint { viper.Set(config.MariaDBCustomEndpointKey, "") } + if model.ModelExperimentsCustomEndpoint { + viper.Set(config.ModelExperimentsCustomEndpointKey, "") + } if model.MongoDBFlexCustomEndpoint { viper.Set(config.MongoDBFlexCustomEndpointKey, "") } @@ -305,6 +310,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(loadBalancerCustomEndpointFlag, false, "Load Balancer API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logMeCustomEndpointFlag, false, "LogMe API base URL. If unset, uses the default base URL") cmd.Flags().Bool(mariaDBCustomEndpointFlag, false, "MariaDB API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(modelExperimentsCustomEndpointFlag, false, "Model Experiments API base URL. If unset, uses the default base URL") cmd.Flags().Bool(mongoDBFlexCustomEndpointFlag, false, "MongoDB Flex API base URL. If unset, uses the default base URL") cmd.Flags().Bool(objectStorageCustomEndpointFlag, false, "Object Storage API base URL. If unset, uses the default base URL") cmd.Flags().Bool(openSearchCustomEndpointFlag, false, "OpenSearch API base URL. If unset, uses the default base URL") @@ -354,6 +360,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { LoadBalancerCustomEndpoint: flags.FlagToBoolValue(p, cmd, loadBalancerCustomEndpointFlag), LogMeCustomEndpoint: flags.FlagToBoolValue(p, cmd, logMeCustomEndpointFlag), MariaDBCustomEndpoint: flags.FlagToBoolValue(p, cmd, mariaDBCustomEndpointFlag), + ModelExperimentsCustomEndpoint: flags.FlagToBoolValue(p, cmd, modelExperimentsCustomEndpointFlag), MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, mongoDBFlexCustomEndpointFlag), ObjectStorageCustomEndpoint: flags.FlagToBoolValue(p, cmd, objectStorageCustomEndpointFlag), ObservabilityCustomEndpoint: flags.FlagToBoolValue(p, cmd, observabilityCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 55008562d..1c5b5966b 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -22,37 +22,38 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool identityProviderCustomClientIdFlag: true, allowedUrlDomainFlag: true, - authorizationCustomEndpointFlag: true, - automationCustomEndpointFlag: true, - albWafCustomEndpointFlag: true, - dnsCustomEndpointFlag: true, - edgeCustomEndpointFlag: true, - loadBalancerCustomEndpointFlag: true, - logMeCustomEndpointFlag: true, - mariaDBCustomEndpointFlag: true, - objectStorageCustomEndpointFlag: true, - observabilityCustomEndpointFlag: true, - openSearchCustomEndpointFlag: true, - rabbitMQCustomEndpointFlag: true, - redisCustomEndpointFlag: true, - resourceManagerCustomEndpointFlag: true, - secretsManagerCustomEndpointFlag: true, - kmsCustomEndpointFlag: true, - serviceAccountCustomEndpointFlag: true, - serverBackupCustomEndpointFlag: true, - serverOsUpdateCustomEndpointFlag: true, - runCommandCustomEndpointFlag: true, - scfCustomEndpointFlag: true, - sfsCustomEndpointFlag: true, - skeCustomEndpointFlag: true, - sqlServerFlexCustomEndpointFlag: true, - iaasAlphaCustomEndpointFlag: true, - iaasCustomEndpointFlag: true, - tokenCustomEndpointFlag: true, - intakeCustomEndpointFlag: true, - logsCustomEndpointFlag: true, - cdnCustomEndpointFlag: true, - vpnCustomEndpointFlag: true, + authorizationCustomEndpointFlag: true, + automationCustomEndpointFlag: true, + albWafCustomEndpointFlag: true, + dnsCustomEndpointFlag: true, + edgeCustomEndpointFlag: true, + loadBalancerCustomEndpointFlag: true, + logMeCustomEndpointFlag: true, + mariaDBCustomEndpointFlag: true, + modelExperimentsCustomEndpointFlag: true, + objectStorageCustomEndpointFlag: true, + observabilityCustomEndpointFlag: true, + openSearchCustomEndpointFlag: true, + rabbitMQCustomEndpointFlag: true, + redisCustomEndpointFlag: true, + resourceManagerCustomEndpointFlag: true, + secretsManagerCustomEndpointFlag: true, + kmsCustomEndpointFlag: true, + serviceAccountCustomEndpointFlag: true, + serverBackupCustomEndpointFlag: true, + serverOsUpdateCustomEndpointFlag: true, + runCommandCustomEndpointFlag: true, + scfCustomEndpointFlag: true, + sfsCustomEndpointFlag: true, + skeCustomEndpointFlag: true, + sqlServerFlexCustomEndpointFlag: true, + iaasAlphaCustomEndpointFlag: true, + iaasCustomEndpointFlag: true, + tokenCustomEndpointFlag: true, + intakeCustomEndpointFlag: true, + logsCustomEndpointFlag: true, + cdnCustomEndpointFlag: true, + vpnCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -73,37 +74,38 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { IdentityProviderCustomClientID: true, AllowedUrlDomain: true, - AuthorizationCustomEndpoint: true, - AutomationCustomEndpoint: true, - AlbWafCustomEndpoint: true, - DNSCustomEndpoint: true, - EdgeCustomEndpoint: true, - LoadBalancerCustomEndpoint: true, - LogMeCustomEndpoint: true, - MariaDBCustomEndpoint: true, - ObjectStorageCustomEndpoint: true, - ObservabilityCustomEndpoint: true, - OpenSearchCustomEndpoint: true, - RabbitMQCustomEndpoint: true, - RedisCustomEndpoint: true, - ResourceManagerCustomEndpoint: true, - SecretsManagerCustomEndpoint: true, - KMSCustomEndpoint: true, - ServiceAccountCustomEndpoint: true, - ServerBackupCustomEndpoint: true, - ServerOsUpdateCustomEndpoint: true, - RunCommandCustomEndpoint: true, - ScfCustomEndpoint: true, - SfsCustomEndpoint: true, - SKECustomEndpoint: true, - SQLServerFlexCustomEndpoint: true, - IaaSAlphaCustomEndpoint: true, - IaaSCustomEndpoint: true, - TokenCustomEndpoint: true, - IntakeCustomEndpoint: true, - LogsCustomEndpoint: true, - CDNCustomEndpoint: true, - VpnCustomEndpoint: true, + AuthorizationCustomEndpoint: true, + AutomationCustomEndpoint: true, + AlbWafCustomEndpoint: true, + DNSCustomEndpoint: true, + EdgeCustomEndpoint: true, + LoadBalancerCustomEndpoint: true, + LogMeCustomEndpoint: true, + MariaDBCustomEndpoint: true, + ModelExperimentsCustomEndpoint: true, + ObjectStorageCustomEndpoint: true, + ObservabilityCustomEndpoint: true, + OpenSearchCustomEndpoint: true, + RabbitMQCustomEndpoint: true, + RedisCustomEndpoint: true, + ResourceManagerCustomEndpoint: true, + SecretsManagerCustomEndpoint: true, + KMSCustomEndpoint: true, + ServiceAccountCustomEndpoint: true, + ServerBackupCustomEndpoint: true, + ServerOsUpdateCustomEndpoint: true, + RunCommandCustomEndpoint: true, + ScfCustomEndpoint: true, + SfsCustomEndpoint: true, + SKECustomEndpoint: true, + SQLServerFlexCustomEndpoint: true, + IaaSAlphaCustomEndpoint: true, + IaaSCustomEndpoint: true, + TokenCustomEndpoint: true, + IntakeCustomEndpoint: true, + LogsCustomEndpoint: true, + CDNCustomEndpoint: true, + VpnCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -148,6 +150,7 @@ func TestParseInput(t *testing.T) { model.LoadBalancerCustomEndpoint = false model.LogMeCustomEndpoint = false model.MariaDBCustomEndpoint = false + model.ModelExperimentsCustomEndpoint = false model.ObjectStorageCustomEndpoint = false model.ObservabilityCustomEndpoint = false model.OpenSearchCustomEndpoint = false @@ -303,6 +306,16 @@ func TestParseInput(t *testing.T) { model.ServiceAccountCustomEndpoint = false }), }, + { + description: "model experiments custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[modelExperimentsCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ModelExperimentsCustomEndpoint = false + }), + }, { description: "scf custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { diff --git a/internal/cmd/modelexperiments/instance/create/create.go b/internal/cmd/modelexperiments/instance/create/create.go new file mode 100644 index 000000000..7e731c4b4 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/create/create.go @@ -0,0 +1,168 @@ +package create + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api/wait" +) + +const ( + nameFlag = "name" + descriptionFlag = "description" + labelsFlag = "labels" + retentionFlag = "deleted-experiment-retention" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Name string + Description *string + Labels *map[string]string + Retention *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an AI Model Experiments instance", + Long: "Creates an AI Model Experiments (MLflow) instance in your STACKIT project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an AI Model Experiments instance with name "my-tracking"`, + `$ stackit model-experiments instance create --name my-tracking`), + examples.NewExample( + `Create an instance with a description and labels`, + `$ stackit model-experiments instance create --name my-tracking --description "team tracking server" --labels env=prod,team=ml`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + 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 + } + + prompt := fmt.Sprintf("Are you sure you want to create an AI Model Experiments instance for project %q?", projectLabel) + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create AI Model Experiments instance: %w", err) + } + if resp == nil { + return fmt.Errorf("create AI Model Experiments instance: response instance is nil") + } + + if !model.Async { + err := spinner.Run(params.Printer, "Creating AI Model Experiments instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.Region, model.ProjectId, resp.Instance.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for AI Model Experiments instance creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Instance name") + cmd.Flags().String(descriptionFlag, "", "Instance description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml"`) + cmd.Flags().String(retentionFlag, "", `Retention period for deleted experiments before permanent purge, e.g. "30d" (min 1d, max 90d)`) + err := flags.MarkFlagsRequired(cmd, nameFlag) + 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, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + Retention: flags.FlagToStringPointer(p, cmd, retentionFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiCreateInstanceRequest { + req := apiClient.DefaultAPI.CreateInstance(ctx, model.ProjectId, model.Region) + + payload := modelexperiments.CreateInstancePayload{ + Name: model.Name, + } + if model.Description != nil { + payload.Description = model.Description + } + if model.Labels != nil { + payload.Labels = model.Labels + } + if model.Retention != nil { + payload.DeletedExperimentRetention = model.Retention + } + + return req.CreateInstancePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *modelexperiments.CreateInstanceResponse) error { + var instance any + if resp != nil { + instance = resp.Instance + } + + return p.OutputResult(outputFormat, instance, func() error { + if resp == nil { + return fmt.Errorf("response instance is nil") + } + + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s AI Model Experiments instance for project %q. Instance ID: %s\n", operationState, projectLabel, resp.Instance.Id) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/instance/create/create_test.go b/internal/cmd/modelexperiments/instance/create/create_test.go new file mode 100644 index 000000000..92e4b28b5 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/create/create_test.go @@ -0,0 +1,205 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "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" +) + +type testCtxKey struct{} + +const testRegion = "eu01" + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "example", + 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, + }, + Name: "example", + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *modelexperiments.ApiCreateInstanceRequest)) modelexperiments.ApiCreateInstanceRequest { + request := testClient.DefaultAPI.CreateInstance(testCtx, testProjectId, testRegion) + request = request.CreateInstancePayload(modelexperiments.CreateInstancePayload{ + Name: "example", + }) + 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: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "with description and retention", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "team tracking server" + flagValues[retentionFlag] = "30d" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("team tracking server") + model.Retention = utils.Ptr("30d") + }), + }, + { + description: "with labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "env=prod,team=ml" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + 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 TestBuildCreateInstanceRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest modelexperiments.ApiCreateInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with description", + model: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("team tracking server") + }), + expectedRequest: fixtureRequest(func(request *modelexperiments.ApiCreateInstanceRequest) { + payload := modelexperiments.CreateInstancePayload{ + Name: "example", + Description: utils.Ptr("team tracking server"), + } + *request = request.CreateInstancePayload(payload) + }), + }, + { + description: "with labels", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + }), + expectedRequest: fixtureRequest(func(request *modelexperiments.ApiCreateInstanceRequest) { + payload := modelexperiments.CreateInstancePayload{ + Name: "example", + Labels: &map[string]string{"env": "prod", "team": "ml"}, + } + *request = request.CreateInstancePayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + resp *modelexperiments.CreateInstanceResponse + wantErr bool + }{ + { + name: "nil response", + resp: nil, + wantErr: true, + }, + { + name: "empty response", + resp: &modelexperiments.CreateInstanceResponse{}, + wantErr: false, + }, + } + params := testparams.NewTestParams() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, "", false, "test-project", tt.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/modelexperiments/instance/delete/delete.go b/internal/cmd/modelexperiments/instance/delete/delete.go new file mode 100644 index 000000000..5e1ca6647 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/delete/delete.go @@ -0,0 +1,124 @@ +package delete + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes an AI Model Experiments instance", + Long: "Deletes an AI Model Experiments instance from a STACKIT project.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an AI Model Experiments instance with ID "xxx"`, + `$ stackit model-experiments 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 + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf( + "Are you sure you want to delete AI Model Experiments instance %q?", + model.InstanceId, + ) + + if err := params.Printer.PromptForConfirmation(prompt); err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("delete AI Model Experiments instance: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, "Deleting AI Model Experiments instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient.DefaultAPI, model.Region, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for AI Model Experiments instance deletion: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, 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, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: inputArgs[0], + } + + p.DebugInputModel(model) + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiDeleteInstanceRequest { + return apiClient.DefaultAPI.DeleteInstance(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resp *modelexperiments.DeleteInstanceResponse) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp.Instance, func() error { + operationState := "Deleted" + if async { + operationState = "Triggered deletion of" + } + p.Outputf("%s AI Model Experiments instance.\n", operationState) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/instance/delete/delete_test.go b/internal/cmd/modelexperiments/instance/delete/delete_test.go new file mode 100644 index 000000000..b8063b1d4 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/delete/delete_test.go @@ -0,0 +1,183 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const testRegion = "eu01" + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(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(*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(*modelexperiments.ApiDeleteInstanceRequest)) modelexperiments.ApiDeleteInstanceRequest { + request := testClient.DefaultAPI.DeleteInstance( + 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: "project id missing", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + 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 TestBuildDeleteInstanceRequest(t *testing.T) { + request := buildRequest( + testCtx, + fixtureInputModel(), + testClient, + ) + + expectedRequest := fixtureRequest() + + diff := cmp.Diff( + request, + expectedRequest, + cmp.AllowUnexported(expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + resp *modelexperiments.DeleteInstanceResponse + async bool + wantErr bool + wantOutput string + }{ + { + name: "nil response", + resp: nil, + wantErr: true, + }, + { + name: "response", + resp: &modelexperiments.DeleteInstanceResponse{}, + wantOutput: "Deleted AI Model Experiments instance.\n", + }, + { + name: "async response", + resp: &modelexperiments.DeleteInstanceResponse{}, + async: true, + wantOutput: "Triggered deletion of AI Model Experiments instance.\n", + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params.Out.Reset() + if err := outputResult( + params.Printer, + "", + tt.async, + tt.resp, + ); (err != nil) != tt.wantErr { + t.Errorf( + "outputResult() error = %v, wantErr %v", + err, + tt.wantErr, + ) + } + if got := params.Out.String(); got != tt.wantOutput { + t.Errorf("outputResult() output = %q, want %q", got, tt.wantOutput) + } + }) + } +} diff --git a/internal/cmd/modelexperiments/instance/describe/describe.go b/internal/cmd/modelexperiments/instance/describe/describe.go new file mode 100644 index 000000000..bcd6d3c4b --- /dev/null +++ b/internal/cmd/modelexperiments/instance/describe/describe.go @@ -0,0 +1,116 @@ +package describe + +import ( + "context" + "fmt" + + "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/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/modelexperiments/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" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Shows details of an AI Model Experiments instance", + Long: "Shows details of an AI Model Experiments instance in a STACKIT project.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show details of an AI Model Experiments instance with ID "xxx"`, + `$ stackit model-experiments instance describe xxx`, + ), + examples.NewExample( + `Get details of an AI Model Experiments instance with ID "xxx" in JSON format`, + `$ stackit model-experiments 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("describe AI Model Experiments 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, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: inputArgs[0], + } + + p.DebugInputModel(model) + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiGetInstanceRequest { + return apiClient.DefaultAPI.GetInstance(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.GetInstanceResponse) error { + if resp == nil { + return fmt.Errorf("response instance is nil") + } + + return p.OutputResult(outputFormat, resp.Instance, func() error { + table := tables.NewTable() + table.AddRow("ID", resp.Instance.Id) + table.AddSeparator() + table.AddRow("NAME", resp.Instance.Name) + table.AddSeparator() + table.AddRow("REGION", utils.PtrString(resp.Instance.Region)) + table.AddSeparator() + table.AddRow("STATUS", resp.Instance.State) + table.AddSeparator() + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/modelexperiments/instance/describe/describe_test.go b/internal/cmd/modelexperiments/instance/describe/describe_test.go new file mode 100644 index 000000000..f8e184005 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/describe/describe_test.go @@ -0,0 +1,211 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const testRegion = "eu01" + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(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 fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + + for _, mod := range mods { + mod(argValues) + } + + return argValues +} + +func fixtureInputModel(mods ...func(*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(*modelexperiments.ApiGetInstanceRequest)) modelexperiments.ApiGetInstanceRequest { + request := testClient.DefaultAPI.GetInstance( + 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(), + 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, + }, + } + + 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 TestBuildDescribeInstanceRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest modelexperiments.ApiGetInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff( + request, + tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + resp *modelexperiments.GetInstanceResponse + wantErr bool + }{ + { + name: "nil response", + resp: nil, + wantErr: true, + }, + { + name: "empty instance", + resp: &modelexperiments.GetInstanceResponse{ + Instance: modelexperiments.Instance{}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult( + params.Printer, + "", + tt.resp, + ); (err != nil) != tt.wantErr { + t.Errorf( + "outputResult() error = %v, wantErr %v", + err, + tt.wantErr, + ) + } + }) + } +} diff --git a/internal/cmd/modelexperiments/instance/instance.go b/internal/cmd/modelexperiments/instance/instance.go new file mode 100644 index 000000000..9b39d3704 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/instance.go @@ -0,0 +1,36 @@ +package instance + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/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" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for AI Model Experiments instances", + Long: "Provides functionality for AI Model Experiments instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + + addSubcommands(cmd, params) + + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/modelexperiments/instance/list/list.go b/internal/cmd/modelexperiments/instance/list/list.go new file mode 100644 index 000000000..6e1c1df21 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/list/list.go @@ -0,0 +1,155 @@ +package list + +import ( + "context" + "fmt" + + "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/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/modelexperiments/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" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const limitFlag = "limit" + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all AI Model Experiments instances", + Long: "Lists all AI Model Experiments instances in a STACKIT project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List AI Model Experiments instances in a project`, + `$ stackit model-experiments instance list`, + ), + examples.NewExample( + `List AI Model Experiments instances in a project in region eu02`, + `$ stackit model-experiments instance list --region eu02`, + ), + examples.NewExample( + `List AI Model Experiments instances in JSON format`, + `$ stackit model-experiments instance list --output-format json`, + ), + examples.NewExample( + `List up to 10 AI Model Experiments instances`, + `$ stackit model-experiments 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 + } + + 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("list AI Model Experiments instances: %w", err) + } + + applyLimit(resp, model.Limit) + + 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 + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + return cmd +} + +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, + } + + p.DebugInputModel(model) + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiListInstancesRequest { + return apiClient.DefaultAPI.ListInstances(ctx, model.ProjectId, model.Region) +} + +func applyLimit(resp *modelexperiments.ListInstancesResponse, limit *int64) { + if resp == nil || limit == nil || int64(len(resp.Instances)) <= *limit { + return + } + + resp.Instances = resp.Instances[:int(*limit)] +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *modelexperiments.ListInstancesResponse) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp.Instances, func() error { + if len(resp.Instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "ARTIFACT STORAGE NAME", "REGION", "STATUS") + + for _, instance := range resp.Instances { + table.AddRow( + instance.Id, + instance.Name, + instance.GetBucketName(), + utils.PtrString(instance.Region), + instance.State, + ) + } + + return table.Display(p) + }) +} diff --git a/internal/cmd/modelexperiments/instance/list/list_test.go b/internal/cmd/modelexperiments/instance/list/list_test.go new file mode 100644 index 000000000..72e5c7c5d --- /dev/null +++ b/internal/cmd/modelexperiments/instance/list/list_test.go @@ -0,0 +1,268 @@ +package list + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "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" +) + +type testCtxKey struct{} + +const testRegion = "eu01" + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(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(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + + for _, mod := range mods { + mod(model) + } + + return model +} + +func fixtureRequest(mods ...func(*modelexperiments.ApiListInstancesRequest)) modelexperiments.ApiListInstancesRequest { + request := testClient.DefaultAPI.ListInstances( + 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: "limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "10" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "limit must be greater than zero", + 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 TestBuildListInstancesRequest(t *testing.T) { + request := buildRequest( + testCtx, + fixtureInputModel(), + testClient, + ) + + expectedRequest := fixtureRequest() + + diff := cmp.Diff( + request, + expectedRequest, + cmp.AllowUnexported(expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestApplyLimit(t *testing.T) { + tests := []struct { + name string + instances []modelexperiments.Instance + limit *int64 + nilResp bool + wantCount int + }{ + { + name: "no limit", + instances: make([]modelexperiments.Instance, 3), + wantCount: 3, + }, + { + name: "limit below result count", + instances: make([]modelexperiments.Instance, 3), + limit: utils.Ptr(int64(2)), + wantCount: 2, + }, + { + name: "limit above result count", + instances: make([]modelexperiments.Instance, 2), + limit: utils.Ptr(int64(3)), + wantCount: 2, + }, + { + name: "nil response", + limit: utils.Ptr(int64(1)), + nilResp: true, + wantCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &modelexperiments.ListInstancesResponse{Instances: tt.instances} + if tt.nilResp { + resp = nil + } + + applyLimit(resp, tt.limit) + + if resp == nil { + if !tt.nilResp { + t.Fatal("applyLimit() unexpectedly returned a nil response") + } + return + } + if tt.nilResp { + t.Fatal("applyLimit() changed a nil response") + } + if got := len(resp.Instances); got != tt.wantCount { + t.Errorf("applyLimit() count = %d, want %d", got, tt.wantCount) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + resp *modelexperiments.ListInstancesResponse + wantErr bool + wantOutput []string + }{ + { + name: "nil response", + resp: nil, + wantErr: true, + }, + { + name: "instances", + resp: &modelexperiments.ListInstancesResponse{ + Instances: []modelexperiments.Instance{ + { + Id: "instance-id", + Name: "instance-name", + BucketName: utils.Ptr("model-experiments-bucket"), + Region: utils.Ptr(testRegion), + State: "READY", + }, + }, + }, + wantErr: false, + wantOutput: []string{"ID", "NAME", "instance-id", "instance-name", "model-experiments-bucket", "READY"}, + }, + { + name: "empty instances", + resp: &modelexperiments.ListInstancesResponse{ + Instances: []modelexperiments.Instance{}, + }, + wantErr: false, + wantOutput: []string{`No instances found for project "test-project"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult( + params.Printer, + "", + "test-project", + tt.resp, + ); (err != nil) != tt.wantErr { + t.Errorf( + "outputResult() error = %v, wantErr %v", + err, + tt.wantErr, + ) + } + + for _, expected := range tt.wantOutput { + if !strings.Contains(params.Out.String(), expected) { + t.Errorf("outputResult() output = %q, want it to contain %q", params.Out.String(), expected) + } + } + }) + } +} diff --git a/internal/cmd/modelexperiments/instance/update/update.go b/internal/cmd/modelexperiments/instance/update/update.go new file mode 100644 index 000000000..692b68085 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/update/update.go @@ -0,0 +1,186 @@ +package update + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + nameFlag = "name" + descriptionFlag = "description" + labelsFlag = "labels" + retentionFlag = "deleted-experiment-retention" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + + Name *string + Description *string + Labels *map[string]*string + Retention *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Updates an AI Model Experiments instance", + Long: "Partially updates an AI Model Experiments instance in a STACKIT project.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the name of an AI Model Experiments instance with ID "xxx"`, + `$ stackit model-experiments instance update xxx --name my-new-name`, + ), + examples.NewExample( + `Update the description of an AI Model Experiments instance`, + `$ stackit model-experiments instance update xxx --description "team tracking server"`, + ), + examples.NewExample( + `Update labels on an AI Model Experiments instance`, + `$ stackit model-experiments instance update xxx --labels env=prod,team=ml`, + ), + examples.NewExample( + `Update multiple fields of an AI Model Experiments instance`, + `$ stackit model-experiments instance update xxx --name my-new-name --description "team tracking server" --labels env=prod,team=ml --deleted-experiment-retention 30d`, + ), + ), + 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("update AI Model Experiments instance: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Instance name") + cmd.Flags().String(descriptionFlag, "", "Instance description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml"`) + cmd.Flags().String(retentionFlag, "", `Retention period for deleted experiments before permanent purge, e.g. "30d" (min 1d, max 90d)`) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + labels, err := cmd.Flags().GetStringToString(labelsFlag) + if err != nil { + return nil, fmt.Errorf("parse %q flag: %w", labelsFlag, err) + } + + var labelsPtr *map[string]*string + + if len(labels) > 0 { + convertedLabels := make(map[string]*string, len(labels)) + + for key, value := range labels { + valueCopy := value + convertedLabels[key] = &valueCopy + } + + labelsPtr = &convertedLabels + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + + InstanceId: inputArgs[0], + + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: labelsPtr, + Retention: flags.FlagToStringPointer(p, cmd, retentionFlag), + } + + if model.Name == nil && + model.Description == nil && + model.Labels == nil && + model.Retention == nil { + return nil, fmt.Errorf("at least one update flag must be provided") + } + + p.DebugInputModel(model) + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiPartialUpdateInstanceRequest { + req := apiClient.DefaultAPI.PartialUpdateInstance(ctx, model.ProjectId, model.Region, model.InstanceId) + + payload := modelexperiments.PartialUpdateInstancePayload{} + + if model.Name != nil { + payload.Name = model.Name + } + + if model.Description != nil { + payload.Description = model.Description + } + + if model.Labels != nil { + payload.Labels = model.Labels + } + + if model.Retention != nil { + payload.DeletedExperimentRetention = model.Retention + } + + return req.PartialUpdateInstancePayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.PartialUpdateInstanceResponse) error { + if resp == nil { + return fmt.Errorf("response instance is nil") + } + + return p.OutputResult(outputFormat, resp.Instance, func() error { + p.Outputf( + "Updated AI Model Experiments instance. Instance ID: %s\n", + resp.Instance.Id, + ) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/instance/update/update_test.go b/internal/cmd/modelexperiments/instance/update/update_test.go new file mode 100644 index 000000000..8ccd47b78 --- /dev/null +++ b/internal/cmd/modelexperiments/instance/update/update_test.go @@ -0,0 +1,313 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "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" +) + +type testCtxKey struct{} + +const testRegion = "eu01" + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(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(*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(*modelexperiments.ApiPartialUpdateInstanceRequest)) modelexperiments.ApiPartialUpdateInstanceRequest { + request := testClient.DefaultAPI.PartialUpdateInstance( + testCtx, + testProjectId, + testRegion, + testInstanceId, + ) + + payload := modelexperiments.PartialUpdateInstancePayload{ + Name: utils.Ptr("example"), + } + + request = request.PartialUpdateInstancePayload(payload) + + 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: "name", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nameFlag] = "example" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("example") + }), + }, + { + description: "description", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "team tracking server" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("team tracking server") + }), + }, + { + description: "labels", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelsFlag] = "env=prod,team=ml" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + labels := map[string]*string{ + "env": utils.Ptr("prod"), + "team": utils.Ptr("ml"), + } + model.Labels = &labels + }), + }, + { + description: "retention", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[retentionFlag] = "30d" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Retention = utils.Ptr("30d") + }), + }, + { + description: "no update flags", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + flagValues[nameFlag] = "example" + }), + 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 TestBuildUpdateInstanceRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest modelexperiments.ApiPartialUpdateInstanceRequest + }{ + { + description: "name", + model: fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("example") + }), + expectedRequest: fixtureRequest(), + }, + { + description: "description", + model: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("team tracking server") + }), + expectedRequest: func() modelexperiments.ApiPartialUpdateInstanceRequest { + request := testClient.DefaultAPI.PartialUpdateInstance( + testCtx, + testProjectId, + testRegion, + testInstanceId, + ) + + request = request.PartialUpdateInstancePayload( + modelexperiments.PartialUpdateInstancePayload{ + Description: utils.Ptr("team tracking server"), + }, + ) + + return request + }(), + }, + { + description: "labels", + model: fixtureInputModel(func(model *inputModel) { + labels := map[string]*string{ + "env": utils.Ptr("prod"), + "team": utils.Ptr("ml"), + } + model.Labels = &labels + }), + expectedRequest: func() modelexperiments.ApiPartialUpdateInstanceRequest { + request := testClient.DefaultAPI.PartialUpdateInstance( + testCtx, + testProjectId, + testRegion, + testInstanceId, + ) + + labels := map[string]*string{ + "env": utils.Ptr("prod"), + "team": utils.Ptr("ml"), + } + return request.PartialUpdateInstancePayload( + modelexperiments.PartialUpdateInstancePayload{Labels: &labels}, + ) + }(), + }, + { + description: "retention", + model: fixtureInputModel(func(model *inputModel) { + model.Retention = utils.Ptr("30d") + }), + expectedRequest: func() modelexperiments.ApiPartialUpdateInstanceRequest { + request := testClient.DefaultAPI.PartialUpdateInstance( + testCtx, + testProjectId, + testRegion, + testInstanceId, + ) + + request = request.PartialUpdateInstancePayload( + modelexperiments.PartialUpdateInstancePayload{ + DeletedExperimentRetention: utils.Ptr("30d"), + }, + ) + + return request + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest( + testCtx, + tt.model, + testClient, + ) + + diff := cmp.Diff( + request, + tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + resp *modelexperiments.PartialUpdateInstanceResponse + wantErr bool + }{ + { + name: "nil response", + resp: nil, + wantErr: true, + }, + { + name: "empty instance", + resp: &modelexperiments.PartialUpdateInstanceResponse{ + Instance: modelexperiments.Instance{}, + }, + wantErr: false, + }, + } + + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult( + params.Printer, + "", + tt.resp, + ); (err != nil) != tt.wantErr { + t.Errorf( + "outputResult() error = %v, wantErr %v", + err, + tt.wantErr, + ) + } + }) + } +} diff --git a/internal/cmd/modelexperiments/modelexperiments.go b/internal/cmd/modelexperiments/modelexperiments.go new file mode 100644 index 000000000..50b47ea84 --- /dev/null +++ b/internal/cmd/modelexperiments/modelexperiments.go @@ -0,0 +1,30 @@ +package modelexperiments + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/token" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "model-experiments", + Short: "Provides functionality for AI Model Experiments", + Long: "Provides functionality for AI Model Experiments.", + 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(token.NewCmd(params)) +} diff --git a/internal/cmd/modelexperiments/token/create/create.go b/internal/cmd/modelexperiments/token/create/create.go new file mode 100644 index 000000000..6d11bb15e --- /dev/null +++ b/internal/cmd/modelexperiments/token/create/create.go @@ -0,0 +1,105 @@ +package create + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + instanceIDFlag = "instance-id" + nameFlag = "name" + descriptionFlag = "description" + labelsFlag = "labels" + ttlDurationFlag = "ttl-duration" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string + Name string + Description *string + Labels *map[string]string + TTLDuration *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{Use: "create", Short: "Creates an instance token", Long: "Creates an auth token for an AI Model Experiments instance.", Args: args.NoArgs, Example: examples.Build(examples.NewExample("Create an auth token", "$ stackit model-experiments token create --instance-id xxx --name my-token")), RunE: func(cmd *cobra.Command, _ []string) error { + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + resp, err := buildRequest(context.Background(), model, apiClient).Execute() + if err != nil { + return fmt.Errorf("create instance 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 instance") + cmd.Flags().String(nameFlag, "", "Token name") + cmd.Flags().String(descriptionFlag, "", "Token description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml"`) + cmd.Flags().String(ttlDurationFlag, "", "Token time to live duration") + _ = flags.MarkFlagsRequired(cmd, instanceIDFlag, nameFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + labels, err := cmd.Flags().GetStringToString(labelsFlag) + if err != nil { + return nil, fmt.Errorf("parse %q flag: %w", labelsFlag, err) + } + var labelsPtr *map[string]string + if len(labels) > 0 { + labelsPtr = &labels + } + model := &inputModel{GlobalFlagModel: globalFlags, InstanceID: flags.FlagToStringValue(p, cmd, instanceIDFlag), Name: flags.FlagToStringValue(p, cmd, nameFlag), Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), Labels: labelsPtr, TTLDuration: flags.FlagToStringPointer(p, cmd, ttlDurationFlag)} + if model.Name == "" { + return nil, fmt.Errorf("%s flag is required", nameFlag) + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiCreateInstanceTokenRequest { + payload := modelexperiments.CreateInstanceTokenPayload{Name: model.Name, Description: model.Description, Labels: model.Labels, TtlDuration: model.TTLDuration} + return apiClient.DefaultAPI.CreateInstanceToken(ctx, model.ProjectId, model.GlobalFlagModel.Region, model.InstanceID).CreateInstanceTokenPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.CreateInstanceTokenResponse) error { + if resp == nil || resp.Token.Name == "" { + return fmt.Errorf("response token is nil") + } + return p.OutputResult(outputFormat, resp.Token, func() error { + p.Outputf( + "Created instance token. ID: %s\nToken: %s\n", + resp.Token.Id, + resp.Token.Content, + ) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/token/create/create_test.go b/internal/cmd/modelexperiments/token/create/create_test.go new file mode 100644 index 000000000..7cd8fea27 --- /dev/null +++ b/internal/cmd/modelexperiments/token/create/create_test.go @@ -0,0 +1,98 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "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" +) + +type testContextKey struct{} + +var testContext = context.WithValue(context.Background(), testContextKey{}, "token-create") +var testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} +var testProjectID = uuid.NewString() +var testInstanceID = uuid.NewString() + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + values := map[string]string{globalflags.ProjectIdFlag: testProjectID, instanceIDFlag: testInstanceID, globalflags.RegionFlag: "eu01", nameFlag: "example"} + for _, mod := range mods { + mod(values) + } + return values +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectID, Region: "eu01", Verbosity: globalflags.VerbosityDefault}, InstanceID: testInstanceID, Name: "example"} + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(*modelexperiments.ApiCreateInstanceTokenRequest)) modelexperiments.ApiCreateInstanceTokenRequest { + request := testClient.DefaultAPI.CreateInstanceToken(testContext, testProjectID, "eu01", testInstanceID).CreateInstanceTokenPayload(modelexperiments.CreateInstanceTokenPayload{Name: "example"}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + flags map[string]string + valid bool + want *inputModel + }{ + {name: "base", flags: fixtureFlagValues(), valid: true, want: fixtureInputModel()}, + {name: "optional fields", flags: fixtureFlagValues(func(values map[string]string) { + values[descriptionFlag] = "service token" + values[labelsFlag] = "env=prod,team=ml" + values[ttlDurationFlag] = "5h" + }), valid: true, want: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("service token") + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + model.TTLDuration = utils.Ptr("5h") + })}, + {name: "name missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, nameFlag) }), valid: false}, + {name: "project missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, globalflags.ProjectIdFlag) }), valid: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { testutils.TestParseInput(t, NewCmd, parseInput, tt.want, nil, tt.flags, tt.valid) }) + } +} + +func TestBuildRequest(t *testing.T) { + model := fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("service token") + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + model.TTLDuration = utils.Ptr("5h") + }) + want := fixtureRequest(func(request *modelexperiments.ApiCreateInstanceTokenRequest) { + *request = request.CreateInstanceTokenPayload(modelexperiments.CreateInstanceTokenPayload{Name: "example", Description: utils.Ptr("service token"), Labels: &map[string]string{"env": "prod", "team": "ml"}, TtlDuration: utils.Ptr("5h")}) + }) + got := buildRequest(testContext, model, testClient) + if diff := cmp.Diff(got, want, cmp.AllowUnexported(want), cmpopts.EquateComparable(testContext)); diff != "" { + t.Fatalf("request mismatch (-got +want):\n%s", diff) + } +} + +func TestOutputResult(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult(params.Printer, "", nil); err == nil { + t.Fatal("expected nil response error") + } + if err := outputResult(params.Printer, "", &modelexperiments.CreateInstanceTokenResponse{}); err == nil { + t.Fatal("expected nil token error") + } +} diff --git a/internal/cmd/modelexperiments/token/delete/delete.go b/internal/cmd/modelexperiments/token/delete/delete.go new file mode 100644 index 000000000..b8d9ac716 --- /dev/null +++ b/internal/cmd/modelexperiments/token/delete/delete.go @@ -0,0 +1,80 @@ +package delete + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + tokenIDArg = "TOKEN_ID" + instanceIDFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TokenID string + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", tokenIDArg), + Short: "Deletes an instance token", + Long: "Deletes an auth token from an AI Model Experiments instance.", + Args: args.SingleArg(tokenIDArg, utils.ValidateUUID), + Example: examples.Build(examples.NewExample(`Delete an auth token with ID "xxx"`, `$ stackit model-experiments token delete xxx --instance-id yyy`)), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + if err := params.Printer.PromptForConfirmation(fmt.Sprintf("Are you sure you want to delete instance token %q?", model.TokenID)); err != nil { + return err + } + if _, err := buildRequest(context.Background(), model, apiClient).Execute(); err != nil { + return fmt.Errorf("delete instance token: %w", err) + } + params.Printer.Outputf("Deleted instance token %q\n", model.TokenID) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIDFlag, "ID of the instance") + _ = flags.MarkFlagsRequired(cmd, instanceIDFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + model := &inputModel{GlobalFlagModel: globalFlags, TokenID: inputArgs[0], InstanceID: flags.FlagToStringValue(p, cmd, instanceIDFlag)} + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiDeleteInstanceTokenRequest { + return apiClient.DefaultAPI.DeleteInstanceToken(ctx, model.ProjectId, model.Region, model.TokenID, model.InstanceID) +} diff --git a/internal/cmd/modelexperiments/token/delete/delete_test.go b/internal/cmd/modelexperiments/token/delete/delete_test.go new file mode 100644 index 000000000..d0b12acda --- /dev/null +++ b/internal/cmd/modelexperiments/token/delete/delete_test.go @@ -0,0 +1,61 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testContextKey struct{} + +var testContext = context.WithValue(context.Background(), testContextKey{}, "token-delete") +var testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} +var testProjectID = uuid.NewString() +var testInstanceID = uuid.NewString() +var testTokenID = uuid.NewString() + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + values := map[string]string{globalflags.ProjectIdFlag: testProjectID, instanceIDFlag: testInstanceID, globalflags.RegionFlag: "eu01"} + for _, mod := range mods { + mod(values) + } + return values +} +func fixtureInputModel() *inputModel { + return &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectID, Region: "eu01", Verbosity: globalflags.VerbosityDefault}, TokenID: testTokenID, InstanceID: testInstanceID} +} +func fixtureRequest() modelexperiments.ApiDeleteInstanceTokenRequest { + return testClient.DefaultAPI.DeleteInstanceToken(testContext, testProjectID, "eu01", testTokenID, testInstanceID) +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + flags map[string]string + valid bool + }{{name: "base", flags: fixtureFlagValues(), valid: true}, {name: "project missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, globalflags.ProjectIdFlag) }), valid: false}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var want *inputModel + if tt.valid { + want = fixtureInputModel() + } + testutils.TestParseInput(t, NewCmd, parseInput, want, []string{testTokenID}, tt.flags, tt.valid) + }) + } +} +func TestBuildRequest(t *testing.T) { + got := buildRequest(testContext, fixtureInputModel(), testClient) + want := fixtureRequest() + if diff := cmp.Diff(got, want, cmp.AllowUnexported(want), cmpopts.EquateComparable(testContext)); diff != "" { + t.Fatalf("request mismatch (-got +want):\n%s", diff) + } +} diff --git a/internal/cmd/modelexperiments/token/describe/describe.go b/internal/cmd/modelexperiments/token/describe/describe.go new file mode 100644 index 000000000..b67d227dd --- /dev/null +++ b/internal/cmd/modelexperiments/token/describe/describe.go @@ -0,0 +1,87 @@ +package describe + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + tokenIDArg = "TOKEN_ID" + instanceIDFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TokenID string + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", tokenIDArg), + Short: "Shows details of an instance token", + Long: "Shows details of an auth token for an AI Model Experiments instance.", + Args: args.SingleArg(tokenIDArg, utils.ValidateUUID), + Example: examples.Build(examples.NewExample(`Show details of an auth token with ID "xxx"`, `$ stackit model-experiments token describe xxx --instance-id yyy`)), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + resp, err := buildRequest(context.Background(), model, apiClient).Execute() + if err != nil { + return fmt.Errorf("describe instance 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 instance") + _ = flags.MarkFlagsRequired(cmd, instanceIDFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + model := &inputModel{GlobalFlagModel: globalFlags, TokenID: inputArgs[0], InstanceID: flags.FlagToStringValue(p, cmd, instanceIDFlag)} + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiGetInstanceTokenRequest { + return apiClient.DefaultAPI.GetInstanceToken(ctx, model.ProjectId, model.Region, model.TokenID, model.InstanceID) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.GetInstanceTokenResponse) error { + if resp == nil || resp.Token.Name == "" { + return fmt.Errorf("response token is nil") + } + return p.OutputResult(outputFormat, resp.Token, func() error { + p.Outputf("Instance token %q (ID: %s)\n", resp.Token.Name, resp.Token.Id) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/token/describe/describe_test.go b/internal/cmd/modelexperiments/token/describe/describe_test.go new file mode 100644 index 000000000..08189dc89 --- /dev/null +++ b/internal/cmd/modelexperiments/token/describe/describe_test.go @@ -0,0 +1,73 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testContextKey struct{} + +var ( + testContext = context.WithValue(context.Background(), testContextKey{}, "token-get") + testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} + testProjectID = uuid.NewString() + testInstanceID = uuid.NewString() + testTokenID = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + values := map[string]string{globalflags.ProjectIdFlag: testProjectID, instanceIDFlag: testInstanceID, globalflags.RegionFlag: "eu01"} + for _, mod := range mods { + mod(values) + } + return values +} +func fixtureInputModel() *inputModel { + return &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectID, Region: "eu01", Verbosity: globalflags.VerbosityDefault}, TokenID: testTokenID, InstanceID: testInstanceID} +} +func fixtureRequest() modelexperiments.ApiGetInstanceTokenRequest { + return testClient.DefaultAPI.GetInstanceToken(testContext, testProjectID, "eu01", testTokenID, testInstanceID) +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + flags map[string]string + valid bool + }{{name: "base", flags: fixtureFlagValues(), valid: true}, {name: "project missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, globalflags.ProjectIdFlag) }), valid: false}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var want *inputModel + if tt.valid { + want = fixtureInputModel() + } + testutils.TestParseInput(t, NewCmd, parseInput, want, []string{testTokenID}, tt.flags, tt.valid) + }) + } +} +func TestBuildDescribeRequest(t *testing.T) { + got := buildRequest(testContext, fixtureInputModel(), testClient) + want := fixtureRequest() + if diff := cmp.Diff(got, want, cmp.AllowUnexported(want), cmpopts.EquateComparable(testContext)); diff != "" { + t.Fatalf("request mismatch (-got +want):\n%s", diff) + } +} +func TestOutputResult(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult(params.Printer, "", nil); err == nil { + t.Fatal("expected nil response error") + } + if err := outputResult(params.Printer, "", &modelexperiments.GetInstanceTokenResponse{}); err == nil { + t.Fatal("expected nil token error") + } +} diff --git a/internal/cmd/modelexperiments/token/list/list.go b/internal/cmd/modelexperiments/token/list/list.go new file mode 100644 index 000000000..a65ce6660 --- /dev/null +++ b/internal/cmd/modelexperiments/token/list/list.go @@ -0,0 +1,91 @@ +package list + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + instanceIDFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{Use: "list", Short: "Lists instance tokens", Long: "Lists all auth tokens for an AI Model Experiments instance.", Args: args.NoArgs, Example: examples.Build(examples.NewExample("List all tokens for an instance", "$ stackit model-experiments token list --instance-id xxx")), RunE: func(cmd *cobra.Command, _ []string) error { + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + resp, err := buildRequest(context.Background(), model, apiClient).Execute() + if err != nil { + return fmt.Errorf("list instance tokens: %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 instance") + _ = flags.MarkFlagsRequired(cmd, instanceIDFlag) +} + +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)} + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiListInstanceTokensRequest { + return apiClient.DefaultAPI.ListInstanceTokens(ctx, model.ProjectId, model.Region, model.InstanceID) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.ListInstanceTokensResponse) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + return p.OutputResult(outputFormat, resp.Tokens, func() error { + if len(resp.Tokens) == 0 { + p.Outputf("No instance tokens found\n") + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION") + for _, token := range resp.Tokens { + description := "" + if token.Description != nil { + description = *token.Description + } + table.AddRow(token.Id, token.Name, description) + } + return table.Display(p) + }) +} diff --git a/internal/cmd/modelexperiments/token/list/list_test.go b/internal/cmd/modelexperiments/token/list/list_test.go new file mode 100644 index 000000000..64abbbc4a --- /dev/null +++ b/internal/cmd/modelexperiments/token/list/list_test.go @@ -0,0 +1,72 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testContextKey struct{} + +var testContext = context.WithValue(context.Background(), testContextKey{}, "token-list") +var testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} +var testProjectID = uuid.NewString() +var testInstanceID = uuid.NewString() + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + values := map[string]string{globalflags.ProjectIdFlag: testProjectID, instanceIDFlag: testInstanceID, globalflags.RegionFlag: "eu01"} + for _, mod := range mods { + mod(values) + } + return values +} +func fixtureInputModel() *inputModel { + return &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectID, Region: "eu01", Verbosity: globalflags.VerbosityDefault}, InstanceID: testInstanceID} +} +func fixtureRequest() modelexperiments.ApiListInstanceTokensRequest { + return testClient.DefaultAPI.ListInstanceTokens(testContext, testProjectID, "eu01", testInstanceID) +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + flags map[string]string + valid bool + }{{name: "base", flags: fixtureFlagValues(), valid: true}, {name: "project missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, globalflags.ProjectIdFlag) }), valid: false}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var want *inputModel + if tt.valid { + want = fixtureInputModel() + } + testutils.TestParseInput(t, NewCmd, parseInput, want, nil, tt.flags, tt.valid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + got := buildRequest(testContext, fixtureInputModel(), testClient) + want := fixtureRequest() + if diff := cmp.Diff(got, want, cmp.AllowUnexported(want), cmpopts.EquateComparable(testContext)); diff != "" { + t.Fatalf("request mismatch (-got +want):\n%s", diff) + } +} + +func TestOutputResult(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult(params.Printer, "", nil); err == nil { + t.Fatal("expected nil response error") + } + if err := outputResult(params.Printer, "", &modelexperiments.ListInstanceTokensResponse{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/cmd/modelexperiments/token/token.go b/internal/cmd/modelexperiments/token/token.go new file mode 100644 index 000000000..8f056590a --- /dev/null +++ b/internal/cmd/modelexperiments/token/token.go @@ -0,0 +1,32 @@ +package token + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/token/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/token/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/token/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/token/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments/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" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Provides functionality for AI Model Experiments instance tokens", + Long: "Provides functionality for AI Model Experiments instance tokens.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + cmd.AddCommand( + list.NewCmd(params), + create.NewCmd(params), + delete.NewCmd(params), + describe.NewCmd(params), + update.NewCmd(params), + ) + return cmd +} diff --git a/internal/cmd/modelexperiments/token/update/update.go b/internal/cmd/modelexperiments/token/update/update.go new file mode 100644 index 000000000..5509689f6 --- /dev/null +++ b/internal/cmd/modelexperiments/token/update/update.go @@ -0,0 +1,109 @@ +package update + +import ( + "context" + "fmt" + + "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/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/modelexperiments/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +const ( + tokenIDArg = "TOKEN_ID" + instanceIDFlag = "instance-id" + nameFlag = "name" + descriptionFlag = "description" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + TokenID string + InstanceID string + Name *string + Description *string + Labels *map[string]string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", tokenIDArg), Short: "Updates an instance token", Long: "Partially updates an auth token for an AI Model Experiments instance.", Args: args.SingleArg(tokenIDArg, utils.ValidateUUID), + Example: examples.Build(examples.NewExample(`Update an auth token with ID "xxx"`, `$ stackit model-experiments token update xxx --instance-id yyy --name updated-token`)), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + resp, err := buildRequest(context.Background(), model, apiClient).Execute() + if err != nil { + return fmt.Errorf("update instance 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 instance") + cmd.Flags().String(nameFlag, "", "Token name") + cmd.Flags().String(descriptionFlag, "", "Token description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels as comma-separated key-value pairs, e.g. "--labels env=prod,team=ml"`) + _ = flags.MarkFlagsRequired(cmd, instanceIDFlag) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + labels, err := cmd.Flags().GetStringToString(labelsFlag) + if err != nil { + return nil, fmt.Errorf("parse %q flag: %w", labelsFlag, err) + } + var labelsPtr *map[string]string + if len(labels) > 0 { + labelsPtr = &labels + } + model := &inputModel{GlobalFlagModel: globalFlags, TokenID: inputArgs[0], InstanceID: flags.FlagToStringValue(p, cmd, instanceIDFlag), Name: flags.FlagToStringPointer(p, cmd, nameFlag), Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), Labels: labelsPtr} + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *modelexperiments.APIClient) modelexperiments.ApiPartialUpdateInstanceTokenRequest { + labels := map[string]*string(nil) + if model.Labels != nil { + labels = make(map[string]*string, len(*model.Labels)) + for key, value := range *model.Labels { + labels[key] = &value + } + } + payload := modelexperiments.PartialUpdateInstanceTokenPayload{Name: model.Name, Description: model.Description, Labels: &labels} + return apiClient.DefaultAPI.PartialUpdateInstanceToken(ctx, model.ProjectId, model.GlobalFlagModel.Region, model.TokenID, model.InstanceID).PartialUpdateInstanceTokenPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, resp *modelexperiments.PartialUpdateInstanceTokenResponse) error { + if resp == nil || resp.Token.Name == "" { + return fmt.Errorf("response token is nil") + } + return p.OutputResult(outputFormat, resp.Token, func() error { + p.Outputf("Updated instance token %q (ID: %s)\n", resp.Token.Name, resp.Token.Id) + return nil + }) +} diff --git a/internal/cmd/modelexperiments/token/update/update_test.go b/internal/cmd/modelexperiments/token/update/update_test.go new file mode 100644 index 000000000..5ca9be558 --- /dev/null +++ b/internal/cmd/modelexperiments/token/update/update_test.go @@ -0,0 +1,93 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "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" +) + +type testContextKey struct{} + +var testContext = context.WithValue(context.Background(), testContextKey{}, "token-patch") +var testClient = &modelexperiments.APIClient{DefaultAPI: modelexperiments.DefaultAPIServiceMock{}} +var testProjectID = uuid.NewString() +var testInstanceID = uuid.NewString() +var testTokenID = uuid.NewString() + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + values := map[string]string{globalflags.ProjectIdFlag: testProjectID, instanceIDFlag: testInstanceID, globalflags.RegionFlag: "eu01"} + for _, mod := range mods { + mod(values) + } + return values +} +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectID, Region: "eu01", Verbosity: globalflags.VerbosityDefault}, TokenID: testTokenID, InstanceID: testInstanceID} + for _, mod := range mods { + mod(model) + } + return model +} +func fixtureRequest(payload modelexperiments.PartialUpdateInstanceTokenPayload) modelexperiments.ApiPartialUpdateInstanceTokenRequest { + return testClient.DefaultAPI.PartialUpdateInstanceToken(testContext, testProjectID, "eu01", testTokenID, testInstanceID).PartialUpdateInstanceTokenPayload(payload) +} + +func TestParseInput(t *testing.T) { + tests := []struct { + name string + flags map[string]string + valid bool + want *inputModel + }{ + {name: "base", flags: fixtureFlagValues(), valid: true, want: fixtureInputModel()}, + {name: "optional fields", flags: fixtureFlagValues(func(values map[string]string) { + values[nameFlag] = "updated" + values[descriptionFlag] = "updated token" + values[labelsFlag] = "env=prod,team=ml" + }), valid: true, want: fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("updated") + model.Description = utils.Ptr("updated token") + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + })}, + {name: "project missing", flags: fixtureFlagValues(func(values map[string]string) { delete(values, globalflags.ProjectIdFlag) }), valid: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.want, []string{testTokenID}, tt.flags, tt.valid) + }) + } +} + +func TestBuildUpdateRequest(t *testing.T) { + model := fixtureInputModel(func(model *inputModel) { + model.Name = utils.Ptr("updated") + model.Description = utils.Ptr("updated token") + model.Labels = &map[string]string{"env": "prod", "team": "ml"} + }) + got := buildRequest(testContext, model, testClient) + labels := map[string]*string{"env": utils.Ptr("prod"), "team": utils.Ptr("ml")} + want := fixtureRequest(modelexperiments.PartialUpdateInstanceTokenPayload{Name: utils.Ptr("updated"), Description: utils.Ptr("updated token"), Labels: &labels}) + if diff := cmp.Diff(got, want, cmp.AllowUnexported(want), cmpopts.EquateComparable(testContext)); diff != "" { + t.Fatalf("request mismatch (-got +want):\n%s", diff) + } +} + +func TestOutputResult(t *testing.T) { + params := testparams.NewTestParams() + if err := outputResult(params.Printer, "", nil); err == nil { + t.Fatal("expected nil response error") + } + if err := outputResult(params.Printer, "", &modelexperiments.PartialUpdateInstanceTokenResponse{}); err == nil { + t.Fatal("expected nil token error") + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 42c156ca2..99bfdbc01 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/logme" "github.com/stackitcloud/stackit-cli/internal/cmd/logs" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb" + "github.com/stackitcloud/stackit-cli/internal/cmd/modelexperiments" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex" "github.com/stackitcloud/stackit-cli/internal/cmd/network" networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/network-area" @@ -182,6 +183,7 @@ func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(logme.NewCmd(params)) cmd.AddCommand(logs.NewCmd(params)) cmd.AddCommand(mariadb.NewCmd(params)) + cmd.AddCommand(modelexperiments.NewCmd(params)) cmd.AddCommand(mongodbflex.NewCmd(params)) cmd.AddCommand(objectstorage.NewCmd(params)) cmd.AddCommand(observability.NewCmd(params)) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 56c53899b..d4d77269a 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -23,22 +23,23 @@ const ( IdentityProviderCustomClientIdKey = "identity_provider_custom_client_id" AllowedUrlDomainKey = "allowed_url_domain" - AuthorizationCustomEndpointKey = "authorization_custom_endpoint" - AutomationCustomEndpointKey = "automation_custom_endpoint" - AlbCustomEndpoint = "alb_custom _endpoint" - AlbWafCustomEndpointKey = "alb_waf_custom_endpoint" - DNSCustomEndpointKey = "dns_custom_endpoint" - EdgeCustomEndpointKey = "edge_custom_endpoint" - LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" - LogMeCustomEndpointKey = "logme_custom_endpoint" - MariaDBCustomEndpointKey = "mariadb_custom_endpoint" - MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" - ObjectStorageCustomEndpointKey = "object_storage_custom_endpoint" - ObservabilityCustomEndpointKey = "observability_custom_endpoint" - OpenSearchCustomEndpointKey = "opensearch_custom_endpoint" - PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint" - RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint" - ScfCustomEndpointKey = "scf_custom_endpoint" + AuthorizationCustomEndpointKey = "authorization_custom_endpoint" + AutomationCustomEndpointKey = "automation_custom_endpoint" + AlbCustomEndpoint = "alb_custom _endpoint" + AlbWafCustomEndpointKey = "alb_waf_custom_endpoint" + DNSCustomEndpointKey = "dns_custom_endpoint" + EdgeCustomEndpointKey = "edge_custom_endpoint" + LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" + LogMeCustomEndpointKey = "logme_custom_endpoint" + MariaDBCustomEndpointKey = "mariadb_custom_endpoint" + ModelExperimentsCustomEndpointKey = "modelexperiments_custom_endpoint" + MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" + ObjectStorageCustomEndpointKey = "object_storage_custom_endpoint" + ObservabilityCustomEndpointKey = "observability_custom_endpoint" + OpenSearchCustomEndpointKey = "opensearch_custom_endpoint" + PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint" + RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint" + ScfCustomEndpointKey = "scf_custom_endpoint" // Deprecated: Will be removed after 2027-08-31. RedisCustomEndpointKey = "redis_custom_endpoint" ResourceManagerEndpointKey = "resource_manager_custom_endpoint" @@ -119,6 +120,7 @@ var ConfigKeys = []string{ OpenSearchCustomEndpointKey, PostgresFlexCustomEndpointKey, RabbitMQCustomEndpointKey, + ModelExperimentsCustomEndpointKey, RedisCustomEndpointKey, ResourceManagerEndpointKey, RunCommandCustomEndpointKey, @@ -231,6 +233,7 @@ func setConfigDefaults() { viper.SetDefault(CDNCustomEndpointKey, "") viper.SetDefault(ValkeyCustomEndpointKey, "") viper.SetDefault(VPNCustomEndpointKey, "") + viper.SetDefault(ModelExperimentsCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/services/modelexperiments/client/client.go b/internal/pkg/services/modelexperiments/client/client.go new file mode 100644 index 000000000..3056f32a4 --- /dev/null +++ b/internal/pkg/services/modelexperiments/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "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" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*modelexperiments.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ModelExperimentsCustomEndpointKey), false, modelexperiments.NewAPIClient) +} diff --git a/internal/pkg/services/modelexperiments/utils/utils.go b/internal/pkg/services/modelexperiments/utils/utils.go new file mode 100644 index 000000000..ad9de4945 --- /dev/null +++ b/internal/pkg/services/modelexperiments/utils/utils.go @@ -0,0 +1,33 @@ +package utils + +import ( + "context" + "errors" + "fmt" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +var ErrResponseNil = errors.New("response is nil") + +func GetInstanceName(ctx context.Context, apiClient modelexperiments.DefaultAPI, projectID, regionID, instanceID string) (string, error) { + resp, err := apiClient.GetInstance(ctx, projectID, regionID, instanceID).Execute() + if err != nil { + return "", fmt.Errorf("get AI Model Experiments instance: %w", err) + } + if resp == nil || resp.Instance.Name == "" { + return "", ErrResponseNil + } + return resp.Instance.Name, nil +} + +func GetTokenName(ctx context.Context, apiClient modelexperiments.DefaultAPI, projectID, regionID, instanceID, tokenID string) (string, error) { + resp, err := apiClient.GetInstanceToken(ctx, projectID, regionID, tokenID, instanceID).Execute() + if err != nil { + return "", fmt.Errorf("get AI Model Experiments token: %w", err) + } + if resp == nil || resp.Token.Name == "" { + return "", ErrResponseNil + } + return resp.Token.Name, nil +} diff --git a/internal/pkg/services/modelexperiments/utils/utils_test.go b/internal/pkg/services/modelexperiments/utils/utils_test.go new file mode 100644 index 000000000..f47c7a9ca --- /dev/null +++ b/internal/pkg/services/modelexperiments/utils/utils_test.go @@ -0,0 +1,139 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + cliutils "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testInstanceName = "instance" + testTokenName = "token" +) + +var ( + testProjectID = uuid.NewString() + testInstanceID = uuid.NewString() + testTokenID = uuid.NewString() +) + +type mockSettings struct { + getInstanceFails bool + getInstanceResp *modelexperiments.GetInstanceResponse + getTokenFails bool + getTokenResp *modelexperiments.GetInstanceTokenResponse +} + +func newAPIMock(settings mockSettings) modelexperiments.DefaultAPI { + return &modelexperiments.DefaultAPIServiceMock{ + GetInstanceExecuteMock: cliutils.Ptr(func(modelexperiments.ApiGetInstanceRequest) (*modelexperiments.GetInstanceResponse, error) { + if settings.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return settings.getInstanceResp, nil + }), + GetInstanceTokenExecuteMock: cliutils.Ptr(func(modelexperiments.ApiGetInstanceTokenRequest) (*modelexperiments.GetInstanceTokenResponse, error) { + if settings.getTokenFails { + return nil, fmt.Errorf("could not get token") + } + return settings.getTokenResp, nil + }), + } +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + name string + settings mockSettings + want string + wantErr bool + }{ + { + name: "success", + settings: mockSettings{ + getInstanceResp: &modelexperiments.GetInstanceResponse{ + Instance: modelexperiments.Instance{Name: testInstanceName}, + }, + }, + want: testInstanceName, + }, + { + name: "request error", + settings: mockSettings{getInstanceFails: true}, + wantErr: true, + }, + { + name: "nil response", + settings: mockSettings{}, + wantErr: true, + }, + { + name: "nil instance", + settings: mockSettings{getInstanceResp: &modelexperiments.GetInstanceResponse{}}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetInstanceName(context.Background(), newAPIMock(tt.settings), testProjectID, testRegion, testInstanceID) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("name = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetTokenName(t *testing.T) { + tests := []struct { + name string + settings mockSettings + want string + wantErr bool + }{ + { + name: "success", + settings: mockSettings{ + getTokenResp: &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{Name: testTokenName}, + }, + }, + want: testTokenName, + }, + { + name: "request error", + settings: mockSettings{getTokenFails: true}, + wantErr: true, + }, + { + name: "nil response", + settings: mockSettings{}, + wantErr: true, + }, + { + name: "empty token name", + settings: mockSettings{getTokenResp: &modelexperiments.GetInstanceTokenResponse{}}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetTokenName(context.Background(), newAPIMock(tt.settings), testProjectID, testRegion, testInstanceID, testTokenID) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Fatalf("name = %q, want %q", got, tt.want) + } + }) + } +}