diff --git a/MIGRATION.md b/MIGRATION.md index 9f3a3e087..d120af593 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -128,39 +128,41 @@ resource "stackit_logme_credential" "example-credential" { ## Available resources -| Community provider | Official provider | Import available? | `id` format | Notes | -|------------------------------------------|-----------------------------------------|-|-|------------------------------------------------------------------| -| stackit_argus_credential | stackit_observability_credential | :x: | | Service deprecated, use stackit_observability_credential instead | -| stackit_argus_instance | stackit_observability_instance | :white_check_mark: | [project_id],[instance_id] | Service deprecated, use stackit_observability_instance instead | -| stackit_argus_job | stackit_observability_scrapeconfig | :white_check_mark: | [project_id],[instance_id],[name] | Service deprecated, use stackit_observability_scrapeconfig instead | -| stackit_elasticsearch_credential | | | | Service deprecated | -| stackit_elasticsearch_instance | | | | Service deprecated | -| stackit_kubernetes_cluster | stackit_ske_cluster | :white_check_mark: | [project_id],[name] | | -| stackit_kubernetes_project | stackit_ske_project | :white_check_mark: | [project_id] | | -| stackit_load_balancer | stackit_loadbalancer | :white_check_mark: | [project_id],[name] | | -| stackit_logme_credential | stackit_logme_credential | :white_check_mark: | [project_id],[instance_id],[credential_id] | | -| stackit_logme_instance | stackit_logme_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_mariadb_credential | stackit_mariadb_credential | :white_check_mark: | [project_id],[instance_id],[credential_id] | | -| stackit_mariadb_instance | stackit_mariadb_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_mongodb_flex_instance | stackit_mongodbflex_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_mongodb_flex_user | stackit_mongodbflex_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | -| stackit_object_storage_bucket | stackit_objectstorage_bucket | :white_check_mark: | [project_id],[name] | | +| Community provider | Official provider | Import available? | `id` format | Notes | +|------------------------------------------|-----------------------------------------|-|-----------------------------------------------------|------------------------------------------------------------------| +| stackit_argus_credential | stackit_observability_credential | :x: | | Service deprecated, use stackit_observability_credential instead | +| stackit_argus_instance | stackit_observability_instance | :white_check_mark: | [project_id],[instance_id] | Service deprecated, use stackit_observability_instance instead | +| stackit_argus_job | stackit_observability_scrapeconfig | :white_check_mark: | [project_id],[instance_id],[name] | Service deprecated, use stackit_observability_scrapeconfig instead | +| stackit_elasticsearch_credential | | | | Service deprecated | +| stackit_elasticsearch_instance | | | | Service deprecated | +| stackit_kubernetes_cluster | stackit_ske_cluster | :white_check_mark: | [project_id],[name] | | +| stackit_kubernetes_project | stackit_ske_project | :white_check_mark: | [project_id] | | +| stackit_load_balancer | stackit_loadbalancer | :white_check_mark: | [project_id],[name] | | +| stackit_logme_credential | stackit_logme_credential | :white_check_mark: | [project_id],[instance_id],[credential_id] | | +| stackit_logme_instance | stackit_logme_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_mariadb_credential | stackit_mariadb_credential | :white_check_mark: | [project_id],[instance_id],[credential_id] | | +| stackit_mariadb_instance | stackit_mariadb_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_mongodb_flex_instance | stackit_mongodbflex_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_mongodb_flex_user | stackit_mongodbflex_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | +| stackit_object_storage_bucket | stackit_objectstorage_bucket | :white_check_mark: | [project_id],[name] | | | stackit_object_storage_credential | stackit_objectstorage_credential | :white_check_mark: | [project_id],[credentials_group_id],[credential_id] | | -| stackit_object_storage_credentials_group | stackit_objectstorage_credentials_group | :white_check_mark: | [project_id],[credentials_group_id] | | -| stackit_object_storage_project | | | | Resource deprecated | -| stackit_observability_credential | stackit_observability_credential | :x: | | | -| stackit_observability_instance | stackit_observability_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_observability_job | stackit_observability_scrapeconfig | :white_check_mark: | [project_id],[instance_id],[name] | | +| stackit_object_storage_credentials_group | stackit_objectstorage_credentials_group | :white_check_mark: | [project_id],[credentials_group_id] | | +| stackit_object_storage_project | | | | Resource deprecated | +| stackit_observability_credential | stackit_observability_credential | :x: | | | +| stackit_observability_instance | stackit_observability_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_observability_job | stackit_observability_scrapeconfig | :white_check_mark: | [project_id],[instance_id],[name] | | | stackit_opensearch_credential | stackit_opensearch_credential | :white_check_mark: | [project_id],[credentials_group_id],[credential_id] | | -| stackit_opensearch_instance | stackit_opensearch_instance | :white_check_mark: | [project_id],[instance_id] | | | -| stackit_postgres_flex_instance | stackit_postgresflex_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_postgres_flex_user | stackit_postgresflex_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | -| stackit_postgres_instance | | | | Resource deprecated | -| stackit_postgres_credential | | | | Resource deprecated -| stackit_project | stackit_resourcemanager_project | :white_check_mark: | [container_id] | | +| stackit_opensearch_instance | stackit_opensearch_instance | :white_check_mark: | [project_id],[instance_id] | | | +| stackit_postgres_flex_instance | stackit_postgresflex_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_postgres_flex_user | stackit_postgresflex_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | +| stackit_postgres_instance | | | | Resource deprecated | +| stackit_postgres_credential | | | | Resource deprecated +| stackit_project | stackit_resourcemanager_project | :white_check_mark: | [container_id] | | | stackit_rabbitmq_credential | stackit_rabbitmq_credential | :white_check_mark: | [project_id],[credentials_group_id],[credential_id] | | -| stackit_rabbitmq_instance | stackit_rabbitmq_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_rabbitmq_instance | stackit_rabbitmq_instance | :white_check_mark: | [project_id],[instance_id] | | | stackit_redis_credential | stackit_redis_credential | :white_check_mark: | [project_id],[credentials_group_id],[credential_id] | | -| stackit_redis_instance | stackit_redis_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_secrets_manager_instance | stackit_secretsmanager_instance | :white_check_mark: | [project_id],[instance_id] | | -| stackit_secrets_manager_user | stackit_secretsmanager_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | +| stackit_redis_instance | stackit_redis_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_secrets_manager_instance | stackit_secretsmanager_instance | :white_check_mark: | [project_id],[instance_id] | | +| stackit_secrets_manager_user | stackit_secretsmanager_user | :warning: | [project_id],[instance_id],[user_id] | `password` field will be empty | +| stackit_valkey_credential | stackit_valkey_credential | :white_check_mark: | [project_id],[region],[instance_id],[credential_id] | | +| stackit_valkey_instance | stackit_valkey_instance | :white_check_mark: | [project_id],[region],[instance_id] | | diff --git a/docs/data-sources/valkey_credential.md b/docs/data-sources/valkey_credential.md new file mode 100644 index 000000000..e7f0f1f62 --- /dev/null +++ b/docs/data-sources/valkey_credential.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_valkey_credential Data Source - stackit" +subcategory: "" +description: |- + Valkey credential data source schema. Must have a region specified in the provider configuration. +--- + +# stackit_valkey_credential (Data Source) + +Valkey credential data source schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +data "stackit_valkey_credential" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + credential_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `credential_id` (String) The credential's ID. +- `instance_id` (String) ID of the Valkey instance. +- `project_id` (String) STACKIT project ID to which the instance is associated. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `host` (String) +- `hosts` (List of String) +- `id` (String) Terraform's internal data source. identifier. It is structured as "`project_id`,`region`,`instance_id`,`credential_id`". +- `load_balanced_host` (String) +- `password` (String, Sensitive) +- `port` (Number) +- `uri` (String, Sensitive) Connection URI. +- `username` (String) diff --git a/docs/data-sources/valkey_instance.md b/docs/data-sources/valkey_instance.md new file mode 100644 index 000000000..79222d3e0 --- /dev/null +++ b/docs/data-sources/valkey_instance.md @@ -0,0 +1,73 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_valkey_instance Data Source - stackit" +subcategory: "" +description: |- + Valkey instance data source schema. Must have a region specified in the provider configuration. +--- + +# stackit_valkey_instance (Data Source) + +Valkey instance data source schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +data "stackit_valkey_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `instance_id` (String) ID of the Valkey instance. +- `project_id` (String) STACKIT Project ID to which the instance is associated. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `cf_guid` (String) +- `cf_organization_guid` (String) +- `cf_space_guid` (String) +- `dashboard_url` (String) +- `id` (String) Terraform's internal data source. identifier. It is structured as "`project_id`,`region`,`instance_id`". +- `image_url` (String) +- `name` (String) Instance name. +- `parameters` (Attributes) (see [below for nested schema](#nestedatt--parameters)) +- `plan_id` (String) The selected plan ID. +- `plan_name` (String) The selected plan name. +- `version` (String) The service version. + + +### Nested Schema for `parameters` + +Read-Only: + +- `down_after_milliseconds` (Number) The number of milliseconds after which the instance is considered down. +- `enable_monitoring` (Boolean) Enable monitoring. +- `failover_timeout` (Number) The failover timeout in milliseconds. +- `graphite` (String) Graphite server URL (host and port). If set, monitoring with Graphite will be enabled. +- `lazyfree_lazy_eviction` (String) The lazy eviction enablement (yes or no). +- `lazyfree_lazy_expire` (String) The lazy expire enablement (yes or no). +- `lua_time_limit` (Number) The Lua time limit. +- `max_disk_threshold` (Number) The maximum disk threshold in MB. If the disk usage exceeds this threshold, the instance will be stopped. +- `maxclients` (Number) The maximum number of clients. +- `maxmemory_policy` (String) The policy to handle the maximum memory (volatile-lru, noeviction, etc). +- `maxmemory_samples` (Number) The maximum memory samples. +- `metrics_frequency` (Number) The frequency in seconds at which metrics are emitted. +- `metrics_prefix` (String) The prefix for the metrics. Could be useful when using Graphite monitoring to prefix the metrics with a certain value, like an API key. +- `min_replicas_max_lag` (Number) The minimum replicas maximum lag. +- `min_replicas_to_write` (Number) The amount of connected replicas that are required for the primary to accept write operations. It can be set to 0 to disable it. +- `monitoring_instance_id` (String) The ID of the STACKIT monitoring instance. +- `notify_keyspace_events` (String) The notify keyspace events. +- `repl_backlog_size` (String) The replication backlog size for the cluster. +- `sgw_acl` (String) Comma separated list of IP networks in CIDR notation which are allowed to access this instance. +- `snapshot` (String) The snapshot configuration. +- `syslog` (List of String) List of syslog servers to send logs to. diff --git a/docs/index.md b/docs/index.md index 160f8a9b8..48c309992 100644 --- a/docs/index.md +++ b/docs/index.md @@ -223,4 +223,5 @@ See this [example](https://professional-service.git.onstackit.cloud/professional - `telemetryrouter_custom_endpoint` (String) Custom endpoint for the Telemetry Router service - `token_custom_endpoint` (String) Custom endpoint for the token API, which is used to request access tokens when using the key flow - `use_oidc` (Boolean) Enables OIDC for Authentication. This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`. +- `valkey_custom_endpoint` (String) Custom endpoint for the Key Value Store service - `vpn_custom_endpoint` (String) Custom endpoint for the VPN service diff --git a/docs/resources/valkey_credential.md b/docs/resources/valkey_credential.md new file mode 100644 index 000000000..26b063609 --- /dev/null +++ b/docs/resources/valkey_credential.md @@ -0,0 +1,72 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_valkey_credential Resource - stackit" +subcategory: "" +description: |- + Valkey credential resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_valkey_credential (Resource) + +Valkey credential resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_valkey_credential" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_valkey_credential" "example_rotate" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } +} +``` + + +## Schema + +### Required + +- `instance_id` (String) ID of the Valkey instance. +- `project_id` (String) STACKIT Project ID to which the instance is associated. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. +- `rotate_when_changed` (Map of String) A map of arbitrary key/value pairs that will force recreation of the resource when they change, enabling resource rotation based on external conditions such as a rotating timestamp. Changing this forces a new resource to be created. + +### Read-Only + +- `credential_id` (String) The credential's ID. +- `host` (String) +- `hosts` (List of String) +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`instance_id`,`credential_id`". +- `load_balanced_host` (String) +- `password` (String, Sensitive) +- `port` (Number) +- `uri` (String, Sensitive) Connection URI. +- `username` (String) + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https://developer.hashicorp.com/terraform/language/import) can be used with the ` + "`" + `id` + "`" + ` attribute, for example: + +```terraform +# Only use the import statement, if you want to import an existing valkey credential +import { + to = stackit_valkey_credential.import-example + id = "${var.project_id},${var.valkey_instance_id},${var.valkey_credential_id}" +} +``` diff --git a/docs/resources/valkey_instance.md b/docs/resources/valkey_instance.md new file mode 100644 index 000000000..c6ded6960 --- /dev/null +++ b/docs/resources/valkey_instance.md @@ -0,0 +1,95 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_valkey_instance Resource - stackit" +subcategory: "" +description: |- + Valkey instance resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_valkey_instance (Resource) + +Valkey instance resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_valkey_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + version = "7" + plan_name = "stackit-keyvalue-1.2.10-replica" + parameters = { + sgw_acl = "193.148.160.0/19,45.129.40.0/21,45.135.244.0/22" + enable_monitoring = false + down_after_milliseconds = 30000 + syslog = ["logs4.your-syslog-endpoint.com:54321"] + } +} +``` + + +## Schema + +### Required + +- `name` (String) Instance name. +- `plan_name` (String) The selected plan name. +- `project_id` (String) STACKIT project ID to which the instance is associated. +- `version` (String) The service version. + +### Optional + +- `parameters` (Attributes) Configuration parameters. Please note that removing a previously configured field from your Terraform configuration won't replace its value in the API. To update a previously configured field, explicitly set a new value for it. (see [below for nested schema](#nestedatt--parameters)) +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `cf_guid` (String) +- `cf_organization_guid` (String) +- `cf_space_guid` (String) +- `dashboard_url` (String) +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_id`". +- `image_url` (String) +- `instance_id` (String) ID of the Valkey instance. +- `plan_id` (String) The selected plan ID. + + +### Nested Schema for `parameters` + +Optional: + +- `down_after_milliseconds` (Number) The number of milliseconds after which the instance is considered down. +- `enable_monitoring` (Boolean) Enable monitoring. +- `failover_timeout` (Number) The failover timeout in milliseconds. +- `graphite` (String) Graphite server URL (host and port). If set, monitoring with Graphite will be enabled. +- `lazyfree_lazy_eviction` (String) The lazy eviction enablement (yes or no). +- `lazyfree_lazy_expire` (String) The lazy expire enablement (yes or no). +- `lua_time_limit` (Number) The Lua time limit. +- `max_disk_threshold` (Number) The maximum disk threshold in MB. If the disk usage exceeds this threshold, the instance will be stopped. +- `maxclients` (Number) The maximum number of clients. +- `maxmemory_policy` (String) The policy to handle the maximum memory (volatile-lru, noeviction, etc). +- `maxmemory_samples` (Number) The maximum memory samples. +- `metrics_frequency` (Number) The frequency in seconds at which metrics are emitted. +- `metrics_prefix` (String) The prefix for the metrics. Could be useful when using Graphite monitoring to prefix the metrics with a certain value, like an API key. +- `min_replicas_max_lag` (Number) The minimum replicas maximum lag. +- `min_replicas_to_write` (Number) The amount of connected replicas that are required for the primary to accept write operations. It can be set to 0 to disable it. +- `monitoring_instance_id` (String) The ID of the STACKIT monitoring instance. +- `notify_keyspace_events` (String) The notify keyspace events. +- `repl_backlog_size` (String) The replication backlog size for the cluster. +- `sgw_acl` (String) Comma separated list of IP networks in CIDR notation which are allowed to access this instance. +- `snapshot` (String) The snapshot configuration. +- `syslog` (List of String) List of syslog servers to send logs to. + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https://developer.hashicorp.com/terraform/language/import) can be used with the ` + "`" + `id` + "`" + ` attribute, for example: + +```terraform +# Only use the import statement, if you want to import an existing valkey instance +import { + to = stackit_valkey_instance.import-example + id = "${var.project_id},${var.valkey_instance_id}" +} +``` diff --git a/examples/data-sources/stackit_valkey_credential/data-source.tf b/examples/data-sources/stackit_valkey_credential/data-source.tf new file mode 100644 index 000000000..1002d11bd --- /dev/null +++ b/examples/data-sources/stackit_valkey_credential/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_valkey_credential" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + credential_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_valkey_instance/data-source.tf b/examples/data-sources/stackit_valkey_instance/data-source.tf new file mode 100644 index 000000000..90ca822fe --- /dev/null +++ b/examples/data-sources/stackit_valkey_instance/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_valkey_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_valkey_credential/import-by-string-id.tf b/examples/resources/stackit_valkey_credential/import-by-string-id.tf new file mode 100644 index 000000000..ebe7fdd5d --- /dev/null +++ b/examples/resources/stackit_valkey_credential/import-by-string-id.tf @@ -0,0 +1,5 @@ +# Only use the import statement, if you want to import an existing valkey credential +import { + to = stackit_valkey_credential.import-example + id = "${var.project_id},${var.valkey_instance_id},${var.valkey_credential_id}" +} diff --git a/examples/resources/stackit_valkey_credential/resource.tf b/examples/resources/stackit_valkey_credential/resource.tf new file mode 100644 index 000000000..0e2f066b8 --- /dev/null +++ b/examples/resources/stackit_valkey_credential/resource.tf @@ -0,0 +1,17 @@ +resource "stackit_valkey_credential" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} + +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_valkey_credential" "example_rotate" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } +} \ No newline at end of file diff --git a/examples/resources/stackit_valkey_instance/import-by-string-id.tf b/examples/resources/stackit_valkey_instance/import-by-string-id.tf new file mode 100644 index 000000000..c0fb5f95e --- /dev/null +++ b/examples/resources/stackit_valkey_instance/import-by-string-id.tf @@ -0,0 +1,5 @@ +# Only use the import statement, if you want to import an existing valkey instance +import { + to = stackit_valkey_instance.import-example + id = "${var.project_id},${var.valkey_instance_id}" +} diff --git a/examples/resources/stackit_valkey_instance/resource.tf b/examples/resources/stackit_valkey_instance/resource.tf new file mode 100644 index 000000000..46d1f518e --- /dev/null +++ b/examples/resources/stackit_valkey_instance/resource.tf @@ -0,0 +1,12 @@ +resource "stackit_valkey_instance" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + version = "7" + plan_name = "stackit-keyvalue-1.2.10-replica" + parameters = { + sgw_acl = "193.148.160.0/19,45.129.40.0/21,45.135.244.0/22" + enable_monitoring = false + down_after_milliseconds = 30000 + syslog = ["logs4.your-syslog-endpoint.com:54321"] + } +} diff --git a/go.mod b/go.mod index 77b7345a1..87630f57e 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.17.0 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 + github.com/stackitcloud/stackit-sdk-go/services/valkey v0.1.1 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 github.com/teambition/rrule-go v1.8.2 golang.org/x/mod v0.38.0 diff --git a/go.sum b/go.sum index bef815f9f..ad6777a58 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,8 @@ github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0 h1:RG+cZzvI github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.4.0/go.mod h1:hgw8janWmDfP2bnuZensxqcAePr49BX5ug8Rq85o+h8= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0 h1:tEKBl3g7SIjZ8aa43nNjS1Rqr/IwxQ3Pr1MArYF0fno= github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.4.0/go.mod h1:WUmgKtwpe90Yq3YbgNxc2clTTULVxCu0ha6lMTjUnII= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.1.1 h1:8UraYJJE+gOcfmizHbJZZ+Tef+GC8nzBvhTCWrcACBQ= +github.com/stackitcloud/stackit-sdk-go/services/valkey v0.1.1/go.mod h1:etzt/a723p327dqha1V72cCoBWjUBS2DtHy3ZTac0SA= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0 h1:LMgbzhPunuelsIsfyEj/5O/aYfNcg/eGHsnZ7AZOhYg= github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0/go.mod h1:toIjQk1dhxdUFVyCWJJja0w/0nFpDid8MWX0ukQfvfo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index d539952e8..b74478a73 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -74,6 +74,7 @@ type ProviderData struct { ServiceAccountCustomEndpoint string TelemetryLinkCustomEndpoint string TelemetryRouterCustomEndpoint string + ValkeyCustomEndpoint string VpnCustomEndpoint string EnableBetaResources bool Experiments []string diff --git a/stackit/internal/services/valkey/credential/datasource.go b/stackit/internal/services/valkey/credential/datasource.go new file mode 100644 index 000000000..f1e40d55b --- /dev/null +++ b/stackit/internal/services/valkey/credential/datasource.go @@ -0,0 +1,260 @@ +package valkey + +import ( + "context" + "fmt" + "net/http" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + valkeyUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/utils" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &credentialDataSource{} +) + +type DataSourceModel struct { + Id types.String `tfsdk:"id"` // needed by TF + CredentialId types.String `tfsdk:"credential_id"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Host types.String `tfsdk:"host"` + Hosts types.List `tfsdk:"hosts"` + LoadBalancedHost types.String `tfsdk:"load_balanced_host"` + Password types.String `tfsdk:"password"` + Port types.Int32 `tfsdk:"port"` + Uri types.String `tfsdk:"uri"` + Username types.String `tfsdk:"username"` +} + +// NewCredentialDataSource is a helper function to simplify the provider implementation. +func NewCredentialDataSource() datasource.DataSource { + return &credentialDataSource{} +} + +// credentialDataSource is the data source implementation. +type credentialDataSource struct { + client *valkey.APIClient + providerData core.ProviderData +} + +// Metadata returns the data source type name. +func (r *credentialDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_valkey_credential" +} + +// Configure adds the provider configured client to the data source. +func (r *credentialDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := valkeyUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Valkey credential client configured") +} + +// Schema defines the schema for the data source. +func (r *credentialDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ //nolint:gosec // description for credential id + "main": "Valkey credential data source schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal data source. identifier. It is structured as \"`project_id`,`region`,`instance_id`,`credential_id`\".", + "credential_id": "The credential's ID.", + "instance_id": "ID of the Valkey instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + "uri": "Connection URI.", + "region": "The resource region. If not defined, the provider region is used.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "credential_id": schema.StringAttribute{ + Description: descriptions["credential_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "host": schema.StringAttribute{ + Computed: true, + }, + "hosts": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + }, + "load_balanced_host": schema.StringAttribute{ + Computed: true, + }, + "password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "port": schema.Int32Attribute{ + Computed: true, + }, + "uri": schema.StringAttribute{ + Description: descriptions["uri"], + Computed: true, + Sensitive: true, + }, + "username": schema.StringAttribute{ + Computed: true, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *credentialDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model DataSourceModel + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + credentialId := model.CredentialId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "credential_id", credentialId) + + recordSetResp, err := r.client.DefaultAPI.GetCredentials(ctx, projectId, region, instanceId, credentialId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading credential", + fmt.Sprintf("Credential with ID %q or instance with ID %q does not exist in project %q.", credentialId, instanceId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapDataSourceFields(ctx, recordSetResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Valkey credential read") +} + +func mapDataSourceFields(ctx context.Context, credentialsResp *valkey.CredentialsResponse, model *DataSourceModel, region string) error { + if credentialsResp == nil { + return fmt.Errorf("response input is nil") + } + if credentialsResp.Raw == nil { + return fmt.Errorf("response credentials raw is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + credentials := credentialsResp.Raw.Credentials + + var credentialId string + if model.CredentialId.ValueString() != "" { + credentialId = model.CredentialId.ValueString() + } else if credentialsResp.Id != "" { + credentialId = credentialsResp.Id + } else { + return fmt.Errorf("credentials id not present") + } + + model.Region = types.StringValue(region) + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.Region.ValueString(), model.InstanceId.ValueString(), credentialId) + + modelHosts, err := utils.ListValueToStringSlice(model.Hosts) + if err != nil { + return err + } + + model.Hosts = types.ListNull(types.StringType) + model.CredentialId = types.StringValue(credentialId) + + if credentials.Hosts != nil { + respHosts := credentials.Hosts + + reconciledHosts := utils.ReconcileStringSlices(modelHosts, respHosts) + + hostsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledHosts) + if diags.HasError() { + return fmt.Errorf("failed to map hosts: %w", core.DiagsToError(diags)) + } + + model.Hosts = hostsTF + } + model.Host = types.StringValue(credentials.Host) + model.LoadBalancedHost = types.StringPointerValue(credentials.LoadBalancedHost) + model.Password = types.StringValue(credentials.Password) + model.Port = types.Int32PointerValue(credentials.Port) + model.Uri = types.StringPointerValue(credentials.Uri) + model.Username = types.StringValue(credentials.Username) + + return nil +} diff --git a/stackit/internal/services/valkey/credential/datasource_test.go b/stackit/internal/services/valkey/credential/datasource_test.go new file mode 100644 index 000000000..9868f55d2 --- /dev/null +++ b/stackit/internal/services/valkey/credential/datasource_test.go @@ -0,0 +1,226 @@ +package valkey + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +func TestMapDataSourceFields(t *testing.T) { + const testRegion = "eu01" + tests := []struct { + description string + state DataSourceModel + input *valkey.CredentialsResponse + expected DataSourceModel + isValid bool + }{ + { + "default_values", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{}, + }, + DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue(""), + Hosts: types.ListNull(types.StringType), + LoadBalancedHost: types.StringNull(), + Password: types.StringValue(""), + Port: types.Int32Null(), + Uri: types.StringNull(), + Username: types.StringValue(""), + }, + true, + }, + { + "simple_values", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Hosts: []string{ + "host_1", + "", + }, + LoadBalancedHost: new("load_balanced_host"), + Password: "password", + Port: new(int32(1234)), + Uri: new("uri"), + Username: "username", + }, + }, + }, + DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue("host"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_1"), + types.StringValue(""), + }), + LoadBalancedHost: types.StringValue("load_balanced_host"), + Password: types.StringValue("password"), + Port: types.Int32Value(1234), + Uri: types.StringValue("uri"), + Username: types.StringValue("username"), + }, + true, + }, + { + "hosts_unordered", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_2"), + types.StringValue(""), + types.StringValue("host_1"), + }), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Hosts: []string{ + "", + "host_1", + "host_2", + }, + LoadBalancedHost: new("load_balanced_host"), + Password: "password", + Port: new(int32(1234)), + Uri: new("uri"), + Username: "username", + }, + }, + }, + DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue("host"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_2"), + types.StringValue(""), + types.StringValue("host_1"), + }), + LoadBalancedHost: types.StringValue("load_balanced_host"), + Password: types.StringValue("password"), + Port: types.Int32Value(1234), + Uri: types.StringValue("uri"), + Username: types.StringValue("username"), + }, + true, + }, + { + "null_fields_and_int_conversions", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "", + Hosts: []string{}, + LoadBalancedHost: nil, + Password: "", + Port: new(int32(2123456789)), + Uri: nil, + Username: "", + }, + }, + }, + DataSourceModel{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue(""), + Hosts: types.ListValueMust(types.StringType, []attr.Value{}), + LoadBalancedHost: types.StringNull(), + Password: types.StringValue(""), + Port: types.Int32Value(2123456789), + Uri: types.StringNull(), + Username: types.StringValue(""), + }, + true, + }, + { + "nil_response", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + nil, + DataSourceModel{}, + false, + }, + { + "no_resource_id", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + &valkey.CredentialsResponse{}, + DataSourceModel{}, + false, + }, + { + "nil_raw_credential", + DataSourceModel{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + }, + &valkey.CredentialsResponse{ + Id: "cid", + }, + DataSourceModel{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapDataSourceFields(context.Background(), tt.input, &tt.state, testRegion) + 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) + } + } + }) + } +} diff --git a/stackit/internal/services/valkey/credential/resource.go b/stackit/internal/services/valkey/credential/resource.go new file mode 100644 index 000000000..4ddb900a5 --- /dev/null +++ b/stackit/internal/services/valkey/credential/resource.go @@ -0,0 +1,435 @@ +package valkey + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + valkeyUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/utils" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "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/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &credentialResource{} + _ resource.ResourceWithConfigure = &credentialResource{} + _ resource.ResourceWithImportState = &credentialResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + CredentialId types.String `tfsdk:"credential_id"` + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + Host types.String `tfsdk:"host"` + Hosts types.List `tfsdk:"hosts"` + LoadBalancedHost types.String `tfsdk:"load_balanced_host"` + Password types.String `tfsdk:"password"` + Port types.Int32 `tfsdk:"port"` + Uri types.String `tfsdk:"uri"` + Username types.String `tfsdk:"username"` + // RotateWhenChanged is a map of arbitrary key/value pairs that will force + // recreation of the resource when they change, enabling resource rotation based on + // external conditions such as a rotating timestamp. Changing this forces a new + // resource to be created. + RotateWhenChanged types.Map `tfsdk:"rotate_when_changed"` +} + +// NewCredentialResource is a helper function to simplify the provider implementation. +func NewCredentialResource() resource.Resource { + return &credentialResource{} +} + +// credentialResource is the resource implementation. +type credentialResource struct { + client *valkey.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *credentialResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_valkey_credential" +} + +// Configure adds the provider configured client to the resource. +func (r *credentialResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := valkeyUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Valkey credential client configured") +} + +// Schema defines the schema for the resource. +func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ //nolint:gosec // description for credential id + "main": "Valkey credential resource schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`instance_id`,`credential_id`\".", + "credential_id": "The credential's ID.", + "instance_id": "ID of the Valkey instance.", + "project_id": "STACKIT Project ID to which the instance is associated.", + "uri": "Connection URI.", + "region": "The resource region. If not defined, the provider region is used.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "credential_id": schema.StringAttribute{ + Description: descriptions["credential_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "host": schema.StringAttribute{ + Computed: true, + }, + "hosts": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + }, + "load_balanced_host": schema.StringAttribute{ + Computed: true, + }, + "password": schema.StringAttribute{ + Computed: true, + Sensitive: true, + }, + "port": schema.Int32Attribute{ + Computed: true, + }, + "uri": schema.StringAttribute{ + Description: descriptions["uri"], + Computed: true, + Sensitive: true, + }, + "username": schema.StringAttribute{ + Computed: true, + }, + "rotate_when_changed": schema.MapAttribute{ + Description: "A map of arbitrary key/value pairs that will force " + + "recreation of the resource when they change, enabling resource rotation " + + "based on external conditions such as a rotating timestamp. Changing " + + "this forces a new resource to be created.", + Optional: true, + Required: false, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *credentialResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + 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 := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + // Create new recordset + credentialsResp, err := r.client.DefaultAPI.CreateCredentials(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + if credentialsResp.Id == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", "Got empty credential id") + return + } + credentialId := credentialsResp.Id + // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "instance_id": instanceId, + "credential_id": credentialId, + }) + if resp.Diagnostics.HasError() { + return + } + + waitResp, err := wait.CreateCredentialsWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId, credentialId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", fmt.Sprintf("Instance creation waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating credential", 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, "Valkey credential created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *credentialResource) 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() + region := r.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + credentialId := model.CredentialId.ValueString() + if credentialId == "" { + // Resource not yet created; ID is unknown. + resp.State.RemoveResource(ctx) + return + } + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "credential_id", credentialId) + + recordSetResp, err := r.client.DefaultAPI.GetCredentials(ctx, projectId, region, instanceId, credentialId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapFields(ctx, recordSetResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading credential", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Valkey credential read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *credentialResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update shouldn't be called + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating credential", "Credential can't be updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *credentialResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // 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() + region := r.providerData.GetRegionWithOverride(model.Region) + instanceId := model.InstanceId.ValueString() + credentialId := model.CredentialId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "credential_id", credentialId) + + // Delete existing record set + err := r.client.DefaultAPI.DeleteCredentials(ctx, projectId, region, instanceId, credentialId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting credential", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteCredentialsWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId, credentialId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting credential", fmt.Sprintf("Instance deletion waiting: %v", err)) + return + } + tflog.Info(ctx, "Valkey credential deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,region,instance_id,credential_id +func (r *credentialResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing credential", + fmt.Sprintf("Expected import identifier with format [project_id],[region],[instance_id],[credential_id], got %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "instance_id": idParts[2], + "credential_id": idParts[3], + }) + tflog.Info(ctx, "Valkey credential state imported") +} + +func mapFields(ctx context.Context, credentialsResp *valkey.CredentialsResponse, model *Model, region string) error { + if credentialsResp == nil { + return fmt.Errorf("response input is nil") + } + if credentialsResp.Raw == nil { + return fmt.Errorf("response credentials raw is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + credentials := credentialsResp.Raw.Credentials + + var credentialId string + if model.CredentialId.ValueString() != "" { + credentialId = model.CredentialId.ValueString() + } else if credentialsResp.Id != "" { + credentialId = credentialsResp.Id + } else { + return fmt.Errorf("credentials id not present") + } + + model.Region = types.StringValue(region) + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), model.Region.ValueString(), model.InstanceId.ValueString(), credentialId) + + modelHosts, err := utils.ListValueToStringSlice(model.Hosts) + if err != nil { + return err + } + + model.Hosts = types.ListNull(types.StringType) + model.CredentialId = types.StringValue(credentialId) + + if credentials.Hosts != nil { + respHosts := credentials.Hosts + + reconciledHosts := utils.ReconcileStringSlices(modelHosts, respHosts) + + hostsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledHosts) + if diags.HasError() { + return fmt.Errorf("failed to map hosts: %w", core.DiagsToError(diags)) + } + + model.Hosts = hostsTF + } + model.Host = types.StringValue(credentials.Host) + model.LoadBalancedHost = types.StringPointerValue(credentials.LoadBalancedHost) + model.Password = types.StringValue(credentials.Password) + model.Port = types.Int32PointerValue(credentials.Port) + model.Uri = types.StringPointerValue(credentials.Uri) + model.Username = types.StringValue(credentials.Username) + + return nil +} diff --git a/stackit/internal/services/valkey/credential/resource_test.go b/stackit/internal/services/valkey/credential/resource_test.go new file mode 100644 index 000000000..9fc6c0f28 --- /dev/null +++ b/stackit/internal/services/valkey/credential/resource_test.go @@ -0,0 +1,237 @@ +package valkey + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +func TestMapFields(t *testing.T) { + const testRegion = "eu01" + tests := []struct { + description string + state Model + input *valkey.CredentialsResponse + expected Model + isValid bool + }{ + { + "default_values", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{}, + }, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue(""), + Hosts: types.ListNull(types.StringType), + LoadBalancedHost: types.StringNull(), + Password: types.StringValue(""), + Port: types.Int32Null(), + Uri: types.StringNull(), + Username: types.StringValue(""), + RotateWhenChanged: types.MapNull(types.StringType), + }, + true, + }, + { + "simple_values", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Hosts: []string{ + "host_1", + "", + }, + LoadBalancedHost: new("load_balanced_host"), + Password: "password", + Port: new(int32(1234)), + Uri: new("uri"), + Username: "username", + }, + }, + }, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue("host"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_1"), + types.StringValue(""), + }), + LoadBalancedHost: types.StringValue("load_balanced_host"), + Password: types.StringValue("password"), + Port: types.Int32Value(1234), + Uri: types.StringValue("uri"), + Username: types.StringValue("username"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + true, + }, + { + "hosts_unordered", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_2"), + types.StringValue(""), + types.StringValue("host_1"), + }), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "host", + Hosts: []string{ + "", + "host_1", + "host_2", + }, + LoadBalancedHost: new("load_balanced_host"), + Password: "password", + Port: new(int32(1234)), + Uri: new("uri"), + Username: "username", + }, + }, + }, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue("host"), + Hosts: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("host_2"), + types.StringValue(""), + types.StringValue("host_1"), + }), + LoadBalancedHost: types.StringValue("load_balanced_host"), + Password: types.StringValue("password"), + Port: types.Int32Value(1234), + Uri: types.StringValue("uri"), + Username: types.StringValue("username"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + true, + }, + { + "null_fields_and_int_conversions", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{ + Id: "cid", + Raw: &valkey.RawCredentials{ + Credentials: valkey.Credentials{ + Host: "", + Hosts: []string{}, + LoadBalancedHost: nil, + Password: "", + Port: new(int32(2123456789)), + Uri: nil, + Username: "", + }, + }, + }, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid,cid", testRegion)), + CredentialId: types.StringValue("cid"), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + Host: types.StringValue(""), + Hosts: types.ListValueMust(types.StringType, []attr.Value{}), + LoadBalancedHost: types.StringNull(), + Password: types.StringValue(""), + Port: types.Int32Value(2123456789), + Uri: types.StringNull(), + Username: types.StringValue(""), + RotateWhenChanged: types.MapNull(types.StringType), + }, + true, + }, + { + "nil_response", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{}, + Model{}, + false, + }, + { + "nil_raw_credential", + Model{ + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + &valkey.CredentialsResponse{ + Id: "cid", + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state, testRegion) + 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) + } + } + }) + } +} diff --git a/stackit/internal/services/valkey/instance/datasource.go b/stackit/internal/services/valkey/instance/datasource.go new file mode 100644 index 000000000..40a95f62a --- /dev/null +++ b/stackit/internal/services/valkey/instance/datasource.go @@ -0,0 +1,315 @@ +package valkey + +import ( + "context" + "fmt" + "net/http" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + valkeyUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/utils" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "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/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &instanceDataSource{} +) + +// NewInstanceDataSource is a helper function to simplify the provider implementation. +func NewInstanceDataSource() datasource.DataSource { + return &instanceDataSource{} +} + +// instanceDataSource is the data source implementation. +type instanceDataSource struct { + client *valkey.APIClient + providerData core.ProviderData +} + +// Metadata returns the data source type name. +func (r *instanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_valkey_instance" +} + +// Configure adds the provider configured client to the data source. +func (r *instanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := valkeyUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Valkey instance client configured") +} + +// Schema defines the schema for the data source. +func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Valkey instance data source schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal data source. identifier. It is structured as \"`project_id`,`region`,`instance_id`\".", + "instance_id": "ID of the Valkey instance.", + "project_id": "STACKIT Project ID to which the instance is associated.", + "name": "Instance name.", + "version": "The service version.", + "plan_name": "The selected plan name.", + "plan_id": "The selected plan ID.", + "region": "The resource region. If not defined, the provider region is used.", + } + + parametersDescriptions := map[string]string{ + "sgw_acl": "Comma separated list of IP networks in CIDR notation which are allowed to access this instance.", + "down_after_milliseconds": "The number of milliseconds after which the instance is considered down.", + "enable_monitoring": "Enable monitoring.", + "failover_timeout": "The failover timeout in milliseconds.", + "graphite": "Graphite server URL (host and port). If set, monitoring with Graphite will be enabled.", + "lazyfree_lazy_eviction": "The lazy eviction enablement (yes or no).", + "lazyfree_lazy_expire": "The lazy expire enablement (yes or no).", + "lua_time_limit": "The Lua time limit.", + "max_disk_threshold": "The maximum disk threshold in MB. If the disk usage exceeds this threshold, the instance will be stopped.", + "maxclients": "The maximum number of clients.", + "maxmemory_policy": "The policy to handle the maximum memory (volatile-lru, noeviction, etc).", + "maxmemory_samples": "The maximum memory samples.", + "metrics_frequency": "The frequency in seconds at which metrics are emitted.", + "metrics_prefix": "The prefix for the metrics. Could be useful when using Graphite monitoring to prefix the metrics with a certain value, like an API key.", + "min_replicas_to_write": "The amount of connected replicas that are required for the primary to accept write operations. It can be set to 0 to disable it.", + "min_replicas_max_lag": "The minimum replicas maximum lag.", + "monitoring_instance_id": "The ID of the STACKIT monitoring instance.", + "notify_keyspace_events": "The notify keyspace events.", + "repl_backlog_size": "The replication backlog size for the cluster.", + "snapshot": "The snapshot configuration.", + "syslog": "List of syslog servers to send logs to.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + }, + "version": schema.StringAttribute{ + Description: descriptions["version"], + Computed: true, + }, + "plan_name": schema.StringAttribute{ + Description: descriptions["plan_name"], + Computed: true, + }, + "plan_id": schema.StringAttribute{ + Description: descriptions["plan_id"], + Computed: true, + }, + "parameters": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "sgw_acl": schema.StringAttribute{ + Description: parametersDescriptions["sgw_acl"], + Computed: true, + }, + "down_after_milliseconds": schema.Int32Attribute{ + Description: parametersDescriptions["down_after_milliseconds"], + Computed: true, + }, + "enable_monitoring": schema.BoolAttribute{ + Description: parametersDescriptions["enable_monitoring"], + Computed: true, + }, + "failover_timeout": schema.Int32Attribute{ + Description: parametersDescriptions["failover_timeout"], + Computed: true, + }, + "graphite": schema.StringAttribute{ + Description: parametersDescriptions["graphite"], + Computed: true, + }, + "lazyfree_lazy_eviction": schema.StringAttribute{ + Description: parametersDescriptions["lazyfree_lazy_eviction"], + Computed: true, + }, + "lazyfree_lazy_expire": schema.StringAttribute{ + Description: parametersDescriptions["lazyfree_lazy_expire"], + Computed: true, + }, + "lua_time_limit": schema.Int32Attribute{ + Description: parametersDescriptions["lua_time_limit"], + Computed: true, + }, + "max_disk_threshold": schema.Int32Attribute{ + Description: parametersDescriptions["max_disk_threshold"], + Computed: true, + }, + "maxclients": schema.Int32Attribute{ + Description: parametersDescriptions["maxclients"], + Computed: true, + }, + "maxmemory_policy": schema.StringAttribute{ + Description: parametersDescriptions["maxmemory_policy"], + Computed: true, + }, + "maxmemory_samples": schema.Int32Attribute{ + Description: parametersDescriptions["maxmemory_samples"], + Computed: true, + }, + "metrics_frequency": schema.Int32Attribute{ + Description: parametersDescriptions["metrics_frequency"], + Computed: true, + }, + "metrics_prefix": schema.StringAttribute{ + Description: parametersDescriptions["metrics_prefix"], + Computed: true, + }, + "min_replicas_max_lag": schema.Int32Attribute{ + Description: parametersDescriptions["min_replicas_max_lag"], + Computed: true, + }, + "min_replicas_to_write": schema.Int32Attribute{ + Description: parametersDescriptions["min_replicas_to_write"], + Computed: true, + }, + "monitoring_instance_id": schema.StringAttribute{ + Description: parametersDescriptions["monitoring_instance_id"], + Computed: true, + }, + "notify_keyspace_events": schema.StringAttribute{ + Description: parametersDescriptions["notify_keyspace_events"], + Computed: true, + }, + "repl_backlog_size": schema.StringAttribute{ + Description: parametersDescriptions["repl_backlog_size"], + Computed: true, + }, + "snapshot": schema.StringAttribute{ + Description: parametersDescriptions["snapshot"], + Computed: true, + }, + "syslog": schema.ListAttribute{ + ElementType: types.StringType, + Description: parametersDescriptions["syslog"], + Computed: true, + }, + }, + Computed: true, + }, + "cf_guid": schema.StringAttribute{ + Description: descriptions["cf_guid"], + Computed: true, + }, + "cf_space_guid": schema.StringAttribute{ + Description: descriptions["cf_space_guid"], + Computed: true, + }, + "dashboard_url": schema.StringAttribute{ + Description: descriptions["dashboard_url"], + Computed: true, + }, + "image_url": schema.StringAttribute{ + Description: descriptions["image_url"], + Computed: true, + }, + "cf_organization_guid": schema.StringAttribute{ + Description: descriptions["cf_organization_guid"], + Computed: true, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *instanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.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 := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading instance", + fmt.Sprintf("Instance with ID %q does not exist in project %q.", instanceId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + http.StatusGone: fmt.Sprintf("Instance %q is gone.", instanceId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(instanceResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Compute and store values not present in the API response + err = loadPlanNameAndVersion(ctx, r.client, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Loading service plan details: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Valkey instance read") +} diff --git a/stackit/internal/services/valkey/instance/resource.go b/stackit/internal/services/valkey/instance/resource.go new file mode 100644 index 000000000..7546142cd --- /dev/null +++ b/stackit/internal/services/valkey/instance/resource.go @@ -0,0 +1,951 @@ +package valkey + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + + valkeyUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/utils" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" + + "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/types" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &instanceResource{} + _ resource.ResourceWithConfigure = &instanceResource{} + _ resource.ResourceWithImportState = &instanceResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + CfGuid types.String `tfsdk:"cf_guid"` + CfSpaceGuid types.String `tfsdk:"cf_space_guid"` + DashboardUrl types.String `tfsdk:"dashboard_url"` + ImageUrl types.String `tfsdk:"image_url"` + Name types.String `tfsdk:"name"` + CfOrganizationGuid types.String `tfsdk:"cf_organization_guid"` + Parameters types.Object `tfsdk:"parameters"` + Version types.String `tfsdk:"version"` + PlanName types.String `tfsdk:"plan_name"` + PlanId types.String `tfsdk:"plan_id"` +} + +// Struct corresponding to DataSourceModel.Parameters +type parametersModel struct { + SgwAcl types.String `tfsdk:"sgw_acl"` + DownAfterMilliseconds types.Int32 `tfsdk:"down_after_milliseconds"` + EnableMonitoring types.Bool `tfsdk:"enable_monitoring"` + FailoverTimeout types.Int32 `tfsdk:"failover_timeout"` + Graphite types.String `tfsdk:"graphite"` + LazyfreeLazyEviction types.String `tfsdk:"lazyfree_lazy_eviction"` + LazyfreeLazyExpire types.String `tfsdk:"lazyfree_lazy_expire"` + LuaTimeLimit types.Int32 `tfsdk:"lua_time_limit"` + MaxDiskThreshold types.Int32 `tfsdk:"max_disk_threshold"` + Maxclients types.Int32 `tfsdk:"maxclients"` + MaxmemoryPolicy types.String `tfsdk:"maxmemory_policy"` + MaxmemorySamples types.Int32 `tfsdk:"maxmemory_samples"` + MetricsFrequency types.Int32 `tfsdk:"metrics_frequency"` + MetricsPrefix types.String `tfsdk:"metrics_prefix"` + MinReplicasToWrite types.Int32 `tfsdk:"min_replicas_to_write"` + MinReplicasMaxLag types.Int32 `tfsdk:"min_replicas_max_lag"` + MonitoringInstanceId types.String `tfsdk:"monitoring_instance_id"` + NotifyKeyspaceEvents types.String `tfsdk:"notify_keyspace_events"` + ReplBacklogSize types.String `tfsdk:"repl_backlog_size"` + Snapshot types.String `tfsdk:"snapshot"` + Syslog types.List `tfsdk:"syslog"` +} + +// Types corresponding to parametersModel +var parametersTypes = map[string]attr.Type{ + "sgw_acl": basetypes.StringType{}, + "down_after_milliseconds": basetypes.Int32Type{}, + "enable_monitoring": basetypes.BoolType{}, + "failover_timeout": basetypes.Int32Type{}, + "graphite": basetypes.StringType{}, + "lazyfree_lazy_eviction": basetypes.StringType{}, + "lazyfree_lazy_expire": basetypes.StringType{}, + "lua_time_limit": basetypes.Int32Type{}, + "max_disk_threshold": basetypes.Int32Type{}, + "maxclients": basetypes.Int32Type{}, + "maxmemory_policy": basetypes.StringType{}, + "maxmemory_samples": basetypes.Int32Type{}, + "metrics_frequency": basetypes.Int32Type{}, + "metrics_prefix": basetypes.StringType{}, + "min_replicas_to_write": basetypes.Int32Type{}, + "min_replicas_max_lag": basetypes.Int32Type{}, + "monitoring_instance_id": basetypes.StringType{}, + "notify_keyspace_events": basetypes.StringType{}, + "repl_backlog_size": basetypes.StringType{}, + "snapshot": basetypes.StringType{}, + "syslog": basetypes.ListType{ElemType: types.StringType}, +} + +// NewInstanceResource is a helper function to simplify the provider implementation. +func NewInstanceResource() resource.Resource { + return &instanceResource{} +} + +// instanceResource is the resource implementation. +type instanceResource struct { + client *valkey.APIClient + providerData core.ProviderData +} + +// Metadata returns the resource type name. +func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_valkey_instance" +} + +// Configure adds the provider configured client to the resource. +func (r *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + var ok bool + r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + apiClient := valkeyUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.client = apiClient + tflog.Info(ctx, "Valkey instance client configured") +} + +// Schema defines the schema for the resource. +func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + descriptions := map[string]string{ + "main": "Valkey instance resource schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`\".", + "instance_id": "ID of the Valkey instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + "name": "Instance name.", + "version": "The service version.", + "plan_name": "The selected plan name.", + "plan_id": "The selected plan ID.", + "parameters": "Configuration parameters. Please note that removing a previously configured field from your Terraform configuration won't replace its value in the API. To update a previously configured field, explicitly set a new value for it.", + "region": "The resource region. If not defined, the provider region is used.", + } + + parametersDescriptions := map[string]string{ + "sgw_acl": "Comma separated list of IP networks in CIDR notation which are allowed to access this instance.", + "down_after_milliseconds": "The number of milliseconds after which the instance is considered down.", + "enable_monitoring": "Enable monitoring.", + "failover_timeout": "The failover timeout in milliseconds.", + "graphite": "Graphite server URL (host and port). If set, monitoring with Graphite will be enabled.", + "lazyfree_lazy_eviction": "The lazy eviction enablement (yes or no).", + "lazyfree_lazy_expire": "The lazy expire enablement (yes or no).", + "lua_time_limit": "The Lua time limit.", + "max_disk_threshold": "The maximum disk threshold in MB. If the disk usage exceeds this threshold, the instance will be stopped.", + "maxclients": "The maximum number of clients.", + "maxmemory_policy": "The policy to handle the maximum memory (volatile-lru, noeviction, etc).", + "maxmemory_samples": "The maximum memory samples.", + "metrics_frequency": "The frequency in seconds at which metrics are emitted.", + "metrics_prefix": "The prefix for the metrics. Could be useful when using Graphite monitoring to prefix the metrics with a certain value, like an API key.", + "min_replicas_to_write": "The amount of connected replicas that are required for the primary to accept write operations. It can be set to 0 to disable it.", + "min_replicas_max_lag": "The minimum replicas maximum lag.", + "monitoring_instance_id": "The ID of the STACKIT monitoring instance.", + "notify_keyspace_events": "The notify keyspace events.", + "repl_backlog_size": "The replication backlog size for the cluster.", + "snapshot": "The snapshot configuration.", + "syslog": "List of syslog servers to send logs to.", + } + + resp.Schema = schema.Schema{ + Description: descriptions["main"], + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "instance_id": schema.StringAttribute{ + Description: descriptions["instance_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "version": schema.StringAttribute{ + Description: descriptions["version"], + Required: true, + }, + "plan_name": schema.StringAttribute{ + Description: descriptions["plan_name"], + Required: true, + }, + "plan_id": schema.StringAttribute{ + Description: descriptions["plan_id"], + Computed: true, + }, + "parameters": schema.SingleNestedAttribute{ + Description: descriptions["parameters"], + Attributes: map[string]schema.Attribute{ + "sgw_acl": schema.StringAttribute{ + Description: parametersDescriptions["sgw_acl"], + Optional: true, + Computed: true, + }, + "down_after_milliseconds": schema.Int32Attribute{ + Description: parametersDescriptions["down_after_milliseconds"], + Optional: true, + Computed: true, + }, + "enable_monitoring": schema.BoolAttribute{ + Description: parametersDescriptions["enable_monitoring"], + Optional: true, + Computed: true, + }, + "failover_timeout": schema.Int32Attribute{ + Description: parametersDescriptions["failover_timeout"], + Optional: true, + Computed: true, + }, + "graphite": schema.StringAttribute{ + Description: parametersDescriptions["graphite"], + Optional: true, + Computed: true, + }, + "lazyfree_lazy_eviction": schema.StringAttribute{ + Description: parametersDescriptions["lazyfree_lazy_eviction"], + Optional: true, + Computed: true, + }, + "lazyfree_lazy_expire": schema.StringAttribute{ + Description: parametersDescriptions["lazyfree_lazy_expire"], + Optional: true, + Computed: true, + }, + "lua_time_limit": schema.Int32Attribute{ + Description: parametersDescriptions["lua_time_limit"], + Optional: true, + Computed: true, + }, + "max_disk_threshold": schema.Int32Attribute{ + Description: parametersDescriptions["max_disk_threshold"], + Optional: true, + Computed: true, + }, + "maxclients": schema.Int32Attribute{ + Description: parametersDescriptions["maxclients"], + Optional: true, + Computed: true, + }, + "maxmemory_policy": schema.StringAttribute{ + Description: parametersDescriptions["maxmemory_policy"], + Optional: true, + Computed: true, + }, + "maxmemory_samples": schema.Int32Attribute{ + Description: parametersDescriptions["maxmemory_samples"], + Optional: true, + Computed: true, + }, + "metrics_frequency": schema.Int32Attribute{ + Description: parametersDescriptions["metrics_frequency"], + Optional: true, + Computed: true, + }, + "metrics_prefix": schema.StringAttribute{ + Description: parametersDescriptions["metrics_prefix"], + Optional: true, + Computed: true, + }, + "min_replicas_max_lag": schema.Int32Attribute{ + Description: parametersDescriptions["min_replicas_max_lag"], + Optional: true, + Computed: true, + }, + "min_replicas_to_write": schema.Int32Attribute{ + Description: parametersDescriptions["min_replicas_to_write"], + Optional: true, + Computed: true, + }, + "monitoring_instance_id": schema.StringAttribute{ + Description: parametersDescriptions["monitoring_instance_id"], + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "notify_keyspace_events": schema.StringAttribute{ + Description: parametersDescriptions["notify_keyspace_events"], + Optional: true, + Computed: true, + }, + "repl_backlog_size": schema.StringAttribute{ + Description: parametersDescriptions["repl_backlog_size"], + Optional: true, + Computed: true, + }, + "snapshot": schema.StringAttribute{ + Description: parametersDescriptions["snapshot"], + Optional: true, + Computed: true, + }, + "syslog": schema.ListAttribute{ + Description: parametersDescriptions["syslog"], + ElementType: types.StringType, + Optional: true, + Computed: true, + }, + }, + Optional: true, + Computed: true, + }, + "cf_guid": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "cf_space_guid": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "dashboard_url": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "image_url": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "cf_organization_guid": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + 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 := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + var parameters *parametersModel + if !(model.Parameters.IsNull() || model.Parameters.IsUnknown()) { + parameters = ¶metersModel{} + diags = model.Parameters.As(ctx, parameters, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + err := r.loadPlanId(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Loading service plan: %v", err)) + return + } + + // Generate API request body from model + payload, err := toCreatePayload(&model, parameters) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Create new instance + createResp, err := r.client.DefaultAPI.CreateInstance(ctx, projectId, region).CreateInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + if createResp.InstanceId == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Got empty instance id") + return + } + instanceId := createResp.InstanceId + // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "instance_id": instanceId, + "region": region, + }) + if resp.Diagnostics.HasError() { + return + } + waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating 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, "Valkey instance created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *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 := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + // Map response body to schema + err = mapFields(instanceResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Compute and store values not present in the API response + err = loadPlanNameAndVersion(ctx, r.client, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Loading service plan details: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Valkey instance read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + 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 := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + var parameters *parametersModel + if !(model.Parameters.IsNull() || model.Parameters.IsUnknown()) { + parameters = ¶metersModel{} + diags = model.Parameters.As(ctx, parameters, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + err := r.loadPlanId(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Loading service plan: %v", err)) + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(&model, parameters) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing instance + err = r.client.DefaultAPI.PartialUpdateInstance(ctx, projectId, instanceId, region).PartialUpdateInstancePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + waitResp, err := wait.PartialUpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Instance update waiting: %v", err)) + return + } + + // Map response body to schema + err = mapFields(waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating 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, "Valkey instance updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + 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 := r.providerData.GetRegionWithOverride(model.Region) + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + + // Delete existing instance + err := r.client.DefaultAPI.DeleteInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + _, err = wait.DeleteInstanceWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", fmt.Sprintf("Instance deletion waiting: %v", err)) + return + } + tflog.Info(ctx, "Valkey instance deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,region,instance_id +func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing instance", + fmt.Sprintf("Expected import identifier with format: [project_id],[region],[instance_id] Got: %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "instance_id": idParts[2], + }) + tflog.Info(ctx, "Valkey instance state imported") +} + +func mapFields(instance *valkey.Instance, model *Model, region string) error { + if instance == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var instanceId string + if model.InstanceId.ValueString() != "" { + instanceId = model.InstanceId.ValueString() + } else if instance.InstanceId != nil { + instanceId = *instance.InstanceId + } else { + return fmt.Errorf("instance id not present") + } + + model.Region = types.StringValue(region) + model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceId) + model.InstanceId = types.StringValue(instanceId) + model.PlanId = types.StringValue(instance.PlanId) + model.CfGuid = types.StringValue(instance.CfGuid) + model.CfSpaceGuid = types.StringValue(instance.CfSpaceGuid) + model.DashboardUrl = types.StringValue(instance.DashboardUrl) + model.ImageUrl = types.StringValue(instance.ImageUrl) + model.Name = types.StringValue(instance.Name) + model.CfOrganizationGuid = types.StringValue(instance.CfOrganizationGuid) + + if instance.Parameters == nil { + model.Parameters = types.ObjectNull(parametersTypes) + } else { + parameters, err := mapParameters(instance.Parameters) + if err != nil { + return fmt.Errorf("mapping parameters: %w", err) + } + model.Parameters = parameters + } + return nil +} + +func mapParameters(params map[string]any) (types.Object, error) { + attributes := map[string]attr.Value{} + for attribute := range parametersTypes { + var valueInterface any + var ok bool + + // This replacement is necessary because Terraform does not allow hyphens in attribute names + // And the API uses hyphens in some of the attribute names, which would cause a mismatch + // The following attributes have hyphens in the API but underscores in the schema + hyphenAttributes := []string{ + "down_after_milliseconds", + "failover_timeout", + "lazyfree_lazy_eviction", + "lazyfree_lazy_expire", + "lua_time_limit", + "maxmemory_policy", + "maxmemory_samples", + "notify_keyspace_events", + "min_replicas_to_write", + "repl_backlog_size", + } + if slices.Contains(hyphenAttributes, attribute) { + alteredAttribute := strings.ReplaceAll(attribute, "_", "-") + valueInterface, ok = params[alteredAttribute] + } else { + valueInterface, ok = params[attribute] + } + if !ok { + // All fields are optional, so this is ok + // Set the value as nil, will be handled accordingly + valueInterface = nil + } + + var value attr.Value + switch parametersTypes[attribute].(type) { + default: + return types.ObjectNull(parametersTypes), fmt.Errorf("found unexpected attribute type '%T'", parametersTypes[attribute]) + case basetypes.StringType: + if valueInterface == nil { + value = types.StringNull() + } else { + valueString, ok := valueInterface.(string) + if !ok { + return types.ObjectNull(parametersTypes), fmt.Errorf("found attribute '%s' of type %T, failed to assert as string", attribute, valueInterface) + } + value = types.StringValue(valueString) + } + case basetypes.BoolType: + if valueInterface == nil { + value = types.BoolNull() + } else { + valueBool, ok := valueInterface.(bool) + if !ok { + return types.ObjectNull(parametersTypes), fmt.Errorf("found attribute '%s' of type %T, failed to assert as bool", attribute, valueInterface) + } + value = types.BoolValue(valueBool) + } + case basetypes.Int32Type: + if valueInterface == nil { + value = types.Int32Null() + } else { + // This may be int64, int32, int or float64 + // We try to assert all 4 + var valueInt32 int32 + switch temp := valueInterface.(type) { + default: + return types.ObjectNull(parametersTypes), fmt.Errorf("found attribute '%s' of type %T, failed to assert as int", attribute, valueInterface) + case int32: + valueInt32 = temp + case float64: + valueInt32 = int32(temp) + } + value = types.Int32Value(valueInt32) + } + case basetypes.ListType: // Assumed to be a list of strings + if valueInterface == nil { + value = types.ListNull(types.StringType) + } else { + // This may be []string{} or []interface{} + // We try to assert all 2 + var valueList []attr.Value + switch temp := valueInterface.(type) { + default: + return types.ObjectNull(parametersTypes), fmt.Errorf("found attribute '%s' of type %T, failed to assert as array of interface", attribute, valueInterface) + case []string: + for _, x := range temp { + valueList = append(valueList, types.StringValue(x)) + } + case []any: + for _, x := range temp { + xString, ok := x.(string) + if !ok { + return types.ObjectNull(parametersTypes), fmt.Errorf("found attribute '%s' with element '%s' of type %T, failed to assert as string", attribute, x, x) + } + valueList = append(valueList, types.StringValue(xString)) + } + } + temp2, diags := types.ListValue(types.StringType, valueList) + if diags.HasError() { + return types.ObjectNull(parametersTypes), fmt.Errorf("failed to map %s: %w", attribute, core.DiagsToError(diags)) + } + value = temp2 + } + } + attributes[attribute] = value + } + + output, diags := types.ObjectValue(parametersTypes, attributes) + if diags.HasError() { + return types.ObjectNull(parametersTypes), fmt.Errorf("failed to create object: %w", core.DiagsToError(diags)) + } + return output, nil +} + +func toCreatePayload(model *Model, parameters *parametersModel) (*valkey.CreateInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + payloadParams, err := toInstanceParams(parameters) + if err != nil { + return nil, fmt.Errorf("converting parameters: %w", err) + } + + return &valkey.CreateInstancePayload{ + InstanceName: model.Name.ValueString(), + Parameters: payloadParams, + PlanId: model.PlanId.ValueString(), + }, nil +} + +func toUpdatePayload(model *Model, parameters *parametersModel) (*valkey.PartialUpdateInstancePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + payloadParams, err := toInstanceParams(parameters) + if err != nil { + return nil, fmt.Errorf("converting parameters: %w", err) + } + + return &valkey.PartialUpdateInstancePayload{ + Parameters: payloadParams, + PlanId: conversion.StringValueToPointer(model.PlanId), + }, nil +} + +func toInstanceParams(parameters *parametersModel) (*valkey.InstanceParameters, error) { + if parameters == nil { + return nil, nil + } + payloadParams := &valkey.InstanceParameters{} + + payloadParams.SgwAcl = conversion.StringValueToPointer(parameters.SgwAcl) + payloadParams.DownAfterMilliseconds = conversion.Int32ValueToPointer(parameters.DownAfterMilliseconds) + payloadParams.EnableMonitoring = conversion.BoolValueToPointer(parameters.EnableMonitoring) + payloadParams.FailoverTimeout = conversion.Int32ValueToPointer(parameters.FailoverTimeout) + payloadParams.Graphite = conversion.StringValueToPointer(parameters.Graphite) + payloadParams.LazyfreeLazyEviction = conversion.StringValueToEnumPointer[valkey.InstanceParametersLazyfreeLazyEviction](parameters.LazyfreeLazyEviction) + payloadParams.LazyfreeLazyExpire = conversion.StringValueToEnumPointer[valkey.InstanceParametersLazyfreeLazyExpire](parameters.LazyfreeLazyExpire) + payloadParams.LuaTimeLimit = conversion.Int32ValueToPointer(parameters.LuaTimeLimit) + payloadParams.MaxDiskThreshold = conversion.Int32ValueToPointer(parameters.MaxDiskThreshold) + payloadParams.Maxclients = conversion.Int32ValueToPointer(parameters.Maxclients) + payloadParams.MaxmemoryPolicy = conversion.StringValueToEnumPointer[valkey.InstanceParametersMaxmemoryPolicy](parameters.MaxmemoryPolicy) + payloadParams.MaxmemorySamples = conversion.Int32ValueToPointer(parameters.MaxmemorySamples) + payloadParams.MetricsFrequency = conversion.Int32ValueToPointer(parameters.MetricsFrequency) + payloadParams.MetricsPrefix = conversion.StringValueToPointer(parameters.MetricsPrefix) + payloadParams.MinReplicasToWrite = conversion.Int32ValueToPointer(parameters.MinReplicasToWrite) + payloadParams.MinReplicasMaxLag = conversion.Int32ValueToPointer(parameters.MinReplicasMaxLag) + payloadParams.MonitoringInstanceId = conversion.StringValueToPointer(parameters.MonitoringInstanceId) + payloadParams.NotifyKeyspaceEvents = conversion.StringValueToPointer(parameters.NotifyKeyspaceEvents) + payloadParams.ReplBacklogSize = conversion.StringValueToPointer(parameters.ReplBacklogSize) + payloadParams.Snapshot = conversion.StringValueToPointer(parameters.Snapshot) + + var err error + payloadParams.Syslog, err = conversion.StringListToSlice(parameters.Syslog) + if err != nil { + return nil, fmt.Errorf("converting syslog: %w", err) + } + + return payloadParams, nil +} + +func (r *instanceResource) loadPlanId(ctx context.Context, model *Model) error { + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + res, err := r.client.DefaultAPI.ListOfferings(ctx, projectId, region).Execute() + if err != nil { + return fmt.Errorf("getting Valkey offerings: %w", err) + } + + version := model.Version.ValueString() + planName := model.PlanName.ValueString() + availableVersions := "" + availablePlanNames := "" + isValidVersion := false + for _, offer := range res.Offerings { + if !strings.EqualFold(offer.Version, version) { + availableVersions = fmt.Sprintf("%s\n- %s", availableVersions, offer.Version) + continue + } + isValidVersion = true + + for _, plan := range offer.Plans { + if plan.Name == "" { + continue + } + if strings.EqualFold(plan.Name, planName) && plan.Id != "" { + model.PlanId = types.StringValue(plan.Id) + return nil + } + availablePlanNames = fmt.Sprintf("%s\n- %s", availablePlanNames, plan.Name) + } + } + + if !isValidVersion { + return fmt.Errorf("couldn't find version '%s', available versions are: %s", version, availableVersions) + } + return fmt.Errorf("couldn't find plan_name '%s' for version %s, available names are: %s", planName, version, availablePlanNames) +} + +func loadPlanNameAndVersion(ctx context.Context, client *valkey.APIClient, model *Model, region string) error { + projectId := model.ProjectId.ValueString() + planId := model.PlanId.ValueString() + res, err := client.DefaultAPI.ListOfferings(ctx, projectId, region).Execute() + if err != nil { + return fmt.Errorf("getting Valkey offerings: %w", err) + } + + for _, offer := range res.Offerings { + for _, plan := range offer.Plans { + if strings.EqualFold(plan.Id, planId) && plan.Id != "" { + model.PlanName = types.StringValue(plan.Name) + model.Version = types.StringValue(offer.Version) + return nil + } + } + } + + return fmt.Errorf("couldn't find plan_name and version for plan_id '%s'", planId) +} diff --git a/stackit/internal/services/valkey/instance/resource_test.go b/stackit/internal/services/valkey/instance/resource_test.go new file mode 100644 index 000000000..affae44ec --- /dev/null +++ b/stackit/internal/services/valkey/instance/resource_test.go @@ -0,0 +1,370 @@ +package valkey + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" +) + +var fixtureModelParameters = types.ObjectValueMust(parametersTypes, map[string]attr.Value{ + "sgw_acl": types.StringValue("acl"), + "down_after_milliseconds": types.Int32Value(10), + "enable_monitoring": types.BoolValue(true), + "failover_timeout": types.Int32Value(10), + "graphite": types.StringValue("1.1.1.1:91"), + "lazyfree_lazy_eviction": types.StringValue("no"), + "lazyfree_lazy_expire": types.StringValue("no"), + "lua_time_limit": types.Int32Value(10), + "max_disk_threshold": types.Int32Value(100), + "maxclients": types.Int32Value(10), + "maxmemory_policy": types.StringValue("volatile-lru"), + "maxmemory_samples": types.Int32Value(10), + "metrics_frequency": types.Int32Value(10), + "metrics_prefix": types.StringValue("prefix"), + "min_replicas_max_lag": types.Int32Value(10), + "min_replicas_to_write": types.Int32Value(1), + "monitoring_instance_id": types.StringValue("mid"), + "notify_keyspace_events": types.StringValue("events"), + "repl_backlog_size": types.StringValue("1mb"), + "snapshot": types.StringValue("snapshot"), + "syslog": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("syslog"), + types.StringValue("syslog2"), + }), +}) + +var fixtureInstanceParameters = valkey.InstanceParameters{ + SgwAcl: new("acl"), + DownAfterMilliseconds: new(int32(10)), + EnableMonitoring: new(true), + FailoverTimeout: new(int32(10)), + Graphite: new("1.1.1.1:91"), + LazyfreeLazyEviction: new(valkey.INSTANCEPARAMETERSLAZYFREELAZYEVICTION_NO), + LazyfreeLazyExpire: new(valkey.INSTANCEPARAMETERSLAZYFREELAZYEXPIRE_NO), + LuaTimeLimit: new(int32(10)), + MaxDiskThreshold: new(int32(100)), + Maxclients: new(int32(10)), + MaxmemoryPolicy: new(valkey.INSTANCEPARAMETERSMAXMEMORYPOLICY_VOLATILE_LRU), + MaxmemorySamples: new(int32(10)), + MetricsFrequency: new(int32(10)), + MetricsPrefix: new("prefix"), + MinReplicasMaxLag: new(int32(10)), + MinReplicasToWrite: new(int32(1)), + MonitoringInstanceId: new("mid"), + NotifyKeyspaceEvents: new("events"), + ReplBacklogSize: new("1mb"), + Snapshot: new("snapshot"), + Syslog: []string{"syslog", "syslog2"}, +} + +func TestMapFields(t *testing.T) { + const testRegion = "eu01" + tests := []struct { + description string + input *valkey.Instance + expected Model + isValid bool + }{ + { + "default_values", + &valkey.Instance{}, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid", testRegion)), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + PlanId: types.StringValue(""), + Name: types.StringValue(""), + CfGuid: types.StringValue(""), + CfSpaceGuid: types.StringValue(""), + DashboardUrl: types.StringValue(""), + ImageUrl: types.StringValue(""), + CfOrganizationGuid: types.StringValue(""), + Parameters: types.ObjectNull(parametersTypes), + }, + true, + }, + { + "simple_values", + &valkey.Instance{ + PlanId: "plan", + CfGuid: "cf", + CfSpaceGuid: "space", + DashboardUrl: "dashboard", + ImageUrl: "image", + InstanceId: new("iid"), + Name: "name", + CfOrganizationGuid: "org", + Parameters: map[string]any{ + "sgw_acl": "acl", + "down-after-milliseconds": int32(10), + "enable_monitoring": true, + "failover-timeout": int32(10), + "graphite": "1.1.1.1:91", + "lazyfree-lazy-eviction": "no", + "lazyfree-lazy-expire": "no", + "lua-time-limit": int32(10), + "max_disk_threshold": int32(100), + "maxclients": int32(10), + "maxmemory-policy": "volatile-lru", + "maxmemory-samples": int32(10), + "metrics_frequency": int32(10), + "metrics_prefix": "prefix", + "min_replicas_max_lag": int32(10), + "min-replicas-to-write": int32(1), + "monitoring_instance_id": "mid", + "notify-keyspace-events": "events", + "repl-backlog-size": "1mb", + "snapshot": "snapshot", + "syslog": []string{"syslog", "syslog2"}, + }, + }, + Model{ + Id: types.StringValue(fmt.Sprintf("pid,%s,iid", testRegion)), + InstanceId: types.StringValue("iid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue(testRegion), + PlanId: types.StringValue("plan"), + Name: types.StringValue("name"), + CfGuid: types.StringValue("cf"), + CfSpaceGuid: types.StringValue("space"), + DashboardUrl: types.StringValue("dashboard"), + ImageUrl: types.StringValue("image"), + CfOrganizationGuid: types.StringValue("org"), + Parameters: fixtureModelParameters, + }, + true, + }, + { + "nil_response", + nil, + Model{}, + false, + }, + { + "no_resource_id", + &valkey.Instance{}, + Model{}, + false, + }, + { + "wrong_param_types_1", + &valkey.Instance{ + Parameters: map[string]any{ + "sgw_acl": true, + }, + }, + Model{}, + false, + }, + { + "wrong_param_types_2", + &valkey.Instance{ + Parameters: map[string]any{ + "sgw_acl": 1, + }, + }, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + InstanceId: tt.expected.InstanceId, + } + err := mapFields(tt.input, state, testRegion) + 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(state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *valkey.CreateInstancePayload + isValid bool + }{ + { + "default_values", + &Model{}, + &valkey.CreateInstancePayload{}, + true, + }, + { + "simple_values", + &Model{ + Name: types.StringValue("name"), + PlanId: types.StringValue("plan"), + Parameters: fixtureModelParameters, + }, + &valkey.CreateInstancePayload{ + InstanceName: "name", + Parameters: &fixtureInstanceParameters, + PlanId: "plan", + }, + true, + }, + { + "null_fields_and_int_conversions", + &Model{ + Name: types.StringValue(""), + PlanId: types.StringValue(""), + Parameters: fixtureModelParameters, + }, + &valkey.CreateInstancePayload{ + InstanceName: "", + Parameters: &fixtureInstanceParameters, + PlanId: "", + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + { + "nil_parameters", + &Model{ + Name: types.StringValue("name"), + PlanId: types.StringValue("plan"), + }, + &valkey.CreateInstancePayload{ + InstanceName: "name", + PlanId: "plan", + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var parameters *parametersModel + if tt.input != nil { + if !(tt.input.Parameters.IsNull() || tt.input.Parameters.IsUnknown()) { + parameters = ¶metersModel{} + diags := tt.input.Parameters.As(context.Background(), parameters, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("Error converting parameters: %v", diags.Errors()) + } + } + } + output, err := toCreatePayload(tt.input, parameters) + 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) { + tests := []struct { + description string + input *Model + expected *valkey.PartialUpdateInstancePayload + isValid bool + }{ + { + "default_values", + &Model{}, + &valkey.PartialUpdateInstancePayload{}, + true, + }, + { + "simple_values", + &Model{ + PlanId: types.StringValue("plan"), + Parameters: fixtureModelParameters, + }, + &valkey.PartialUpdateInstancePayload{ + Parameters: &fixtureInstanceParameters, + PlanId: new("plan"), + }, + true, + }, + { + "null_fields_and_int_conversions", + &Model{ + PlanId: types.StringValue(""), + Parameters: fixtureModelParameters, + }, + &valkey.PartialUpdateInstancePayload{ + Parameters: &fixtureInstanceParameters, + PlanId: new(""), + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + { + "nil_parameters", + &Model{ + PlanId: types.StringValue("plan"), + }, + &valkey.PartialUpdateInstancePayload{ + PlanId: new("plan"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var parameters *parametersModel + if tt.input != nil { + if !(tt.input.Parameters.IsNull() || tt.input.Parameters.IsUnknown()) { + parameters = ¶metersModel{} + diags := tt.input.Parameters.As(context.Background(), parameters, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("Error converting parameters: %v", diags.Errors()) + } + } + } + output, err := toUpdatePayload(tt.input, parameters) + 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/valkey/utils/util.go b/stackit/internal/services/valkey/utils/util.go new file mode 100644 index 000000000..a3e7aa8f6 --- /dev/null +++ b/stackit/internal/services/valkey/utils/util.go @@ -0,0 +1,30 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/stackitcloud/stackit-sdk-go/core/config" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" +) + +func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *valkey.APIClient { + apiClientConfigOptions := []config.ConfigurationOption{ + config.WithCustomAuth(providerData.RoundTripper), + utils.UserAgentConfigOption(providerData.Version), + } + if providerData.ValkeyCustomEndpoint != "" { + apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.ValkeyCustomEndpoint)) + } + apiClient, err := valkey.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/valkey/utils/util_test.go b/stackit/internal/services/valkey/utils/util_test.go new file mode 100644 index 000000000..761315cd0 --- /dev/null +++ b/stackit/internal/services/valkey/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" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + + "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://valkey-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 *valkey.APIClient + }{ + { + name: "default endpoint", + args: args{ + providerData: &core.ProviderData{ + Version: testVersion, + }, + }, + expected: func() *valkey.APIClient { + apiClient, err := valkey.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, + ValkeyCustomEndpoint: testCustomEndpoint, + }, + }, + expected: func() *valkey.APIClient { + apiClient, err := valkey.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/services/valkey/valkey_acc_test.go b/stackit/internal/services/valkey/valkey_acc_test.go new file mode 100644 index 000000000..7f797375c --- /dev/null +++ b/stackit/internal/services/valkey/valkey_acc_test.go @@ -0,0 +1,326 @@ +package valkey_test + +import ( + "context" + "fmt" + "regexp" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + valkey "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/valkey/v2api/wait" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +// Instance resource data +var instanceResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": testutil.ResourceNameWithDateTime("valkey"), + "plan_id": "65778323-a25f-4cc8-ae87-b17fb051cba1", + "plan_name": "stackit-keyvalue-1.4.10-single", + "version": "8", + "sgw_acl_invalid": "1.2.3.4/4", + "sgw_acl_valid": "192.168.0.0/16", + "sgw_acl_valid2": "10.10.10.0/24", +} + +func parametersConfig(params map[string]string) string { + nonStringParams := []string{ + "down_after_milliseconds", + "enable_monitoring", + "failover_timeout", + "lua_time_limit", + "max_disk_threshold", + "maxclients", + "maxmemory_samples", + "metrics_frequency", + "min_replicas_max_lag", + "min_replicas_to_write", + "syslog", + } + var parameters strings.Builder + parameters.WriteString("parameters = {") + for k, v := range params { + if utils.Contains(nonStringParams, k) { + parameters.WriteString(fmt.Sprintf("%s = %s\n", k, v)) + } else { + parameters.WriteString(fmt.Sprintf("%s = %q\n", k, v)) + } + } + parameters.WriteString("\n}") + return parameters.String() +} + +func resourceConfig(params map[string]string) string { + return fmt.Sprintf(` + %s + + resource "stackit_valkey_instance" "instance" { + project_id = "%s" + name = "%s" + plan_name = "%s" + version = "%s" + %s + } + + %s + `, + testutil.NewConfigBuilder().BuildProviderConfig(), + instanceResource["project_id"], + instanceResource["name"], + instanceResource["plan_name"], + instanceResource["version"], + parametersConfig(params), + resourceConfigCredential(), + ) +} + +func resourceConfigCredential() string { + return ` + resource "stackit_valkey_credential" "credential" { + project_id = stackit_valkey_instance.instance.project_id + instance_id = stackit_valkey_instance.instance.instance_id + } + ` +} + +func TestAccValkeyResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckValkeyDestroy, + Steps: []resource.TestStep{ + // Creation fail + { + Config: resourceConfig(map[string]string{"sgw_acl": instanceResource["sgw_acl_invalid"]}), + ExpectError: regexp.MustCompile(`.*sgw_acl is invalid.*`), + }, + // Creation + { + Config: resourceConfig(map[string]string{ + "sgw_acl": instanceResource["sgw_acl_valid"], + "down_after_milliseconds": "10000", + "enable_monitoring": "false", + "failover_timeout": "30000", + "graphite": "graphite.example.com:2003", + "lazyfree_lazy_eviction": "no", + "lazyfree_lazy_expire": "no", + "lua_time_limit": "5000", + "max_disk_threshold": "80", + "maxclients": "10000", + "maxmemory_policy": "volatile-lru", + "maxmemory_samples": "5", + "metrics_frequency": "10", + "metrics_prefix": "prefix", + "min_replicas_max_lag": "15", + "min_replicas_to_write": "10", + "notify_keyspace_events": "Ex", + "repl_backlog_size": "1mb", + "syslog": `["syslog.example.com:123"]`, + }), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_valkey_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "plan_id", instanceResource["plan_id"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "plan_name", instanceResource["plan_name"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "version", instanceResource["version"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "name", instanceResource["name"]), + + // Instance Params data + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.sgw_acl", instanceResource["sgw_acl_valid"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.down_after_milliseconds", "10000"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.enable_monitoring", "false"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.failover_timeout", "30000"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.graphite", "graphite.example.com:2003"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.lazyfree_lazy_eviction", "no"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.lazyfree_lazy_expire", "no"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.lua_time_limit", "5000"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.max_disk_threshold", "80"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.maxclients", "10000"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.maxmemory_policy", "volatile-lru"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.maxmemory_samples", "5"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.metrics_frequency", "10"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.metrics_prefix", "prefix"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.min_replicas_max_lag", "15"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.min_replicas_to_write", "10"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.notify_keyspace_events", "Ex"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.repl_backlog_size", "1mb"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.syslog.#", "1"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.syslog.0", "syslog.example.com:123"), + + // Credential data + resource.TestCheckResourceAttrPair( + "stackit_valkey_credential.credential", "project_id", + "stackit_valkey_instance.instance", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_valkey_credential.credential", "instance_id", + "stackit_valkey_instance.instance", "instance_id", + ), + resource.TestCheckResourceAttrSet("stackit_valkey_credential.credential", "credential_id"), + resource.TestCheckResourceAttrSet("stackit_valkey_credential.credential", "host"), + ), + }, + // data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_valkey_instance" "instance" { + project_id = stackit_valkey_instance.instance.project_id + instance_id = stackit_valkey_instance.instance.instance_id + } + + data "stackit_valkey_credential" "credential" { + project_id = stackit_valkey_credential.credential.project_id + instance_id = stackit_valkey_credential.credential.instance_id + credential_id = stackit_valkey_credential.credential.credential_id + }`, + resourceConfig(nil), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("data.stackit_valkey_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrPair("stackit_valkey_instance.instance", "instance_id", + "data.stackit_valkey_credential.credential", "instance_id"), + resource.TestCheckResourceAttrPair("data.stackit_valkey_instance.instance", "instance_id", + "data.stackit_valkey_credential.credential", "instance_id"), + resource.TestCheckResourceAttr("data.stackit_valkey_instance.instance", "plan_id", instanceResource["plan_id"]), + resource.TestCheckResourceAttr("data.stackit_valkey_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttrSet("data.stackit_valkey_instance.instance", "parameters.sgw_acl"), + + // Credentials data + resource.TestCheckResourceAttr("data.stackit_valkey_credential.credential", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("data.stackit_valkey_credential.credential", "credential_id"), + resource.TestCheckResourceAttrSet("data.stackit_valkey_credential.credential", "host"), + resource.TestCheckResourceAttrSet("data.stackit_valkey_credential.credential", "port"), + resource.TestCheckResourceAttrSet("data.stackit_valkey_credential.credential", "uri"), + resource.TestCheckResourceAttrSet("data.stackit_valkey_credential.credential", "load_balanced_host"), + ), + }, + // Import + { + ResourceName: "stackit_valkey_instance.instance", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_valkey_instance.instance"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_valkey_instance.instance") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, region, instanceId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "stackit_valkey_credential.credential", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_valkey_credential.credential"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_valkey_credential.credential") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + credentialId, ok := r.Primary.Attributes["credential_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute credential_id") + } + return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, region, instanceId, credentialId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: resourceConfig(map[string]string{"sgw_acl": instanceResource["sgw_acl_valid2"]}), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance data + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_valkey_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "plan_id", instanceResource["plan_id"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "plan_name", instanceResource["plan_name"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "version", instanceResource["version"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.sgw_acl", instanceResource["sgw_acl_valid2"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func checkInstanceDeleteSuccess(i *valkey.Instance) bool { + if i.LastOperation.Type != valkey.INSTANCELASTOPERATIONTYPE_DELETE { + return false + } + + if i.LastOperation.Type == valkey.INSTANCELASTOPERATIONTYPE_DELETE { + if i.LastOperation.State != "succeeded" { + return false + } else if strings.Contains(i.LastOperation.Description, "DeleteFailed") || strings.Contains(i.LastOperation.Description, "failed") { + return false + } + } + return true +} + +func testAccCheckValkeyDestroy(s *terraform.State) error { + ctx := context.Background() + client, err := valkey.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.ValkeyCustomEndpoint, false)...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + instancesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_valkey_instance" { + continue + } + // instance terraform ID: "[project_id],[region],[instance_id]" + instanceId := strings.Split(rs.Primary.ID, core.Separator)[2] + instancesToDestroy = append(instancesToDestroy, instanceId) + } + + instancesResp, err := client.DefaultAPI.ListInstances(ctx, testutil.ProjectId, "eu01").Execute() + if err != nil { + return fmt.Errorf("getting instancesResp: %w", err) + } + + instances := instancesResp.Instances + for i := range instances { + if instances[i].InstanceId == nil { + continue + } + if utils.Contains(instancesToDestroy, *instances[i].InstanceId) { + if !checkInstanceDeleteSuccess(&instances[i]) { + err := client.DefaultAPI.DeleteInstance(ctx, testutil.ProjectId, "eu01", *instances[i].InstanceId).Execute() + if err != nil { + return fmt.Errorf("destroying instance %s during CheckDestroy: %w", *instances[i].InstanceId, err) + } + _, err = wait.DeleteInstanceWaitHandler(ctx, client.DefaultAPI, testutil.ProjectId, "eu01", *instances[i].InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("destroying instance %s during CheckDestroy: waiting for deletion %w", *instances[i].InstanceId, err) + } + } + } + } + return nil +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 512a08641..45216cbc3 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -105,6 +105,7 @@ var ( 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"} + ValkeyCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VALKEY_CUSTOM_ENDPOINT", providerName: "valkey_custom_endpoint"} allCustomEndpoints = []customEndpointConfig{ ALBCustomEndpoint, @@ -142,6 +143,7 @@ var ( SKECustomEndpoint, TelemetryRouterCustomEndpoint, TelemetryLinkCustomEndpoint, + ValkeyCustomEndpoint, } ) diff --git a/stackit/provider.go b/stackit/provider.go index e436c837f..a8b8f2e83 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -137,6 +137,8 @@ import ( telemetryRouterAccessToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/accesstoken" telemetryRouterDestination "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/destination" telemetryRouterInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/instance" + valkeyCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/credential" + valkeyInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/instance" vpnConnection "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/connection" vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway" vpnGatewayStatus "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway_status" @@ -220,6 +222,7 @@ type providerModel struct { TelemetryLinkCustomEndpoint types.String `tfsdk:"telemetrylink_custom_endpoint"` TelemetryRouterCustomEndpoint types.String `tfsdk:"telemetryrouter_custom_endpoint"` TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"` + ValkeyCustomEndpoint types.String `tfsdk:"valkey_custom_endpoint"` VpnCustomEndpoint types.String `tfsdk:"vpn_custom_endpoint"` OIDCTokenRequestURL types.String `tfsdk:"oidc_request_url"` OIDCTokenRequestToken types.String `tfsdk:"oidc_request_token"` @@ -281,6 +284,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "telemetrylink_custom_endpoint": "Custom endpoint for the Telemetry Link service", "telemetryrouter_custom_endpoint": "Custom endpoint for the Telemetry Router service", "token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow", + "valkey_custom_endpoint": "Custom endpoint for the Key Value Store service", "vpn_custom_endpoint": "Custom endpoint for the VPN service", "enable_beta_resources": "Enable beta resources. Default is false.", "experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", strings.Join(features.AvailableExperiments, ", ")), @@ -497,6 +501,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["telemetrylink_custom_endpoint"], }, + "valkey_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["valkey_custom_endpoint"], + }, "vpn_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["vpn_custom_endpoint"], @@ -591,6 +599,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, setStringField(providerConfig.SqlServerFlexCustomEndpoint, func(v string) { providerData.SQLServerFlexCustomEndpoint = v }) setStringField(providerConfig.TelemetryRouterCustomEndpoint, func(v string) { providerData.TelemetryRouterCustomEndpoint = v }) setStringField(providerConfig.TelemetryLinkCustomEndpoint, func(v string) { providerData.TelemetryLinkCustomEndpoint = v }) + setStringField(providerConfig.ValkeyCustomEndpoint, func(v string) { providerData.ValkeyCustomEndpoint = v }) setStringField(providerConfig.VpnCustomEndpoint, func(v string) { providerData.VpnCustomEndpoint = v }) if !(providerConfig.Experiments.IsUnknown() || providerConfig.Experiments.IsNull()) { @@ -771,6 +780,8 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource telemetryRouterInstance.NewTelemetryRouterInstanceDataSource, telemetryRouterDestination.NewTelemetryRouterDestinationDataSource, telemetryLink.NewTelemetryLinkDataSource, + valkeyInstance.NewInstanceDataSource, + valkeyCredential.NewCredentialDataSource, vpnGateway.NewVPNGatewayDataSource, vpnGatewayStatus.NewVPNGatewayStatusDataSource, vpnConnection.NewVPNConnectionDataSource, @@ -883,6 +894,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { telemetryRouterInstance.NewTelemetryRouterInstanceResource, telemetryRouterDestination.NewTelemetryRouterDestinationResource, telemetryLink.NewTelemetryLinkResource, + valkeyInstance.NewInstanceResource, + valkeyCredential.NewCredentialResource, vpnConnection.NewVpnConnectionResource, vpnGateway.NewGatewayResource, } diff --git a/stackit/testdata/provider-all-attributes.tf b/stackit/testdata/provider-all-attributes.tf index 6f8067792..f421bf50b 100644 --- a/stackit/testdata/provider-all-attributes.tf +++ b/stackit/testdata/provider-all-attributes.tf @@ -33,6 +33,7 @@ provider "stackit" { ske_custom_endpoint = "https://ske.api.stackit.cloud" service_enablement_custom_endpoint = "https://service-enablement.api.stackit.cloud" token_custom_endpoint = "https://token.api.stackit.cloud" + valkey_custom_endpoint = "https://valkey.api.stackit.cloud" enable_beta_resources = "true" }