diff --git a/docs/index.md b/docs/index.md index 368beb47e..a28a56621 100644 --- a/docs/index.md +++ b/docs/index.md @@ -182,6 +182,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service - `logs_custom_endpoint` (String) Custom endpoint for the Logs service - `mariadb_custom_endpoint` (String) Custom endpoint for the MariaDB service +- `modelexperiments_custom_endpoint` (String) Custom endpoint for the AI Model Experiments service - `modelserving_custom_endpoint` (String) Custom endpoint for the AI Model Serving service - `mongodbflex_custom_endpoint` (String) Custom endpoint for the MongoDB Flex service - `objectstorage_custom_endpoint` (String) Custom endpoint for the Object Storage service diff --git a/docs/resources/modelexperiments_instance.md b/docs/resources/modelexperiments_instance.md new file mode 100644 index 000000000..986a3f11f --- /dev/null +++ b/docs/resources/modelexperiments_instance.md @@ -0,0 +1,71 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_modelexperiments_instance Resource - stackit" +subcategory: "" +description: |- + AI Model Experiment Instance Resource schema. + Example Usage + + + resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" + } +--- + +# stackit_modelexperiments_instance (Resource) + +AI Model Experiment Instance Resource schema. + +## Example Usage + +```terraform + +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" +} +``` + +## Example Usage + +```terraform +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "example name" + description = "Example description" + deleted_experiment_retention = "30d" + labels = { + label = "example label" + } +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the AI model experiments instance. +- `project_id` (String) STACKIT project ID to which the AI model experiments instance is associated. + +### Optional + +- `deleted_experiment_retention` (String) The deleted experiment retention of the AI model experiments instance. +- `description` (String) The description of the AI model experiments instance. +- `labels` (Map of String) A map of arbitrary key/value pairs that can be attached to the AI model experiments instance +- `region` (String) Region to which the AI model experiments instance is associated. If not defined, the provider region is used + +### Read-Only + +- `bucket_name` (String) The object storage bucket name of the AI model experiments instance. +- `error_message` (String) Error messages of the AI model experiments instance. +- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`instance_id`". +- `instance_id` (String) The AI model experiments instance ID. +- `state` (String) State of the AI model experiments instance. +- `url` (String) URL of the AI model experiments instance. diff --git a/docs/resources/modelexperiments_token.md b/docs/resources/modelexperiments_token.md new file mode 100644 index 000000000..472a7bc11 --- /dev/null +++ b/docs/resources/modelexperiments_token.md @@ -0,0 +1,99 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_modelexperiments_token Resource - stackit" +subcategory: "" +description: |- + AI Model Experiment Instance Token Resource schema. + Example Usage + + + resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" + } + + resource "stackit_modelexperiments_token" "token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + region = "eu01" + instance_id = stackit_modelexperiments_instance.example.instance_id + description = "Example description" + } +--- + +# stackit_modelexperiments_token (Resource) + +AI Model Experiment Instance Token Resource schema. + +## Example Usage + +```terraform + +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" +} + +resource "stackit_modelexperiments_token" "token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + region = "eu01" + instance_id = stackit_modelexperiments_instance.example.instance_id + description = "Example description" +} +``` + +## Example Usage + +```terraform +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "Example name" + description = "Example description" + deleted_experiment_retention = "30d" + labels = { + label = "Example label" + } +} + +resource "stackit_modelexperiments_token" "token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token nane" + region = "eu01" + instance_id = stackit_modelexperiments_instance.example.instance_id + description = "Example token description" + ttl_duration = "1h" + labels = { + label = "Example label" + } +} +``` + + +## Schema + +### Required + +- `instance_id` (String) The AI model experiments instance ID. +- `name` (String) Name of the AI model experiments instance token. +- `project_id` (String) STACKIT project ID to which the AI model experiments instance token is associated. + +### Optional + +- `description` (String) The description of the AI model experiments instance token. +- `labels` (Map of String) A map of arbitrary key/value pairs for the AI model experiments instance token. +- `region` (String) Region to which the AI model experiments instance token is associated. If not defined, the provider region is used +- `ttl_duration` (String) The TTL duration of the AI model experiments instance token. E.g. 5h30m40s,5h,5h30m,30m,30s + +### Read-Only + +- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`token_id`". +- `state` (String) State of the AI model experiments instance token. +- `token` (String, Sensitive) Content of the AI model experiments instance token. +- `token_id` (String) The AI model experiments instance token ID. +- `valid_until` (String) The time until the AI model experiments instance token is valid. diff --git a/examples/resources/stackit_modelexperiments_instance/resource.tf b/examples/resources/stackit_modelexperiments_instance/resource.tf new file mode 100644 index 000000000..bc1182b49 --- /dev/null +++ b/examples/resources/stackit_modelexperiments_instance/resource.tf @@ -0,0 +1,10 @@ +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "example name" + description = "Example description" + deleted_experiment_retention = "30d" + labels = { + label = "example label" + } +} \ No newline at end of file diff --git a/examples/resources/stackit_modelexperiments_token/resource.tf b/examples/resources/stackit_modelexperiments_token/resource.tf new file mode 100644 index 000000000..3f056af71 --- /dev/null +++ b/examples/resources/stackit_modelexperiments_token/resource.tf @@ -0,0 +1,22 @@ +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "Example name" + description = "Example description" + deleted_experiment_retention = "30d" + labels = { + label = "Example label" + } +} + +resource "stackit_modelexperiments_token" "token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token nane" + region = "eu01" + instance_id = stackit_modelexperiments_instance.example.instance_id + description = "Example token description" + ttl_duration = "1h" + labels = { + label = "Example label" + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 2339c9453..b220db31b 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/logme v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.0.0 + github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.11.0 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.9.0 @@ -49,6 +50,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.3.0 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 github.com/teambition/rrule-go v1.8.2 + go.uber.org/mock v0.6.0 golang.org/x/mod v0.37.0 ) diff --git a/go.sum b/go.sum index 99763d1c5..782785602 100644 --- a/go.sum +++ b/go.sum @@ -700,6 +700,8 @@ github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.0 h1:g7zpfQFFq3UhAWrM github.com/stackitcloud/stackit-sdk-go/services/logs v0.10.0/go.mod h1:tvRejL8w5KpGBbLFPQ+dXOJURgZ3OMbZmwxlKQrGMuA= github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.0.0 h1:G/OqKHAgmH/GgqagGaow1aV6jkmVdTCexCM425PbXaE= github.com/stackitcloud/stackit-sdk-go/services/mariadb v1.0.0/go.mod h1:joa89Y1dyn0j22FstRcIKfW2ada3FDxNfttxSvq27uY= +github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.2.0 h1:4cM9P38lQkJRtz0ZdDRhMam/J2rlL0m3sa1JBcIPcdc= +github.com/stackitcloud/stackit-sdk-go/services/modelexperiments v0.2.0/go.mod h1:TW2PYG0kSrfAos3yY8wUxDem0J9ZYSXulnbzBZ4BTaE= github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.11.0 h1:LfcQ++Z8a13jrJ5NOaCY/hwToh/e+QJj0eS6rd6s6k8= github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.11.0/go.mod h1:u7T85YqoqncJevbPU1ODKthbmxxEh1zw+bVaAO8v0Sg= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.11.0 h1:mjcTktPsrqN/XvtuYs0O23n1lbbYkY5lvSxrNtzfaPs= @@ -841,6 +843,8 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index d71a0cfed..b2bd160ab 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -57,6 +57,7 @@ type ProviderData struct { MariaDBCustomEndpoint string MongoDBFlexCustomEndpoint string ModelServingCustomEndpoint string + ModelExperimentsCustomEndpoint string ObjectStorageCustomEndpoint string ObservabilityCustomEndpoint string OpenSearchCustomEndpoint string diff --git a/stackit/internal/services/modelexperiments/instance/description.md b/stackit/internal/services/modelexperiments/instance/description.md new file mode 100644 index 000000000..d3adab332 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/description.md @@ -0,0 +1,13 @@ +AI Model Experiment Instance Resource schema. + +## Example Usage + +```terraform + +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" +} +``` \ No newline at end of file diff --git a/stackit/internal/services/modelexperiments/instance/mock/instance.go b/stackit/internal/services/modelexperiments/instance/mock/instance.go new file mode 100644 index 000000000..9c42629b0 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/mock/instance.go @@ -0,0 +1,332 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api (interfaces: DefaultAPI) +// +// Generated by this command: +// +// mockgen -destination=./mock/instance.go -package=mock_instance github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api DefaultAPI +// + +// Package mock_instance is a generated GoMock package. +package mock_instance + +import ( + context "context" + reflect "reflect" + + v1api "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + gomock "go.uber.org/mock/gomock" +) + +// MockDefaultAPI is a mock of DefaultAPI interface. +type MockDefaultAPI struct { + ctrl *gomock.Controller + recorder *MockDefaultAPIMockRecorder + isgomock struct{} +} + +// MockDefaultAPIMockRecorder is the mock recorder for MockDefaultAPI. +type MockDefaultAPIMockRecorder struct { + mock *MockDefaultAPI +} + +// NewMockDefaultAPI creates a new mock instance. +func NewMockDefaultAPI(ctrl *gomock.Controller) *MockDefaultAPI { + mock := &MockDefaultAPI{ctrl: ctrl} + mock.recorder = &MockDefaultAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDefaultAPI) EXPECT() *MockDefaultAPIMockRecorder { + return m.recorder +} + +// CreateInstance mocks base method. +func (m *MockDefaultAPI) CreateInstance(ctx context.Context, projectId, regionId string) v1api.ApiCreateInstanceRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateInstance", ctx, projectId, regionId) + ret0, _ := ret[0].(v1api.ApiCreateInstanceRequest) + return ret0 +} + +// CreateInstance indicates an expected call of CreateInstance. +func (mr *MockDefaultAPIMockRecorder) CreateInstance(ctx, projectId, regionId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateInstance", reflect.TypeOf((*MockDefaultAPI)(nil).CreateInstance), ctx, projectId, regionId) +} + +// CreateInstanceExecute mocks base method. +func (m *MockDefaultAPI) CreateInstanceExecute(r v1api.ApiCreateInstanceRequest) (*v1api.CreateInstanceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateInstanceExecute", r) + ret0, _ := ret[0].(*v1api.CreateInstanceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateInstanceExecute indicates an expected call of CreateInstanceExecute. +func (mr *MockDefaultAPIMockRecorder) CreateInstanceExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateInstanceExecute", reflect.TypeOf((*MockDefaultAPI)(nil).CreateInstanceExecute), r) +} + +// CreateInstanceToken mocks base method. +func (m *MockDefaultAPI) CreateInstanceToken(ctx context.Context, projectId, regionId, instanceId string) v1api.ApiCreateInstanceTokenRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateInstanceToken", ctx, projectId, regionId, instanceId) + ret0, _ := ret[0].(v1api.ApiCreateInstanceTokenRequest) + return ret0 +} + +// CreateInstanceToken indicates an expected call of CreateInstanceToken. +func (mr *MockDefaultAPIMockRecorder) CreateInstanceToken(ctx, projectId, regionId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateInstanceToken", reflect.TypeOf((*MockDefaultAPI)(nil).CreateInstanceToken), ctx, projectId, regionId, instanceId) +} + +// CreateInstanceTokenExecute mocks base method. +func (m *MockDefaultAPI) CreateInstanceTokenExecute(r v1api.ApiCreateInstanceTokenRequest) (*v1api.CreateInstanceTokenResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateInstanceTokenExecute", r) + ret0, _ := ret[0].(*v1api.CreateInstanceTokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateInstanceTokenExecute indicates an expected call of CreateInstanceTokenExecute. +func (mr *MockDefaultAPIMockRecorder) CreateInstanceTokenExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateInstanceTokenExecute", reflect.TypeOf((*MockDefaultAPI)(nil).CreateInstanceTokenExecute), r) +} + +// DeleteInstance mocks base method. +func (m *MockDefaultAPI) DeleteInstance(ctx context.Context, projectId, regionId, instanceId string) v1api.ApiDeleteInstanceRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteInstance", ctx, projectId, regionId, instanceId) + ret0, _ := ret[0].(v1api.ApiDeleteInstanceRequest) + return ret0 +} + +// DeleteInstance indicates an expected call of DeleteInstance. +func (mr *MockDefaultAPIMockRecorder) DeleteInstance(ctx, projectId, regionId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteInstance", reflect.TypeOf((*MockDefaultAPI)(nil).DeleteInstance), ctx, projectId, regionId, instanceId) +} + +// DeleteInstanceExecute mocks base method. +func (m *MockDefaultAPI) DeleteInstanceExecute(r v1api.ApiDeleteInstanceRequest) (*v1api.DeleteInstanceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteInstanceExecute", r) + ret0, _ := ret[0].(*v1api.DeleteInstanceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteInstanceExecute indicates an expected call of DeleteInstanceExecute. +func (mr *MockDefaultAPIMockRecorder) DeleteInstanceExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteInstanceExecute", reflect.TypeOf((*MockDefaultAPI)(nil).DeleteInstanceExecute), r) +} + +// DeleteInstanceToken mocks base method. +func (m *MockDefaultAPI) DeleteInstanceToken(ctx context.Context, projectId, regionId, tokenId, instanceId string) v1api.ApiDeleteInstanceTokenRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteInstanceToken", ctx, projectId, regionId, tokenId, instanceId) + ret0, _ := ret[0].(v1api.ApiDeleteInstanceTokenRequest) + return ret0 +} + +// DeleteInstanceToken indicates an expected call of DeleteInstanceToken. +func (mr *MockDefaultAPIMockRecorder) DeleteInstanceToken(ctx, projectId, regionId, tokenId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteInstanceToken", reflect.TypeOf((*MockDefaultAPI)(nil).DeleteInstanceToken), ctx, projectId, regionId, tokenId, instanceId) +} + +// DeleteInstanceTokenExecute mocks base method. +func (m *MockDefaultAPI) DeleteInstanceTokenExecute(r v1api.ApiDeleteInstanceTokenRequest) (*v1api.DeleteInstanceTokenResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteInstanceTokenExecute", r) + ret0, _ := ret[0].(*v1api.DeleteInstanceTokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteInstanceTokenExecute indicates an expected call of DeleteInstanceTokenExecute. +func (mr *MockDefaultAPIMockRecorder) DeleteInstanceTokenExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteInstanceTokenExecute", reflect.TypeOf((*MockDefaultAPI)(nil).DeleteInstanceTokenExecute), r) +} + +// GetInstance mocks base method. +func (m *MockDefaultAPI) GetInstance(ctx context.Context, projectId, regionId, instanceId string) v1api.ApiGetInstanceRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstance", ctx, projectId, regionId, instanceId) + ret0, _ := ret[0].(v1api.ApiGetInstanceRequest) + return ret0 +} + +// GetInstance indicates an expected call of GetInstance. +func (mr *MockDefaultAPIMockRecorder) GetInstance(ctx, projectId, regionId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstance", reflect.TypeOf((*MockDefaultAPI)(nil).GetInstance), ctx, projectId, regionId, instanceId) +} + +// GetInstanceExecute mocks base method. +func (m *MockDefaultAPI) GetInstanceExecute(r v1api.ApiGetInstanceRequest) (*v1api.GetInstanceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstanceExecute", r) + ret0, _ := ret[0].(*v1api.GetInstanceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInstanceExecute indicates an expected call of GetInstanceExecute. +func (mr *MockDefaultAPIMockRecorder) GetInstanceExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceExecute", reflect.TypeOf((*MockDefaultAPI)(nil).GetInstanceExecute), r) +} + +// GetInstanceToken mocks base method. +func (m *MockDefaultAPI) GetInstanceToken(ctx context.Context, projectId, regionId, tokenId, instanceId string) v1api.ApiGetInstanceTokenRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstanceToken", ctx, projectId, regionId, tokenId, instanceId) + ret0, _ := ret[0].(v1api.ApiGetInstanceTokenRequest) + return ret0 +} + +// GetInstanceToken indicates an expected call of GetInstanceToken. +func (mr *MockDefaultAPIMockRecorder) GetInstanceToken(ctx, projectId, regionId, tokenId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceToken", reflect.TypeOf((*MockDefaultAPI)(nil).GetInstanceToken), ctx, projectId, regionId, tokenId, instanceId) +} + +// GetInstanceTokenExecute mocks base method. +func (m *MockDefaultAPI) GetInstanceTokenExecute(r v1api.ApiGetInstanceTokenRequest) (*v1api.GetInstanceTokenResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstanceTokenExecute", r) + ret0, _ := ret[0].(*v1api.GetInstanceTokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInstanceTokenExecute indicates an expected call of GetInstanceTokenExecute. +func (mr *MockDefaultAPIMockRecorder) GetInstanceTokenExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceTokenExecute", reflect.TypeOf((*MockDefaultAPI)(nil).GetInstanceTokenExecute), r) +} + +// ListInstanceTokens mocks base method. +func (m *MockDefaultAPI) ListInstanceTokens(ctx context.Context, projectId, regionId, instanceId string) v1api.ApiListInstanceTokensRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInstanceTokens", ctx, projectId, regionId, instanceId) + ret0, _ := ret[0].(v1api.ApiListInstanceTokensRequest) + return ret0 +} + +// ListInstanceTokens indicates an expected call of ListInstanceTokens. +func (mr *MockDefaultAPIMockRecorder) ListInstanceTokens(ctx, projectId, regionId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstanceTokens", reflect.TypeOf((*MockDefaultAPI)(nil).ListInstanceTokens), ctx, projectId, regionId, instanceId) +} + +// ListInstanceTokensExecute mocks base method. +func (m *MockDefaultAPI) ListInstanceTokensExecute(r v1api.ApiListInstanceTokensRequest) (*v1api.ListInstanceTokensResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInstanceTokensExecute", r) + ret0, _ := ret[0].(*v1api.ListInstanceTokensResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInstanceTokensExecute indicates an expected call of ListInstanceTokensExecute. +func (mr *MockDefaultAPIMockRecorder) ListInstanceTokensExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstanceTokensExecute", reflect.TypeOf((*MockDefaultAPI)(nil).ListInstanceTokensExecute), r) +} + +// ListInstances mocks base method. +func (m *MockDefaultAPI) ListInstances(ctx context.Context, projectId, regionId string) v1api.ApiListInstancesRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInstances", ctx, projectId, regionId) + ret0, _ := ret[0].(v1api.ApiListInstancesRequest) + return ret0 +} + +// ListInstances indicates an expected call of ListInstances. +func (mr *MockDefaultAPIMockRecorder) ListInstances(ctx, projectId, regionId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstances", reflect.TypeOf((*MockDefaultAPI)(nil).ListInstances), ctx, projectId, regionId) +} + +// ListInstancesExecute mocks base method. +func (m *MockDefaultAPI) ListInstancesExecute(r v1api.ApiListInstancesRequest) (*v1api.ListInstancesResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInstancesExecute", r) + ret0, _ := ret[0].(*v1api.ListInstancesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInstancesExecute indicates an expected call of ListInstancesExecute. +func (mr *MockDefaultAPIMockRecorder) ListInstancesExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInstancesExecute", reflect.TypeOf((*MockDefaultAPI)(nil).ListInstancesExecute), r) +} + +// PartialUpdateInstance mocks base method. +func (m *MockDefaultAPI) PartialUpdateInstance(ctx context.Context, projectId, regionId, instanceId string) v1api.ApiPartialUpdateInstanceRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PartialUpdateInstance", ctx, projectId, regionId, instanceId) + ret0, _ := ret[0].(v1api.ApiPartialUpdateInstanceRequest) + return ret0 +} + +// PartialUpdateInstance indicates an expected call of PartialUpdateInstance. +func (mr *MockDefaultAPIMockRecorder) PartialUpdateInstance(ctx, projectId, regionId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PartialUpdateInstance", reflect.TypeOf((*MockDefaultAPI)(nil).PartialUpdateInstance), ctx, projectId, regionId, instanceId) +} + +// PartialUpdateInstanceExecute mocks base method. +func (m *MockDefaultAPI) PartialUpdateInstanceExecute(r v1api.ApiPartialUpdateInstanceRequest) (*v1api.PartialUpdateInstanceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PartialUpdateInstanceExecute", r) + ret0, _ := ret[0].(*v1api.PartialUpdateInstanceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PartialUpdateInstanceExecute indicates an expected call of PartialUpdateInstanceExecute. +func (mr *MockDefaultAPIMockRecorder) PartialUpdateInstanceExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PartialUpdateInstanceExecute", reflect.TypeOf((*MockDefaultAPI)(nil).PartialUpdateInstanceExecute), r) +} + +// PartialUpdateInstanceToken mocks base method. +func (m *MockDefaultAPI) PartialUpdateInstanceToken(ctx context.Context, projectId, regionId, tokenId, instanceId string) v1api.ApiPartialUpdateInstanceTokenRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PartialUpdateInstanceToken", ctx, projectId, regionId, tokenId, instanceId) + ret0, _ := ret[0].(v1api.ApiPartialUpdateInstanceTokenRequest) + return ret0 +} + +// PartialUpdateInstanceToken indicates an expected call of PartialUpdateInstanceToken. +func (mr *MockDefaultAPIMockRecorder) PartialUpdateInstanceToken(ctx, projectId, regionId, tokenId, instanceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PartialUpdateInstanceToken", reflect.TypeOf((*MockDefaultAPI)(nil).PartialUpdateInstanceToken), ctx, projectId, regionId, tokenId, instanceId) +} + +// PartialUpdateInstanceTokenExecute mocks base method. +func (m *MockDefaultAPI) PartialUpdateInstanceTokenExecute(r v1api.ApiPartialUpdateInstanceTokenRequest) (*v1api.PartialUpdateInstanceTokenResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PartialUpdateInstanceTokenExecute", r) + ret0, _ := ret[0].(*v1api.PartialUpdateInstanceTokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PartialUpdateInstanceTokenExecute indicates an expected call of PartialUpdateInstanceTokenExecute. +func (mr *MockDefaultAPIMockRecorder) PartialUpdateInstanceTokenExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PartialUpdateInstanceTokenExecute", reflect.TypeOf((*MockDefaultAPI)(nil).PartialUpdateInstanceTokenExecute), r) +} diff --git a/stackit/internal/services/modelexperiments/instance/resource.go b/stackit/internal/services/modelexperiments/instance/resource.go new file mode 100644 index 000000000..82b5b7420 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource.go @@ -0,0 +1,604 @@ +package instance + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api/wait" + serviceenablement "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api" + serviceEnablementWait "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + modelexperimentsutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/utils" + serviceEnablementUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/serviceenablement/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ resource.Resource = &instanceResource{} + _ resource.ResourceWithConfigure = &instanceResource{} + _ resource.ResourceWithModifyPlan = &instanceResource{} +) + +//go:embed description.md +var markdownDescription string + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + DeletedExperimentRetention types.String `tfsdk:"deleted_experiment_retention"` + Labels types.Map `tfsdk:"labels"` + State types.String `tfsdk:"state"` + BucketName types.String `tfsdk:"bucket_name"` + ErrorMessage types.String `tfsdk:"error_message"` + InstanceId types.String `tfsdk:"instance_id"` + Url types.String `tfsdk:"url"` +} + +// NewInstanceResource is a helper function to simplify the provider implementation. +// +//go:generate mockgen -destination=./mock/instance.go -package=mock_instance github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api DefaultAPI +func NewInstanceResourceEmpty() resource.Resource { + return &instanceResource{} +} + +func NewInstanceResource(client modelexperiments.DefaultAPI, serviceClient serviceenablement.DefaultAPI, providerData core.ProviderData) resource.Resource { //nolint:gocritic + return &instanceResource{ + client: client, + providerData: providerData, + serviceEnablementClient: serviceClient, + } +} + +// instanceResource is the resource implementation. +type instanceResource struct { + client modelexperiments.DefaultAPI + providerData core.ProviderData + serviceEnablementClient serviceenablement.DefaultAPI +} + +// Metadata returns the resource type name. +func (i *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_modelexperiments_instance" +} + +// Configure adds the provider configured client to the resource. +func (i *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + i.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := modelexperimentsutils.ConfigureClient(ctx, &i.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + serviceEnablementClient := serviceEnablementUtils.ConfigureClient(ctx, &i.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + i.client = apiClient.DefaultAPI + i.serviceEnablementClient = serviceEnablementClient.DefaultAPI + tflog.Info(ctx, "Model experiments client configured") +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (i *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion( + ctx, + configModel.Region, + &planModel.Region, + i.providerData.GetRegion(), + resp, + ) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Schema defines the schema for the resource. +func (i *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: markdownDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`instance_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the AI model experiments instance is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: "Region to which the AI model experiments instance is associated. If not defined, the provider region is used", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: "The AI model experiments instance ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "labels": schema.MapAttribute{ + Description: "A map of arbitrary key/value pairs that can be attached to the AI model experiments instance", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "description": schema.StringAttribute{ + Description: "The description of the AI model experiments instance.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(0, 160), + }, + }, + "name": schema.StringAttribute{ + Description: "Name of the AI model experiments instance.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 64), + }, + }, + "state": schema.StringAttribute{ + Description: "State of the AI model experiments instance.", + Computed: true, + }, + "url": schema.StringAttribute{ + Description: "URL of the AI model experiments instance.", + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(0, 1000), + }, + }, + "deleted_experiment_retention": schema.StringAttribute{ + Description: "The deleted experiment retention of the AI model experiments instance.", + Optional: true, + Computed: true, + }, + "bucket_name": schema.StringAttribute{ + Description: "The object storage bucket name of the AI model experiments instance.", + Computed: true, + }, + "error_message": schema.StringAttribute{ + Description: "Error messages of the AI model experiments instance.", + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (i *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + err := i.serviceEnablementClient.EnableServiceRegional(ctx, region, projectId, utils.ModelExperimentsServiceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling AI model experiments", + fmt.Sprintf("Service not available in region %s \n%v", region, err), + ) + return + } + } + + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error enabling AI model experiments", + fmt.Sprintf("Error enabling AI model experiments: %v", err), + ) + return + } + + _, err = serviceEnablementWait.EnableServiceWaitHandler(ctx, i.serviceEnablementClient, region, projectId, utils.ModelExperimentsServiceId). + WaitWithContext(ctx) + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error enabling AI model experiments", + fmt.Sprintf("Error enabling AI model experiments: %v", err), + ) + return + } + + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + createInstanceResp, err := i.client.CreateInstance(ctx, projectId, region).CreateInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating AI model experiments instance", + fmt.Sprintf("Calling API: %v", err), + ) + return + } + ctx = core.LogResponse(ctx) + + if createInstanceResp.Instance.Id == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Got empty instance id") + return + } + + instanceId := createInstanceResp.Instance.Id + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "instance_id": instanceId, + }) + if resp.Diagnostics.HasError() { + return + } + + // If model experiments instance is impaired, write state avoid dangling resources and return + waitResp, err := wait.CreateInstanceWaitHandler(ctx, i.client, region, projectId, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance", fmt.Sprintf("Waiting for instance to be active: %v", err)) + } + + // Map response body to schema + err = mapCreateResponse(ctx, createInstanceResp, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance created") +} + +// Read refreshes the Terraform state with the latest data. +func (i *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + if instanceId == "" { + // Resource not yet created; ID is unknown. + resp.State.RemoveResource(ctx) + return + } + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + getInstanceResp, err := i.client.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading AI model experiments instance", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + err = mapInstance(ctx, &getInstanceResp.Instance, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (i *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var plan Model + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get current state + var state Model + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := state.ProjectId.ValueString() + instanceId := state.InstanceId.ValueString() + + region := i.providerData.GetRegionWithOverride(plan.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(&plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + updateInstanceResp, err := i.client.PartialUpdateInstance(ctx, projectId, region, instanceId).PartialUpdateInstancePayload(*payload).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting AI model experiments instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapInstance(ctx, &updateInstanceResp.Instance, &plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (i *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + _, err := i.client.DeleteInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting AI model experiments instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteInstanceWaitHandler(ctx, i.client, region, projectId, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting AI model experiments instance", fmt.Sprintf("Waiting for instance to be deleted: %v", err)) + return + } + + tflog.Info(ctx, "Model experiments instance deleted") +} + +// mapCreateResponse maps the instace creation response and GET instance response to the model +func mapCreateResponse(ctx context.Context, instanceCreateResp *modelexperiments.CreateInstanceResponse, waitResp *modelexperiments.GetInstanceResponse, model *Model, region string) error { + if instanceCreateResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + instance := instanceCreateResp.Instance + + if instance.Id == "" { + return fmt.Errorf("instance id not present") + } + + mapValue, diags := types.MapValueFrom(ctx, types.StringType, instance.Labels) + if diags.HasError() { + return fmt.Errorf("failure in mapping labels") + } + + if waitResp == nil { + model.State = types.StringValue(string(instanceCreateResp.Instance.State)) + } else { + model.State = types.StringValue(string(waitResp.Instance.State)) + model.BucketName = types.StringValue(*waitResp.Instance.BucketName) + } + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceCreateResp.Instance.Id) + model.InstanceId = types.StringValue(instance.Id) + model.Name = types.StringValue(instance.Name) + model.Description = types.StringPointerValue(instance.Description) + model.DeletedExperimentRetention = types.StringPointerValue(instance.DeletedExperimentRetention) + model.ErrorMessage = types.StringPointerValue(instance.ErrorMessage) + model.Labels = mapValue + model.Url = types.StringPointerValue(&instance.Url) + + return nil +} + +// mapInstance maps instances to the resource model +func mapInstance(ctx context.Context, instance *modelexperiments.Instance, model *Model) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + + if instance.Id == "" { + return fmt.Errorf("instance id not present") + } + + mapValue, diags := types.MapValueFrom(ctx, types.StringType, instance.Labels) + if diags.HasError() { + return fmt.Errorf("failure in mapping labels") + } + + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.Region.ValueString(), instance.Id) + model.InstanceId = types.StringValue(instance.Id) + model.Name = types.StringValue(instance.Name) + model.State = types.StringValue(string(instance.State)) + model.Description = types.StringPointerValue(instance.Description) + model.DeletedExperimentRetention = types.StringPointerValue(instance.DeletedExperimentRetention) + model.BucketName = types.StringPointerValue(instance.BucketName) + model.ErrorMessage = types.StringPointerValue(instance.ErrorMessage) + model.Labels = mapValue + model.Url = types.StringValue(instance.Url) + + return nil +} + +func toCreatePayload(model *Model) (*modelexperiments.CreateInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &modelexperiments.CreateInstancePayload{ + Name: model.Name.ValueString(), + Description: conversion.StringValueToPointer(model.Description), + DeletedExperimentRetention: conversion.StringValueToPointer(model.DeletedExperimentRetention), + Labels: labels, + }, nil +} + +func toUpdatePayload(model *Model) (*modelexperiments.PartialUpdateInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &modelexperiments.PartialUpdateInstancePayload{ + Name: conversion.StringValueToPointer(model.Name), + Description: conversion.StringValueToPointer(model.Description), + Labels: labels, + DeletedExperimentRetention: conversion.StringValueToPointer(model.DeletedExperimentRetention), + }, nil +} diff --git a/stackit/internal/services/modelexperiments/instance/resource_create_test.go b/stackit/internal/services/modelexperiments/instance/resource_create_test.go new file mode 100644 index 000000000..9f983d232 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource_create_test.go @@ -0,0 +1,347 @@ +package instance_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + serviceenablement "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestCreate_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + instanceId := uuid.New() + url := "url" + bucketName := "bucket" + deletetExpRetention := "1m" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + Version: "1.0.0", + } + + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, tc.MockServiceEnablementClient, providerData) + + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiEnableServiceRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegionalExecute(gomock.Any()).Return(nil) + + serviceEnablementResp := &serviceenablement.ServiceStatus{ + State: serviceenablement.SERVICESTATUSSTATE_ENABLED.Ptr(), + } + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiGetServiceStatusRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegionalExecute(gomock.Any()).Return(serviceEnablementResp, nil) + + createResp := &modelexperiments.CreateInstanceResponse{ + Instance: modelexperiments.Instance{ + DeletedExperimentRetention: &deletetExpRetention, + Description: &description, + Name: instanceName, + Region: ®ion, + Url: url, + Id: instanceId.String(), + State: "pending", + }, + } + tc.MockInstanceCLient.EXPECT().CreateInstance(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceExecute(gomock.Any()).Return(createResp, nil) + + getResp := &modelexperiments.GetInstanceResponse{ + Instance: modelexperiments.Instance{ + DeletedExperimentRetention: &deletetExpRetention, + BucketName: &bucketName, + Description: &description, + Name: instanceName, + Region: ®ion, + Url: url, + Id: instanceId.String(), + State: "active", + }, + } + + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(getResp, nil) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := testutils.CreateInstanceTestModel(projectId.String(), region, instanceName, description) + req := testutils.CreateInstanceRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + instanceRes.Create(tc.Ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("Create should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + var stateAfterCreate instance.Model + diags := resp.State.Get(tc.Ctx, &stateAfterCreate) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + // state should be created correctly + if tfId != stateAfterCreate.Id { + t.Fatalf("expected %v, got %v", tfId.String(), stateAfterCreate.Id.ValueString()) + } + if instanceId.String() != stateAfterCreate.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), stateAfterCreate.InstanceId.ValueString()) + } + if projectId.String() != stateAfterCreate.ProjectId.ValueString() { + t.Fatalf("expected %v, got %v", projectId.String(), stateAfterCreate.ProjectId.ValueString()) + } + if instanceName != stateAfterCreate.Name.ValueString() { + t.Fatalf("expected %v, got %v", instanceName, stateAfterCreate.Name.ValueString()) + } + if description != stateAfterCreate.Description.ValueString() { + t.Fatalf("expected %v, got %v", description, stateAfterCreate.Description.ValueString()) + } + if stateAfterCreate.State.ValueString() != "active" { + t.Fatalf("expected %v, got %v", "active", stateAfterCreate.State.ValueString()) + } + if url != stateAfterCreate.Url.ValueString() { + t.Fatalf("expected %v, got %v", url, stateAfterCreate.Url.ValueString()) + } + if region != stateAfterCreate.Region.ValueString() { + t.Fatalf("expected %v, got %v", region, stateAfterCreate.Region.ValueString()) + } + if bucketName != stateAfterCreate.BucketName.ValueString() { + t.Fatalf("expected %v, got %v", bucketName, stateAfterCreate.BucketName.ValueString()) + } + if deletetExpRetention != stateAfterCreate.DeletedExperimentRetention.ValueString() { + t.Fatalf("expected %v, got %v", deletetExpRetention, stateAfterCreate.DeletedExperimentRetention.ValueString()) + } +} + +func TestCreate_ServiceEnablementFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + Version: "1.0.0", + } + + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, tc.MockServiceEnablementClient, providerData) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiEnableServiceRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegionalExecute(gomock.Any()).Return(oapiErr) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := testutils.CreateInstanceTestModel(projectId.String(), region, instanceName, description) + req := testutils.CreateInstanceRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + instanceRes.Create(tc.Ctx, req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should not succeed, but got no errors") + } + + // state should not be created + var stateAfterCreate *instance.Model + diags := resp.State.Get(tc.Ctx, &stateAfterCreate) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if stateAfterCreate != nil { + t.Fatalf("State not nil") + } +} + +func TestCreate_GetInstanceFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + instanceId := uuid.New() + url := "url" + deletetExpRetention := "1m" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + Version: "1.0.0", + } + + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, tc.MockServiceEnablementClient, providerData) + + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiEnableServiceRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegionalExecute(gomock.Any()).Return(nil) + + serviceEnablementResp := &serviceenablement.ServiceStatus{ + State: serviceenablement.SERVICESTATUSSTATE_ENABLED.Ptr(), + } + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiGetServiceStatusRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegionalExecute(gomock.Any()).Return(serviceEnablementResp, nil) + + createResp := &modelexperiments.CreateInstanceResponse{ + Instance: modelexperiments.Instance{ + DeletedExperimentRetention: &deletetExpRetention, + Description: &description, + Name: instanceName, + Region: ®ion, + Url: url, + Id: instanceId.String(), + State: "pending", + }, + } + tc.MockInstanceCLient.EXPECT().CreateInstance(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceExecute(gomock.Any()).Return(createResp, nil) + + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(nil, fmt.Errorf("server error")) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := testutils.CreateInstanceTestModel(projectId.String(), region, instanceName, description) + req := testutils.CreateInstanceRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + instanceRes.Create(tc.Ctx, req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should succeed with errors") + } + + var stateAfterCreate instance.Model + diags := resp.State.Get(tc.Ctx, &stateAfterCreate) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + // state should be created even if get request failed + if tfId != stateAfterCreate.Id { + t.Fatalf("expected %v, got %v", tfId.String(), stateAfterCreate.Id.ValueString()) + } + if instanceId.String() != stateAfterCreate.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), stateAfterCreate.InstanceId.ValueString()) + } + if projectId.String() != stateAfterCreate.ProjectId.ValueString() { + t.Fatalf("expected %v, got %v", projectId.String(), stateAfterCreate.ProjectId.ValueString()) + } + if instanceName != stateAfterCreate.Name.ValueString() { + t.Fatalf("expected %v, got %v", instanceName, stateAfterCreate.Name.ValueString()) + } + if description != stateAfterCreate.Description.ValueString() { + t.Fatalf("expected %v, got %v", description, stateAfterCreate.Description.ValueString()) + } + if stateAfterCreate.State.ValueString() != "pending" { + t.Fatalf("expected %v, got %v", "pending", stateAfterCreate.State.ValueString()) + } + if url != stateAfterCreate.Url.ValueString() { + t.Fatalf("expected %v, got %v", url, stateAfterCreate.Url.ValueString()) + } + if region != stateAfterCreate.Region.ValueString() { + t.Fatalf("expected %v, got %v", region, stateAfterCreate.Region.ValueString()) + } + if stateAfterCreate.BucketName.ValueString() != "" { + t.Fatalf("expected %v, got %v", "", stateAfterCreate.BucketName.ValueString()) + } + if deletetExpRetention != stateAfterCreate.DeletedExperimentRetention.ValueString() { + t.Fatalf("expected %v, got %v", deletetExpRetention, stateAfterCreate.DeletedExperimentRetention.ValueString()) + } +} + +func TestCreate_InstanceCreateFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + Version: "1.0.0", + } + + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, tc.MockServiceEnablementClient, providerData) + + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiEnableServiceRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().EnableServiceRegionalExecute(gomock.Any()).Return(nil) + + serviceEnablementResp := &serviceenablement.ServiceStatus{ + State: serviceenablement.SERVICESTATUSSTATE_ENABLED.Ptr(), + } + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegional(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceenablement.ApiGetServiceStatusRegionalRequest{ + ApiService: tc.MockServiceEnablementClient, + }) + tc.MockServiceEnablementClient.EXPECT().GetServiceStatusRegionalExecute(gomock.Any()).Return(serviceEnablementResp, nil) + + tc.MockInstanceCLient.EXPECT().CreateInstance(gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceExecute(gomock.Any()).Return(nil, fmt.Errorf("server error")) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := testutils.CreateInstanceTestModel(projectId.String(), region, instanceName, description) + req := testutils.CreateInstanceRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + instanceRes.Create(tc.Ctx, req, resp) + + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should not succeed, but got no errors") + } + + // no state should be created + var stateAfterCreate *instance.Model + diags := resp.State.Get(tc.Ctx, &stateAfterCreate) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if stateAfterCreate != nil { + t.Fatalf("State not nil") + } +} diff --git a/stackit/internal/services/modelexperiments/instance/resource_delete_test.go b/stackit/internal/services/modelexperiments/instance/resource_delete_test.go new file mode 100644 index 000000000..5649acc57 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource_delete_test.go @@ -0,0 +1,223 @@ +package instance_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" +) + +func TestDelete_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + region := "eu01" + instanceId := uuid.New() + + tc.MockInstanceCLient.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceExecute(gomock.Any()).Return(nil, nil) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Delete(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Delete should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } +} + +func TestDelete_DeleteInstanceFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + region := "eu01" + instanceId := uuid.New() + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Delete(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Delete should not succeed, but got no errors") + } + + // state should not be removed + var finalState instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + if instanceId.String() != finalState.InstanceId.ValueString() { + t.Fatalf("state should not have been deleted - expected %v, got %v", instanceId.String(), finalState.InstanceId.ValueString()) + } +} + +func TestDelete_InstanceAlreadyDeleted(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + region := "eu01" + instanceId := uuid.New() + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Delete(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Delete should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + // state should be removed + var finalState *instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if finalState != nil { + t.Fatalf("state should have been deleted - got %v", finalState) + } +} + +func TestDelete_GetInstanceFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + region := "eu01" + instanceId := uuid.New() + + tc.MockInstanceCLient.EXPECT().DeleteInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceExecute(gomock.Any()).Return(nil, nil) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Delete(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Delete should not succeed, but got no errors") + } + + // state should not be removed + var finalState instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + if instanceId.String() != finalState.InstanceId.ValueString() { + t.Fatalf("state should not have been deleted - expected %v, got %v", instanceId.String(), state.InstanceId.ValueString()) + } +} diff --git a/stackit/internal/services/modelexperiments/instance/resource_read_test.go b/stackit/internal/services/modelexperiments/instance/resource_read_test.go new file mode 100644 index 000000000..110cb33ab --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource_read_test.go @@ -0,0 +1,273 @@ +package instance_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestRead_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + instanceId := uuid.New() + url := "url" + instanceNameUpdated := "updatedName" + bucketName := "bucket" + deletetExpRetention := "1m" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + + getResp := &modelexperiments.GetInstanceResponse{ + Instance: modelexperiments.Instance{ + DeletedExperimentRetention: &deletetExpRetention, + BucketName: &bucketName, + Description: &description, + Name: instanceNameUpdated, + Region: ®ion, + Url: url, + Id: instanceId.String(), + State: "active", + }, + } + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(getResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := instance.Model{ + Id: tfId, + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(instanceName), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + DeletedExperimentRetention: types.StringValue(deletetExpRetention), + BucketName: types.StringValue(bucketName), + State: types.StringValue("active"), + Url: types.StringValue(url), + } + + req := testutils.ReadInstanceRequest(tc.Ctx, schemaResp, currentState) + resp := testutils.ReadInstanceResponse(tc.Ctx, schemaResp, nil) + + instanceRes.Read(tc.Ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("Get should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + var refreshedState instance.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + // state should be written according to GetInstance Response + if tfId != refreshedState.Id { + t.Fatalf("expected %v, got %v", tfId.String(), refreshedState.Id.ValueString()) + } + if instanceId.String() != refreshedState.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), refreshedState.InstanceId.ValueString()) + } + if projectId.String() != refreshedState.ProjectId.ValueString() { + t.Fatalf("expected %v, got %v", projectId.String(), refreshedState.ProjectId.ValueString()) + } + if instanceNameUpdated != refreshedState.Name.ValueString() { + t.Fatalf("expected %v, got %v", instanceNameUpdated, refreshedState.Name.ValueString()) + } + if description != refreshedState.Description.ValueString() { + t.Fatalf("expected %v, got %v", description, refreshedState.Description.ValueString()) + } + if refreshedState.State.ValueString() != "active" { + t.Fatalf("expected %v, got %v", "active", refreshedState.State.ValueString()) + } + if url != refreshedState.Url.ValueString() { + t.Fatalf("expected %v, got %v", url, refreshedState.Url.ValueString()) + } + if region != refreshedState.Region.ValueString() { + t.Fatalf("expected %v, got %v", region, refreshedState.Region.ValueString()) + } + if bucketName != refreshedState.BucketName.ValueString() { + t.Fatalf("expected %v, got %v", bucketName, refreshedState.BucketName.ValueString()) + } + if deletetExpRetention != refreshedState.DeletedExperimentRetention.ValueString() { + t.Fatalf("expected %v, got %v", deletetExpRetention, refreshedState.DeletedExperimentRetention.ValueString()) + } +} + +func TestRead_InstanceIdEmptyFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + region := "eu01" + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(""), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + } + + req := testutils.ReadInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.ReadInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Read(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Get should not succeed, but got no errors") + } + + // state should be removed + var refreshedState *instance.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if refreshedState != nil { + t.Fatalf("State not nil") + } +} + +func TestRead_InstanceNotFound(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceId := uuid.New() + instanceName := "test" + region := "eu01" + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: 404, + } + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceName), + Labels: types.MapNull(types.StringType), + } + + req := testutils.ReadInstanceRequest(tc.Ctx, schemaResp, state) + resp := testutils.ReadInstanceResponse(tc.Ctx, schemaResp, &state) + + instanceRes.Read(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Get should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + // state should be removed + var refreshedState *instance.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if refreshedState != nil { + t.Fatalf("State not nil") + } +} + +func TestRead_GetRequestFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + description := "description" + region := "eu01" + instanceId := uuid.New() + url := "url" + bucketName := "bucket" + deletetExpRetention := "1m" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: 400, + } + tc.MockInstanceCLient.EXPECT().GetInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := instance.Model{ + Id: tfId, + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(instanceName), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + DeletedExperimentRetention: types.StringValue(deletetExpRetention), + BucketName: types.StringValue(bucketName), + State: types.StringValue("active"), + Url: types.StringValue(url), + } + + req := testutils.ReadInstanceRequest(tc.Ctx, schemaResp, currentState) + resp := testutils.ReadInstanceResponse(tc.Ctx, schemaResp, nil) + + instanceRes.Read(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Get should not succeed") + } + + // resp state should not be set + var refreshedState *instance.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + if refreshedState != nil { + t.Fatalf("State not nil") + } +} diff --git a/stackit/internal/services/modelexperiments/instance/resource_test.go b/stackit/internal/services/modelexperiments/instance/resource_test.go new file mode 100644 index 000000000..b6eb9cbe2 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource_test.go @@ -0,0 +1,371 @@ +package instance + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +func TestMapInstanceFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + input modelexperiments.Instance + expected Model + isValid bool + }{ + { + description: "should error when state is nil", + state: nil, + input: modelexperiments.Instance{ + Id: "id", + }, + expected: Model{}, + isValid: false, + }, + { + description: "should error when instance id is not present", + state: &Model{}, + input: modelexperiments.Instance{}, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + input: modelexperiments.Instance{ + Id: "id", + BucketName: new("bucketName"), + Description: new("description"), + DeletedExperimentRetention: new("30d"), + ErrorMessage: nil, + Labels: &map[string]string{"key": "value"}, + State: "active", + Url: "url", + Name: "name", + }, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + BucketName: types.StringValue("bucketName"), + DeletedExperimentRetention: types.StringValue("30d"), + Url: types.StringValue("url"), + ErrorMessage: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + err := mapInstance(ctx, &tt.input, tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapCreateResponseFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + inputCreateResponse *modelexperiments.CreateInstanceResponse + inputGetResponse *modelexperiments.GetInstanceResponse + expected Model + isValid bool + }{ + { + description: "should error when instance create response is nil", + state: &Model{}, + inputCreateResponse: nil, + inputGetResponse: &modelexperiments.GetInstanceResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should error when state is nil", + state: nil, + inputCreateResponse: &modelexperiments.CreateInstanceResponse{}, + inputGetResponse: &modelexperiments.GetInstanceResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should error when instance id is not present", + state: &Model{}, + inputCreateResponse: &modelexperiments.CreateInstanceResponse{ + Instance: modelexperiments.Instance{}, + }, + inputGetResponse: &modelexperiments.GetInstanceResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly even if Get Response is nil", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + inputCreateResponse: &modelexperiments.CreateInstanceResponse{ + Instance: modelexperiments.Instance{ + Id: "id", + Description: new("description"), + DeletedExperimentRetention: new("30d"), + ErrorMessage: nil, + Labels: &map[string]string{"key": "value"}, + State: "pending", + Url: "url", + Name: "name", + }}, + inputGetResponse: nil, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("pending"), + DeletedExperimentRetention: types.StringValue("30d"), + Url: types.StringValue("url"), + ErrorMessage: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + }, + isValid: true, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + inputCreateResponse: &modelexperiments.CreateInstanceResponse{ + Instance: modelexperiments.Instance{ + Id: "id", + Description: new("description"), + DeletedExperimentRetention: new("30d"), + ErrorMessage: nil, + Labels: &map[string]string{"key": "value"}, + State: "pending", + Url: "url", + Name: "name", + }}, + inputGetResponse: &modelexperiments.GetInstanceResponse{ + Instance: modelexperiments.Instance{ + State: "active", + BucketName: new("bucketName"), + }, + }, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + BucketName: types.StringValue("bucketName"), + DeletedExperimentRetention: types.StringValue("30d"), + Url: types.StringValue("url"), + ErrorMessage: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + err := mapCreateResponse(ctx, tt.inputCreateResponse, tt.inputGetResponse, tt.state, "eu01") + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelexperiments.CreateInstancePayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should error when map is not correct", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.Int64Type, map[string]attr.Value{"key": types.Int64Value(33)}), + DeletedExperimentRetention: types.StringValue("50d"), + }, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + DeletedExperimentRetention: types.StringNull(), + }, + expected: &modelexperiments.CreateInstancePayload{ + Name: "name", + Description: new("desc"), + Labels: &map[string]string{"key": "value"}, + DeletedExperimentRetention: nil, + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelexperiments.PartialUpdateInstancePayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should error when map is not correct", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.Int64Type, map[string]attr.Value{"key": types.Int64Value(33)}), + DeletedExperimentRetention: types.StringValue("50d"), + }, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + DeletedExperimentRetention: types.StringValue("50d"), + }, + expected: &modelexperiments.PartialUpdateInstancePayload{ + Name: new("name"), + Description: new("desc"), + Labels: &map[string]string{"key": "value"}, + DeletedExperimentRetention: new("50d"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toUpdatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/modelexperiments/instance/resource_update_test.go b/stackit/internal/services/modelexperiments/instance/resource_update_test.go new file mode 100644 index 000000000..ccc0f1cc3 --- /dev/null +++ b/stackit/internal/services/modelexperiments/instance/resource_update_test.go @@ -0,0 +1,302 @@ +package instance_test + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestUpdate_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + instanceNameUpdated := "update name" + description := "description" + descriptionUpdated := "description updated" + region := "eu01" + instanceId := uuid.New() + url := "url" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + bucketName := "bucket" + deletetExpRetention := "1m" + + updateResp := &modelexperiments.PartialUpdateInstanceResponse{ + Instance: modelexperiments.Instance{ + DeletedExperimentRetention: &deletetExpRetention, + BucketName: &bucketName, + Description: &descriptionUpdated, + Name: instanceNameUpdated, + Region: ®ion, + Url: url, + Id: instanceId.String(), + State: "active", + }, + } + + tc.MockInstanceCLient.EXPECT().PartialUpdateInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceExecute(gomock.Any()).Return(updateResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := instance.Model{ + Id: tfId, + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(instanceName), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + DeletedExperimentRetention: types.StringValue(deletetExpRetention), + BucketName: types.StringValue(bucketName), + State: types.StringValue("active"), + Url: types.StringValue(url), + } + + plannedState := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceNameUpdated), + Description: types.StringValue(descriptionUpdated), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateInstanceRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateInstanceResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + instanceRes.Update(tc.Ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("Update should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + // state should be updated + var finalState instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + if tfId != finalState.Id { + t.Fatalf("expected %v, got %v", tfId.String(), finalState.Id.ValueString()) + } + if instanceId.String() != finalState.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), finalState.InstanceId.ValueString()) + } + if projectId.String() != finalState.ProjectId.ValueString() { + t.Fatalf("expected %v, got %v", projectId.String(), finalState.ProjectId.ValueString()) + } + if instanceNameUpdated != finalState.Name.ValueString() { + t.Fatalf("expected %v, got %v", instanceNameUpdated, finalState.Name.ValueString()) + } + if descriptionUpdated != finalState.Description.ValueString() { + t.Fatalf("expected %v, got %v", descriptionUpdated, finalState.Description.ValueString()) + } + if finalState.State.ValueString() != "active" { + t.Fatalf("expected %v, got %v", "active", finalState.State.ValueString()) + } + if url != finalState.Url.ValueString() { + t.Fatalf("expected %v, got %v", url, finalState.Url.ValueString()) + } + if region != finalState.Region.ValueString() { + t.Fatalf("expected %v, got %v", region, finalState.Region.ValueString()) + } + if bucketName != finalState.BucketName.ValueString() { + t.Fatalf("expected %v, got %v", bucketName, finalState.BucketName.ValueString()) + } + if deletetExpRetention != finalState.DeletedExperimentRetention.ValueString() { + t.Fatalf("expected %v, got %v", deletetExpRetention, finalState.DeletedExperimentRetention.ValueString()) + } +} + +func TestUpdate_InstanceNotFound(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + instanceNameUpdated := "update name" + description := "description" + descriptionUpdated := "description updated" + region := "eu01" + instanceId := uuid.New() + url := "url" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + bucketName := "bucket" + deletetExpRetention := "1m" + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: 404, + } + tc.MockInstanceCLient.EXPECT().PartialUpdateInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := instance.Model{ + Id: tfId, + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(instanceName), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + DeletedExperimentRetention: types.StringValue(deletetExpRetention), + BucketName: types.StringValue(bucketName), + State: types.StringValue("active"), + Url: types.StringValue(url), + } + + plannedState := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceNameUpdated), + Description: types.StringValue(descriptionUpdated), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateInstanceRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateInstanceResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + instanceRes.Update(tc.Ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("Update should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + // state should be deleted + var finalState *instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + if finalState != nil { + t.Fatalf("State should not be written") + } +} + +func TestUpdate_InstanceUpdateError(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + instanceName := "test" + instanceNameUpdated := "update name" + description := "description" + descriptionUpdated := "description updated" + region := "eu01" + instanceId := uuid.New() + url := "url" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, instanceId.String()) + bucketName := "bucket" + deletetExpRetention := "1m" + + tc.MockInstanceCLient.EXPECT().PartialUpdateInstance(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceExecute(gomock.Any()).Return(nil, fmt.Errorf("server error")) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + instanceRes := instance.NewInstanceResource(tc.MockInstanceCLient, nil, providerData) + + schemaResp := resource.SchemaResponse{} + instanceRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := instance.Model{ + Id: tfId, + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(instanceName), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + DeletedExperimentRetention: types.StringValue(deletetExpRetention), + BucketName: types.StringValue(bucketName), + State: types.StringValue("active"), + Url: types.StringValue(url), + } + + plannedState := instance.Model{ + ProjectId: types.StringValue(projectId.String()), + Region: types.StringValue(region), + Name: types.StringValue(instanceNameUpdated), + Description: types.StringValue(descriptionUpdated), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateInstanceRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateInstanceResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + instanceRes.Update(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Update should not succeed, but got no errors") + } + + // state should not be updated + var finalState instance.Model + diags := resp.State.Get(tc.Ctx, &finalState) + if diags.HasError() { + t.Fatalf("Failed to get state: %v", diags.Errors()) + } + + if tfId != finalState.Id { + t.Fatalf("expected %v, got %v", tfId.String(), finalState.Id.ValueString()) + } + if instanceId.String() != finalState.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), finalState.InstanceId.ValueString()) + } + if projectId.String() != finalState.ProjectId.ValueString() { + t.Fatalf("expected %v, got %v", projectId.String(), finalState.ProjectId.ValueString()) + } + if instanceName != finalState.Name.ValueString() { + t.Fatalf("expected %v, got %v", instanceName, finalState.Name.ValueString()) + } + if description != finalState.Description.ValueString() { + t.Fatalf("expected %v, got %v", description, finalState.Description.ValueString()) + } + if finalState.State.ValueString() != "active" { + t.Fatalf("expected %v, got %v", "active", finalState.State.ValueString()) + } + if url != finalState.Url.ValueString() { + t.Fatalf("expected %v, got %v", url, finalState.Url.ValueString()) + } + if region != finalState.Region.ValueString() { + t.Fatalf("expected %v, got %v", region, finalState.Region.ValueString()) + } + if bucketName != finalState.BucketName.ValueString() { + t.Fatalf("expected %v, got %v", bucketName, finalState.BucketName.ValueString()) + } + if deletetExpRetention != finalState.DeletedExperimentRetention.ValueString() { + t.Fatalf("expected %v, got %v", deletetExpRetention, finalState.DeletedExperimentRetention.ValueString()) + } +} diff --git a/stackit/internal/services/modelexperiments/modelexperiments_acc_test.go b/stackit/internal/services/modelexperiments/modelexperiments_acc_test.go new file mode 100644 index 000000000..1a152f00a --- /dev/null +++ b/stackit/internal/services/modelexperiments/modelexperiments_acc_test.go @@ -0,0 +1,183 @@ +package modelexperiments_test + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +//nolint:all +var instanceResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": "tf acc test instance01", + "description": "my description", + "description_updated": "my description updated", + "region": testutil.Region, + "token_name": "tf acc test token01", + "token_description": "my token description", + "token_description_updated": "my token description updated", +} + +func inputInstanceConfig(instanceName, instanceDescription, token_name, token_description string) string { + return fmt.Sprintf(` + %s + + resource "stackit_modelexperiments_instance" "instance" { + project_id = "%s" + name = "%s" + region = "%s" + description = "%s" + } + + resource "stackit_modelexperiments_token" "token" { + project_id = "%s" + name = "%s" + region = "%s" + instance_id = stackit_modelexperiments_instance.instance.instance_id + description = "%s" + } + `, + testutil.NewConfigBuilder().BuildProviderConfig(), + instanceResource["project_id"], + instanceName, + instanceResource["region"], + instanceDescription, + instanceResource["project_id"], + token_name, + instanceResource["region"], + token_description, + ) +} + +func TestAccModelExperimentsInstanceResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckModelExperimentsInstanceDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: inputInstanceConfig( + instanceResource["name"], + instanceResource["description"], + instanceResource["token_name"], + instanceResource["token_description"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "region", instanceResource["region"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "description", instanceResource["description"]), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "state"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "bucket_name"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "deleted_experiment_retention"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "url"), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "region", instanceResource["region"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "name", instanceResource["token_name"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "description", instanceResource["token_description"]), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "token_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "state"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "token"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "valid_until"), + ), + }, + // Update + { + Config: inputInstanceConfig( + instanceResource["name"], + instanceResource["description_updated"], + instanceResource["token_name"], + instanceResource["token_description_updated"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "region", instanceResource["region"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_instance.instance", "description", instanceResource["description_updated"]), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "state"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "bucket_name"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "deleted_experiment_retention"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_instance.instance", "url"), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "region", instanceResource["region"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "name", instanceResource["token_name"]), + resource.TestCheckResourceAttr("stackit_modelexperiments_token.token", "description", instanceResource["token_description_updated"]), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "instance_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "token_id"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "state"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "token"), + resource.TestCheckResourceAttrSet("stackit_modelexperiments_token.token", "valid_until"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckModelExperimentsInstanceDestroy(s *terraform.State) error { + fmt.Println("destroying resources") + ctx := context.Background() + client, err := modelexperiments.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.ModelExperimentsCustomEndpoint, false)...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + instancesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_modelexperiments_instance" { + continue + } + + // Token terraform ID: "[project_id],[region],[token_id]" + idParts := strings.Split(rs.Primary.ID, core.Separator) + if len(idParts) != 3 { + return fmt.Errorf("invalid ID: %s", rs.Primary.ID) + } + if idParts[2] != "" { + instancesToDestroy = append(instancesToDestroy, idParts[2]) + } + } + + if len(instancesToDestroy) == 0 { + return nil + } + + instancesResp, err := client.DefaultAPI.ListInstances(ctx, testutil.ProjectId, testutil.Region).Execute() + if err != nil { + return fmt.Errorf("getting instanceResp: %w", err) + } + + if len(instancesResp.Instances) == 0 { + fmt.Print("No instances found for project \n") + return nil + } + + items := instancesResp.Instances + for i := range items { + if slices.Contains(instancesToDestroy, items[i].Name) { + _, err := client.DefaultAPI.DeleteInstance(ctx, testutil.ProjectId, testutil.Region, items[i].Id).Execute() + if err != nil { + return fmt.Errorf("destroying instance %s during CheckDestroy: %w", items[i].Name, err) + } + _, err = wait.DeleteInstanceWaitHandler(ctx, client.DefaultAPI, testutil.Region, testutil.ProjectId, items[i].Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("destroying token %s during CheckDestroy: waiting for deletion %w", items[i].Name, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/modelexperiments/testutils/test_utils.go b/stackit/internal/services/modelexperiments/testutils/test_utils.go new file mode 100644 index 000000000..610e2ae1d --- /dev/null +++ b/stackit/internal/services/modelexperiments/testutils/test_utils.go @@ -0,0 +1,238 @@ +package testutils + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + mock_instance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance/mock" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" + mock_serviceenablement "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/utils/mock" +) + +type TestContext struct { + T *testing.T + MockCtrl *gomock.Controller + MockInstanceCLient *mock_instance.MockDefaultAPI + MockServiceEnablementClient *mock_serviceenablement.MockDefaultAPI + Ctx context.Context +} + +func NewTestContext(t *testing.T) *TestContext { + ctrl := gomock.NewController(t) + mockClient := mock_instance.NewMockDefaultAPI(ctrl) + mockServiceClient := mock_serviceenablement.NewMockDefaultAPI(ctrl) + return &TestContext{ + T: t, + MockCtrl: ctrl, + MockInstanceCLient: mockClient, + MockServiceEnablementClient: mockServiceClient, + Ctx: context.Background(), + } +} + +func CreateInstanceTestModel(projectId, region, name, description string) instance.Model { + return instance.Model{ + ProjectId: types.StringValue(projectId), + Region: types.StringValue(region), + Name: types.StringValue(name), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + } +} + +func CreateInstanceRequest(ctx context.Context, schema resource.SchemaResponse, model instance.Model) resource.CreateRequest { //nolint:gocritic + req := resource.CreateRequest{} + req.Plan = tfsdk.Plan{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.Plan.Set(ctx, model) + return req +} + +func CreateInstanceTokenRequest(ctx context.Context, schema resource.SchemaResponse, model token.Model) resource.CreateRequest { //nolint:gocritic + req := resource.CreateRequest{} + req.Plan = tfsdk.Plan{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.Plan.Set(ctx, model) + return req +} + +func CreateResponse(schema resource.SchemaResponse) *resource.CreateResponse { //nolint:gocritic + resp := &resource.CreateResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + return resp +} + +func UpdateInstanceRequest(ctx context.Context, schema resource.SchemaResponse, currentState, plannedState instance.Model) resource.UpdateRequest { //nolint:gocritic + req := resource.UpdateRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.Plan = tfsdk.Plan{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, currentState) + req.Plan.Set(ctx, plannedState) + return req +} + +func UpdateTokenRequest(ctx context.Context, schema resource.SchemaResponse, currentState, plannedState token.Model) resource.UpdateRequest { //nolint:gocritic + req := resource.UpdateRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.Plan = tfsdk.Plan{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, currentState) + req.Plan.Set(ctx, plannedState) + return req +} + +// UpdateInstanceResponse creates a test Update response +// Optionally initialize with current state to simulate Terraform framework behavior +func UpdateInstanceResponse(ctx context.Context, schema resource.SchemaResponse, currentState *instance.Model) *resource.UpdateResponse { //nolint:gocritic + resp := &resource.UpdateResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Update errors without calling State.Set(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} + +func UpdateTokenResponse(ctx context.Context, schema resource.SchemaResponse, currentState *token.Model) *resource.UpdateResponse { //nolint:gocritic + resp := &resource.UpdateResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Update errors without calling State.Set(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} + +// DeleteInstanceRequest creates a test Delete request +func DeleteInstanceRequest(ctx context.Context, schema resource.SchemaResponse, state instance.Model) resource.DeleteRequest { //nolint:gocritic + req := resource.DeleteRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, state) + return req +} + +func DeleteTokenRequest(ctx context.Context, schema resource.SchemaResponse, state token.Model) resource.DeleteRequest { //nolint:gocritic + req := resource.DeleteRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, state) + return req +} + +// DeleteInstanceResponse creates a test Delete response +// Optionally initialize with current state to simulate Terraform framework behavior +func DeleteInstanceResponse(ctx context.Context, schema resource.SchemaResponse, currentState *instance.Model) *resource.DeleteResponse { //nolint:gocritic + resp := &resource.DeleteResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Delete errors without calling State.RemoveResource(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} + +func DeleteTokenResponse(ctx context.Context, schema resource.SchemaResponse, currentState *token.Model) *resource.DeleteResponse { //nolint:gocritic + resp := &resource.DeleteResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Delete errors without calling State.RemoveResource(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} + +// ReadInstanceRequest creates a test Read request +func ReadInstanceRequest(ctx context.Context, schema resource.SchemaResponse, state instance.Model) resource.ReadRequest { //nolint:gocritic + req := resource.ReadRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, state) + return req +} + +func ReadTokenRequest(ctx context.Context, schema resource.SchemaResponse, state token.Model) resource.ReadRequest { //nolint:gocritic + req := resource.ReadRequest{} + req.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + req.State.Set(ctx, state) + return req +} + +// ReadInstanceResponse creates a test Read response +func ReadInstanceResponse(ctx context.Context, schema resource.SchemaResponse, currentState *instance.Model) *resource.ReadResponse { //nolint:gocritic + resp := &resource.ReadResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Delete errors without calling State.RemoveResource(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} + +func ReadTokenResponse(ctx context.Context, schema resource.SchemaResponse, currentState *token.Model) *resource.ReadResponse { //nolint:gocritic + resp := &resource.ReadResponse{} + resp.State = tfsdk.State{ + Schema: schema.Schema, + Raw: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + } + // Initialize with current state to simulate framework behavior + // When Delete errors without calling State.RemoveResource(), this state is preserved + if currentState != nil { + resp.State.Set(ctx, *currentState) + } + return resp +} diff --git a/stackit/internal/services/modelexperiments/token/description.md b/stackit/internal/services/modelexperiments/token/description.md new file mode 100644 index 000000000..6f4886e3d --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/description.md @@ -0,0 +1,21 @@ +AI Model Experiment Instance Token Resource schema. + +## Example Usage + +```terraform + +resource "stackit_modelexperiments_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example instance" + region = "eu01" + description = "Example description" +} + +resource "stackit_modelexperiments_token" "token" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + region = "eu01" + instance_id = stackit_modelexperiments_instance.example.instance_id + description = "Example description" +} +``` \ No newline at end of file diff --git a/stackit/internal/services/modelexperiments/token/resource.go b/stackit/internal/services/modelexperiments/token/resource.go new file mode 100644 index 000000000..df3b93cd6 --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource.go @@ -0,0 +1,610 @@ +package token + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + modelexperimentsutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +var ( + _ resource.Resource = &tokenResource{} + _ resource.ResourceWithConfigure = &tokenResource{} + _ resource.ResourceWithModifyPlan = &tokenResource{} +) + +//go:embed description.md +var markdownDescription string + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + InstanceId types.String `tfsdk:"instance_id"` + TokenId types.String `tfsdk:"token_id"` + Labels types.Map `tfsdk:"labels"` + State types.String `tfsdk:"state"` + ValidUntil types.String `tfsdk:"valid_until"` + TTLDuration types.String `tfsdk:"ttl_duration"` + Token types.String `tfsdk:"token"` +} + +// NewInstanceTokenResource is a helper function to simplify the provider implementation. +func NewInstanceTokenResourceEmpty() resource.Resource { + return &tokenResource{} +} + +func NewInstanceTokenResource(client modelexperiments.DefaultAPI, providerData core.ProviderData) resource.Resource { //nolint:gocritic + return &tokenResource{ + client: client, + providerData: providerData, + } +} + +// tokenResource is the resource implementation. +type tokenResource struct { + client modelexperiments.DefaultAPI + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (i *tokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_modelexperiments_token" +} + +// Configure adds the provider configured client to the resource. +func (i *tokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + i.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := modelexperimentsutils.ConfigureClient(ctx, &i.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + i.client = apiClient.DefaultAPI + tflog.Info(ctx, "Model experiments client configured") +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (i *tokenResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion( + ctx, + configModel.Region, + &planModel.Region, + i.providerData.GetRegion(), + resp, + ) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Schema defines the schema for the resource. +func (i *tokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: markdownDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`token_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the AI model experiments instance token is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: "Region to which the AI model experiments instance token is associated. If not defined, the provider region is used", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "Name of the AI model experiments instance token.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 64), + }, + }, + "instance_id": schema.StringAttribute{ + Description: "The AI model experiments instance ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "token_id": schema.StringAttribute{ + Description: "The AI model experiments instance token ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "labels": schema.MapAttribute{ + Description: "A map of arbitrary key/value pairs for the AI model experiments instance token.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "description": schema.StringAttribute{ + Description: "The description of the AI model experiments instance token.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(0, 160), + }, + }, + "state": schema.StringAttribute{ + Description: "State of the AI model experiments instance token.", + Computed: true, + }, + "token": schema.StringAttribute{ + Description: "Content of the AI model experiments instance token.", + Computed: true, + Sensitive: true, + }, + "valid_until": schema.StringAttribute{ + Description: "The time until the AI model experiments instance token is valid.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "ttl_duration": schema.StringAttribute{ + Description: "The TTL duration of the AI model experiments instance token. E.g. 5h30m40s,5h,5h30m,30m,30s", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.ValidDurationString(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (i *tokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance token", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + createInstanceTokenResp, err := i.client.CreateInstanceToken(ctx, projectId, region, instanceId).CreateInstanceTokenPayload(*payload).Execute() + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating AI model experiments instance token", + fmt.Sprintf("Calling API: %v", err), + ) + return + } + ctx = core.LogResponse(ctx) + + if createInstanceTokenResp.Token.Id == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance token", "Got empty token id") + return + } + + tokenId := createInstanceTokenResp.Token.Id + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "instance_id": instanceId, + "token_id": tokenId, + }) + if resp.Diagnostics.HasError() { + return + } + + waitResp, err := wait.CreateInstanceTokenWaitHandler(ctx, i.client, region, projectId, instanceId, tokenId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance token", fmt.Sprintf("Waiting for instance to be active: %v", err)) + } + + // Map response body to schema + err = mapCreateResponse(ctx, createInstanceTokenResp, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating AI model experiments instance token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance token created") +} + +// Read refreshes the Terraform state with the latest data. +func (i *tokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + tokenId := model.TokenId.ValueString() + if tokenId == "" { + // Resource not yet created; ID is unknown. + resp.State.RemoveResource(ctx) + return + } + + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + getInstanceTokenResp, err := i.client.GetInstanceToken(ctx, projectId, region, tokenId, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + // Remove the resource from the state so Terraform will recreate it + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading AI model experiments instance token", fmt.Sprintf("Calling API: %v", err)) + return + } + ctx = core.LogResponse(ctx) + + if getInstanceTokenResp != nil && getInstanceTokenResp.Token.State == modelexperiments.TOKENSTATE_INACTIVE { + resp.State.RemoveResource(ctx) + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Error updating AI model experiments instance token", "AI model experiments token has expired") + return + } + + err = mapToken(ctx, &getInstanceTokenResp.Token, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance token read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (i *tokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var plan Model + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get current state + var state Model + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := state.ProjectId.ValueString() + instanceId := plan.InstanceId.ValueString() + tokenId := state.TokenId.ValueString() + region := i.providerData.GetRegionWithOverride(plan.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(&plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + updateInstanceTokenResp, err := i.client.PartialUpdateInstanceToken(ctx, projectId, region, tokenId, instanceId).PartialUpdateInstanceTokenPayload(*payload).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + // Remove the resource from the state so Terraform will recreate it + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating AI model experiments instance token", + fmt.Sprintf( + "Calling API: %v, tokenId: %s, instanceId: %s, region: %s, projectId: %s", + err, + tokenId, + instanceId, + region, + projectId, + ), + ) + return + } + + ctx = core.LogResponse(ctx) + + if updateInstanceTokenResp != nil && updateInstanceTokenResp.Token.State == modelexperiments.TOKENSTATE_INACTIVE { + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Error updating AI model experiments instance token", "AI model experiments token has expired") + resp.State.RemoveResource(ctx) + return + } + + plan.Token = state.Token + err = mapToken(ctx, &updateInstanceTokenResp.Token, &plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating AI model experiments instance token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model experiments instance token updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (i *tokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + instanceId := model.InstanceId.ValueString() + tokenId := model.TokenId.ValueString() + + region := i.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + _, err := i.client.DeleteInstanceToken(ctx, projectId, region, tokenId, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting AI model experiments instance token", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteInstanceTokenWaitHandler(ctx, i.client, region, projectId, instanceId, tokenId). + WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting AI model experiments instance token", fmt.Sprintf("Waiting for instance to be deleted: %v", err)) + return + } + + tflog.Info(ctx, "Model experiments instance token deleted") +} + +// mapCreateResponse maps the instace creation response and GET instance response to the model +func mapCreateResponse(ctx context.Context, instanceTokenResp *modelexperiments.CreateInstanceTokenResponse, waitResp *modelexperiments.GetInstanceTokenResponse, model *Model, region string) error { + if instanceTokenResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + token := instanceTokenResp.Token + + if token.Id == "" { + return fmt.Errorf("token id not present") + } + + if waitResp == nil { + model.State = types.StringValue(string(instanceTokenResp.Token.State)) + } else { + model.State = types.StringValue(string(waitResp.Token.State)) + } + + mapValue, diags := types.MapValueFrom(ctx, types.StringType, token.Labels) + if diags.HasError() { + return fmt.Errorf("failure in mapping labels") + } + + validUntil := types.StringNull() + if !token.ValidUntil.IsZero() { + validUntil = types.StringValue(token.ValidUntil.Format(time.RFC3339)) + } + + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, token.Id) + model.TokenId = types.StringValue(token.Id) + model.Name = types.StringValue(token.Name) + model.Description = types.StringPointerValue(token.Description) + model.ValidUntil = validUntil + model.Token = types.StringValue(token.Content) + model.Labels = mapValue + + return nil +} + +// mapToken maps instances to the resource model +func mapToken(ctx context.Context, token *modelexperiments.TokenMetadata, model *Model) error { + if model == nil { + return fmt.Errorf("model input is nil") + } + + if token.Id == "" { + return fmt.Errorf("token id not present") + } + + mapValue, diags := types.MapValueFrom(ctx, types.StringType, token.Labels) + if diags.HasError() { + return fmt.Errorf("failure in mapping labels") + } + + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.Region.ValueString(), token.Id) + model.TokenId = types.StringValue(token.Id) + model.Name = types.StringValue(token.Name) + model.State = types.StringValue(string(token.State)) + model.Description = types.StringPointerValue(token.Description) + model.ValidUntil = types.StringValue(token.ValidUntil.Format(time.RFC3339)) + model.Labels = mapValue + + return nil +} + +func toCreatePayload(model *Model) (*modelexperiments.CreateInstanceTokenPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &modelexperiments.CreateInstanceTokenPayload{ + Name: model.Name.ValueString(), + Description: conversion.StringValueToPointer(model.Description), + TtlDuration: conversion.StringValueToPointer(model.TTLDuration), + Labels: labels, + }, nil +} + +func toUpdatePayload(model *Model) (*modelexperiments.PartialUpdateInstanceTokenPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + modelLabels := model.Labels.Elements() + labels, err := conversion.ToOptStringMap(modelLabels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &modelexperiments.PartialUpdateInstanceTokenPayload{ + Name: conversion.StringValueToPointer(model.Name), + Description: conversion.StringValueToPointer(model.Description), + Labels: labels, + }, nil +} diff --git a/stackit/internal/services/modelexperiments/token/resource_create_test.go b/stackit/internal/services/modelexperiments/token/resource_create_test.go new file mode 100644 index 000000000..267ae0d52 --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource_create_test.go @@ -0,0 +1,348 @@ +package token_test + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestCreate_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + content := "token" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + createTokenResp := &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Content: content, + Description: &description, + Id: tokenId.String(), + Name: name, + Region: region, + State: "creating", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().CreateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceTokenExecute(gomock.Any()).Return(createTokenResp, nil) + + getTokenResp := &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + Description: &description, + Id: tokenId.String(), + Name: name, + Region: region, + State: "active", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(getTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.CreateInstanceTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + tokenRes.Create(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Create should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + var createdState token.Model + diags := resp.State.Get(tc.Ctx, &createdState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if createdState.ProjectId.ValueString() != projectId.String() { + t.Fatalf("ProjectId mismatch: got %v, want %v", createdState.ProjectId.ValueString(), projectId.String()) + } + if createdState.Region.ValueString() != region { + t.Fatalf("Region mismatch: got %v, want %v", createdState.Region.ValueString(), region) + } + if createdState.Name.ValueString() != name { + t.Fatalf("Name mismatch: got %v, want %v", createdState.Name.ValueString(), name) + } + if createdState.Description.ValueString() != description { + t.Fatalf("Description mismatch: got %v, want %v", createdState.Description.ValueString(), description) + } + if createdState.InstanceId.ValueString() != instanceId.String() { + t.Fatalf("InstanceId mismatch: got %v, want %v", createdState.InstanceId.ValueString(), instanceId.String()) + } + if createdState.TokenId.ValueString() != tokenId.String() { + t.Fatalf("TokenId mismatch: got %v, want %v", createdState.TokenId.ValueString(), tokenId.String()) + } + if createdState.Id != tfId { + t.Fatalf("Id mismatch: got %v, want %v", createdState.Id.ValueString(), tfId) + } + if createdState.State.ValueString() != "active" { + t.Fatalf("State mismatch: got %v, want active", createdState.State.ValueString()) + } + if createdState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("ValidUntil mismatch: got %v, want 2099-01-01T00:00:00Z", createdState.ValidUntil.ValueString()) + } + if !createdState.Labels.IsNull() { + t.Fatalf("Labels should be null") + } + if createdState.Token.ValueString() != content { + t.Fatalf("Token mismatch: got %v, want %v", createdState.Token.ValueString(), content) + } +} + +func TestCreate_TokenIdEmpty(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + validUntil := time.Now() + content := "token" + + createTokenResp := &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Content: content, + Description: &description, + Id: "", + Name: name, + Region: region, + State: "creating", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().CreateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceTokenExecute(gomock.Any()).Return(createTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.CreateInstanceTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + tokenRes.Create(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should not succeed but got no errors") + } + + // state should not be created + var createdState *token.Model + diags := resp.State.Get(tc.Ctx, &createdState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if createdState != nil { + t.Fatalf("expected nil, got %v", createdState) + } +} + +func TestCreate_CreateTokenFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: 400, + } + tc.MockInstanceCLient.EXPECT().CreateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.CreateInstanceTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + tokenRes.Create(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should not succeed but got no errors") + } + + // state should not be created + var createdState *token.Model + diags := resp.State.Get(tc.Ctx, &createdState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if createdState != nil { + t.Fatalf("expected nil, got %v", createdState) + } +} + +func TestCreate_GetTokenFailure(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + content := "token" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + createTokenResp := &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Content: content, + Description: &description, + Id: tokenId.String(), + Name: name, + Region: region, + State: "creating", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().CreateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiCreateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().CreateInstanceTokenExecute(gomock.Any()).Return(createTokenResp, nil) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: 404, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.CreateInstanceTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.CreateResponse(schemaResp) + + tokenRes.Create(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Create should not succeed but got no errors") + } + + // state should be created + var createdState token.Model + diags := resp.State.Get(tc.Ctx, &createdState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if createdState.ProjectId.ValueString() != projectId.String() { + t.Fatalf("ProjectId mismatch: got %v, want %v", createdState.ProjectId.ValueString(), projectId.String()) + } + if createdState.Region.ValueString() != region { + t.Fatalf("Region mismatch: got %v, want %v", createdState.Region.ValueString(), region) + } + if createdState.Name.ValueString() != name { + t.Fatalf("Name mismatch: got %v, want %v", createdState.Name.ValueString(), name) + } + if createdState.Description.ValueString() != description { + t.Fatalf("Description mismatch: got %v, want %v", createdState.Description.ValueString(), description) + } + if createdState.InstanceId.ValueString() != instanceId.String() { + t.Fatalf("InstanceId mismatch: got %v, want %v", createdState.InstanceId.ValueString(), instanceId.String()) + } + if createdState.TokenId.ValueString() != tokenId.String() { + t.Fatalf("TokenId mismatch: got %v, want %v", createdState.TokenId.ValueString(), tokenId.String()) + } + if createdState.Id != tfId { + t.Fatalf("Id mismatch: got %v, want %v", createdState.Id.ValueString(), tfId) + } + if createdState.State.ValueString() != "creating" { + t.Fatalf("State mismatch: got %v, want creating", createdState.State.ValueString()) + } + if createdState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("ValidUntil mismatch: got %v, want 2099-01-01T00:00:00Z", createdState.ValidUntil.ValueString()) + } + if !createdState.Labels.IsNull() { + t.Fatalf("Labels should be null") + } + if createdState.Token.ValueString() != content { + t.Fatalf("Token mismatch: got %v, want %v", createdState.Token.ValueString(), content) + } +} diff --git a/stackit/internal/services/modelexperiments/token/resource_delete_test.go b/stackit/internal/services/modelexperiments/token/resource_delete_test.go new file mode 100644 index 000000000..5ceef4985 --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource_delete_test.go @@ -0,0 +1,231 @@ +package token_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" +) + +func TestDelete_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + instanceId := uuid.New() + tokenId := uuid.New() + + tc.MockInstanceCLient.EXPECT().DeleteInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceTokenExecute(gomock.Any()).Return(nil, nil) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(name), + TokenId: types.StringValue(tokenId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteTokenRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteTokenResponse(tc.Ctx, schemaResp, nil) + + tokenRes.Delete(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Delete should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } +} + +func TestDelete_DeleteTokenFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + instanceId := uuid.New() + tokenId := uuid.New() + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().DeleteInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(name), + TokenId: types.StringValue(tokenId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteTokenRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteTokenResponse(tc.Ctx, schemaResp, &state) + + tokenRes.Delete(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Delete should not succeed") + } + + // state should not be removed + var deletedState token.Model + diags := resp.State.Get(tc.Ctx, &deletedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if instanceId.String() != deletedState.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), deletedState.InstanceId.ValueString()) + } +} + +func TestDelete_TokenNotFound(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + instanceId := uuid.New() + tokenId := uuid.New() + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().DeleteInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(name), + TokenId: types.StringValue(tokenId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteTokenRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteTokenResponse(tc.Ctx, schemaResp, &state) + + tokenRes.Delete(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Delete should succeed, but got errors: %v", resp.Diagnostics.Errors()) + } + + // state should be removed + var deletedState *token.Model + diags := resp.State.Get(tc.Ctx, &deletedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if deletedState != nil { + t.Fatalf("should be nil") + } +} + +func TestDelete_GetTokenFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + instanceId := uuid.New() + tokenId := uuid.New() + + tc.MockInstanceCLient.EXPECT().DeleteInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiDeleteInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().DeleteInstanceTokenExecute(gomock.Any()).Return(nil, nil) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + state := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Region: types.StringValue(region), + Name: types.StringValue(name), + TokenId: types.StringValue(tokenId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.DeleteTokenRequest(tc.Ctx, schemaResp, state) + resp := testutils.DeleteTokenResponse(tc.Ctx, schemaResp, &state) + + tokenRes.Delete(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Delete should not succeed") + } + + // state should not be removed + var deletedState token.Model + diags := resp.State.Get(tc.Ctx, &deletedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if instanceId.String() != deletedState.InstanceId.ValueString() { + t.Fatalf("expected %v, got %v", instanceId.String(), deletedState.InstanceId.ValueString()) + } +} diff --git a/stackit/internal/services/modelexperiments/token/resource_read_test.go b/stackit/internal/services/modelexperiments/token/resource_read_test.go new file mode 100644 index 000000000..1e9a6a22b --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource_read_test.go @@ -0,0 +1,339 @@ +package token_test + +import ( + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestRead_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + newName := "new token name" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + tokenContent := "token" + state := "active" + id := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + getTokenResp := &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + Description: &description, + Id: tokenId.String(), + Name: newName, + Region: region, + State: "active", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(getTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: id, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + req := testutils.ReadTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.ReadTokenResponse(tc.Ctx, schemaResp, nil) + tokenRes.Read(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Get should succeed but got errors") + } + + // state should be written according to GetInstanceToken Response + var refreshedState token.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if tokenId.String() != refreshedState.TokenId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", tokenId.String(), refreshedState.TokenId.ValueString()) + } + if projectId.String() != refreshedState.ProjectId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", projectId.String(), refreshedState.ProjectId.ValueString()) + } + if instanceId.String() != refreshedState.InstanceId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", instanceId.String(), refreshedState.InstanceId.ValueString()) + } + if newName != refreshedState.Name.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", name, refreshedState.Name.ValueString()) + } + if refreshedState.State.ValueString() != "active" { + t.Fatalf("Should be equal - expected %v, got %v", "active", refreshedState.State.ValueString()) + } + if description != refreshedState.Description.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", description, refreshedState.Description.ValueString()) + } + if tokenContent != refreshedState.Token.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", tokenContent, refreshedState.Token.ValueString()) + } + if id.ValueString() != refreshedState.Id.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", id.ValueString(), refreshedState.Id.ValueString()) + } + if region != refreshedState.Region.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", region, refreshedState.Region.ValueString()) + } + if refreshedState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("Should be equal - expected %v, got %v", "2099-01-01T00:00:00Z", refreshedState.ValidUntil.ValueString()) + } +} + +func TestRead_TokenNotFound(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + id := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: id, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + req := testutils.ReadTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.ReadTokenResponse(tc.Ctx, schemaResp, &model) + + tokenRes.Read(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Get should succeed but got errors") + } + + // state should be removed + var refreshedState *token.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if refreshedState != nil { + t.Fatalf("should be nil") + } +} + +func TestRead_GetTokenRequestFailed(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + id := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: id, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + req := testutils.ReadTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.ReadTokenResponse(tc.Ctx, schemaResp, &model) + + tokenRes.Read(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("Get should not succeed") + } + + // state should not be edited + var refreshedState token.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if tokenId.String() != refreshedState.TokenId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", tokenId.String(), refreshedState.TokenId.ValueString()) + } + if projectId.String() != refreshedState.ProjectId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", projectId.String(), refreshedState.ProjectId.ValueString()) + } + if instanceId.String() != refreshedState.InstanceId.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", instanceId.String(), refreshedState.InstanceId.ValueString()) + } + if name != refreshedState.Name.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", name, refreshedState.Name.ValueString()) + } + if refreshedState.State.ValueString() != "active" { + t.Fatalf("Should be equal - expected %v, got %v", "active", refreshedState.State.ValueString()) + } + if description != refreshedState.Description.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", description, refreshedState.Description.ValueString()) + } + if tokenContent != refreshedState.Token.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", tokenContent, refreshedState.Token.ValueString()) + } + if id.ValueString() != refreshedState.Id.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", id.ValueString(), refreshedState.Id.ValueString()) + } + if region != refreshedState.Region.ValueString() { + t.Fatalf("Should be equal - expected %v, got %v", region, refreshedState.Region.ValueString()) + } + if refreshedState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("Should be equal - expected %v, got %v", "2099-01-01T00:00:00Z", refreshedState.ValidUntil.ValueString()) + } +} + +func TestRead_TokenInvalidError(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + region := "eu01" + description := "token description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + id := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + + getTokenResp := &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + Description: &description, + Id: tokenId.String(), + Name: name, + Region: region, + State: "inactive", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().GetInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiGetInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().GetInstanceTokenExecute(gomock.Any()).Return(getTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: "eu01", + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + model := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: id, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + req := testutils.ReadTokenRequest(tc.Ctx, schemaResp, model) + resp := testutils.ReadTokenResponse(tc.Ctx, schemaResp, &model) + + tokenRes.Read(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("Get should succeed but got errors") + } + + // state should be removed + var refreshedState *token.Model + diags := resp.State.Get(tc.Ctx, &refreshedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if refreshedState != nil { + t.Fatalf("should be nil") + } +} diff --git a/stackit/internal/services/modelexperiments/token/resource_test.go b/stackit/internal/services/modelexperiments/token/resource_test.go new file mode 100644 index 000000000..fb75e5aa4 --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource_test.go @@ -0,0 +1,427 @@ +package token + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" +) + +func TestMapTokenFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + input modelexperiments.TokenMetadata + expected Model + isValid bool + }{ + { + description: "should error when state is nil", + state: nil, + input: modelexperiments.TokenMetadata{ + Id: "id", + }, + expected: Model{}, + isValid: false, + }, + { + description: "should error when token id is not present", + state: &Model{}, + input: modelexperiments.TokenMetadata{}, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + TokenId: types.StringValue("id"), + }, + input: modelexperiments.TokenMetadata{ + Id: "id", + Description: new("description"), + Labels: &map[string]string{"key": "value"}, + State: "active", + Name: "name", + ValidUntil: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + }, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + TokenId: types.StringValue("id"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + err := mapToken(ctx, &tt.input, tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapCreateResponseFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + inputCreateResponse *modelexperiments.CreateInstanceTokenResponse + inputGetResponse *modelexperiments.GetInstanceTokenResponse + expected Model + isValid bool + }{ + { + description: "should error when token create response is nil", + state: &Model{}, + inputCreateResponse: nil, + inputGetResponse: &modelexperiments.GetInstanceTokenResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should error when state is nil", + state: nil, + inputCreateResponse: &modelexperiments.CreateInstanceTokenResponse{}, + inputGetResponse: &modelexperiments.GetInstanceTokenResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should error when token id is not present", + state: &Model{}, + inputCreateResponse: &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{}, + }, + inputGetResponse: &modelexperiments.GetInstanceTokenResponse{}, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly even if Get Response is nil", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + inputCreateResponse: &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Id: "id", + Content: "token", + Description: new("description"), + Labels: &map[string]string{"key": "value"}, + State: "active", + Name: "name", + ValidUntil: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + }}, + inputGetResponse: nil, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + Token: types.StringValue("token"), + TokenId: types.StringValue("id"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + }, + isValid: true, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + inputCreateResponse: &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Id: "id", + Content: "token", + Description: new("description"), + Labels: &map[string]string{"key": "value"}, + State: "active", + Name: "name", + ValidUntil: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + }}, + inputGetResponse: &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + State: "active", + }, + }, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + Token: types.StringValue("token"), + TokenId: types.StringValue("id"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + }, + isValid: true, + }, + { + description: "should map fields correctly with label nil", + state: &Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + InstanceId: types.StringValue("id"), + Region: types.StringValue("eu01"), + }, + inputCreateResponse: &modelexperiments.CreateInstanceTokenResponse{ + Token: modelexperiments.Token{ + Id: "id", + Content: "token", + Description: new("description"), + Labels: nil, + State: "active", + Name: "name", + ValidUntil: time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + }}, + inputGetResponse: &modelexperiments.GetInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + State: "active", + }, + }, + expected: Model{ + Id: types.StringValue("pid,eu01,id"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + InstanceId: types.StringValue("id"), + Name: types.StringValue("name"), + Description: types.StringValue("description"), + State: types.StringValue("active"), + Token: types.StringValue("token"), + TokenId: types.StringValue("id"), + Labels: types.MapNull(types.StringType), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + err := mapCreateResponse(ctx, tt.inputCreateResponse, tt.inputGetResponse, tt.state, "eu01") + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelexperiments.CreateInstanceTokenPayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should error when map is not correct", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.Int64Type, map[string]attr.Value{"key": types.Int64Value(33)}), + TTLDuration: types.StringValue("30d"), + }, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + TTLDuration: types.StringValue("30d"), + }, + expected: &modelexperiments.CreateInstanceTokenPayload{ + Name: "name", + Description: new("desc"), + Labels: &map[string]string{"key": "value"}, + TtlDuration: new("30d"), + }, + isValid: true, + }, + { + description: "should convert correctly without labels", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapNull(types.StringType), + TTLDuration: types.StringValue("30d"), + }, + expected: &modelexperiments.CreateInstanceTokenPayload{ + Name: "name", + Description: new("desc"), + TtlDuration: new("30d"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelexperiments.PartialUpdateInstanceTokenPayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should error when map is not correct", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.Int64Type, map[string]attr.Value{"key": types.Int64Value(33)}), + }, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"key": types.StringValue("value")}), + }, + expected: &modelexperiments.PartialUpdateInstanceTokenPayload{ + Name: new("name"), + Description: new("desc"), + Labels: &map[string]string{"key": "value"}, + }, + isValid: true, + }, + { + description: "should convert correctly without labels", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + Labels: types.MapNull(types.StringType), + }, + expected: &modelexperiments.PartialUpdateInstanceTokenPayload{ + Name: new("name"), + Description: new("desc"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toUpdatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/modelexperiments/token/resource_update_test.go b/stackit/internal/services/modelexperiments/token/resource_update_test.go new file mode 100644 index 000000000..2d8eeb575 --- /dev/null +++ b/stackit/internal/services/modelexperiments/token/resource_update_test.go @@ -0,0 +1,395 @@ +package token_test + +import ( + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + "go.uber.org/mock/gomock" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/testutils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func TestUpdate_Success(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + nameUpdated := "token update" + region := "eu01" + description := "token description" + descriptionUpdated := "description" + instanceId := uuid.New() + tokenId := uuid.New() + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + tokenContent := "token" + state := "active" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + updateTokenResp := &modelexperiments.PartialUpdateInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + Description: &descriptionUpdated, + Id: tokenId.String(), + Name: nameUpdated, + Region: region, + State: "active", + ValidUntil: validUntil, + }, + } + + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceTokenExecute(gomock.Any()).Return(updateTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: tfId, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + plannedState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(nameUpdated), + Region: types.StringValue(region), + Description: types.StringValue(descriptionUpdated), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateTokenRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateTokenResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + tokenRes.Update(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("update should succeed") + } + + // state should be updated + var updatedState token.Model + diags := resp.State.Get(tc.Ctx, &updatedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if updatedState.ProjectId.ValueString() != projectId.String() { + t.Fatalf("ProjectId mismatch: got %v, want %v", updatedState.ProjectId.ValueString(), projectId.String()) + } + if updatedState.Region.ValueString() != region { + t.Fatalf("Region mismatch: got %v, want %v", updatedState.Region.ValueString(), region) + } + if updatedState.Name.ValueString() != nameUpdated { + t.Fatalf("Name mismatch: got %v, want %v", updatedState.Name.ValueString(), nameUpdated) + } + if updatedState.Description.ValueString() != descriptionUpdated { + t.Fatalf("Description mismatch: got %v, want %v", updatedState.Description.ValueString(), descriptionUpdated) + } + if updatedState.InstanceId.ValueString() != instanceId.String() { + t.Fatalf("InstanceId mismatch: got %v, want %v", updatedState.InstanceId.ValueString(), instanceId.String()) + } + if updatedState.TokenId.ValueString() != tokenId.String() { + t.Fatalf("TokenId mismatch: got %v, want %v", updatedState.TokenId.ValueString(), tokenId.String()) + } + if updatedState.Id != tfId { + t.Fatalf("Id mismatch: got %v, want %v", updatedState.Id.ValueString(), tfId) + } + if updatedState.State.ValueString() != "active" { + t.Fatalf("State mismatch: got %v, want active", updatedState.State.ValueString()) + } + if updatedState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("ValidUntil mismatch: got %v, want 2099-01-01T00:00:00Z", updatedState.ValidUntil.ValueString()) + } + if !updatedState.Labels.IsNull() { + t.Fatalf("Labels should be null") + } + if updatedState.Token.ValueString() != tokenContent { + t.Fatalf("Token mismatch: got %v, want %v", updatedState.Token.ValueString(), tokenContent) + } +} + +func TestUpdate_TokenNotFound(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + nameUpdated := "token update" + region := "eu01" + description := "token description" + descriptionUpdated := "description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: tfId, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + plannedState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(nameUpdated), + Region: types.StringValue(region), + Description: types.StringValue(descriptionUpdated), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateTokenRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateTokenResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + tokenRes.Update(tc.Ctx, req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("update should succeed") + } + + // state should be removed + var updatedState *token.Model + diags := resp.State.Get(tc.Ctx, &updatedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if updatedState != nil { + t.Fatalf("state should be nil") + } +} + +func TestUpdate_TokenUpdateError(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + nameUpdated := "token update" + region := "eu01" + description := "token description" + descriptionUpdated := "description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + tfId := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + + oapiErr := &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusInternalServerError, + } + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceTokenExecute(gomock.Any()).Return(nil, oapiErr) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: tfId, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + plannedState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(nameUpdated), + Region: types.StringValue(region), + Description: types.StringValue(descriptionUpdated), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateTokenRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateTokenResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + tokenRes.Update(tc.Ctx, req, resp) + if !resp.Diagnostics.HasError() { + t.Fatalf("update should not succeed") + } + + // state should not be changed + var updatedState token.Model + diags := resp.State.Get(tc.Ctx, &updatedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + + if updatedState.ProjectId.ValueString() != projectId.String() { + t.Fatalf("ProjectId mismatch: got %v, want %v", updatedState.ProjectId.ValueString(), projectId.String()) + } + if updatedState.Region.ValueString() != region { + t.Fatalf("Region mismatch: got %v, want %v", updatedState.Region.ValueString(), region) + } + if updatedState.Name.ValueString() != name { + t.Fatalf("Name mismatch: got %v, want %v", updatedState.Name.ValueString(), name) + } + if updatedState.Description.ValueString() != description { + t.Fatalf("Description mismatch: got %v, want %v", updatedState.Description.ValueString(), description) + } + if updatedState.InstanceId.ValueString() != instanceId.String() { + t.Fatalf("InstanceId mismatch: got %v, want %v", updatedState.InstanceId.ValueString(), instanceId.String()) + } + if updatedState.TokenId.ValueString() != tokenId.String() { + t.Fatalf("TokenId mismatch: got %v, want %v", updatedState.TokenId.ValueString(), tokenId.String()) + } + if updatedState.Id != tfId { + t.Fatalf("Id mismatch: got %v, want %v", updatedState.Id.ValueString(), tfId) + } + if updatedState.State.ValueString() != "active" { + t.Fatalf("State mismatch: got %v, want active", updatedState.State.ValueString()) + } + if updatedState.ValidUntil.ValueString() != "2099-01-01T00:00:00Z" { + t.Fatalf("ValidUntil mismatch: got %v, want 2099-01-01T00:00:00Z", updatedState.ValidUntil.ValueString()) + } + if !updatedState.Labels.IsNull() { + t.Fatalf("Labels should be null") + } + if updatedState.Token.ValueString() != tokenContent { + t.Fatalf("Token mismatch: got %v, want %v", updatedState.Token.ValueString(), tokenContent) + } +} + +func TestUpdate_TokenInvalidStateError(t *testing.T) { + tc := testutils.NewTestContext(t) + + projectId := uuid.New() + name := "token" + nameUpdated := "token update" + region := "eu01" + description := "token description" + descriptionUpdated := "description" + instanceId := uuid.New() + tokenId := uuid.New() + tokenContent := "token" + state := "active" + id := utils.BuildInternalTerraformId(projectId.String(), region, tokenId.String()) + validUntil := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC) + + updateTokenResp := &modelexperiments.PartialUpdateInstanceTokenResponse{ + Token: modelexperiments.TokenMetadata{ + Description: &descriptionUpdated, + Id: tokenId.String(), + Name: nameUpdated, + Region: region, + State: "inactive", + ValidUntil: validUntil, + }, + } + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceToken(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(modelexperiments.ApiPartialUpdateInstanceTokenRequest{ + ApiService: tc.MockInstanceCLient, + }) + tc.MockInstanceCLient.EXPECT().PartialUpdateInstanceTokenExecute(gomock.Any()).Return(updateTokenResp, nil) + + providerData := core.ProviderData{ + DefaultRegion: region, + } + tokenRes := token.NewInstanceTokenResource(tc.MockInstanceCLient, providerData) + + schemaResp := resource.SchemaResponse{} + tokenRes.Schema(tc.Ctx, resource.SchemaRequest{}, &schemaResp) + + currentState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + InstanceId: types.StringValue(instanceId.String()), + Name: types.StringValue(name), + Region: types.StringValue(region), + Description: types.StringValue(description), + Labels: types.MapNull(types.StringType), + Token: types.StringValue(tokenContent), + TokenId: types.StringValue(tokenId.String()), + Id: id, + State: types.StringValue(state), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + } + + plannedState := token.Model{ + ProjectId: types.StringValue(projectId.String()), + Name: types.StringValue(nameUpdated), + Region: types.StringValue(region), + Description: types.StringValue(descriptionUpdated), + InstanceId: types.StringValue(instanceId.String()), + Labels: types.MapNull(types.StringType), + } + + req := testutils.UpdateTokenRequest(tc.Ctx, schemaResp, currentState, plannedState) + resp := testutils.UpdateTokenResponse(tc.Ctx, schemaResp, ¤tState) + + // Execute Update + tokenRes.Update(tc.Ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("update should succeed") + } + + // state should be removed + var updatedState *token.Model + diags := resp.State.Get(tc.Ctx, &updatedState) + if diags.HasError() { + t.Fatalf("failed to get state") + } + if updatedState != nil { + t.Fatalf("state should be nil") + } +} diff --git a/stackit/internal/services/modelexperiments/utils/mock/serviceenablement.go b/stackit/internal/services/modelexperiments/utils/mock/serviceenablement.go new file mode 100644 index 000000000..6d0fce345 --- /dev/null +++ b/stackit/internal/services/modelexperiments/utils/mock/serviceenablement.go @@ -0,0 +1,156 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api (interfaces: DefaultAPI) +// +// Generated by this command: +// +// mockgen -destination=./mock/serviceenablement.go -package=mock_serviceenablement github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api DefaultAPI +// + +// Package mock_serviceenablement is a generated GoMock package. +package mock_serviceenablement + +import ( + context "context" + reflect "reflect" + + v2api "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api" + gomock "go.uber.org/mock/gomock" +) + +// MockDefaultAPI is a mock of DefaultAPI interface. +type MockDefaultAPI struct { + ctrl *gomock.Controller + recorder *MockDefaultAPIMockRecorder + isgomock struct{} +} + +// MockDefaultAPIMockRecorder is the mock recorder for MockDefaultAPI. +type MockDefaultAPIMockRecorder struct { + mock *MockDefaultAPI +} + +// NewMockDefaultAPI creates a new mock instance. +func NewMockDefaultAPI(ctrl *gomock.Controller) *MockDefaultAPI { + mock := &MockDefaultAPI{ctrl: ctrl} + mock.recorder = &MockDefaultAPIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDefaultAPI) EXPECT() *MockDefaultAPIMockRecorder { + return m.recorder +} + +// DisableServiceRegional mocks base method. +func (m *MockDefaultAPI) DisableServiceRegional(ctx context.Context, region, projectId, serviceId string) v2api.ApiDisableServiceRegionalRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisableServiceRegional", ctx, region, projectId, serviceId) + ret0, _ := ret[0].(v2api.ApiDisableServiceRegionalRequest) + return ret0 +} + +// DisableServiceRegional indicates an expected call of DisableServiceRegional. +func (mr *MockDefaultAPIMockRecorder) DisableServiceRegional(ctx, region, projectId, serviceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisableServiceRegional", reflect.TypeOf((*MockDefaultAPI)(nil).DisableServiceRegional), ctx, region, projectId, serviceId) +} + +// DisableServiceRegionalExecute mocks base method. +func (m *MockDefaultAPI) DisableServiceRegionalExecute(r v2api.ApiDisableServiceRegionalRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisableServiceRegionalExecute", r) + ret0, _ := ret[0].(error) + return ret0 +} + +// DisableServiceRegionalExecute indicates an expected call of DisableServiceRegionalExecute. +func (mr *MockDefaultAPIMockRecorder) DisableServiceRegionalExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisableServiceRegionalExecute", reflect.TypeOf((*MockDefaultAPI)(nil).DisableServiceRegionalExecute), r) +} + +// EnableServiceRegional mocks base method. +func (m *MockDefaultAPI) EnableServiceRegional(ctx context.Context, region, projectId, serviceId string) v2api.ApiEnableServiceRegionalRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnableServiceRegional", ctx, region, projectId, serviceId) + ret0, _ := ret[0].(v2api.ApiEnableServiceRegionalRequest) + return ret0 +} + +// EnableServiceRegional indicates an expected call of EnableServiceRegional. +func (mr *MockDefaultAPIMockRecorder) EnableServiceRegional(ctx, region, projectId, serviceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableServiceRegional", reflect.TypeOf((*MockDefaultAPI)(nil).EnableServiceRegional), ctx, region, projectId, serviceId) +} + +// EnableServiceRegionalExecute mocks base method. +func (m *MockDefaultAPI) EnableServiceRegionalExecute(r v2api.ApiEnableServiceRegionalRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnableServiceRegionalExecute", r) + ret0, _ := ret[0].(error) + return ret0 +} + +// EnableServiceRegionalExecute indicates an expected call of EnableServiceRegionalExecute. +func (mr *MockDefaultAPIMockRecorder) EnableServiceRegionalExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableServiceRegionalExecute", reflect.TypeOf((*MockDefaultAPI)(nil).EnableServiceRegionalExecute), r) +} + +// GetServiceStatusRegional mocks base method. +func (m *MockDefaultAPI) GetServiceStatusRegional(ctx context.Context, region, projectId, serviceId string) v2api.ApiGetServiceStatusRegionalRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceStatusRegional", ctx, region, projectId, serviceId) + ret0, _ := ret[0].(v2api.ApiGetServiceStatusRegionalRequest) + return ret0 +} + +// GetServiceStatusRegional indicates an expected call of GetServiceStatusRegional. +func (mr *MockDefaultAPIMockRecorder) GetServiceStatusRegional(ctx, region, projectId, serviceId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceStatusRegional", reflect.TypeOf((*MockDefaultAPI)(nil).GetServiceStatusRegional), ctx, region, projectId, serviceId) +} + +// GetServiceStatusRegionalExecute mocks base method. +func (m *MockDefaultAPI) GetServiceStatusRegionalExecute(r v2api.ApiGetServiceStatusRegionalRequest) (*v2api.ServiceStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceStatusRegionalExecute", r) + ret0, _ := ret[0].(*v2api.ServiceStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceStatusRegionalExecute indicates an expected call of GetServiceStatusRegionalExecute. +func (mr *MockDefaultAPIMockRecorder) GetServiceStatusRegionalExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceStatusRegionalExecute", reflect.TypeOf((*MockDefaultAPI)(nil).GetServiceStatusRegionalExecute), r) +} + +// ListServiceStatusRegional mocks base method. +func (m *MockDefaultAPI) ListServiceStatusRegional(ctx context.Context, region, projectId string) v2api.ApiListServiceStatusRegionalRequest { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListServiceStatusRegional", ctx, region, projectId) + ret0, _ := ret[0].(v2api.ApiListServiceStatusRegionalRequest) + return ret0 +} + +// ListServiceStatusRegional indicates an expected call of ListServiceStatusRegional. +func (mr *MockDefaultAPIMockRecorder) ListServiceStatusRegional(ctx, region, projectId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListServiceStatusRegional", reflect.TypeOf((*MockDefaultAPI)(nil).ListServiceStatusRegional), ctx, region, projectId) +} + +// ListServiceStatusRegionalExecute mocks base method. +func (m *MockDefaultAPI) ListServiceStatusRegionalExecute(r v2api.ApiListServiceStatusRegionalRequest) (*v2api.ListServiceStatusRegional200Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListServiceStatusRegionalExecute", r) + ret0, _ := ret[0].(*v2api.ListServiceStatusRegional200Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListServiceStatusRegionalExecute indicates an expected call of ListServiceStatusRegionalExecute. +func (mr *MockDefaultAPIMockRecorder) ListServiceStatusRegionalExecute(r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListServiceStatusRegionalExecute", reflect.TypeOf((*MockDefaultAPI)(nil).ListServiceStatusRegionalExecute), r) +} diff --git a/stackit/internal/services/modelexperiments/utils/util.go b/stackit/internal/services/modelexperiments/utils/util.go new file mode 100644 index 000000000..9e22235d8 --- /dev/null +++ b/stackit/internal/services/modelexperiments/utils/util.go @@ -0,0 +1,46 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + modelexperiment "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + INSTANCESTATE_CREATING = "creating" + INSTANCESTATE_ACTIVE = "active" + INSTANCESTATE_DELETING = "deleting" + INSTANCESTATE_PENDING = "pending" + INSTANCESTATE_UPDATING = "updating" + INSTANCESTATE_IMPAIRED = "impaired" + INSTANCESTATE_RECONCILING = "reconciling" + + TOKENSTATE_ACTIVE = "active" + TOKENSTATE_CREATING = "creating" + TOKENSTATE_DELETING = "deleting" + TOKENSTATE_INACTIVE = "inactive" +) + +//go:generate mockgen -destination=./mock/serviceenablement.go -package=mock_serviceenablement github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/v2api DefaultAPI +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *modelexperiment.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.ModelExperimentsCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ModelExperimentsCustomEndpoint)) + } + apiClient, err := modelexperiment.NewAPIClient(apiClientConfigOptions...) + if err != nil { + core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return nil + } + + return apiClient +} diff --git a/stackit/internal/services/modelexperiments/utils/util_test.go b/stackit/internal/services/modelexperiments/utils/util_test.go new file mode 100644 index 000000000..1bbd7f242 --- /dev/null +++ b/stackit/internal/services/modelexperiments/utils/util_test.go @@ -0,0 +1,94 @@ +package utils + +import ( + "context" + "os" + "reflect" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/stackitcloud/stackit-sdk-go/core/config" + modelexperiments "github.com/stackitcloud/stackit-sdk-go/services/modelexperiments/v1api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +const ( + testVersion = "1.2.3" + testCustomEndpoint = "https://modelexperiments-custom-endpoint.api.stackit.cloud" +) + +func TestConfigureClient(t *testing.T) { + /* mock authentication by setting service account token env variable */ + os.Clearenv() + err := os.Setenv(sdkClients.ServiceAccountToken, "mock-val") + if err != nil { + t.Errorf("error setting env variable: %v", err) + } + + type args struct { + providerData *core.ProviderData + } + tests := []struct { + name string + args args + wantErr bool + expected *modelexperiments.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *modelexperiments.APIClient { + apiClient, err := modelexperiments.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + { + name: "custom endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + ModelExperimentsCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *modelexperiments.APIClient { + apiClient, err := modelexperiments.NewAPIClient( + utils.UserAgentConfigOption(testVersion), + config.WithEndpoint(testCustomEndpoint), + ) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + return apiClient + }(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + diags := diag.Diagnostics{} + + actual := ConfigureClient(ctx, tt.args.providerData, &diags) + if diags.HasError() != tt.wantErr { + t.Errorf("ConfigureClient() error = %v, want %v", diags.HasError(), tt.wantErr) + } + + if !reflect.DeepEqual(actual, tt.expected) { + t.Errorf("ConfigureClient() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index e119666ad..1eac4b2a3 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -67,42 +67,44 @@ var ( // TestImageLocalFilePath is the local path to an image file used for image acceptance tests TestImageLocalFilePath = getenv("TF_ACC_TEST_IMAGE_LOCAL_FILE_PATH", "default") - ALBCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CUSTOM_ENDPOINT", providerName: "alb_custom_endpoint"} - ALBCertCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CERT_CUSTOM_ENDPOINT", providerName: "alb_certificates_custom_endpoint"} - CdnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_CDN_CUSTOM_ENDPOINT", providerName: "cdn_custom_endpoint"} - DnsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DNS_CUSTOM_ENDPOINT", providerName: "dns_custom_endpoint"} - DremioCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DREMIO_CUSTOM_ENDPOINT", providerName: "dremio_custom_endpoint"} - EdgeCloudCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_EDGECLOUD_CUSTOM_ENDPOINT", providerName: "edgecloud_custom_endpoint"} - GitCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_GIT_CUSTOM_ENDPOINT", providerName: "git_custom_endpoint"} - IaaSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_IAAS_CUSTOM_ENDPOINT", providerName: "iaas_custom_endpoint"} - KMSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_KMS_CUSTOM_ENDPOINT", providerName: "kms_custom_endpoint"} - LoadBalancerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT", providerName: "loadbalancer_custom_endpoint"} - LogMeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOGME_CUSTOM_ENDPOINT", providerName: "logme_custom_endpoint"} - LogsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOGS_CUSTOM_ENDPOINT", providerName: "logs_custom_endpoint"} - MariaDBCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MARIADB_CUSTOM_ENDPOINT", providerName: "mariadb_custom_endpoint"} - ModelServingCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MODELSERVING_CUSTOM_ENDPOINT", providerName: "modelserving_custom_endpoint"} - AuthorizationCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_AUTHORIZATION_CUSTOM_ENDPOINT", providerName: "authorization_custom_endpoint"} - MongoDBFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT", providerName: "mongodbflex_custom_endpoint"} - OpenSearchCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT", providerName: "opensearch_custom_endpoint"} - ObservabilityCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OBSERVABILITY_CUSTOM_ENDPOINT", providerName: "observability_custom_endpoint"} - ObjectStorageCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OBJECTSTORAGE_CUSTOM_ENDPOINT", providerName: "objectstorage_custom_endpoint"} - PostgresFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_POSTGRESFLEX_CUSTOM_ENDPOINT", providerName: "postgresflex_custom_endpoint"} - RabbitMQCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_RABBITMQ_CUSTOM_ENDPOINT", providerName: "rabbitmq_custom_endpoint"} - RedisCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_REDIS_CUSTOM_ENDPOINT", providerName: "redis_custom_endpoint"} - ResourceManagerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_RESOURCEMANAGER_CUSTOM_ENDPOINT", providerName: "resourcemanager_custom_endpoint"} - ScfCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SCF_CUSTOM_ENDPOINT", providerName: "scf_custom_endpoint"} - SecretsManagerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SECRETSMANAGER_CUSTOM_ENDPOINT", providerName: "secretsmanager_custom_endpoint"} - SQLServerFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SQLSERVERFLEX_CUSTOM_ENDPOINT", providerName: "sqlserverflex_custom_endpoint"} - ServerBackupCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVER_BACKUP_CUSTOM_ENDPOINT", providerName: "server_backup_custom_endpoint"} - ServerUpdateCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVER_UPDATE_CUSTOM_ENDPOINT", providerName: "server_update_custom_endpoint"} - SFSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SFS_CUSTOM_ENDPOINT", providerName: "sfs_custom_endpoint"} - ServiceAccountCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT", providerName: "service_account_custom_endpoint"} - TokenCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TOKEN_CUSTOM_ENDPOINT", providerName: "token_custom_endpoint"} - VpnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VPN_CUSTOM_ENDPOINT", providerName: "vpn_custom_endpoint"} - SKECustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SKE_CUSTOM_ENDPOINT", providerName: "ske_custom_endpoint"} - IntakeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_INTAKE_CUSTOM_ENDPOINT", providerName: "intake_custom_endpoint"} - TelemetryRouterCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYROUTER_CUSTOM_ENDPOINT", providerName: "telemetryrouter_custom_endpoint"} - TelemetryLinkCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYLINK_CUSTOM_ENDPOINT", providerName: "telemetrylink_custom_endpoint"} + ALBCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CUSTOM_ENDPOINT", providerName: "alb_custom_endpoint"} + ALBCertCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_ALB_CERT_CUSTOM_ENDPOINT", providerName: "alb_certificates_custom_endpoint"} + CdnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_CDN_CUSTOM_ENDPOINT", providerName: "cdn_custom_endpoint"} + DnsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DNS_CUSTOM_ENDPOINT", providerName: "dns_custom_endpoint"} + DremioCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_DREMIO_CUSTOM_ENDPOINT", providerName: "dremio_custom_endpoint"} + EdgeCloudCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_EDGECLOUD_CUSTOM_ENDPOINT", providerName: "edgecloud_custom_endpoint"} + GitCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_GIT_CUSTOM_ENDPOINT", providerName: "git_custom_endpoint"} + IaaSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_IAAS_CUSTOM_ENDPOINT", providerName: "iaas_custom_endpoint"} + KMSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_KMS_CUSTOM_ENDPOINT", providerName: "kms_custom_endpoint"} + LoadBalancerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT", providerName: "loadbalancer_custom_endpoint"} + LogMeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOGME_CUSTOM_ENDPOINT", providerName: "logme_custom_endpoint"} + LogsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_LOGS_CUSTOM_ENDPOINT", providerName: "logs_custom_endpoint"} + MariaDBCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MARIADB_CUSTOM_ENDPOINT", providerName: "mariadb_custom_endpoint"} + ModelServingCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MODELSERVING_CUSTOM_ENDPOINT", providerName: "modelserving_custom_endpoint"} + ModelExperimentsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MODELEXPERIMENTS_CUSTOM_ENDPOINT", providerName: "modelexperiments_custom_endpoint"} + AuthorizationCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_AUTHORIZATION_CUSTOM_ENDPOINT", providerName: "authorization_custom_endpoint"} + MongoDBFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT", providerName: "mongodbflex_custom_endpoint"} + OpenSearchCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT", providerName: "opensearch_custom_endpoint"} + ObservabilityCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OBSERVABILITY_CUSTOM_ENDPOINT", providerName: "observability_custom_endpoint"} + ObjectStorageCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OBJECTSTORAGE_CUSTOM_ENDPOINT", providerName: "objectstorage_custom_endpoint"} + PostgresFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_POSTGRESFLEX_CUSTOM_ENDPOINT", providerName: "postgresflex_custom_endpoint"} + RabbitMQCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_RABBITMQ_CUSTOM_ENDPOINT", providerName: "rabbitmq_custom_endpoint"} + RedisCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_REDIS_CUSTOM_ENDPOINT", providerName: "redis_custom_endpoint"} + ResourceManagerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_RESOURCEMANAGER_CUSTOM_ENDPOINT", providerName: "resourcemanager_custom_endpoint"} + ScfCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SCF_CUSTOM_ENDPOINT", providerName: "scf_custom_endpoint"} + SecretsManagerCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SECRETSMANAGER_CUSTOM_ENDPOINT", providerName: "secretsmanager_custom_endpoint"} + SQLServerFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SQLSERVERFLEX_CUSTOM_ENDPOINT", providerName: "sqlserverflex_custom_endpoint"} + ServerBackupCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVER_BACKUP_CUSTOM_ENDPOINT", providerName: "server_backup_custom_endpoint"} + ServerUpdateCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVER_UPDATE_CUSTOM_ENDPOINT", providerName: "server_update_custom_endpoint"} + SFSCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SFS_CUSTOM_ENDPOINT", providerName: "sfs_custom_endpoint"} + ServiceAccountCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ACCOUNT_CUSTOM_ENDPOINT", providerName: "service_account_custom_endpoint"} + ServiceEnablementCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SERVICE_ENABLEMENT_CUSTOM_ENDPOINT", providerName: "service_enablement_custom_endpoint"} + TokenCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TOKEN_CUSTOM_ENDPOINT", providerName: "token_custom_endpoint"} + VpnCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VPN_CUSTOM_ENDPOINT", providerName: "vpn_custom_endpoint"} + SKECustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_SKE_CUSTOM_ENDPOINT", providerName: "ske_custom_endpoint"} + IntakeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_INTAKE_CUSTOM_ENDPOINT", providerName: "intake_custom_endpoint"} + TelemetryRouterCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYROUTER_CUSTOM_ENDPOINT", providerName: "telemetryrouter_custom_endpoint"} + TelemetryLinkCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYLINK_CUSTOM_ENDPOINT", providerName: "telemetrylink_custom_endpoint"} allCustomEndpoints = []customEndpointConfig{ ALBCustomEndpoint, @@ -118,6 +120,7 @@ var ( LogsCustomEndpoint, MariaDBCustomEndpoint, ModelServingCustomEndpoint, + ModelExperimentsCustomEndpoint, AuthorizationCustomEndpoint, MongoDBFlexCustomEndpoint, OpenSearchCustomEndpoint, @@ -134,6 +137,7 @@ var ( ServerUpdateCustomEndpoint, SFSCustomEndpoint, ServiceAccountCustomEndpoint, + ServiceEnablementCustomEndpoint, TokenCustomEndpoint, VpnCustomEndpoint, SKECustomEndpoint, diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index b81600a64..40cae9994 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -24,9 +24,10 @@ import ( ) const ( - SKEServiceId = "cloud.stackit.ske" - ModelServingServiceId = "cloud.stackit.model-serving" - EdgecloudServiceId = "cloud.stackit.edge-cloud" + SKEServiceId = "cloud.stackit.ske" + ModelServingServiceId = "cloud.stackit.model-serving" + EdgecloudServiceId = "cloud.stackit.edge-cloud" + ModelExperimentsServiceId = "cloud.stackit.model-experiments" ) var ( diff --git a/stackit/provider.go b/stackit/provider.go index 7c653f474..047bff2d6 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -76,6 +76,8 @@ import ( logsInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logs/instance" mariaDBCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/credential" mariaDBInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/instance" + modelExperimentsInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/instance" + modelExperimentsToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelexperiments/token" modelServingToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelserving/token" mongoDBFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/instance" mongoDBFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/user" @@ -191,6 +193,7 @@ type providerModel struct { LogsCustomEndpoint types.String `tfsdk:"logs_custom_endpoint"` MariaDBCustomEndpoint types.String `tfsdk:"mariadb_custom_endpoint"` ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"` + ModelExperimentsCustomEndpoint types.String `tfsdk:"modelexperiments_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` ObjectStorageCustomEndpoint types.String `tfsdk:"objectstorage_custom_endpoint"` ObservabilityCustomEndpoint types.String `tfsdk:"observability_custom_endpoint"` @@ -248,6 +251,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "kms_custom_endpoint": "Custom endpoint for the KMS service", "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", + "modelexperiments_custom_endpoint": "Custom endpoint for the AI Model Experiments service", "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", "logme_custom_endpoint": "Custom endpoint for the LogMe service", "logs_custom_endpoint": "Custom endpoint for the Logs service", @@ -407,6 +411,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["modelserving_custom_endpoint"], }, + "modelexperiments_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["modelexperiments_custom_endpoint"], + }, "authorization_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["authorization_custom_endpoint"], @@ -570,6 +578,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.LogsCustomEndpoint, func(v string) { providerData.LogsCustomEndpoint = v }) setStringField(providerConfig.MariaDBCustomEndpoint, func(v string) { providerData.MariaDBCustomEndpoint = v }) setStringField(providerConfig.ModelServingCustomEndpoint, func(v string) { providerData.ModelServingCustomEndpoint = v }) + setStringField(providerConfig.ModelExperimentsCustomEndpoint, func(v string) { providerData.ModelExperimentsCustomEndpoint = v }) setStringField(providerConfig.MongoDBFlexCustomEndpoint, func(v string) { providerData.MongoDBFlexCustomEndpoint = v }) setStringField(providerConfig.ObjectStorageCustomEndpoint, func(v string) { providerData.ObjectStorageCustomEndpoint = v }) setStringField(providerConfig.ObservabilityCustomEndpoint, func(v string) { providerData.ObservabilityCustomEndpoint = v }) @@ -815,6 +824,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { mariaDBInstance.NewInstanceResource, mariaDBCredential.NewCredentialResource, modelServingToken.NewTokenResource, + modelExperimentsInstance.NewInstanceResourceEmpty, + modelExperimentsToken.NewInstanceTokenResourceEmpty, mongoDBFlexInstance.NewInstanceResource, mongoDBFlexUser.NewUserResource, objectStorageBucket.NewBucketResource,