From 221906eeab6c2350a3f928e655a148a0009541cb Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 10 Aug 2026 20:21:04 +0200 Subject: [PATCH 1/8] feat(valkey): onboard key value store service relates to STACKITTPR-765 --- MIGRATION.md | 66 +- docs/data-sources/valkey_credential.md | 45 + docs/data-sources/valkey_instance.md | 71 ++ docs/index.md | 1 + docs/resources/valkey_credential.md | 72 ++ docs/resources/valkey_instance.md | 93 ++ .../stackit_valkey_credential/data-source.tf | 5 + .../stackit_valkey_instance/data-source.tf | 4 + .../import-by-string-id.tf | 5 + .../stackit_valkey_credential/resource.tf | 17 + .../import-by-string-id.tf | 5 + .../stackit_valkey_instance/resource.tf | 12 + go.mod | 1 + go.sum | 2 + stackit/internal/core/core.go | 1 + .../services/valkey/credential/datasource.go | 260 +++++ .../valkey/credential/datasource_test.go | 226 +++++ .../services/valkey/credential/resource.go | 435 ++++++++ .../valkey/credential/resource_test.go | 237 +++++ .../services/valkey/instance/datasource.go | 305 ++++++ .../services/valkey/instance/resource.go | 931 ++++++++++++++++++ .../services/valkey/instance/resource_test.go | 364 +++++++ .../internal/services/valkey/utils/util.go | 30 + .../services/valkey/utils/util_test.go | 94 ++ .../services/valkey/valkey_acc_test.go | 321 ++++++ stackit/internal/testutil/testutil.go | 2 + stackit/provider.go | 13 + stackit/testdata/provider-all-attributes.tf | 1 + 28 files changed, 3587 insertions(+), 32 deletions(-) create mode 100644 docs/data-sources/valkey_credential.md create mode 100644 docs/data-sources/valkey_instance.md create mode 100644 docs/resources/valkey_credential.md create mode 100644 docs/resources/valkey_instance.md create mode 100644 examples/data-sources/stackit_valkey_credential/data-source.tf create mode 100644 examples/data-sources/stackit_valkey_instance/data-source.tf create mode 100644 examples/resources/stackit_valkey_credential/import-by-string-id.tf create mode 100644 examples/resources/stackit_valkey_credential/resource.tf create mode 100644 examples/resources/stackit_valkey_instance/import-by-string-id.tf create mode 100644 examples/resources/stackit_valkey_instance/resource.tf create mode 100644 stackit/internal/services/valkey/credential/datasource.go create mode 100644 stackit/internal/services/valkey/credential/datasource_test.go create mode 100644 stackit/internal/services/valkey/credential/resource.go create mode 100644 stackit/internal/services/valkey/credential/resource_test.go create mode 100644 stackit/internal/services/valkey/instance/datasource.go create mode 100644 stackit/internal/services/valkey/instance/resource.go create mode 100644 stackit/internal/services/valkey/instance/resource_test.go create mode 100644 stackit/internal/services/valkey/utils/util.go create mode 100644 stackit/internal/services/valkey/utils/util_test.go create mode 100644 stackit/internal/services/valkey/valkey_acc_test.go diff --git a/MIGRATION.md b/MIGRATION.md index 9f3a3e087..9b783f41f 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],[instance_id],[credential_id] | | +| stackit_valkey_instance | stackit_valkey_instance | :white_check_mark: | [project_id],[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..9f3df57bd --- /dev/null +++ b/docs/data-sources/valkey_instance.md @@ -0,0 +1,71 @@ +--- +# 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. +- `monitoring_instance_id` (String) The ID of the STACKIT monitoring instance. +- `notify_keyspace_events` (String) The notify keyspace events. +- `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..6f6ede321 --- /dev/null +++ b/docs/resources/valkey_instance.md @@ -0,0 +1,93 @@ +--- +# 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. +- `monitoring_instance_id` (String) The ID of the STACKIT monitoring instance. +- `notify_keyspace_events` (String) The notify keyspace events. +- `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..9fd05bd27 --- /dev/null +++ b/stackit/internal/services/valkey/instance/datasource.go @@ -0,0 +1,305 @@ +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_max_lag": "The minimum replicas maximum lag.", + "monitoring_instance_id": "The ID of the STACKIT monitoring instance.", + "notify_keyspace_events": "The notify keyspace events.", + "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, + }, + "monitoring_instance_id": schema.StringAttribute{ + Description: parametersDescriptions["monitoring_instance_id"], + Computed: true, + }, + "notify_keyspace_events": schema.StringAttribute{ + Description: parametersDescriptions["notify_keyspace_events"], + 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..62d5956e6 --- /dev/null +++ b/stackit/internal/services/valkey/instance/resource.go @@ -0,0 +1,931 @@ +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"` + MinReplicasMaxLag types.Int32 `tfsdk:"min_replicas_max_lag"` + MonitoringInstanceId types.String `tfsdk:"monitoring_instance_id"` + NotifyKeyspaceEvents types.String `tfsdk:"notify_keyspace_events"` + 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_max_lag": basetypes.Int32Type{}, + "monitoring_instance_id": basetypes.StringType{}, + "notify_keyspace_events": 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_max_lag": "The minimum replicas maximum lag.", + "monitoring_instance_id": "The ID of the STACKIT monitoring instance.", + "notify_keyspace_events": "The notify keyspace events.", + "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, + }, + "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, + }, + "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", + } + 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.MinReplicasMaxLag = conversion.Int32ValueToPointer(parameters.MinReplicasMaxLag) + payloadParams.MonitoringInstanceId = conversion.StringValueToPointer(parameters.MonitoringInstanceId) + payloadParams.NotifyKeyspaceEvents = conversion.StringValueToPointer(parameters.NotifyKeyspaceEvents) + 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..52b1ae7ea --- /dev/null +++ b/stackit/internal/services/valkey/instance/resource_test.go @@ -0,0 +1,364 @@ +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), + "monitoring_instance_id": types.StringValue("mid"), + "notify_keyspace_events": types.StringValue("events"), + "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)), + MonitoringInstanceId: new("mid"), + NotifyKeyspaceEvents: new("events"), + 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), + "monitoring_instance_id": "mid", + "notify-keyspace-events": "events", + "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..82d256c4c --- /dev/null +++ b/stackit/internal/services/valkey/valkey_acc_test.go @@ -0,0 +1,321 @@ +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", + "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", + "notify_keyspace_events": "Ex", + "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.notify_keyspace_events", "Ex"), + 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" } From 8444e8a6dc61c3ad24ab7d5cfe067ea67d252bde Mon Sep 17 00:00:00 2001 From: GokceGK Date: Tue, 11 Aug 2026 08:08:50 +0200 Subject: [PATCH 2/8] feat(valkey): update migration.md relates to STACKITTPR-765 --- MIGRATION.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9b783f41f..d120af593 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -164,5 +164,5 @@ resource "stackit_logme_credential" "example-credential" { | 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],[instance_id],[credential_id] | | -| stackit_valkey_instance | stackit_valkey_instance | :white_check_mark: | [project_id],[instance_id] | | +| 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] | | From 1cfc5678af8c56583b031552054f60851331f0de Mon Sep 17 00:00:00 2001 From: GokceGK Date: Wed, 12 Aug 2026 12:03:27 +0200 Subject: [PATCH 3/8] feat(valkey): onboard new fields relates to STACKITTPR-765 --- docs/data-sources/valkey_instance.md | 4 +++- docs/resources/valkey_instance.md | 4 +++- .../services/valkey/instance/datasource.go | 12 +++++++++- .../services/valkey/instance/resource.go | 22 ++++++++++++++++++- .../services/valkey/instance/resource_test.go | 6 +++++ .../services/valkey/valkey_acc_test.go | 5 +++++ 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/data-sources/valkey_instance.md b/docs/data-sources/valkey_instance.md index 9f3df57bd..79222d3e0 100644 --- a/docs/data-sources/valkey_instance.md +++ b/docs/data-sources/valkey_instance.md @@ -62,10 +62,12 @@ Read-Only: - `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 +- `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/resources/valkey_instance.md b/docs/resources/valkey_instance.md index 6f6ede321..c6ded6960 100644 --- a/docs/resources/valkey_instance.md +++ b/docs/resources/valkey_instance.md @@ -70,10 +70,12 @@ Optional: - `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 +- `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/stackit/internal/services/valkey/instance/datasource.go b/stackit/internal/services/valkey/instance/datasource.go index 9fd05bd27..40a95f62a 100644 --- a/stackit/internal/services/valkey/instance/datasource.go +++ b/stackit/internal/services/valkey/instance/datasource.go @@ -86,10 +86,12 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques "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", + "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.", } @@ -195,6 +197,10 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques 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, @@ -203,6 +209,10 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques 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, diff --git a/stackit/internal/services/valkey/instance/resource.go b/stackit/internal/services/valkey/instance/resource.go index 62d5956e6..7546142cd 100644 --- a/stackit/internal/services/valkey/instance/resource.go +++ b/stackit/internal/services/valkey/instance/resource.go @@ -72,9 +72,11 @@ type parametersModel struct { 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"` } @@ -95,9 +97,11 @@ var parametersTypes = map[string]attr.Type{ "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}, } @@ -163,10 +167,12 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r "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", + "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.", } @@ -305,6 +311,11 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r 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, @@ -319,6 +330,11 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r 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, @@ -710,6 +726,8 @@ func mapParameters(params map[string]any) (types.Object, error) { "maxmemory_policy", "maxmemory_samples", "notify_keyspace_events", + "min_replicas_to_write", + "repl_backlog_size", } if slices.Contains(hyphenAttributes, attribute) { alteredAttribute := strings.ReplaceAll(attribute, "_", "-") @@ -857,9 +875,11 @@ func toInstanceParams(parameters *parametersModel) (*valkey.InstanceParameters, 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 diff --git a/stackit/internal/services/valkey/instance/resource_test.go b/stackit/internal/services/valkey/instance/resource_test.go index 52b1ae7ea..affae44ec 100644 --- a/stackit/internal/services/valkey/instance/resource_test.go +++ b/stackit/internal/services/valkey/instance/resource_test.go @@ -28,8 +28,10 @@ var fixtureModelParameters = types.ObjectValueMust(parametersTypes, map[string]a "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"), @@ -53,8 +55,10 @@ var fixtureInstanceParameters = valkey.InstanceParameters{ 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"}, } @@ -113,8 +117,10 @@ func TestMapFields(t *testing.T) { "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"}, }, diff --git a/stackit/internal/services/valkey/valkey_acc_test.go b/stackit/internal/services/valkey/valkey_acc_test.go index 82d256c4c..7f797375c 100644 --- a/stackit/internal/services/valkey/valkey_acc_test.go +++ b/stackit/internal/services/valkey/valkey_acc_test.go @@ -40,6 +40,7 @@ func parametersConfig(params map[string]string) string { "maxmemory_samples", "metrics_frequency", "min_replicas_max_lag", + "min_replicas_to_write", "syslog", } var parameters strings.Builder @@ -116,7 +117,9 @@ func TestAccValkeyResource(t *testing.T) { "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( @@ -144,7 +147,9 @@ func TestAccValkeyResource(t *testing.T) { 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"), From 770f996d464ecd109009455084e395d424d8a9b1 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 31 Aug 2026 11:31:01 +0200 Subject: [PATCH 4/8] feat(valkey): add modify plan functions relates to STACKITTPR-765 --- docs/resources/valkey_credential.md | 2 +- docs/resources/valkey_instance.md | 2 +- .../import-by-string-id.tf | 2 +- .../import-by-string-id.tf | 2 +- .../services/valkey/credential/resource.go | 31 +++++++++++++++++ .../services/valkey/instance/resource.go | 33 ++++++++++++++++++- 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/resources/valkey_credential.md b/docs/resources/valkey_credential.md index 26b063609..4cd3d1908 100644 --- a/docs/resources/valkey_credential.md +++ b/docs/resources/valkey_credential.md @@ -67,6 +67,6 @@ In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https:// # 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}" + id = "${var.project_id},${var.region},${var.valkey_instance_id},${var.valkey_credential_id}" } ``` diff --git a/docs/resources/valkey_instance.md b/docs/resources/valkey_instance.md index c6ded6960..d1b82934a 100644 --- a/docs/resources/valkey_instance.md +++ b/docs/resources/valkey_instance.md @@ -90,6 +90,6 @@ In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https:// # 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}" + id = "${var.project_id},${var.region},${var.valkey_instance_id}" } ``` diff --git a/examples/resources/stackit_valkey_credential/import-by-string-id.tf b/examples/resources/stackit_valkey_credential/import-by-string-id.tf index ebe7fdd5d..7b202354b 100644 --- a/examples/resources/stackit_valkey_credential/import-by-string-id.tf +++ b/examples/resources/stackit_valkey_credential/import-by-string-id.tf @@ -1,5 +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}" + id = "${var.project_id},${var.region},${var.valkey_instance_id},${var.valkey_credential_id}" } diff --git a/examples/resources/stackit_valkey_instance/import-by-string-id.tf b/examples/resources/stackit_valkey_instance/import-by-string-id.tf index c0fb5f95e..018a2244a 100644 --- a/examples/resources/stackit_valkey_instance/import-by-string-id.tf +++ b/examples/resources/stackit_valkey_instance/import-by-string-id.tf @@ -1,5 +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}" + id = "${var.project_id},${var.region},${var.valkey_instance_id}" } diff --git a/stackit/internal/services/valkey/credential/resource.go b/stackit/internal/services/valkey/credential/resource.go index 4ddb900a5..f2976ce0b 100644 --- a/stackit/internal/services/valkey/credential/resource.go +++ b/stackit/internal/services/valkey/credential/resource.go @@ -34,6 +34,7 @@ var ( _ resource.Resource = &credentialResource{} _ resource.ResourceWithConfigure = &credentialResource{} _ resource.ResourceWithImportState = &credentialResource{} + _ resource.ResourceWithModifyPlan = &credentialResource{} ) type Model struct { @@ -88,6 +89,36 @@ func (r *credentialResource) Configure(ctx context.Context, req resource.Configu tflog.Info(ctx, "Valkey credential client configured") } +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *credentialResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + // Schema defines the schema for the resource. func (r *credentialResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ //nolint:gosec // description for credential id diff --git a/stackit/internal/services/valkey/instance/resource.go b/stackit/internal/services/valkey/instance/resource.go index 7546142cd..115b6f4ed 100644 --- a/stackit/internal/services/valkey/instance/resource.go +++ b/stackit/internal/services/valkey/instance/resource.go @@ -37,6 +37,7 @@ var ( _ resource.Resource = &instanceResource{} _ resource.ResourceWithConfigure = &instanceResource{} _ resource.ResourceWithImportState = &instanceResource{} + _ resource.ResourceWithModifyPlan = &instanceResource{} ) type Model struct { @@ -138,6 +139,36 @@ func (r *instanceResource) Configure(ctx context.Context, req resource.Configure tflog.Info(ctx, "Valkey instance client configured") } +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + // Schema defines the schema for the resource. func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ @@ -577,7 +608,7 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques return } // Update existing instance - err = r.client.DefaultAPI.PartialUpdateInstance(ctx, projectId, instanceId, region).PartialUpdateInstancePayload(*payload).Execute() + err = r.client.DefaultAPI.PartialUpdateInstance(ctx, projectId, region, instanceId).PartialUpdateInstancePayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Calling API: %v", err)) return From 77447e4525e19525b2af43d03de9c8602cfe454f Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 31 Aug 2026 11:50:04 +0200 Subject: [PATCH 5/8] feat(valkey): remove deprecated migration section from README relates to STACKITTPR-765 --- MIGRATION.md | 168 --------------------------------------------------- README.md | 4 -- 2 files changed, 172 deletions(-) delete mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index d120af593..000000000 --- a/MIGRATION.md +++ /dev/null @@ -1,168 +0,0 @@ -# Migration Guide - -In this guide we want to offer some strategy for a migration of configurations or existing resources to the STACKIT official provider. In relation to the [Terraform community provider](https://github.com/SchwarzIT/terraform-provider-stackit), some attribute names and structure have changed, as well as the internal resource ID structure. - -To import your existing infrastructure resources to the new provider, you'll need the internal ID of each resource. The structure of the new provider's internal ID can be located in the [documentation](./docs/resources) file for each resource, specifically within the description of the `id` attribute. - -## How-to - -Before you begin the migration process, please ensure that you have done the necessary steps for the [authentication](./README.md#authentication). - -For existing resources created with the old provider, you'll need to import them into your new configuration. Terraform provides a feature for importing existing resources and auto-generating new Terraform configuration files. To generate configuration code for the imported resources, refer to the official [Terraform documentation](https://developer.hashicorp.com/terraform/language/import/generating-configuration) for step-by-step guidance. - -Once the configuration is generated, compare the generated file with your existing configuration. Be aware that field names may have changed so you should consider that when comparing. However, possibly not all attributes from the generated configuration will be needed for managing the infrastructure, as the generator seems to create configurations containing read-only (computed-only) attributes (that make the `terraform apply` fail). Check the Terraform plan for the imported resource to identify any differences. - -If you encounter any other issues or have additional questions, don't hesitate to raise them by [opening an issue](https://github.com/stackitcloud/terraform-provider-stackit/issues/new/choose). - -### Example (SKE service) - -Import configuration: - -```terraform -# Import -import { - id = "project_id" - to = stackit_ske_project.project-example -} - -import { - id = "project_id,example-cluster" - to = stackit_ske_cluster.cluster-example -} -``` - -Generated configuration: - -```terraform -# __generated__ by Terraform -# Please review these resources and move them into your main configuration files. - -# __generated__ by Terraform from "project_id" -resource "stackit_ske_project" "project-example" { - project_id = "project_id" -} - -# __generated__ by Terraform from "project_id,example-cluster" -resource "stackit_ske_cluster" "cluster-example" { - allow_privileged_containers = null - extensions = null - hibernations = null - kubernetes_version_min = "1.25" - maintenance = { - enable_kubernetes_version_updates = true - enable_machine_image_version_updates = true - end = "09:47:00Z" - start = "08:47:00Z" - } - name = "example-cluster" - node_pools = [ - { - availability_zones = ["region-a", "region-b"] - cri = "containerd" - labels = { - l1 = "value1" - l2 = "value2" - } - machine_type = "b1.2" - max_surge = 1 - max_unavailable = 1 - maximum = 10 - minimum = 3 - name = "example-np" - os_name = "flatcar" - os_version = "3510.2.3" - taints = [ - { - effect = "PreferNoSchedule" - key = "tk" - value = "tkv" - }, - ] - volume_size = 40 - volume_type = "example-type" - }, - ] - project_id = "project_id" -} -``` - -### Example (LogMe service) - -Import configuration: - -```terraform -import { - id = "project_id,instance_id" - to = stackit_logme_instance.example-instance -} - -import { - id = "project_id,instance_id,credential_id" - to = stackit_logme_credential.example-credential -} -``` - -Generated configuration: - -```terraform -# __generated__ by Terraform -# Please review these resources and move them into your main configuration files. - -# __generated__ by Terraform -resource "stackit_logme_instance" "example-instance" { - name = "example-instance" - parameters = { - sgw_acl = "0.0.0.0/0" - } - plan_name = null - project_id = "project_id" - version = null -} - -# __generated__ by Terraform from "project_id,instance_id,credential_id" -resource "stackit_logme_credential" "example-credential" { - instance_id = "instance_id" - project_id = "project_id" -} -``` - -## 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] | | -| 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_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_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_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_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/README.md b/README.md index 4f087aad7..8d1680bd5 100644 --- a/README.md +++ b/README.md @@ -285,10 +285,6 @@ $ TF_ACC=1 \ For some services the acceptance tests take more time. By setting the timeout via the flag `-timeout=` to a higher time, you ensure that the tests will not be stopped. -## Migration - -For guidance on how to migrate to using this provider, please see our [Migration Guide](./MIGRATION.md). - ## Reporting Issues If you encounter any issues or have suggestions for improvements, please open an issue in the [repository](https://github.com/stackitcloud/terraform-provider-stackit/issues). From fe3d8f8278f9977adba81f088bd9fae31a39ac90 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 31 Aug 2026 11:58:36 +0200 Subject: [PATCH 6/8] feat(valkey): fix linter issues relates to STACKITTPR-765 --- go.mod | 2 +- stackit/internal/testutil/testutil.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 892206315..4e15b26ac 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.18.0 github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.5.1 github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.5.1 - github.com/stackitcloud/stackit-sdk-go/services/valkey v0.1.1 + github.com/stackitcloud/stackit-sdk-go/services/valkey v0.1.1 github.com/stackitcloud/stackit-sdk-go/services/vpn v0.15.0 github.com/teambition/rrule-go v1.8.2 go.uber.org/mock v0.6.0 diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 4145e3bfe..ea4ac6060 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -107,7 +107,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"} + ValkeyCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_VALKEY_CUSTOM_ENDPOINT", providerName: "valkey_custom_endpoint"} allCustomEndpoints = []customEndpointConfig{ ALBCustomEndpoint, From 9ea0e82dac5e0202bdc02cc5d89a2deb01c4e3c1 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Mon, 31 Aug 2026 14:28:47 +0200 Subject: [PATCH 7/8] feat(valkey): fix acc tests and add validators relates to STACKITTPR-765 --- .../internal/services/valkey/instance/resource.go | 6 ++++++ stackit/internal/services/valkey/valkey_acc_test.go | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/stackit/internal/services/valkey/instance/resource.go b/stackit/internal/services/valkey/instance/resource.go index 115b6f4ed..1310e3741 100644 --- a/stackit/internal/services/valkey/instance/resource.go +++ b/stackit/internal/services/valkey/instance/resource.go @@ -8,6 +8,8 @@ import ( "slices" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" valkeyUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/valkey/utils" @@ -346,6 +348,10 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Description: parametersDescriptions["min_replicas_to_write"], Optional: true, Computed: true, + Validators: []validator.Int32{ + int32validator.AtLeast(0), + int32validator.AtMost(3), + }, }, "monitoring_instance_id": schema.StringAttribute{ Description: parametersDescriptions["monitoring_instance_id"], diff --git a/stackit/internal/services/valkey/valkey_acc_test.go b/stackit/internal/services/valkey/valkey_acc_test.go index 7f797375c..7f8c615bc 100644 --- a/stackit/internal/services/valkey/valkey_acc_test.go +++ b/stackit/internal/services/valkey/valkey_acc_test.go @@ -25,8 +25,8 @@ var instanceResource = map[string]string{ "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", + "sgw_acl_valid": "193.148.160.0/19", + "sgw_acl_valid2": "45.129.40.0/21", } func parametersConfig(params map[string]string) string { @@ -116,8 +116,8 @@ func TestAccValkeyResource(t *testing.T) { "maxmemory_samples": "5", "metrics_frequency": "10", "metrics_prefix": "prefix", - "min_replicas_max_lag": "15", - "min_replicas_to_write": "10", + "min_replicas_max_lag": "10", + "min_replicas_to_write": "1", "notify_keyspace_events": "Ex", "repl_backlog_size": "1mb", "syslog": `["syslog.example.com:123"]`, @@ -146,8 +146,8 @@ func TestAccValkeyResource(t *testing.T) { 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.min_replicas_max_lag", "10"), + resource.TestCheckResourceAttr("stackit_valkey_instance.instance", "parameters.min_replicas_to_write", "1"), 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"), From 46c2ae7f27e39c22891fa1bebd0ee128d3bf2c67 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Tue, 1 Sep 2026 10:45:24 +0200 Subject: [PATCH 8/8] feat(valkey): remove version value from the example to avoid outdated info possibility relates to STACKITTPR-765 --- docs/resources/valkey_instance.md | 2 +- examples/resources/stackit_valkey_instance/resource.tf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/resources/valkey_instance.md b/docs/resources/valkey_instance.md index d1b82934a..095054339 100644 --- a/docs/resources/valkey_instance.md +++ b/docs/resources/valkey_instance.md @@ -16,7 +16,7 @@ Valkey instance resource schema. Must have a `region` specified in the provider resource "stackit_valkey_instance" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-instance" - version = "7" + version = "x" 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" diff --git a/examples/resources/stackit_valkey_instance/resource.tf b/examples/resources/stackit_valkey_instance/resource.tf index 46d1f518e..87aa9133b 100644 --- a/examples/resources/stackit_valkey_instance/resource.tf +++ b/examples/resources/stackit_valkey_instance/resource.tf @@ -1,7 +1,7 @@ resource "stackit_valkey_instance" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-instance" - version = "7" + version = "x" 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"