diff --git a/.github/workflows/integration_tests_pr.yml b/.github/workflows/integration_tests_pr.yml index ae1334652..26ab634f6 100644 --- a/.github/workflows/integration_tests_pr.yml +++ b/.github/workflows/integration_tests_pr.yml @@ -15,6 +15,7 @@ on: run_long_tests: description: 'Setting this value will skip long running tests (e.g. Database related cases): (true|false)' required: false + default: false name: Integration tests on PR @@ -49,6 +50,8 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ inputs.sha }} + fetch-depth: 0 + submodules: 'recursive' - run: make deps diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 124b4cfa4..73a4fdaf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,3 +24,69 @@ jobs: gpg-private-key-passphrase: ${{ secrets.PASSPHRASE }} with: setup-go-version-file: 'go.mod' + + verify-publications: + needs: terraform-provider-release + runs-on: ubuntu-latest + name: Verifying TF Registry Publications + strategy: + matrix: + registry: + - "https://registry.opentofu.org/v1" + - "https://registry.terraform.io/v1" + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + env: + REGISTRY: ${{ matrix.registry }} + with: + script: | + async function verifyPublication(targetVersion, registry) { + const url = `${registry}/providers/linode/linode/versions`; + + const response = await fetch(url); + if (!response.ok) { + console.log(`Error response status: ${response.status}`); + } + + const json = await response.json(); + + return json.versions.find((v) => v.version == targetVersion) != null; + } + + let prefix = "refs/tags/v"; + if (!context.ref.startsWith(prefix)) { + throw new Error(`Invalid ref: ${context.ref}`); + } + + const TARGET_VERSION = context.ref.slice(prefix.length); + const REGISTRY = process.env.REGISTRY; + + // 1 retry request per minute, 3 hours in total + const REGISTRY_POLL_RETRIES = ${{ vars.REGISTRY_POLL_RETRIES }}; + const REGISTRY_POLL_INTERVAL = ${{ vars.REGISTRY_POLL_INTERVAL }}; + + console.log(`Verifying publication of v${TARGET_VERSION} on ${REGISTRY}`); + + let found = false; + let count = 0; + while (!found && count < REGISTRY_POLL_RETRIES) { + count++; + found = await verifyPublication(TARGET_VERSION, REGISTRY); + if (found) { + break; + } + console.log( + `Publication of v${TARGET_VERSION} on ${REGISTRY} isn't found, retrying in ${REGISTRY_POLL_INTERVAL} ms...` + ); + await new Promise((r) => setTimeout(r, REGISTRY_POLL_INTERVAL)); + } + if (found) { + console.log( + `Verified that Linode Provider v${TARGET_VERSION} has been successfully published on ${REGISTRY}.` + ); + } else { + throw new Error( + `Timeout waiting for Linode Provider v${TARGET_VERSION} publication on ${REGISTRY}` + ); + } diff --git a/Makefile b/Makefile index 2bc3696ce..7e3c93403 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ COUNT?=1 PARALLEL?=10 PKG_NAME=linode/... TIMEOUT?=240m -RUN_LONG_TESTS?=False +RUN_LONG_TESTS?=false SWEEP?="tf_test,tf-test" TEST_TAGS="integration" @@ -61,7 +61,7 @@ test: fmt-check smoke-test unit-test int-test .PHONY: unit-test unit-test: fmt-check - go test -v --tags=unit ./$(PKG_NAME) | grep -v "\[no test files\]" + go test -v --tags=unit ./$(PKG_NAME) .PHONY: int-test int-test: fmt-check generate-ip-env-fw-e2e include-env @@ -70,7 +70,7 @@ int-test: fmt-check generate-ip-env-fw-e2e include-env RUN_LONG_TESTS=$(RUN_LONG_TESTS) \ TF_VAR_ipv4_addr=${PUBLIC_IPV4} \ TF_VAR_ipv6_addr=${PUBLIC_IPV6} \ - go test --tags="$(TEST_TAGS)" -v ./$(PKG_NAME) -count $(COUNT) -timeout $(TIMEOUT) -ldflags="-X=github.com/linode/terraform-provider-linode/v2/version.ProviderVersion=acc" -parallel=$(PARALLEL) $(ARGS) | grep -v "\[no test files\]" + go test --tags="$(TEST_TAGS)" -v ./$(PKG_NAME) -count $(COUNT) -timeout $(TIMEOUT) -ldflags="-X=github.com/linode/terraform-provider-linode/v2/version.ProviderVersion=acc" -parallel=$(PARALLEL) $(ARGS) .PHONY: include-env include-env: $(IP_ENV_FILE) @@ -87,7 +87,7 @@ smoke-test: fmt-check TF_ACC=1 \ LINODE_API_VERSION="v4beta" \ RUN_LONG_TESTS=$(RUN_LONG_TESTS) \ - go test -v -run smoke ./linode/... -count $(COUNT) -timeout $(TIMEOUT) -parallel=$(PARALLEL) -ldflags="-X=github.com/linode/terraform-provider-linode/v2/version.ProviderVersion=acc" | grep -v "\[no test files\]" + go test -v -run smoke ./linode/... -count $(COUNT) -timeout $(TIMEOUT) -parallel=$(PARALLEL) -ldflags="-X=github.com/linode/terraform-provider-linode/v2/version.ProviderVersion=acc" .PHONY: docs-check docs-check: diff --git a/docs/data-sources/image.md b/docs/data-sources/image.md index 0169b2d73..854c4c106 100644 --- a/docs/data-sources/image.md +++ b/docs/data-sources/image.md @@ -48,3 +48,11 @@ The Linode Image resource exports the following attributes: * `type` - How the Image was created. Manual Images can be created at any time. "Automatic" Images are created automatically from a deleted Linode. (`manual`, `automatic`) * `vendor` - The upstream distribution vendor. `None` for private Images. + +* `tags` - A list of customized tags. + +* `total_size` - The total size of the image in all available regions. + +* `replications` - A list of image replication regions and corresponding status. + * `region` - The region of an image replica. + * `status` - The status of an image replica. diff --git a/docs/data-sources/images.md b/docs/data-sources/images.md index 8df4e4c20..5f2d3f6ea 100644 --- a/docs/data-sources/images.md +++ b/docs/data-sources/images.md @@ -87,6 +87,14 @@ Each Linode image will be stored in the `images` attribute and will export the f * `vendor` - The upstream distribution vendor. `None` for private Images. +* `tags` - A list of customized tags. + +* `total_size` - The total size of the image in all available regions. + +* `replications` - A list of image replication regions and corresponding status. + * `region` - The region of an image replica. + * `status` - The status of an image replica. + ## Filterable Fields * `created_by` @@ -106,3 +114,5 @@ Each Linode image will be stored in the `images` attribute and will export the f * `status` * `vendor` + +* `tags` diff --git a/docs/data-sources/instances.md b/docs/data-sources/instances.md index a2a49d638..e4dd579ce 100644 --- a/docs/data-sources/instances.md +++ b/docs/data-sources/instances.md @@ -119,6 +119,8 @@ Each Linode instance will be stored in the `instances` attribute and will export * [`backups`](#backups) - Information about the Linode's backup status. +* [`placement_group`](#placement-groups) - Information about the Linode's Placement Groups. + ### Disks * `disk` @@ -214,7 +216,19 @@ The following arguments are available in an `ipv4` configuration block of an `in * `day` - The day of the week that your Linode's weekly Backup is taken. If not set manually, a day will be chosen for you. Backups are taken every day, but backups taken on this day are preferred when selecting backups to retain for a longer period. If not set manually, then when backups are initially enabled, this may come back as "Scheduling" until the day is automatically selected. * `window` - The window ('W0'-'W22') in which your backups will be taken, in UTC. A backups window is a two-hour span of time in which the backup may occur. For example, 'W10' indicates that your backups should be taken between 10:00 and 12:00. If you do not choose a backup window, one will be selected for you automatically. If not set manually, when backups are initially enabled this may come back as Scheduling until the window is automatically selected. - + +### Placement Groups + +* `placement_group` + + * `id` - The ID of the Placement Group in the Linode API. + + * `placement_group_type` - The placement group type to use when placing Linodes in this group. + + * `placement_group_policy` - Whether Linodes must be able to become compliant during assignment. (Default `strict`) + + * `label` - The label of the Placement Group. This field can only contain ASCII letters, digits and dashes. + ## Filterable Fields * `group` diff --git a/docs/resources/image.md b/docs/resources/image.md index 1388890b7..88ff02066 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -27,6 +27,7 @@ resource "linode_image" "bar" { description = "Image taken from foo" disk_id = linode_instance.foo.disk.0.id linode_id = linode_instance.foo.id + tags = ["image-tag", "test"] } resource "linode_instance" "bar_based" { @@ -43,12 +44,30 @@ resource "linode_image" "foobar" { label = "foobar-image" description = "An image uploaded from Terraform!" region = "us-southeast" + tags = ["image-tag", "test"] file_path = "path/to/image.img.gz" file_hash = filemd5("path/to/image.img.gz") } ``` +Upload and replicate an image from a local file: + +```hcl +resource "linode_image" "foobar" { + label = "foobar-image" + description = "An image uploaded from Terraform!" + region = "us-southeast" + tags = ["image-tag", "test"] + + file_path = "path/to/image.img.gz" + file_hash = filemd5("path/to/image.img.gz") + + // Note: Image replication may not be available to all users. + replica_regions = ["us-southeast", "us-east", "eu-west"] +} +``` + ## Argument Reference The following arguments are supported: @@ -57,6 +76,12 @@ The following arguments are supported: * `description` - (Optional) A detailed description of this Image. +* `tags` - (Optional) A list of customized tags. + +* `replica_regions` - (Optional) A list of regions that customer wants to replicate this image in. At least one valid region is required and only core regions allowed. Existing images in the regions not passed will be removed. **Note:** Image replication may not be available to all users. See Replicate an Image [here](https://techdocs.akamai.com/linode-api/reference/post-replicate-image) for more details. + +* `wait_for_replications` - (Optional) Whether to wait for all image replications become `available`. Default to false. + - - - The following arguments apply to creating an image from an existing Linode Instance: @@ -75,13 +100,13 @@ The following arguments apply to uploading an image: * `file_hash` - (Optional) The MD5 hash of the file to be uploaded. This is used to trigger file updates. -* `region` - (Required) The region of the image. See all regions [here](https://api.linode.com/v4/regions). +* `region` - (Required) The region of the image. See all regions [here](https://techdocs.akamai.com/linode-api/reference/get-regions). ### Timeouts The `timeouts` block allows you to specify [timeouts](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts) for certain actions: -* `create` - (Defaults to 20 mins) Used when creating the instance image (until the instance is available) +* `create` - (Defaults to 30 mins) Used when creating the instance image (until the instance is available) ## Attributes Reference @@ -105,6 +130,12 @@ This resource exports the following attributes: * `vendor` - The upstream distribution vendor. Nil for private Images. +* `total_size` - The total size of the image in all available regions. + +* `replications` - A list of image replications region and corresponding status. + * `region` - The region of an image replica. + * `status` - The status of an image replica. + ## Import Linodes Images can be imported using the Linode Image `id`, e.g. diff --git a/go.mod b/go.mod index 748f18cfc..4956d58ee 100644 --- a/go.mod +++ b/go.mod @@ -9,26 +9,26 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 github.com/aws/smithy-go v1.20.3 - github.com/go-resty/resty/v2 v2.13.1 + github.com/go-resty/resty/v2 v2.14.0 github.com/google/go-cmp v0.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/terraform-plugin-framework v1.10.0 + github.com/hashicorp/terraform-plugin-framework v1.11.0 github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 - github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0 + github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-mux v0.16.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 - github.com/hashicorp/terraform-plugin-testing v1.9.0 - github.com/linode/linodego v1.38.0 + github.com/hashicorp/terraform-plugin-testing v1.10.0 + github.com/linode/linodego v1.39.0 github.com/linode/linodego/k8s v1.25.2 github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.25.0 - golang.org/x/net v0.27.0 - golang.org/x/sync v0.7.0 + golang.org/x/crypto v0.26.0 + golang.org/x/net v0.28.0 + golang.org/x/sync v0.8.0 ) require ( @@ -66,8 +66,9 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/hc-install v0.7.0 // indirect + github.com/hashicorp/hc-install v0.8.0 // indirect github.com/hashicorp/hcl/v2 v2.21.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect @@ -95,13 +96,13 @@ require ( github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/zclconf/go-cty v1.14.4 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/time v0.5.0 // indirect + github.com/zclconf/go-cty v1.15.0 // indirect + golang.org/x/mod v0.19.0 // indirect + golang.org/x/oauth2 v0.22.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/term v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/time v0.6.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect diff --git a/go.sum b/go.sum index 76d1394ba..ec7eb6d9e 100644 --- a/go.sum +++ b/go.sum @@ -79,8 +79,8 @@ github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2Kv github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= -github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= +github.com/go-resty/resty/v2 v2.14.0 h1:/rhkzsAqGQkozwfKS5aFAbb6TyKd3zyFRWcdRXLPCAU= +github.com/go-resty/resty/v2 v2.14.0/go.mod h1:IW6mekUOsElt9C7oWr0XRt9BNSD6D5rr9mhk6NjmNHg= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= @@ -122,13 +122,15 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.7.0 h1:Uu9edVqjKQxxuD28mR5TikkKDd/p55S8vzPC1659aBk= -github.com/hashicorp/hc-install v0.7.0/go.mod h1:ELmmzZlGnEcqoUMKUuykHaPCIR1sYLYX+KSggWSKZuA= +github.com/hashicorp/hc-install v0.8.0 h1:LdpZeXkZYMQhoKPCecJHlKvUkQFixN/nvyR1CdfOLjI= +github.com/hashicorp/hc-install v0.8.0/go.mod h1:+MwJYjDfCruSD/udvBmRB22Nlkwwkwf5sAB6uTIhSaU= github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= github.com/hashicorp/hcl/v2 v2.21.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= @@ -137,12 +139,12 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc= -github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= +github.com/hashicorp/terraform-plugin-framework v1.11.0 h1:M7+9zBArexHFXDx/pKTxjE6n/2UCXY6b8FIq9ZYhwfE= +github.com/hashicorp/terraform-plugin-framework v1.11.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 h1:gm5b1kHgFFhaKFhm4h2TgvMUlNzFAtUqlcOWnWPm+9E= github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1/go.mod h1:MsjL1sQ9L7wGwzJ5RjcI6FzEMdyoBnw+XK8ZnOvQOLY= -github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0 h1:XLI93Oqw2/KTzYjgCXrUnm8LBkGAiHC/mDQg5g5Vob4= -github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0/go.mod h1:mGuieb3bqKFYwEYB4lCMt302Z3siyv4PFYk/41wAUps= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 h1:v3DapR8gsp3EM8fKMh6up9cJUFQ2iRaFsYLP8UJnCco= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0/go.mod h1:c3PnGE9pHBDfdEVG9t1S1C9ia5LW+gkFR0CygXlM8ak= github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 h1:bxZfGo9DIUoLLtHMElsu+zwqI4IsMZQBRRy4iLzZJ8E= github.com/hashicorp/terraform-plugin-framework-validators v0.13.0/go.mod h1:wGeI02gEhj9nPANU62F2jCaHjXulejm/X+af4PdZaNo= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= @@ -153,8 +155,8 @@ github.com/hashicorp/terraform-plugin-mux v0.16.0 h1:RCzXHGDYwUwwqfYYWJKBFaS3fQs github.com/hashicorp/terraform-plugin-mux v0.16.0/go.mod h1:PF79mAsPc8CpusXPfEVa4X8PtkB+ngWoiUClMrNZlYo= github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 h1:kJiWGx2kiQVo97Y5IOGR4EMcZ8DtMswHhUuFibsCQQE= github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0/go.mod h1:sl/UoabMc37HA6ICVMmGO+/0wofkVIRxf+BMb/dnoIg= -github.com/hashicorp/terraform-plugin-testing v1.9.0 h1:xOsQRqqlHKXpFq6etTxih3ubdK3HVDtfE1IY7Rpd37o= -github.com/hashicorp/terraform-plugin-testing v1.9.0/go.mod h1:fhhVx/8+XNJZTD5o3b4stfZ6+q7z9+lIWigIYdT6/44= +github.com/hashicorp/terraform-plugin-testing v1.10.0 h1:2+tmRNhvnfE4Bs8rB6v58S/VpqzGC6RCh9Y8ujdn+aw= +github.com/hashicorp/terraform-plugin-testing v1.10.0/go.mod h1:iWRW3+loP33WMch2P/TEyCxxct/ZEcCGMquSLSCVsrc= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -185,8 +187,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linode/linodego v1.38.0 h1:wP3oW9OhGc6vhze8NPf2knbwH4TzSbrjzuCd9okjbTY= -github.com/linode/linodego v1.38.0/go.mod h1:L7GXKFD3PoN2xSEtFc04wIXP5WK65O10jYQx0PQISWQ= +github.com/linode/linodego v1.39.0 h1:gRsj2PXX+HTO3eYQaXEuQGsLeeLFDSBDontC5JL3Nn8= +github.com/linode/linodego v1.39.0/go.mod h1:da8KzAQKSm5obwa06yXk5CZSDFMP9Wb08GA/O+aR9W0= github.com/linode/linodego/k8s v1.25.2 h1:PY6S0sAD3xANVvM9WY38bz9GqMTjIbytC8IJJ9Cv23o= github.com/linode/linodego/k8s v1.25.2/go.mod h1:DC1XCSRZRGsmaa/ggpDPSDUmOM6aK1bhSIP6+f9Cwhc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -257,24 +259,29 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= -github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -283,20 +290,25 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -313,18 +325,23 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -332,18 +349,21 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/linode/acceptance/util.go b/linode/acceptance/util.go index 823ba0314..fbc0f47cc 100644 --- a/linode/acceptance/util.go +++ b/linode/acceptance/util.go @@ -570,8 +570,12 @@ func ModifyProviderMeta(provider *schema.Provider, modifier ProviderMetaModifier } } -// GetRegionsWithCaps returns a list of regions that support the given capabilities. -func GetRegionsWithCaps(capabilities []string, filters ...RegionFilterFunc) ([]string, error) { +// GetRegionsWithCaps returns a list of region IDs that support the given capabilities +// Parameters: +// - capabilities: Required capabilities that the regions must support. +// - siteType: The site type to filter by ("core" or "distributed" or "any"). +// - filters: Optional custom filters for additional criteria. +func GetRegionsWithCaps(capabilities []string, regionType string, filters ...RegionFilterFunc) ([]string, error) { client, err := GetTestClient() if err != nil { return nil, err @@ -582,8 +586,14 @@ func GetRegionsWithCaps(capabilities []string, filters ...RegionFilterFunc) ([]s return nil, err } - // Filter on capabilities + // Filter on capabilities and site type regionsWithCaps := slices.DeleteFunc(regions, func(region linodego.Region) bool { + // Check if the site type matches + // Skip site type check if "any" is passed + if !strings.EqualFold(regionType, "any") && !strings.EqualFold(region.SiteType, regionType) { + return true + } + capsMap := make(map[string]bool) for _, c := range region.Capabilities { @@ -620,8 +630,8 @@ func GetRegionsWithCaps(capabilities []string, filters ...RegionFilterFunc) ([]s } // GetRandomRegionWithCaps gets a random region given a list of region capabilities. -func GetRandomRegionWithCaps(capabilities []string, filters ...RegionFilterFunc) (string, error) { - regions, err := GetRegionsWithCaps(capabilities, filters...) +func GetRandomRegionWithCaps(capabilities []string, regionType string, filters ...RegionFilterFunc) (string, error) { + regions, err := GetRegionsWithCaps(capabilities, regionType, filters...) if err != nil { return "", err } diff --git a/linode/accountavailability/datasource_test.go b/linode/accountavailability/datasource_test.go index c2d8bb647..92fed0966 100644 --- a/linode/accountavailability/datasource_test.go +++ b/linode/accountavailability/datasource_test.go @@ -16,7 +16,7 @@ func TestAccDataSourceNodeBalancers_basic(t *testing.T) { resourceName := "data.linode_account_availability.foobar" - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } diff --git a/linode/backup/datasource_test.go b/linode/backup/datasource_test.go index b63d521a4..6db6e2259 100644 --- a/linode/backup/datasource_test.go +++ b/linode/backup/datasource_test.go @@ -18,7 +18,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databaseaccesscontrols/resource_test.go b/linode/databaseaccesscontrols/resource_test.go index fa181bfa8..10255e818 100644 --- a/linode/databaseaccesscontrols/resource_test.go +++ b/linode/databaseaccesscontrols/resource_test.go @@ -44,7 +44,7 @@ func init() { postgresEngineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databasebackups/datasource_test.go b/linode/databasebackups/datasource_test.go index 157674b57..48e9a67fd 100644 --- a/linode/databasebackups/datasource_test.go +++ b/linode/databasebackups/datasource_test.go @@ -34,7 +34,7 @@ func init() { engineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databasemysql/resource_test.go b/linode/databasemysql/resource_test.go index 705061c64..ebdbb69ca 100644 --- a/linode/databasemysql/resource_test.go +++ b/linode/databasemysql/resource_test.go @@ -42,7 +42,7 @@ func init() { engineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databasemysqlbackups/datasource_test.go b/linode/databasemysqlbackups/datasource_test.go index 9c5dfd78b..2d25b391f 100644 --- a/linode/databasemysqlbackups/datasource_test.go +++ b/linode/databasemysqlbackups/datasource_test.go @@ -33,7 +33,7 @@ func init() { engineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databasepostgresql/resource_test.go b/linode/databasepostgresql/resource_test.go index c869b3318..afa66a1ec 100644 --- a/linode/databasepostgresql/resource_test.go +++ b/linode/databasepostgresql/resource_test.go @@ -42,7 +42,7 @@ func init() { engineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/databases/datasource_test.go b/linode/databases/datasource_test.go index e3e35d9ea..38a515e6c 100644 --- a/linode/databases/datasource_test.go +++ b/linode/databases/datasource_test.go @@ -32,7 +32,7 @@ func init() { engineVersion = v.ID - region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/firewall/resource_test.go b/linode/firewall/resource_test.go index d051628e6..38bc0934f 100644 --- a/linode/firewall/resource_test.go +++ b/linode/firewall/resource_test.go @@ -27,7 +27,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall", "NodeBalancers"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall", "NodeBalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/firewalldevice/resource_test.go b/linode/firewalldevice/resource_test.go index e38da295a..e595db960 100644 --- a/linode/firewalldevice/resource_test.go +++ b/linode/firewalldevice/resource_test.go @@ -21,7 +21,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall", "NodeBalancers"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall", "NodeBalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/firewalls/datasource_test.go b/linode/firewalls/datasource_test.go index c4b5b1465..0c879c938 100644 --- a/linode/firewalls/datasource_test.go +++ b/linode/firewalls/datasource_test.go @@ -17,7 +17,7 @@ const testFirewallDataName = "data.linode_firewalls.test" var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/helper/compare.go b/linode/helper/compare.go index 357e7aec0..172a45c79 100644 --- a/linode/helper/compare.go +++ b/linode/helper/compare.go @@ -51,12 +51,14 @@ func StringListElementsEqual(a, b []string) bool { return true } -// Check if `subset` is a subset of `superset`, or in other words, whether slice `superset` contains all elements of slice `subset`. +// Check if `subset` is a subset of `superset`, or in other words, assuming no duplicated items are in the sets, +// whether slice `superset` contains all elements of slice `subset`. func ValidateStringSubset(superset, subset []string) bool { return ValidateSubset(TypedSliceToAny(superset), TypedSliceToAny(subset)) } -// Check if `subset` is a subset of `superset`, or in other words, whether slice `superset` contains all elements of slice `subset`. +// Check if `subset` is a subset of `superset`, or in other words, whether slice `superset` contains all elements of slice `subset`, +// assuming no duplicated items are in the sets. func ValidateSubset(superset, subset []any) bool { for _, v := range subset { if !slices.Contains(superset, v) { @@ -67,6 +69,18 @@ func ValidateSubset(superset, subset []any) bool { return true } +// Check if two slices are equivalent without considering ordering, +// assuming no duplicated items are in the sets. +func CompareSets(a, b []any) bool { + return ValidateSubset(a, b) && ValidateSubset(b, a) +} + +// Check if two string slices are equivalent without considering ordering, +// assuming no duplicated items are in the sets. +func CompareStringSets(a, b []string) bool { + return CompareSets(TypedSliceToAny(a), TypedSliceToAny(b)) +} + func CompareScopes(s1, s2 string) bool { s1AccountScope := s1 == "*" s2AccountScope := s2 == "*" diff --git a/linode/helper/objects.go b/linode/helper/objects.go index 180379f7b..e009753d2 100644 --- a/linode/helper/objects.go +++ b/linode/helper/objects.go @@ -99,6 +99,15 @@ func IsObjNotFoundErr(err error) bool { return errors.As(err, &apiErr) && (apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "Forbidden") } +// isBucketNotFoundError checks if the error is due to the bucket not being found. +func IsBucketNotFoundError(err error) bool { + tflog.Debug( + context.Background(), + fmt.Sprintf("received an error: %s, checking whether it's a bucket not found error", err), + ) + return strings.Contains(err.Error(), "Bucket not found") +} + // Purge all objects, wiping out all versions and delete markers for versioned objects. func PurgeAllObjects( ctx context.Context, diff --git a/linode/image/datasource_test.go b/linode/image/datasource_test.go index d8d044184..bd9115bc0 100644 --- a/linode/image/datasource_test.go +++ b/linode/image/datasource_test.go @@ -3,8 +3,10 @@ package image_test import ( + "os" "testing" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/linode/terraform-provider-linode/v2/linode/acceptance" "github.com/linode/terraform-provider-linode/v2/linode/image/tmpl" @@ -36,3 +38,44 @@ func TestAccDataSourceImage_basic(t *testing.T) { }, }) } + +func TestAccDataSourceImage_replicate(t *testing.T) { + t.Parallel() + + resourceName := "data.linode_image.foobar" + imageName := acctest.RandomWithPrefix("tf_test") + // TODO: Use random region once image gen2 works globally or with specific capabilities + replicateRegion := "eu-west" + + file, err := createTempFile("tf-test-image-data-replicate-file", testImageBytes) + if err != nil { + t.Fatal(err) + } + defer os.Remove(file.Name()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + + CheckDestroy: checkImageDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.DataReplicate(t, imageName, file.Name(), testRegion, replicateRegion), + Check: resource.ComposeTestCheckFunc( + checkImageExists(resourceName, nil), + resource.TestCheckResourceAttr(resourceName, "label", imageName), + resource.TestCheckResourceAttr(resourceName, "description", "really descriptive text"), + resource.TestCheckResourceAttrSet(resourceName, "created"), + resource.TestCheckResourceAttrSet(resourceName, "created_by"), + resource.TestCheckResourceAttrSet(resourceName, "size"), + resource.TestCheckResourceAttr(resourceName, "type", "manual"), + resource.TestCheckResourceAttr(resourceName, "is_public", "false"), + resource.TestCheckResourceAttrSet(resourceName, "deprecated"), + resource.TestCheckResourceAttrSet(resourceName, "tags.#"), + resource.TestCheckResourceAttrSet(resourceName, "total_size"), + resource.TestCheckResourceAttr(resourceName, "replications.#", "2"), + ), + }, + }, + }) +} diff --git a/linode/image/framework_datasource.go b/linode/image/framework_datasource.go index d56439d9e..dedb7c753 100644 --- a/linode/image/framework_datasource.go +++ b/linode/image/framework_datasource.go @@ -66,6 +66,9 @@ func (d *DataSource) Read( return } - data.ParseImage(image) + resp.Diagnostics.Append(data.ParseImage(ctx, image)...) + if resp.Diagnostics.HasError() { + return + } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/linode/image/framework_models.go b/linode/image/framework_models.go index 517b354ff..bb969c95a 100644 --- a/linode/image/framework_models.go +++ b/linode/image/framework_models.go @@ -1,6 +1,7 @@ package image import ( + "context" "time" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" @@ -12,29 +13,39 @@ import ( ) type ResourceModel struct { - ID types.String `tfsdk:"id"` - Label types.String `tfsdk:"label"` - DiskID types.Int64 `tfsdk:"disk_id"` - LinodeID types.Int64 `tfsdk:"linode_id"` - FilePath types.String `tfsdk:"file_path"` - Region types.String `tfsdk:"region"` - FileHash types.String `tfsdk:"file_hash"` - Description types.String `tfsdk:"description"` - CloudInit types.Bool `tfsdk:"cloud_init"` - Capabilities types.List `tfsdk:"capabilities"` - Created timetypes.RFC3339 `tfsdk:"created"` - CreatedBy types.String `tfsdk:"created_by"` - Deprecated types.Bool `tfsdk:"deprecated"` - IsPublic types.Bool `tfsdk:"is_public"` - Size types.Int64 `tfsdk:"size"` - Status types.String `tfsdk:"status"` - Type types.String `tfsdk:"type"` - Expiry timetypes.RFC3339 `tfsdk:"expiry"` - Vendor types.String `tfsdk:"vendor"` - Timeouts timeouts.Value `tfsdk:"timeouts"` + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + DiskID types.Int64 `tfsdk:"disk_id"` + LinodeID types.Int64 `tfsdk:"linode_id"` + FilePath types.String `tfsdk:"file_path"` + Region types.String `tfsdk:"region"` + FileHash types.String `tfsdk:"file_hash"` + Description types.String `tfsdk:"description"` + CloudInit types.Bool `tfsdk:"cloud_init"` + Capabilities types.List `tfsdk:"capabilities"` + Created timetypes.RFC3339 `tfsdk:"created"` + CreatedBy types.String `tfsdk:"created_by"` + Deprecated types.Bool `tfsdk:"deprecated"` + IsPublic types.Bool `tfsdk:"is_public"` + Size types.Int64 `tfsdk:"size"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Expiry timetypes.RFC3339 `tfsdk:"expiry"` + Vendor types.String `tfsdk:"vendor"` + Timeouts timeouts.Value `tfsdk:"timeouts"` + Tags types.List `tfsdk:"tags"` + TotalSize types.Int64 `tfsdk:"total_size"` + ReplicaRegions types.List `tfsdk:"replica_regions"` + Replications types.List `tfsdk:"replications"` + WaitForReplications types.Bool `tfsdk:"wait_for_replications"` } -func (data *ResourceModel) FlattenImage(image *linodego.Image, preserveKnown bool, diags *diag.Diagnostics) { +func (data *ResourceModel) FlattenImage( + ctx context.Context, + image *linodego.Image, + preserveKnown bool, + diags *diag.Diagnostics, +) { data.ID = helper.KeepOrUpdateString(data.ID, image.ID, preserveKnown) data.Label = helper.KeepOrUpdateString(data.Label, image.Label, preserveKnown) data.Description = helper.KeepOrUpdateString( @@ -64,6 +75,23 @@ func (data *ResourceModel) FlattenImage(image *linodego.Image, preserveKnown boo data.Expiry, timetypes.NewRFC3339TimePointerValue(image.Expiry), preserveKnown, ) data.Vendor = helper.KeepOrUpdateString(data.Vendor, image.Vendor, preserveKnown) + data.TotalSize = helper.KeepOrUpdateInt64(data.TotalSize, int64(image.TotalSize), preserveKnown) + + tags, newDiags := types.ListValue(types.StringType, helper.StringSliceToFrameworkValueSlice(image.Tags)) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + data.Tags = helper.KeepOrUpdateValue(data.Tags, tags, preserveKnown) + + replications, newDiags := flattenReplications(ctx, image.Regions) + diags.Append(newDiags...) + if diags.HasError() { + return + } + + data.Replications = helper.KeepOrUpdateValue(data.Replications, *replications, preserveKnown) } func (data *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { @@ -87,29 +115,44 @@ func (data *ResourceModel) CopyFrom(other ResourceModel, preserveKnown bool) { data.Expiry = helper.KeepOrUpdateValue(data.Expiry, other.Expiry, preserveKnown) data.Vendor = helper.KeepOrUpdateValue(data.Vendor, other.Vendor, preserveKnown) data.Timeouts = helper.KeepOrUpdateValue(data.Timeouts, other.Timeouts, preserveKnown) + data.Tags = helper.KeepOrUpdateValue(data.Tags, other.Tags, preserveKnown) + data.TotalSize = helper.KeepOrUpdateValue(data.TotalSize, other.TotalSize, preserveKnown) + data.ReplicaRegions = helper.KeepOrUpdateValue(data.ReplicaRegions, other.ReplicaRegions, preserveKnown) + data.Replications = helper.KeepOrUpdateValue(data.Replications, other.Replications, preserveKnown) + data.WaitForReplications = helper.KeepOrUpdateValue(data.WaitForReplications, other.WaitForReplications, preserveKnown) } // ImageModel describes the Terraform resource data model to match the // resource schema. type ImageModel struct { - ID types.String `tfsdk:"id"` - Label types.String `tfsdk:"label"` - Description types.String `tfsdk:"description"` - Capabilities []types.String `tfsdk:"capabilities"` - Created types.String `tfsdk:"created"` - CreatedBy types.String `tfsdk:"created_by"` - Deprecated types.Bool `tfsdk:"deprecated"` - IsPublic types.Bool `tfsdk:"is_public"` - Size types.Int64 `tfsdk:"size"` - Status types.String `tfsdk:"status"` - Type types.String `tfsdk:"type"` - Expiry types.String `tfsdk:"expiry"` - Vendor types.String `tfsdk:"vendor"` + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + Capabilities []types.String `tfsdk:"capabilities"` + Created types.String `tfsdk:"created"` + CreatedBy types.String `tfsdk:"created_by"` + Deprecated types.Bool `tfsdk:"deprecated"` + IsPublic types.Bool `tfsdk:"is_public"` + Size types.Int64 `tfsdk:"size"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Expiry types.String `tfsdk:"expiry"` + Vendor types.String `tfsdk:"vendor"` + Tags types.List `tfsdk:"tags"` + TotalSize types.Int64 `tfsdk:"total_size"` + Replications []ReplicationModel `tfsdk:"replications"` +} + +// ReplicationModel describes an image replication. +type ReplicationModel struct { + Region types.String `tfsdk:"region"` + Status types.String `tfsdk:"status"` } func (data *ImageModel) ParseImage( + ctx context.Context, image *linodego.Image, -) { +) diag.Diagnostics { data.ID = types.StringValue(image.ID) data.Label = types.StringValue(image.Label) @@ -132,4 +175,42 @@ func (data *ImageModel) ParseImage( data.Status = types.StringValue(string(image.Status)) data.Type = types.StringValue(image.Type) data.Vendor = types.StringValue(image.Vendor) + data.TotalSize = types.Int64Value(int64(image.TotalSize)) + + tags, diags := types.ListValueFrom(ctx, types.StringType, image.Tags) + if diags.HasError() { + return diags + } + data.Tags = tags + + data.Replications = parseReplicationModels(image.Regions) + + return nil +} + +func parseReplicationModels( + regions []linodego.ImageRegion, +) []ReplicationModel { + replications := make([]ReplicationModel, len(regions)) + + for i, r := range regions { + replications[i].Region = types.StringValue(r.Region) + replications[i].Status = types.StringValue(string(r.Status)) + } + + return replications +} + +func flattenReplications( + ctx context.Context, + regions []linodego.ImageRegion, +) (*types.List, diag.Diagnostics) { + replications := parseReplicationModels(regions) + + result, diags := types.ListValueFrom(ctx, replicationObjType, replications) + if diags.HasError() { + return nil, diags + } + + return &result, nil } diff --git a/linode/image/framework_models_unit_test.go b/linode/image/framework_models_unit_test.go index f5b5c1e1f..12d51136f 100644 --- a/linode/image/framework_models_unit_test.go +++ b/linode/image/framework_models_unit_test.go @@ -3,6 +3,7 @@ package image import ( + "context" "testing" "time" @@ -28,10 +29,22 @@ func TestParseImage(t *testing.T) { Deprecated: false, Created: createdTime, Expiry: nil, + TotalSize: 2500, + Tags: []string{"test"}, + Regions: []linodego.ImageRegion{ + { + Region: "us-east", + Status: linodego.ImageRegionStatus("available"), + }, + { + Region: "us-west", + Status: linodego.ImageRegionStatus("pending replication"), + }, + }, } var imageModel ImageModel - imageModel.ParseImage(&mockImage) + imageModel.ParseImage(context.Background(), &mockImage) assert.Equal(t, types.StringValue("linode/debian11"), imageModel.ID) assert.Equal(t, types.StringValue("linode"), imageModel.CreatedBy) @@ -46,4 +59,10 @@ func TestParseImage(t *testing.T) { assert.Equal(t, types.BoolValue(false), imageModel.Deprecated) assert.Equal(t, imageModel.Created, types.StringValue(createdTimeFormatted)) assert.Empty(t, imageModel.Expiry) + assert.Equal(t, types.Int64Value(2500), imageModel.TotalSize) + assert.Equal(t, types.StringValue("us-east"), imageModel.Replications[0].Region) + assert.Equal(t, types.StringValue("available"), imageModel.Replications[0].Status) + assert.Equal(t, types.StringValue("us-west"), imageModel.Replications[1].Region) + assert.Equal(t, types.StringValue("pending replication"), imageModel.Replications[1].Status) + assert.Contains(t, imageModel.Tags.String(), "test") } diff --git a/linode/image/framework_resource.go b/linode/image/framework_resource.go index 1218977e1..0f7724c65 100644 --- a/linode/image/framework_resource.go +++ b/linode/image/framework_resource.go @@ -21,7 +21,7 @@ import ( ) const ( - DefaultVolumeCreateTimeout = 30 * time.Minute + DefaultImageCreateTimeout = 30 * time.Minute ) func NewResource() resource.Resource { @@ -66,6 +66,11 @@ func createResourceFromUpload( CloudInit: plan.CloudInit.ValueBool(), } + resp.Diagnostics.Append(plan.Tags.ElementsAs(ctx, &createOpts.Tags, true)...) + if resp.Diagnostics.HasError() { + return nil + } + tflog.Trace(ctx, "client.CreateImageUpload(...)", map[string]any{ "options": createOpts, }) @@ -92,19 +97,8 @@ func createResourceFromUpload( return image } - tflog.Debug(ctx, "Waiting for a single image to be ready") - tflog.Trace(ctx, "client.WaitForImageStatus(...)", map[string]any{ - "status": linodego.ImageStatusAvailable, - }) - - image, err = client.WaitForImageStatus( - ctx, - image.ID, - linodego.ImageStatusAvailable, - timeoutSeconds, - ) - if err != nil { - resp.Diagnostics.AddError("Failed to Wait for Image to be Available", err.Error()) + image = waitForImageToBeAvailable(ctx, client, image.ID, timeoutSeconds, &resp.Diagnostics) + if resp.Diagnostics.HasError() { return image } @@ -141,6 +135,12 @@ func createResourceFromLinode( Description: plan.Description.ValueString(), CloudInit: plan.CloudInit.ValueBool(), } + + resp.Diagnostics.Append(plan.Tags.ElementsAs(ctx, &createOpts.Tags, true)...) + if resp.Diagnostics.HasError() { + return nil + } + tflog.Trace(ctx, "client.CreateImage(...)", map[string]any{ "options": createOpts, }) @@ -195,7 +195,7 @@ func (r *Resource) Create( return } - createTimeout, diags := plan.Timeouts.Create(ctx, DefaultVolumeCreateTimeout) + createTimeout, diags := plan.Timeouts.Create(ctx, DefaultImageCreateTimeout) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -213,7 +213,29 @@ func (r *Resource) Create( image = createResourceFromUpload(ctx, &plan, client, resp, timeoutSeconds) } - plan.FlattenImage(image, true, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if !plan.ReplicaRegions.IsNull() && !plan.ReplicaRegions.IsUnknown() { + plan.ID = types.StringValue(image.ID) + + // make sure image is ready for replication + waitForImageToBeAvailable(ctx, client, image.ID, timeoutSeconds, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Refresh image from replication + image, diags = replicateImage(ctx, &plan, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + plan.FlattenImage(ctx, image, true, &resp.Diagnostics) + if resp.Diagnostics.HasError() { return } @@ -263,7 +285,7 @@ func (r *Resource) Read( return } - state.FlattenImage(image, true, &resp.Diagnostics) + state.FlattenImage(ctx, image, true, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -304,6 +326,14 @@ func (r *Resource) Update( shouldUpdate = true } + if !state.Tags.Equal(plan.Tags) { + resp.Diagnostics.Append(plan.Tags.ElementsAs(ctx, &updateOpts.Tags, true)...) + if resp.Diagnostics.HasError() { + return + } + shouldUpdate = true + } + if shouldUpdate { tflog.Debug(ctx, "client.UpdateImage(...)", map[string]any{ "options": updateOpts, @@ -314,7 +344,52 @@ func (r *Resource) Update( resp.Diagnostics.AddError("Failed to Update Image", err.Error()) return } - plan.FlattenImage(image, true, &resp.Diagnostics) + plan.FlattenImage(ctx, image, true, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + } + + if !state.ReplicaRegions.Equal(plan.ReplicaRegions) { + isAvailableRegionLeft, diags := atLeastOneAvailableRegion(ctx, &plan, &state) + if diags != nil { + resp.Diagnostics.Append(diags...) + return + } + + if plan.ReplicaRegions.IsNull() || plan.ReplicaRegions.IsUnknown() || !isAvailableRegionLeft { + resp.Diagnostics.AddError( + "Invalid regions to replicate.", + "At least one available region must be specified. "+ + "Note: Image is not allowed to be deleted by sending an empty regions list.") + return + } + + createTimeout, diags := plan.Timeouts.Create(ctx, DefaultImageCreateTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + timeoutSeconds := helper.FrameworkSafeFloat64ToInt(createTimeout.Seconds(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // make sure image is ready for replication + waitForImageToBeAvailable(ctx, client, plan.ID.ValueString(), timeoutSeconds, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + image, diags := replicateImage(ctx, &plan, client) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Refresh image from replication + plan.FlattenImage(ctx, image, true, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } @@ -427,3 +502,112 @@ func uploadImageAndStoreHash( plan.FileHash = types.StringValue(hex.EncodeToString(hash.Sum(nil))) } + +func replicateImage( + ctx context.Context, + plan *ResourceModel, + client *linodego.Client, +) (*linodego.Image, diag.Diagnostics) { + var replicationOpts linodego.ImageReplicateOptions + diags := plan.ReplicaRegions.ElementsAs(ctx, &replicationOpts.Regions, false) + if diags.HasError() { + return nil, diags + } + + tflog.Debug(ctx, "client.ReplicateImage(...)", map[string]any{ + "opts": replicationOpts, + }) + + imageID := plan.ID.ValueString() + image, err := client.ReplicateImage(ctx, imageID, replicationOpts) + if err != nil { + diags.AddError( + fmt.Sprintf("Failed to replicate image %v", imageID), + err.Error(), + ) + return nil, diags + } + + if plan.WaitForReplications.ValueBool() { + var replicaRegionWaitList []string + + image, err = client.GetImage(ctx, imageID) + for _, region := range image.Regions { + // remove pending deletion replicas from the wait list + if region.Status != linodego.ImageRegionStatusPendingDeletion { + replicaRegionWaitList = append(replicaRegionWaitList, region.Region) + } + } + + tflog.Trace(ctx, "client.WaitForImageRegionStatus(...)", map[string]any{ + "status": linodego.ImageRegionStatusAvailable, + }) + + for _, region := range replicaRegionWaitList { + image, err = client.WaitForImageRegionStatus(ctx, imageID, region, linodego.ImageRegionStatusAvailable) + if err != nil { + diags.AddError( + fmt.Sprintf("Failed to get image %v replication status in region %v", imageID, region), + err.Error(), + ) + return nil, diags + } + } + } + + return image, diags +} + +func atLeastOneAvailableRegion( + ctx context.Context, + plan *ResourceModel, + state *ResourceModel, +) (bool, diag.Diagnostics) { + var planRegions, stateRegions []string + diags := plan.ReplicaRegions.ElementsAs(ctx, &planRegions, true) + if diags.HasError() { + return false, diags + } + diags = state.ReplicaRegions.ElementsAs(ctx, &stateRegions, true) + if diags.HasError() { + return false, diags + } + + set := make(map[string]bool) + for _, v := range stateRegions { + set[v] = true + } + + for _, v := range planRegions { + if set[v] { + return true, nil + } + } + + return false, nil +} + +func waitForImageToBeAvailable( + ctx context.Context, + client *linodego.Client, + imageID string, + timeoutSeconds int, + diags *diag.Diagnostics, +) *linodego.Image { + tflog.Debug(ctx, "Waiting for a single image to be ready") + tflog.Trace(ctx, "client.WaitForImageStatus(...)", map[string]any{ + "status": linodego.ImageStatusAvailable, + }) + + image, err := client.WaitForImageStatus( + ctx, + imageID, + linodego.ImageStatusAvailable, + timeoutSeconds, + ) + if err != nil { + diags.AddError("Failed to Wait for Image to be Available", err.Error()) + } + + return image +} diff --git a/linode/image/framework_schema_datasource.go b/linode/image/framework_schema_datasource.go index d5e4fc2f9..19f5997a0 100644 --- a/linode/image/framework_schema_datasource.go +++ b/linode/image/framework_schema_datasource.go @@ -60,8 +60,36 @@ var ImageAttributes = map[string]schema.Attribute{ Description: "The upstream distribution vendor. Nil for private Images.", Computed: true, }, + "tags": schema.ListAttribute{ + Description: "The customized tags for the image.", + Computed: true, + ElementType: types.StringType, + }, + "total_size": schema.Int64Attribute{ + Description: "The total size of the image in all available regions.", + Computed: true, + }, +} + +var ReplicationsBlock = map[string]schema.Block{ + "replications": schema.ListNestedBlock{ + Description: "A list of image replications region and corresponding status.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "region": schema.StringAttribute{ + Description: "The region of an image replica.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The status of an image replica.", + Computed: true, + }, + }, + }, + }, } var frameworkDatasourceSchema = schema.Schema{ Attributes: ImageAttributes, + Blocks: ReplicationsBlock, } diff --git a/linode/image/framework_schema_resource.go b/linode/image/framework_schema_resource.go index 5cc247f63..301bd763b 100644 --- a/linode/image/framework_schema_resource.go +++ b/linode/image/framework_schema_resource.go @@ -4,6 +4,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" @@ -16,6 +17,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +var replicationObjType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "region": types.StringType, + "status": types.StringType, + }, +} + var frameworkResourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -94,7 +102,6 @@ var frameworkResourceSchema = schema.Schema{ }, "file_hash": schema.StringAttribute{ Description: "The MD5 hash of the image file.", - Computed: true, Optional: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), @@ -187,5 +194,33 @@ var frameworkResourceSchema = schema.Schema{ listplanmodifier.UseStateForUnknown(), }, }, + "tags": schema.ListAttribute{ + Description: "The customized tags for the image.", + Computed: true, + Optional: true, + ElementType: types.StringType, + }, + "total_size": schema.Int64Attribute{ + Description: "The total size of the image in all available regions.", + Computed: true, + }, + "replica_regions": schema.ListAttribute{ + Description: "A list of regions that customer wants to replicate this image in. " + + "At least one available region is required and only core regions allowed. " + + "Existing images in the regions not passed will be removed.", + Optional: true, + ElementType: types.StringType, + }, + "replications": schema.ListAttribute{ + Description: "A list of image replications region and corresponding status.", + Computed: true, + ElementType: replicationObjType, + }, + "wait_for_replications": schema.BoolAttribute{ + Description: "Whether to wait for all image replications become `available`.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, }, } diff --git a/linode/image/resource_test.go b/linode/image/resource_test.go index 906c5bf4c..3eb565377 100644 --- a/linode/image/resource_test.go +++ b/linode/image/resource_test.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "os" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" @@ -43,7 +44,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } @@ -89,7 +90,7 @@ func TestAccImage_basic(t *testing.T) { CheckDestroy: checkImageDestroy, Steps: []resource.TestStep{ { - Config: tmpl.Basic(t, imageName, testRegion, label), + Config: tmpl.Basic(t, imageName, testRegion, label, "test-tag"), Check: resource.ComposeTestCheckFunc( checkImageExists(resName, nil), resource.TestCheckResourceAttr(resName, "label", imageName), @@ -101,6 +102,7 @@ func TestAccImage_basic(t *testing.T) { resource.TestCheckResourceAttr(resName, "is_public", "false"), resource.TestCheckResourceAttr(resName, "capabilities.0", "cloud-init"), resource.TestCheckResourceAttrSet(resName, "deprecated"), + resource.TestCheckResourceAttr(resName, "tags.#", "1"), ), }, { @@ -127,16 +129,18 @@ func TestAccImage_update(t *testing.T) { Steps: []resource.TestStep{ { - Config: tmpl.Basic(t, imageName, testRegion, label), + Config: tmpl.Basic(t, imageName, testRegion, label, "test-tag"), Check: resource.ComposeTestCheckFunc( checkImageExists(resName, nil), resource.TestCheckResourceAttr(resName, "label", imageName), resource.TestCheckResourceAttr(resName, "description", "descriptive text"), resource.TestCheckResourceAttrSet(resName, "capabilities.#"), + resource.TestCheckResourceAttr(resName, "tags.#", "1"), + resource.TestCheckResourceAttr(resName, "tags.0", "test-tag"), ), }, { - Config: tmpl.Updates(t, imageName, testRegion, label), + Config: tmpl.Updates(t, imageName, testRegion, label, "updated-tag"), Check: resource.ComposeTestCheckFunc( checkImageExists(resName, nil), resource.TestCheckResourceAttr(resName, "label", fmt.Sprintf("%s_renamed", imageName)), @@ -147,6 +151,8 @@ func TestAccImage_update(t *testing.T) { resource.TestCheckResourceAttr(resName, "type", "manual"), resource.TestCheckResourceAttr(resName, "is_public", "false"), resource.TestCheckResourceAttrSet(resName, "deprecated"), + resource.TestCheckResourceAttr(resName, "tags.#", "1"), + resource.TestCheckResourceAttr(resName, "tags.0", "updated-tag"), ), }, { @@ -179,7 +185,7 @@ func TestAccImage_uploadFile(t *testing.T) { CheckDestroy: checkImageDestroy, Steps: []resource.TestStep{ { - Config: tmpl.Upload(t, imageName, file.Name(), testRegion), + Config: tmpl.Upload(t, imageName, file.Name(), testRegion, "test-tag"), Check: resource.ComposeTestCheckFunc( checkImageExists(resName, &image), resource.TestCheckResourceAttr(resName, "label", imageName), @@ -192,13 +198,14 @@ func TestAccImage_uploadFile(t *testing.T) { resource.TestCheckResourceAttrSet(resName, "deprecated"), resource.TestCheckResourceAttr(resName, "file_hash", testImageMD5), resource.TestCheckResourceAttr(resName, "status", string(linodego.ImageStatusAvailable)), + resource.TestCheckResourceAttr(resName, "tags.#", "1"), ), }, { PreConfig: func() { file.Write(testImageBytesNew) }, - Config: tmpl.Upload(t, imageName, file.Name(), testRegion), + Config: tmpl.Upload(t, imageName, file.Name(), testRegion, "test-tag"), Check: resource.ComposeTestCheckFunc( checkImageExists(resName, &image), resource.TestCheckResourceAttr(resName, "status", string(linodego.ImageStatusAvailable)), @@ -208,6 +215,62 @@ func TestAccImage_uploadFile(t *testing.T) { }) } +func TestAccImage_replicate(t *testing.T) { + t.Parallel() + + resName := "linode_image.foobar" + imageName := acctest.RandomWithPrefix("tf_test") + // Override the testRegion to be a fixed value because we need to make sure these three regions are different + testRegion = "us-east" + replicateRegion := "eu-west" + replicateNewRegion := "us-central" + + file, err := createTempFile("tf-test-image-replicate-file", testImageBytes) + if err != nil { + t.Fatal(err) + } + defer os.Remove(file.Name()) + + var image linodego.Image + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: checkImageDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.Replicate(t, imageName, file.Name(), testRegion, replicateRegion), + Check: resource.ComposeTestCheckFunc( + checkImageExists(resName, &image), + resource.TestCheckResourceAttr(resName, "label", imageName), + resource.TestCheckResourceAttr(resName, "replications.#", "2"), + ), + }, + { + // Remove the one of the available region and replicate the image in a new region + Config: tmpl.Replicate(t, imageName, file.Name(), testRegion, replicateNewRegion), + Check: resource.ComposeTestCheckFunc( + checkImageExists(resName, &image), + resource.TestCheckResourceAttr(resName, "label", imageName), + resource.TestCheckResourceAttr(resName, "replications.#", "2"), + ), + }, + { + // Remove all available region and replicate the image in new regions + Config: tmpl.Replicate(t, imageName, file.Name(), "us-west", "us-mia"), + ExpectError: regexp.MustCompile( + "At least one available region must be specified"), + }, + { + // Remove all available region + Config: tmpl.NoReplicaRegions(t, imageName, file.Name(), testRegion), + ExpectError: regexp.MustCompile( + "At least one available region must be specified"), + }, + }, + }) +} + func checkImageExists(name string, image *linodego.Image) resource.TestCheckFunc { return func(s *terraform.State) error { client := acceptance.TestAccProvider.Meta().(*helper.ProviderMeta).Client diff --git a/linode/image/tmpl/basic.gotf b/linode/image/tmpl/basic.gotf index 338cc1a3b..0d6c5dcb9 100644 --- a/linode/image/tmpl/basic.gotf +++ b/linode/image/tmpl/basic.gotf @@ -21,6 +21,7 @@ resource "linode_image" "foobar" { label = "{{.Image}}" description = "descriptive text" cloud_init = true + tags = ["{{.Tag}}"] } {{ end }} \ No newline at end of file diff --git a/linode/image/tmpl/data_replicate.gotf b/linode/image/tmpl/data_replicate.gotf new file mode 100644 index 000000000..5b4a86938 --- /dev/null +++ b/linode/image/tmpl/data_replicate.gotf @@ -0,0 +1,9 @@ +{{ define "image_data_replicate" }} + +{{ template "image_replicate" . }} + +data "linode_image" "foobar" { + id = linode_image.foobar.id +} + +{{ end }} \ No newline at end of file diff --git a/linode/image/tmpl/no_replica_regions.gotf b/linode/image/tmpl/no_replica_regions.gotf new file mode 100644 index 000000000..9cda202db --- /dev/null +++ b/linode/image/tmpl/no_replica_regions.gotf @@ -0,0 +1,11 @@ +{{ define "image_no_replica_regions" }} + +resource "linode_image" "foobar" { + label = "{{.Image}}" + file_path = "{{.FilePath}}" + file_hash = filemd5("{{.FilePath}}") + region = "{{ .Region }}" + description = "really descriptive text" +} + +{{ end }} \ No newline at end of file diff --git a/linode/image/tmpl/replicate.gotf b/linode/image/tmpl/replicate.gotf new file mode 100644 index 000000000..3e23476c9 --- /dev/null +++ b/linode/image/tmpl/replicate.gotf @@ -0,0 +1,13 @@ +{{ define "image_replicate" }} + +resource "linode_image" "foobar" { + label = "{{.Image}}" + file_path = "{{.FilePath}}" + file_hash = filemd5("{{.FilePath}}") + region = "{{ .Region }}" + description = "really descriptive text" + replica_regions = ["{{ .Region }}", "{{ .ReplicaRegion }}"] + wait_for_replications = true +} + +{{ end }} \ No newline at end of file diff --git a/linode/image/tmpl/template.go b/linode/image/tmpl/template.go index a99428257..7e4e5ae7d 100644 --- a/linode/image/tmpl/template.go +++ b/linode/image/tmpl/template.go @@ -7,37 +7,61 @@ import ( ) type TemplateData struct { - Image string - ID string - FilePath string - Region string - Label string + Image string + ID string + FilePath string + Region string + Label string + Tag string + ReplicaRegion string } -func Basic(t *testing.T, image, region, label string) string { +func Basic(t *testing.T, image, region, label, tag string) string { return acceptance.ExecuteTemplate(t, "image_basic", TemplateData{ Image: image, Region: region, Label: label, + Tag: tag, }) } -func Updates(t *testing.T, image, region, label string) string { +func Updates(t *testing.T, image, region, label, tag string) string { return acceptance.ExecuteTemplate(t, "image_updates", TemplateData{ Image: image, Region: region, Label: label, + Tag: tag, }) } -func Upload(t *testing.T, image, upload, region string) string { +func Upload(t *testing.T, image, upload, region, tag string) string { return acceptance.ExecuteTemplate(t, "image_upload", TemplateData{ Image: image, FilePath: upload, Region: region, + Tag: tag, + }) +} + +func Replicate(t *testing.T, image, upload, region, replicaRegion string) string { + return acceptance.ExecuteTemplate(t, + "image_replicate", TemplateData{ + Image: image, + Region: region, + FilePath: upload, + ReplicaRegion: replicaRegion, + }) +} + +func NoReplicaRegions(t *testing.T, image, upload, region string) string { + return acceptance.ExecuteTemplate(t, + "image_no_replica_regions", TemplateData{ + Image: image, + Region: region, + FilePath: upload, }) } @@ -45,3 +69,13 @@ func DataBasic(t *testing.T, id string) string { return acceptance.ExecuteTemplate(t, "image_data_basic", TemplateData{ID: id}) } + +func DataReplicate(t *testing.T, image, upload, region, replicaRegion string) string { + return acceptance.ExecuteTemplate(t, + "image_data_replicate", TemplateData{ + Image: image, + Region: region, + FilePath: upload, + ReplicaRegion: replicaRegion, + }) +} diff --git a/linode/image/tmpl/updates.gotf b/linode/image/tmpl/updates.gotf index 17d84ccf8..33f24cc4f 100644 --- a/linode/image/tmpl/updates.gotf +++ b/linode/image/tmpl/updates.gotf @@ -20,6 +20,7 @@ resource "linode_image" "foobar" { disk_id = "${linode_instance.foobar.disk.0.id}" label = "{{.Image}}_renamed" description = "more descriptive text" + tags = ["{{.Tag}}"] } {{ end }} \ No newline at end of file diff --git a/linode/image/tmpl/upload.gotf b/linode/image/tmpl/upload.gotf index 7b72919cf..9f6d10904 100644 --- a/linode/image/tmpl/upload.gotf +++ b/linode/image/tmpl/upload.gotf @@ -6,6 +6,7 @@ resource "linode_image" "foobar" { file_hash = filemd5("{{.FilePath}}") region = "{{ .Region }}" description = "really descriptive text" + tags = ["{{.Tag}}"] } {{ end }} \ No newline at end of file diff --git a/linode/images/datasource_test.go b/linode/images/datasource_test.go index 7c015de04..2109e2a39 100644 --- a/linode/images/datasource_test.go +++ b/linode/images/datasource_test.go @@ -15,7 +15,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } @@ -42,20 +42,26 @@ func TestAccDataSourceImages_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "images.0.description", "descriptive text"), resource.TestCheckResourceAttr(resourceName, "images.0.is_public", "false"), resource.TestCheckResourceAttr(resourceName, "images.0.type", "manual"), + acceptance.CheckListContains(resourceName, "images.0.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.0.created"), resource.TestCheckResourceAttrSet(resourceName, "images.0.created_by"), resource.TestCheckResourceAttrSet(resourceName, "images.0.size"), resource.TestCheckResourceAttrSet(resourceName, "images.0.deprecated"), resource.TestCheckResourceAttrSet(resourceName, "images.0.capabilities.#"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.total_size"), + resource.TestCheckResourceAttrSet(resourceName, "images.0.replications.#"), resource.TestCheckResourceAttr(resourceName, "images.1.label", imageName), resource.TestCheckResourceAttr(resourceName, "images.1.description", "descriptive text"), resource.TestCheckResourceAttr(resourceName, "images.1.is_public", "false"), resource.TestCheckResourceAttr(resourceName, "images.1.type", "manual"), + acceptance.CheckListContains(resourceName, "images.1.tags", "test"), resource.TestCheckResourceAttrSet(resourceName, "images.1.created"), resource.TestCheckResourceAttrSet(resourceName, "images.1.created_by"), resource.TestCheckResourceAttrSet(resourceName, "images.1.size"), resource.TestCheckResourceAttrSet(resourceName, "images.1.deprecated"), - resource.TestCheckResourceAttrSet(resourceName, "images.0.capabilities.#"), + resource.TestCheckResourceAttrSet(resourceName, "images.1.capabilities.#"), + resource.TestCheckResourceAttrSet(resourceName, "images.1.total_size"), + resource.TestCheckResourceAttrSet(resourceName, "images.1.replications.#"), ), }, diff --git a/linode/images/framework_datasource.go b/linode/images/framework_datasource.go index b2ede85f7..6e3cf19b9 100644 --- a/linode/images/framework_datasource.go +++ b/linode/images/framework_datasource.go @@ -62,7 +62,10 @@ func (d *DataSource) Read( } } - data.parseImages(helper.AnySliceToTyped[linodego.Image](result)) + resp.Diagnostics.Append(data.parseImages(ctx, helper.AnySliceToTyped[linodego.Image](result))...) + if resp.Diagnostics.HasError() { + return + } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/linode/images/framework_models.go b/linode/images/framework_models.go index fa319dca7..4bc6b4448 100644 --- a/linode/images/framework_models.go +++ b/linode/images/framework_models.go @@ -1,6 +1,9 @@ package images import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v2/linode/helper/frameworkfilter" @@ -19,14 +22,20 @@ type ImageFilterModel struct { } func (data *ImageFilterModel) parseImages( + ctx context.Context, images []linodego.Image, -) { +) diag.Diagnostics { result := make([]image.ImageModel, len(images)) for i := range images { var imgData image.ImageModel - imgData.ParseImage(&images[i]) + diags := imgData.ParseImage(ctx, &images[i]) + if diags.HasError() { + return diags + } result[i] = imgData } data.Images = result + + return nil } diff --git a/linode/images/framework_models_unit_test.go b/linode/images/framework_models_unit_test.go index 036fa8f29..72009e1fd 100644 --- a/linode/images/framework_models_unit_test.go +++ b/linode/images/framework_models_unit_test.go @@ -3,6 +3,7 @@ package images import ( + "context" "testing" "time" @@ -49,7 +50,7 @@ func TestParseImages(t *testing.T) { data := ImageFilterModel{} - data.parseImages(images) + data.parseImages(context.Background(), images) assert.Len(t, data.Images, len(images)) diff --git a/linode/images/framework_schema_datasource.go b/linode/images/framework_schema_datasource.go index 76cbab49b..625156bd9 100644 --- a/linode/images/framework_schema_datasource.go +++ b/linode/images/framework_schema_datasource.go @@ -14,6 +14,8 @@ var filterConfig = frameworkfilter.Config{ "type": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, "vendor": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + // TODO: check if tags become API filterable + "tags": {TypeFunc: frameworkfilter.FilterTypeString}, "created_by": {TypeFunc: frameworkfilter.FilterTypeString}, "id": {TypeFunc: frameworkfilter.FilterTypeString}, "status": {TypeFunc: frameworkfilter.FilterTypeString}, @@ -39,6 +41,7 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The returned list of Images.", NestedObject: schema.NestedBlockObject{ Attributes: image.ImageAttributes, + Blocks: image.ReplicationsBlock, }, }, }, diff --git a/linode/images/tmpl/data_base.gotf b/linode/images/tmpl/data_base.gotf index 0088a002c..f5426b2e0 100644 --- a/linode/images/tmpl/data_base.gotf +++ b/linode/images/tmpl/data_base.gotf @@ -17,6 +17,7 @@ resource "linode_image" "foobar" { disk_id = "${linode_instance.foobar.disk.0.id}" label = "{{.Image}}" description = "descriptive text" + tags = ["test"] } resource "linode_image" "foobar2" { @@ -24,6 +25,7 @@ resource "linode_image" "foobar2" { disk_id = "${linode_instance.foobar.disk.0.id}" label = "{{.Image}}" description = "descriptive text" + tags = ["test", "gen2"] } {{ end }} \ No newline at end of file diff --git a/linode/images/tmpl/data_basic.gotf b/linode/images/tmpl/data_basic.gotf index 8aeb8f0b7..227bed5dd 100644 --- a/linode/images/tmpl/data_basic.gotf +++ b/linode/images/tmpl/data_basic.gotf @@ -12,6 +12,11 @@ data "linode_images" "foobar" { name = "is_public" values = ["false"] } + + filter { + name = "tags" + values = ["test"] + } } {{ end }} \ No newline at end of file diff --git a/linode/instance/datasource_test.go b/linode/instance/datasource_test.go index 93aa8dabe..220305749 100644 --- a/linode/instance/datasource_test.go +++ b/linode/instance/datasource_test.go @@ -42,6 +42,44 @@ func TestAccDataSourceInstances_basic(t *testing.T) { resource.TestCheckResourceAttr(resName, "instances.0.disk.#", "2"), resource.TestCheckResourceAttr(resName, "instances.0.config.#", "1"), resource.TestCheckResourceAttrSet(resName, "instances.0.config.0.id"), + resource.TestCheckResourceAttr(resName, "instances.0.placement_group.#", "0"), + ), + }, + }, + }) +} + +func TestAccDataSourceInstances_withPG(t *testing.T) { + t.Parallel() + + resName := "data.linode_instances.foobar" + instanceName := acctest.RandomWithPrefix("tf_test") + + pgIDs := []string{"foobar"} + + // Resolve a region with support for PGs + targetRegion, err := acceptance.GetRandomRegionWithCaps( + []string{"Linodes", "Placement Group"}, + "core", + ) + if err != nil { + t.Fatal(err) + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + + Steps: []resource.TestStep{ + { + Config: tmpl.DataWithPG(t, instanceName, targetRegion, "foobar", pgIDs), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resName, "instances.#", "1"), + resource.TestCheckResourceAttr(resName, "instances.0.placement_group.#", "1"), + resource.TestCheckResourceAttrSet(resName, "instances.0.placement_group.0.id"), + resource.TestCheckResourceAttrSet(resName, "instances.0.placement_group.0.placement_group_type"), + resource.TestCheckResourceAttrSet(resName, "instances.0.placement_group.0.placement_group_policy"), ), }, }, diff --git a/linode/instance/flatten.go b/linode/instance/flatten.go index 4612983e6..656d4d92f 100644 --- a/linode/instance/flatten.go +++ b/linode/instance/flatten.go @@ -4,8 +4,6 @@ import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v2/linode/helper" ) @@ -55,6 +53,7 @@ func flattenInstance( result["backups"] = flattenInstanceBackups(*instance) result["specs"] = flattenInstanceSpecs(*instance) result["alerts"] = flattenInstanceAlerts(*instance) + result["placement_group"] = flattenInstancePlacementGroup(*instance) instanceDisks, err := client.ListInstanceDisks(ctx, id, nil) if err != nil { @@ -223,23 +222,16 @@ func flattenInstanceSimple(instance *linodego.Instance) (map[string]interface{}, return result, nil } -// NOTE: The ResourceData needs to be passed down here because compliant_only is NOT returned -// by the API and instead needs to be carried over from the old state. -func flattenInstancePlacementGroup(d *schema.ResourceData, pg *linodego.InstancePlacementGroup) []map[string]any { - if pg == nil { +func flattenInstancePlacementGroup(instance linodego.Instance) []map[string]any { + if instance.PlacementGroup == nil { return nil } result := map[string]any{ - "id": pg.ID, - "label": pg.Label, - "placement_group_type": string(pg.PlacementGroupType), - "placement_group_policy": pg.PlacementGroupPolicy, - } - - // Inherit compliant_only if it already exists in state - if compliantOnly, ok := d.GetOk("placement_group.0.compliant_only"); ok { - result["compliant_only"] = compliantOnly.(bool) + "id": instance.PlacementGroup.ID, + "label": instance.PlacementGroup.Label, + "placement_group_type": instance.PlacementGroup.PlacementGroupType, + "placement_group_policy": instance.PlacementGroup.PlacementGroupPolicy, } return []map[string]any{result} diff --git a/linode/instance/resource.go b/linode/instance/resource.go index 2cae7c148..1a5e66d67 100644 --- a/linode/instance/resource.go +++ b/linode/instance/resource.go @@ -126,7 +126,16 @@ func readResource(ctx context.Context, d *schema.ResourceData, meta interface{}) d.Set("specs", flatSpecs) d.Set("alerts", flatAlerts) - d.Set("placement_group", flattenInstancePlacementGroup(d, instance.PlacementGroup)) + var placementGroupMap map[string]interface{} + flattenedGroups := flattenInstancePlacementGroup(*instance) + if len(flattenedGroups) > 0 { + placementGroupMap = flattenedGroups[0] + // Inherit compliant_only if it already exists in state + if compliantOnly, ok := d.GetOk("placement_group.0.compliant_only"); ok { + placementGroupMap["compliant_only"] = compliantOnly.(bool) + } + d.Set("placement_group", []map[string]interface{}{placementGroupMap}) + } disks, swapSize := flattenInstanceDisks(instanceDisks) d.Set("disk", disks) diff --git a/linode/instance/resource_test.go b/linode/instance/resource_test.go index 5365513e5..6214e1dfa 100644 --- a/linode/instance/resource_test.go +++ b/linode/instance/resource_test.go @@ -30,7 +30,9 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"Vlans", "VPCs"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{ + linodego.CapabilityVlans, linodego.CapabilityVPCs, linodego.CapabilityDiskEncryption, + }, "core") if err != nil { log.Fatal(err) } @@ -2061,7 +2063,7 @@ func TestAccResourceInstance_userData(t *testing.T) { var instance linodego.Instance instanceName := acctest.RandomWithPrefix("tf_test") - region, err := acceptance.GetRandomRegionWithCaps([]string{"Metadata"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Metadata"}, "core") if err != nil { t.Fatal(err) } @@ -2163,7 +2165,7 @@ func TestAccResourceInstance_firewallOnCreation(t *testing.T) { var instance linodego.Instance instanceName := acctest.RandomWithPrefix("tf_test") - region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Cloud Firewall"}, "core") rootPass := acctest.RandString(12) if err != nil { t.Fatal(err) @@ -2327,7 +2329,7 @@ func TestAccResourceInstance_migration(t *testing.T) { // Resolve a region to migrate to targetRegion, err := acceptance.GetRandomRegionWithCaps( - []string{"Linodes"}, + []string{"Linodes"}, "core", func(v linodego.Region) bool { return v.ID != testRegion }, @@ -2384,7 +2386,7 @@ func TestAccResourceInstance_withPG(t *testing.T) { // Resolve a region with support for PGs targetRegion, err := acceptance.GetRandomRegionWithCaps( - []string{"Linodes", "Placement Group"}, + []string{"Linodes", "Placement Group"}, "core", ) if err != nil { t.Fatal(err) @@ -2431,7 +2433,7 @@ func TestAccResourceInstance_pgAssignment(t *testing.T) { // Resolve a region with support for PGs testRegion, err := acceptance.GetRandomRegionWithCaps( - []string{"Linodes", "Placement Group"}, + []string{"Linodes", "Placement Group"}, "core", ) if err != nil { t.Fatal(err) diff --git a/linode/instance/schema_datasource.go b/linode/instance/schema_datasource.go index 64c08a432..430a3a644 100644 --- a/linode/instance/schema_datasource.go +++ b/linode/instance/schema_datasource.go @@ -406,4 +406,37 @@ var instanceDataSourceSchema = map[string]*schema.Schema{ }, }, }, + "placement_group": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Description: "The placement group's ID. You need to provide it for all operations impacting it.", + Computed: true, + }, + "label": { + Type: schema.TypeString, + Description: "The unique name set for the placement group.", + Computed: true, + }, + "placement_group_type": { + Type: schema.TypeString, + Description: "How compute instances are distributed in your placement group. " + + "anti-affinity:local places compute instances in separate fault domains, but still in the same region.", + Computed: true, + }, + "placement_group_policy": { + Type: schema.TypeString, + Description: "How the API enforces your placement_group_type. Set to strict, your group is strict. You can't " + + "add more compute instances to your placement group if your preferred container lacks capacity or is" + + " unavailable. Set to flexible, your group is flexible. You can add more compute instances to it even if " + + "they violate the placement_group_type. If you violate the placement_group_type your placement group becomes " + + "non-compliant and you need to wait for our assistance.", + Computed: true, + }, + }, + }, + }, } diff --git a/linode/instance/tmpl/template.go b/linode/instance/tmpl/template.go index 236a181a3..faf50a2c8 100644 --- a/linode/instance/tmpl/template.go +++ b/linode/instance/tmpl/template.go @@ -603,6 +603,16 @@ func DataBasic(t *testing.T, label, region string, rootPass string) string { }) } +func DataWithPG(t *testing.T, label, region, assignedGroup string, groups []string) string { + return acceptance.ExecuteTemplate(t, + "instance_data_with_pg", TemplateData{ + Label: label, + Region: region, + PlacementGroups: groups, + AssignedGroup: assignedGroup, + }) +} + func DataMultiple(t *testing.T, label, tag, region string, rootPass string) string { return acceptance.ExecuteTemplate(t, "instance_data_multiple", TemplateData{ diff --git a/linode/instance/tmpl/templates/data_with_pg.gotf b/linode/instance/tmpl/templates/data_with_pg.gotf new file mode 100644 index 000000000..72adb239f --- /dev/null +++ b/linode/instance/tmpl/templates/data_with_pg.gotf @@ -0,0 +1,28 @@ +{{ define "instance_data_with_pg" }} + +{{ template "e2e_test_firewall" . }} + +resource "linode_placement_group" "foobar" { + label = "{{ $.Label }}" + region = "{{ $.Region }}" + placement_group_type = "anti_affinity:local" + placement_group_policy = "flexible" +} + +resource "linode_instance" "foobar" { + label = "{{ .Label }}" + type = "g6-nanode-1" + region = "{{ .Region }}" + placement_group { + id = linode_placement_group.foobar.id + } +} + +data "linode_instances" "foobar" { + filter { + name = "id" + values = [linode_instance.foobar.id] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/instance/tmpl/templates/private_image.gotf b/linode/instance/tmpl/templates/private_image.gotf index d8c5e96ff..ad2746716 100644 --- a/linode/instance/tmpl/templates/private_image.gotf +++ b/linode/instance/tmpl/templates/private_image.gotf @@ -7,11 +7,7 @@ resource "linode_instance" "foobar-orig" { group = "tf_test" type = "g6-nanode-1" region = "{{ .Region }}" - disk { - label = "disk" - size = 1000 - filesystem = "ext4" - } + image = "linode/alpine3.19" firewall_id = linode_firewall.e2e_test_firewall.id } diff --git a/linode/instanceconfig/resource_test.go b/linode/instanceconfig/resource_test.go index 1fe960744..bf55c05da 100644 --- a/linode/instanceconfig/resource_test.go +++ b/linode/instanceconfig/resource_test.go @@ -21,7 +21,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"vlans", "VPCs"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"vlans", "VPCs"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/instancedisk/resource_test.go b/linode/instancedisk/resource_test.go index 3ff68616a..f168440db 100644 --- a/linode/instancedisk/resource_test.go +++ b/linode/instancedisk/resource_test.go @@ -21,7 +21,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } diff --git a/linode/instanceip/resource_test.go b/linode/instanceip/resource_test.go index 284bd2283..5019e306a 100644 --- a/linode/instanceip/resource_test.go +++ b/linode/instanceip/resource_test.go @@ -18,7 +18,7 @@ const testInstanceIPResName = "linode_instance_ip.test" var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps(nil) + region, err := acceptance.GetRandomRegionWithCaps(nil, "core") if err != nil { log.Fatal(err) } diff --git a/linode/instancenetworking/datasource_test.go b/linode/instancenetworking/datasource_test.go index 58554c445..d09925106 100644 --- a/linode/instancenetworking/datasource_test.go +++ b/linode/instancenetworking/datasource_test.go @@ -18,7 +18,7 @@ const testInstanceNetworkResName = "data.linode_instance_networking.test" var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs", "Linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs", "Linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/ipv6ranges/datasource_test.go b/linode/ipv6ranges/datasource_test.go index 511bcace1..c25027454 100644 --- a/linode/ipv6ranges/datasource_test.go +++ b/linode/ipv6ranges/datasource_test.go @@ -15,7 +15,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/lke/cluster.go b/linode/lke/cluster.go index 59ae77171..dcb7ea86e 100644 --- a/linode/lke/cluster.go +++ b/linode/lke/cluster.go @@ -15,6 +15,7 @@ import ( type NodePoolSpec struct { ID int + Tags []string Type string Count int AutoScalerEnabled bool @@ -41,6 +42,7 @@ func ReconcileLKENodePoolSpecs( createOpts := linodego.LKENodePoolCreateOptions{ Count: spec.Count, Type: spec.Type, + Tags: spec.Tags, } if createOpts.Count == 0 { @@ -110,6 +112,7 @@ func ReconcileLKENodePoolSpecs( updateOpts := linodego.LKENodePoolUpdateOptions{ Count: newSpec.Count, + Tags: &newSpecs[i].Tags, } // Only include the autoscaler if the autoscaler has updated @@ -335,6 +338,10 @@ func matchPoolsWithSchema(pools []linodego.LKENodePool, declaredPools []interfac continue } + if !helper.CompareStringSets(helper.ExpandStringSet(declaredPool["tags"].(*schema.Set)), apiPool.Tags) { + continue + } + // Pair the API pool with the declared pool result[i] = apiPool delete(apiPools, apiPool.ID) @@ -387,6 +394,7 @@ func expandLinodeLKENodePoolSpecs(pool []interface{}, preserveNoTarget bool) (po poolSpecs = append(poolSpecs, NodePoolSpec{ ID: specMap["id"].(int), Type: specMap["type"].(string), + Tags: helper.ExpandStringSet(specMap["tags"].(*schema.Set)), Count: specMap["count"].(int), AutoScalerEnabled: autoscaler.Enabled, AutoScalerMin: autoscaler.Min, @@ -396,7 +404,7 @@ func expandLinodeLKENodePoolSpecs(pool []interface{}, preserveNoTarget bool) (po return } -func flattenLKENodePools(pools []linodego.LKEClusterPool) []map[string]interface{} { +func flattenLKENodePools(pools []linodego.LKENodePool) []map[string]interface{} { flattened := make([]map[string]interface{}, len(pools)) for i, pool := range pools { @@ -424,6 +432,7 @@ func flattenLKENodePools(pools []linodego.LKEClusterPool) []map[string]interface "id": pool.ID, "count": pool.Count, "type": pool.Type, + "tags": pool.Tags, "nodes": nodes, "autoscaler": autoscaler, } diff --git a/linode/lke/cluster_test.go b/linode/lke/cluster_test.go index 297e18c4c..4e1d93200 100644 --- a/linode/lke/cluster_test.go +++ b/linode/lke/cluster_test.go @@ -38,10 +38,10 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 123, Type: "g6-standard-1", Count: 2}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-1", Count: 3}, + {ID: 123, Type: "g6-standard-1", Count: 3, Tags: []string{"example"}}, }, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 3}, + 123: {Count: 3, Tags: &[]string{"example"}}, }, expectedToCreate: []linodego.LKENodePoolCreateOptions{}, expectedToDelete: []int{}, @@ -67,15 +67,15 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 124, Type: "g6-standard-1", Count: 10}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-1", Count: 9}, // bumped from 1 to 9 - {ID: 124, Type: "g6-standard-2", Count: 10}, // type changed + {ID: 123, Type: "g6-standard-1", Count: 9, Tags: []string{"example"}}, // bumped from 1 to 9 + {ID: 124, Type: "g6-standard-2", Count: 10, Tags: []string{"example"}}, // type changed }, expectedToDelete: []int{124}, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 9}, + 123: {Count: 9, Tags: &[]string{"example"}}, }, expectedToCreate: []linodego.LKENodePoolCreateOptions{ - {Type: "g6-standard-2", Count: 10}, + {Type: "g6-standard-2", Count: 10, Tags: []string{"example"}}, }, }, { @@ -87,15 +87,16 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 127, Type: "g6-standard-3", Count: 2}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-3", Count: 2}, - {ID: 124, Type: "g6-standard-3", Count: 9}, - {ID: 126, Type: "g6-standard-3", Count: 8}, - {ID: 127, Type: "g6-standard-3", Count: 2}, + {ID: 123, Type: "g6-standard-3", Count: 2, Tags: []string{"example"}}, + {ID: 124, Type: "g6-standard-3", Count: 9, Tags: []string{"example"}}, + {ID: 126, Type: "g6-standard-3", Count: 8, Tags: []string{"example"}}, + {ID: 127, Type: "g6-standard-3", Count: 2, Tags: []string{"example"}}, }, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 2}, - 124: {Count: 9}, - 126: {Count: 8}, + 123: {Count: 2, Tags: &[]string{"example"}}, + 124: {Count: 9, Tags: &[]string{"example"}}, + 126: {Count: 8, Tags: &[]string{"example"}}, + 127: {Count: 2, Tags: &[]string{"example"}}, }, expectedToDelete: []int{}, expectedToCreate: []linodego.LKENodePoolCreateOptions{}, @@ -106,10 +107,10 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 123, Type: "g6-standard-3", Count: 3}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 3, AutoScalerMax: 7}, + {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 3, AutoScalerMax: 7, Tags: []string{"example"}}, }, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: true, Min: 3, Max: 7}}, + 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: true, Min: 3, Max: 7}, Tags: &[]string{"example"}}, }, expectedToDelete: []int{}, expectedToCreate: []linodego.LKENodePoolCreateOptions{}, @@ -120,10 +121,10 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 3, AutoScalerMax: 7}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: false}, + {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: false, Tags: []string{"example"}}, }, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: false, Min: 0, Max: 0}}, + 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: false, Min: 0, Max: 0}, Tags: &[]string{"example"}}, }, expectedToDelete: []int{}, expectedToCreate: []linodego.LKENodePoolCreateOptions{}, @@ -134,10 +135,10 @@ func TestReconcileLKENodePoolSpecs(t *testing.T) { {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 3, AutoScalerMax: 7}, }, newSpecs: []lke.NodePoolSpec{ - {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 5, AutoScalerMax: 10}, + {ID: 123, Type: "g6-standard-3", Count: 3, AutoScalerEnabled: true, AutoScalerMin: 5, AutoScalerMax: 10, Tags: []string{"example"}}, }, expectedToUpdate: map[int]linodego.LKENodePoolUpdateOptions{ - 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: true, Min: 5, Max: 10}}, + 123: {Count: 3, Autoscaler: &linodego.LKENodePoolAutoscaler{Enabled: true, Min: 5, Max: 10}, Tags: &[]string{"example"}}, }, expectedToDelete: []int{}, expectedToCreate: []linodego.LKENodePoolCreateOptions{}, diff --git a/linode/lke/datasource_test.go b/linode/lke/datasource_test.go index afcdfa51d..b56a5de7e 100644 --- a/linode/lke/datasource_test.go +++ b/linode/lke/datasource_test.go @@ -32,7 +32,7 @@ func TestAccDataSourceLKECluster_basic(t *testing.T) { resource.TestCheckResourceAttr(dataSourceClusterName, "status", "ready"), resource.TestCheckResourceAttr(dataSourceClusterName, "tags.#", "1"), resource.TestCheckResourceAttr(dataSourceClusterName, "pools.#", "1"), - resource.TestCheckResourceAttr(dataSourceClusterName, "pools.0.type", "g6-standard-2"), + resource.TestCheckResourceAttr(dataSourceClusterName, "pools.0.type", "g6-standard-1"), resource.TestCheckResourceAttr(dataSourceClusterName, "pools.0.count", "3"), resource.TestCheckResourceAttr(dataSourceClusterName, "pools.0.nodes.#", "3"), resource.TestCheckResourceAttr(dataSourceClusterName, "pools.0.autoscaler.#", "0"), diff --git a/linode/lke/framework_models.go b/linode/lke/framework_models.go index a910b1405..da58b18a2 100644 --- a/linode/lke/framework_models.go +++ b/linode/lke/framework_models.go @@ -122,35 +122,30 @@ func (data *LKEDataModel) parseLKEAttributes( } pool.Tags = tags - poolNodes := make([]LKENodePoolNode, len(p.Linodes)) - for j, n := range p.Linodes { - var node LKENodePoolNode - node.ID = types.StringValue(n.ID) - node.InstanceID = types.Int64Value(int64(n.InstanceID)) - node.Status = types.StringValue(string(n.Status)) - - poolNodes[j] = node + pool.Nodes = make([]LKENodePoolNode, len(p.Linodes)) + for i, linode := range p.Linodes { + pool.Nodes[i].ID = types.StringValue(linode.ID) + pool.Nodes[i].InstanceID = types.Int64Value(int64(linode.InstanceID)) + pool.Nodes[i].Status = types.StringValue(string(linode.Status)) } - pool.Nodes = poolNodes // Only parse the autoscaler when it's enabled in order to keep returning // the same list result of SDKv2. if p.Autoscaler.Enabled { - var autoscaler LKENodePoolAutoscaler - autoscaler.Enabled = types.BoolValue(p.Autoscaler.Enabled) - autoscaler.Min = types.Int64Value(int64(p.Autoscaler.Min)) - autoscaler.Max = types.Int64Value(int64(p.Autoscaler.Max)) - pool.Autoscaler = []LKENodePoolAutoscaler{autoscaler} + pool.Autoscaler = []LKENodePoolAutoscaler{ + { + Enabled: types.BoolValue(p.Autoscaler.Enabled), + Min: types.Int64Value(int64(p.Autoscaler.Min)), + Max: types.Int64Value(int64(p.Autoscaler.Max)), + }, + } } - poolDisks := make([]LKENodePoolDisk, len(p.Disks)) - for k, d := range p.Disks { - var poolDisk LKENodePoolDisk - poolDisk.Size = types.Int64Value(int64(d.Size)) - poolDisk.Type = types.StringValue(d.Type) - poolDisks[k] = poolDisk + pool.Disks = make([]LKENodePoolDisk, len(p.Disks)) + for i, d := range p.Disks { + pool.Disks[i].Size = types.Int64Value(int64(d.Size)) + pool.Disks[i].Type = types.StringValue(d.Type) } - pool.Disks = poolDisks lkePools[i] = pool } diff --git a/linode/lke/resource.go b/linode/lke/resource.go index 8c723ab26..0b7111602 100644 --- a/linode/lke/resource.go +++ b/linode/lke/resource.go @@ -177,6 +177,7 @@ func createResource(ctx context.Context, d *schema.ResourceData, meta interface{ createOpts.NodePools = append(createOpts.NodePools, linodego.LKENodePoolCreateOptions{ Type: poolSpec["type"].(string), + Tags: helper.ExpandStringSet(poolSpec["tags"].(*schema.Set)), Count: count, Autoscaler: autoscaler, }) diff --git a/linode/lke/resource_test.go b/linode/lke/resource_test.go index 28426c018..1260618bf 100644 --- a/linode/lke/resource_test.go +++ b/linode/lke/resource_test.go @@ -68,7 +68,7 @@ func init() { k8sVersionPrevious = k8sVersions[len(k8sVersions)-2] } - region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}, "core") if err != nil { log.Fatal(err) } @@ -183,9 +183,11 @@ func TestAccResourceLKECluster_basic_smoke(t *testing.T) { resource.TestCheckResourceAttr(resourceClusterName, "status", "ready"), resource.TestCheckResourceAttr(resourceClusterName, "tags.#", "1"), resource.TestCheckResourceAttr(resourceClusterName, "pool.#", "1"), - resource.TestCheckResourceAttr(resourceClusterName, "pool.0.type", "g6-standard-2"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.type", "g6-standard-1"), resource.TestCheckResourceAttr(resourceClusterName, "pool.0.count", "3"), resource.TestCheckResourceAttr(resourceClusterName, "pool.0.nodes.#", "3"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.#", "1"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.0", "test"), resource.TestCheckResourceAttr(resourceClusterName, "control_plane.#", "1"), resource.TestCheckResourceAttr(resourceClusterName, "control_plane.0.high_availability", "false"), resource.TestCheckResourceAttrSet(resourceClusterName, "id"), @@ -283,6 +285,8 @@ func TestAccResourceLKECluster_basicUpdates(t *testing.T) { resource.TestCheckResourceAttr(resourceClusterName, "label", clusterName), resource.TestCheckResourceAttr(resourceClusterName, "tags.#", "1"), resource.TestCheckResourceAttr(resourceClusterName, "pool.#", "1"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.#", "1"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.0", "test"), resource.TestCheckResourceAttr(resourceClusterName, "pool.0.count", "3"), ), }, @@ -291,6 +295,9 @@ func TestAccResourceLKECluster_basicUpdates(t *testing.T) { Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceClusterName, "label", newClusterName), resource.TestCheckResourceAttr(resourceClusterName, "tags.#", "2"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.#", "2"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.0", "test"), + resource.TestCheckResourceAttr(resourceClusterName, "pool.0.tags.1", "test-2"), resource.TestCheckResourceAttr(resourceClusterName, "pool.0.count", "4"), ), }, diff --git a/linode/lke/schema_resource.go b/linode/lke/schema_resource.go index 037117d38..a3c4356d6 100644 --- a/linode/lke/schema_resource.go +++ b/linode/lke/schema_resource.go @@ -1,8 +1,12 @@ package lke import ( + "strconv" + "strings" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/linode/terraform-provider-linode/v2/linode/helper" ) var resourceSchema = map[string]*schema.Schema{ @@ -79,6 +83,33 @@ var resourceSchema = map[string]*schema.Schema{ Description: "A Linode Type for all of the nodes in the Node Pool.", Required: true, }, + "tags": { + Type: schema.TypeSet, + Description: "A set of tags applied to this node pool.", + + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + DiffSuppressOnRefresh: true, + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + setAttrName := k[0:strings.LastIndex(k, ".")] + + index, err := strconv.Atoi(k[strings.LastIndex(k, "."):]) + if err != nil { + return false + } + + tags := helper.ExpandStringSet(d.Get(setAttrName).(*schema.Set)) + for i, item := range tags { + if i == index { + continue + } + if strings.EqualFold(item, oldValue) || strings.EqualFold(item, newValue) { + return true + } + } + return false + }, + }, "nodes": { Type: schema.TypeList, Elem: &schema.Resource{ diff --git a/linode/lke/tmpl/autoscaler_many_pools.gotf b/linode/lke/tmpl/autoscaler_many_pools.gotf index 93ef113bf..a74b13774 100644 --- a/linode/lke/tmpl/autoscaler_many_pools.gotf +++ b/linode/lke/tmpl/autoscaler_many_pools.gotf @@ -1,16 +1,16 @@ {{ define "lke_cluster_autoscaler_many_pools" }} resource "linode_lke_cluster" "test" { - label = "{{.Label}}" + label = "{{ .Label }}" region = "{{ .Region }}" - k8s_version = "{{.K8sVersion}}" + k8s_version = "{{ .K8sVersion }}" tags = ["test"] pool { autoscaler { min = 3 max = 8 } - type = "g6-standard-4" + type = "g6-standard-1" count = 5 } pool { @@ -18,7 +18,7 @@ resource "linode_lke_cluster" "test" { min = 1 max = 8 } - type = "g6-standard-2" + type = "g6-standard-1" count = 3 } } diff --git a/linode/lke/tmpl/autoscaler_updates.gotf b/linode/lke/tmpl/autoscaler_updates.gotf index 09b0cecb4..41297b2d7 100644 --- a/linode/lke/tmpl/autoscaler_updates.gotf +++ b/linode/lke/tmpl/autoscaler_updates.gotf @@ -10,7 +10,7 @@ resource "linode_lke_cluster" "test" { min = 1 max = 8 } - type = "g6-standard-2" + type = "g6-standard-1" count = 3 } } diff --git a/linode/lke/tmpl/basic.gotf b/linode/lke/tmpl/basic.gotf index 53980a83d..8d55bd96f 100644 --- a/linode/lke/tmpl/basic.gotf +++ b/linode/lke/tmpl/basic.gotf @@ -7,8 +7,9 @@ resource "linode_lke_cluster" "test" { tags = ["test"] pool { - type = "g6-standard-2" + type = "g6-standard-1" count = 3 + tags = ["test"] } } diff --git a/linode/lke/tmpl/many_pools.gotf b/linode/lke/tmpl/many_pools.gotf index 1f1bce9ad..250c3e775 100644 --- a/linode/lke/tmpl/many_pools.gotf +++ b/linode/lke/tmpl/many_pools.gotf @@ -7,12 +7,12 @@ resource "linode_lke_cluster" "test" { tags = ["test"] pool { - type = "g6-standard-2" + type = "g6-standard-1" count = 1 } pool { - type = "g6-standard-2" + type = "g6-standard-1" count = 1 } } diff --git a/linode/lke/tmpl/no_count.gotf b/linode/lke/tmpl/no_count.gotf index cf3e3ed75..3fe976199 100644 --- a/linode/lke/tmpl/no_count.gotf +++ b/linode/lke/tmpl/no_count.gotf @@ -7,7 +7,7 @@ resource "linode_lke_cluster" "test" { k8s_version = "{{.K8sVersion}}" pool { - type = "g6-standard-2" + type = "g6-standard-1" } } diff --git a/linode/lke/tmpl/updates.gotf b/linode/lke/tmpl/updates.gotf index cfa133894..e6e985dbb 100644 --- a/linode/lke/tmpl/updates.gotf +++ b/linode/lke/tmpl/updates.gotf @@ -7,8 +7,9 @@ resource "linode_lke_cluster" "test" { tags = ["test", "new_tag"] pool { - type = "g6-standard-2" + type = "g6-standard-1" count = 4 + tags = ["test", "test-2"] } } diff --git a/linode/lkeclusters/datasource_test.go b/linode/lkeclusters/datasource_test.go index a97f132db..ce4932463 100644 --- a/linode/lkeclusters/datasource_test.go +++ b/linode/lkeclusters/datasource_test.go @@ -44,7 +44,7 @@ func init() { k8sVersionLatest = k8sVersions[len(k8sVersions)-1] - region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/lkenodepool/framework_resource_test.go b/linode/lkenodepool/framework_resource_test.go index c3a508109..c9515b620 100644 --- a/linode/lkenodepool/framework_resource_test.go +++ b/linode/lkenodepool/framework_resource_test.go @@ -62,7 +62,7 @@ func init() { k8sVersion = k8sVersions[len(k8sVersions)-1] - region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"kubernetes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/nb/resource_test.go b/linode/nb/resource_test.go index af516d452..a31f1a2f3 100644 --- a/linode/nb/resource_test.go +++ b/linode/nb/resource_test.go @@ -30,7 +30,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/nbconfig/resource_test.go b/linode/nbconfig/resource_test.go index 2e59dba62..10d8571f7 100644 --- a/linode/nbconfig/resource_test.go +++ b/linode/nbconfig/resource_test.go @@ -24,7 +24,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/nbconfigs/datasource_test.go b/linode/nbconfigs/datasource_test.go index b04c35d61..02175b225 100644 --- a/linode/nbconfigs/datasource_test.go +++ b/linode/nbconfigs/datasource_test.go @@ -19,7 +19,7 @@ func TestAccDataSourceNodeBalancerConfigs_basic(t *testing.T) { resourceName := "data.linode_nodebalancer_configs.foo" nbLabel := acctest.RandomWithPrefix("tf_test") - nbRegion, err := acceptance.GetRandomRegionWithCaps([]string{"NodeBalancers"}) + nbRegion, err := acceptance.GetRandomRegionWithCaps([]string{"NodeBalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/nbnode/resource_test.go b/linode/nbnode/resource_test.go index 2d4fe7b74..9c5b35055 100644 --- a/linode/nbnode/resource_test.go +++ b/linode/nbnode/resource_test.go @@ -21,7 +21,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"nodebalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/nbs/datasource_test.go b/linode/nbs/datasource_test.go index 665d8bbf7..f7c0c7093 100644 --- a/linode/nbs/datasource_test.go +++ b/linode/nbs/datasource_test.go @@ -18,7 +18,7 @@ func TestAccDataSourceNodeBalancers_basic(t *testing.T) { resourceName := "data.linode_nodebalancers.nbs" nbLabel := acctest.RandomWithPrefix("tf_test") - nbRegion, err := acceptance.GetRandomRegionWithCaps([]string{"NodeBalancers"}) + nbRegion, err := acceptance.GetRandomRegionWithCaps([]string{"NodeBalancers"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/networkingip/datasource_test.go b/linode/networkingip/datasource_test.go index c1079b04d..ae4b2e013 100644 --- a/linode/networkingip/datasource_test.go +++ b/linode/networkingip/datasource_test.go @@ -16,7 +16,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/obj/resource.go b/linode/obj/resource.go index baf8c1a46..5c44d376a 100644 --- a/linode/obj/resource.go +++ b/linode/obj/resource.go @@ -52,14 +52,21 @@ func readResource(ctx context.Context, d *schema.ResourceData, meta any) diag.Di key := d.Get("key").(string) objKeys, diags, teardownKeysCleanUp := GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_only") - if teardownKeysCleanUp != nil { - defer teardownKeysCleanUp() - } - if diags != nil { + // Check if the error is due to the bucket being not found + if helper.IsBucketNotFoundError(fmt.Errorf(diags[0].Summary)) { + d.SetId("") + tflog.Warn(ctx, + "couldn't find the bucket, removing the object from the TF state") + return nil + } return diags } + if teardownKeysCleanUp != nil { + defer teardownKeysCleanUp() + } + s3client, err := helper.S3ConnectionFromData(ctx, d, meta, objKeys.AccessKey, objKeys.SecretKey) if err != nil { return diag.FromErr(err) diff --git a/linode/obj/resource_test.go b/linode/obj/resource_test.go index 805cff04b..95de2fa79 100644 --- a/linode/obj/resource_test.go +++ b/linode/obj/resource_test.go @@ -26,7 +26,7 @@ var ( ) func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/obj/schema_resource.go b/linode/obj/schema_resource.go index f0256a57e..b21fbdfca 100644 --- a/linode/obj/schema_resource.go +++ b/linode/obj/schema_resource.go @@ -14,8 +14,10 @@ var resourceSchema = map[string]*schema.Schema{ ForceNew: true, }, "cluster": { - Type: schema.TypeString, - Description: "The target cluster that the bucket is in.", + Type: schema.TypeString, + Description: "The target cluster that the bucket is in.", + Deprecated: "The cluster attribute has been deprecated, please consider switching to the region attribute. " + + "For example, a cluster value of `us-mia-1` can be translated to a region value of `us-mia`.", Optional: true, ForceNew: true, ExactlyOneOf: []string{"cluster", "region"}, diff --git a/linode/objbucket/framework_datasource_schema.go b/linode/objbucket/framework_datasource_schema.go index e0cb1a768..85e8f2bcf 100644 --- a/linode/objbucket/framework_datasource_schema.go +++ b/linode/objbucket/framework_datasource_schema.go @@ -11,8 +11,11 @@ var frameworkDatasourceSchema = schema.Schema{ Attributes: map[string]schema.Attribute{ "cluster": schema.StringAttribute{ Description: "The ID of the Object Storage Cluster this bucket is in.", - Optional: true, - Computed: true, + DeprecationMessage: "The cluster attribute has been deprecated, please consider " + + "switching to the region attribute. For example, a cluster value of `us-mia-1` " + + "can be translated to a region value of `us-mia`.", + Optional: true, + Computed: true, Validators: []validator.String{ stringvalidator.ExactlyOneOf( path.MatchRelative().AtParent().AtName("region"), diff --git a/linode/objbucket/resource_test.go b/linode/objbucket/resource_test.go index 92fa3562d..2671a5b24 100644 --- a/linode/objbucket/resource_test.go +++ b/linode/objbucket/resource_test.go @@ -45,7 +45,7 @@ var ( ) func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/objkey/resource_test.go b/linode/objkey/frameowkr_resource_test.go similarity index 96% rename from linode/objkey/resource_test.go rename to linode/objkey/frameowkr_resource_test.go index 66c5547ae..1ec80623d 100644 --- a/linode/objkey/resource_test.go +++ b/linode/objkey/frameowkr_resource_test.go @@ -30,7 +30,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Object Storage"}, "core") if err != nil { log.Fatal(err) } @@ -117,6 +117,8 @@ func TestAccResourceObjectKey_limited_cluster(t *testing.T) { resource.TestCheckResourceAttr(resName, "bucket_access.1.region", testRegion), resource.TestCheckResourceAttr(resName, "bucket_access.0.permissions", "read_only"), resource.TestCheckResourceAttr(resName, "bucket_access.1.permissions", "read_write"), + resource.TestCheckResourceAttr(resName, "regions.#", "1"), + resource.TestCheckResourceAttr(resName, "regions.0", testRegion), ), }, }, @@ -152,6 +154,8 @@ func TestAccResourceObjectKey_limited(t *testing.T) { resource.TestCheckResourceAttr(resName, "bucket_access.1.region", testRegion), resource.TestCheckResourceAttr(resName, "bucket_access.0.permissions", "read_only"), resource.TestCheckResourceAttr(resName, "bucket_access.1.permissions", "read_write"), + resource.TestCheckResourceAttr(resName, "regions.#", "1"), + resource.TestCheckResourceAttr(resName, "regions.0", testRegion), ), }, }, diff --git a/linode/objkey/framework_config_validation.go b/linode/objkey/framework_config_validation.go deleted file mode 100644 index 3fbcb2bae..000000000 --- a/linode/objkey/framework_config_validation.go +++ /dev/null @@ -1,46 +0,0 @@ -package objkey - -import ( - "context" - - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/linode/terraform-provider-linode/v2/linode/helper" -) - -func (r Resource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { - var config ResourceModel - - resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) - - if resp.Diagnostics.HasError() { - return - } - - validateRegionsAgainstBucketAccessEntries(ctx, config, &resp.Diagnostics) -} - -func validateRegionsAgainstBucketAccessEntries(ctx context.Context, config ResourceModel, diags *diag.Diagnostics) { - // regions will be computed if not configured, so it's okay to be null. - if config.BucketAccess == nil || config.Regions.IsNull() { - return - } - - var regions []string - var bucketRegions []string - - config.Regions.ElementsAs(ctx, ®ions, true) - - for _, ba := range config.BucketAccess { - bucketRegions = append(bucketRegions, ba.Region.ValueString()) - } - - if !helper.ValidateStringSubset(regions, bucketRegions) { - diags.AddAttributeError( - path.Root("regions"), - "Incomplete Regions", - "All regions of the buckets defined in `bucket_access` blocks are expected in the `regions` attribute.", - ) - } -} diff --git a/linode/objkey/framework_model.go b/linode/objkey/framework_model.go index 72accf9d4..c23563811 100644 --- a/linode/objkey/framework_model.go +++ b/linode/objkey/framework_model.go @@ -52,6 +52,27 @@ func (plan ResourceModel) GetUpdateOptions( return } +func (plan ResourceModel) GetCreateOptions(ctx context.Context) (opts linodego.ObjectStorageKeyCreateOptions) { + opts.Label = plan.Label.ValueString() + + if plan.BucketAccess != nil { + accessSlice := make( + []linodego.ObjectStorageKeyBucketAccess, + len(plan.BucketAccess), + ) + + for i, v := range plan.BucketAccess { + accessSlice[i] = v.toLinodeObject() + } + + opts.BucketAccess = &accessSlice + } + + plan.Regions.ElementsAs(ctx, &opts.Regions, false) + + return +} + func getObjectStorageKeyRegionIDs(regions []linodego.ObjectStorageKeyRegion) []string { regionIDs := make([]string, len(regions)) for i, r := range regions { diff --git a/linode/objkey/framework_resource.go b/linode/objkey/framework_resource.go index 108e5097b..982ded767 100644 --- a/linode/objkey/framework_resource.go +++ b/linode/objkey/framework_resource.go @@ -35,30 +35,20 @@ func (r *Resource) Create( resp *resource.CreateResponse, ) { tflog.Debug(ctx, "Create linode_object_storage_key") - var data ResourceModel + var plan ResourceModel client := r.Meta.Client - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } - createOpts := linodego.ObjectStorageKeyCreateOptions{ - Label: data.Label.ValueString(), + validateRegionsAgainstBucketAccesses(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return } - if data.BucketAccess != nil { - accessSlice := make( - []linodego.ObjectStorageKeyBucketAccess, - len(data.BucketAccess), - ) - - for i, v := range data.BucketAccess { - accessSlice[i] = v.toLinodeObject() - } - - createOpts.BucketAccess = &accessSlice - } + createOpts := plan.GetCreateOptions(ctx) tflog.Debug(ctx, "client.CreateObjectStorageKey(...)", map[string]any{ "options": createOpts, @@ -77,13 +67,13 @@ func (r *Resource) Create( "label": key.Label, }) - data.FlattenObjectStorageKey(ctx, key, true, &resp.Diagnostics) + plan.FlattenObjectStorageKey(ctx, key, true, &resp.Diagnostics) // IDs should always be overridden during creation (see #1085) // TODO: Remove when Crossplane empty string ID issue is resolved - data.ID = types.StringValue(strconv.Itoa(key.ID)) + plan.ID = types.StringValue(strconv.Itoa(key.ID)) - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *Resource) Read( @@ -152,6 +142,11 @@ func (r *Resource) Update( return } + validateRegionsAgainstBucketAccesses(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + ctx = helper.SetLogFieldBulk(ctx, map[string]any{ "key_id": state.ID.ValueString(), "label": state.Label.ValueString(), diff --git a/linode/objkey/framework_resource_schema.go b/linode/objkey/framework_resource_schema.go index b4f92689d..333fed30e 100644 --- a/linode/objkey/framework_resource_schema.go +++ b/linode/objkey/framework_resource_schema.go @@ -1,6 +1,7 @@ package objkey import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/path" @@ -61,6 +62,7 @@ var frameworkResourceSchema = schema.Schema{ Optional: true, Computed: true, ElementType: types.StringType, + Validators: []validator.Set{setvalidator.SizeAtLeast(1)}, }, "regions_details": schema.SetAttribute{ Description: "A set of objects containing the detailed info of the regions where " + diff --git a/linode/objkey/helpers.go b/linode/objkey/helpers.go new file mode 100644 index 000000000..f511a8abd --- /dev/null +++ b/linode/objkey/helpers.go @@ -0,0 +1,61 @@ +package objkey + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +func getRegionFromCluster(cluster string) (string, error) { + s := strings.Split(cluster, "-") + if len(s) <= 2 { + return "", fmt.Errorf("failed to parse cluster %q", cluster) + } + return strings.Join(s[:2], "-"), nil +} + +func validateRegionsAgainstBucketAccesses(ctx context.Context, plan ResourceModel, diags *diag.Diagnostics) { + // regions will be computed if not configured, so it's okay to be null or unknown. + if plan.BucketAccess == nil || plan.Regions.IsNull() || plan.Regions.IsUnknown() { + return + } + + var regions []string + var bucketRegions []string + + plan.Regions.ElementsAs(ctx, ®ions, true) + + for _, ba := range plan.BucketAccess { + var bucketRegion string + var err error + + if ba.Region.IsNull() || ba.Region.IsUnknown() { + bucketRegion, err = getRegionFromCluster(ba.Cluster.ValueString()) + if err != nil { + diags.AddWarning("Failed to Parse Cluster", err.Error()) + continue + } + } else { + bucketRegion = ba.Region.ValueString() + } + + if !slices.Contains(bucketRegions, bucketRegion) { + bucketRegions = append(bucketRegions, bucketRegion) + } + } + + if !helper.ValidateStringSubset(regions, bucketRegions) { + diags.AddAttributeError( + path.Root("regions"), + "Incomplete Regions", + "All regions of the buckets defined in `bucket_access` blocks are expected in the `regions` set attribute.\n"+ + fmt.Sprintf("Regions in the `regions` set attribute: %v\n", regions)+ + fmt.Sprintf("Regions in the `bucket_access` blocks: %v\n", bucketRegions), + ) + } +} diff --git a/linode/objkey/tmpl/limited.gotf b/linode/objkey/tmpl/limited.gotf index fa2705fa3..6ddb5a06d 100644 --- a/linode/objkey/tmpl/limited.gotf +++ b/linode/objkey/tmpl/limited.gotf @@ -1,33 +1,36 @@ {{ define "object_key_limited" }} resource "linode_object_storage_bucket" "foobar" { - {{if .Region }} - region = "{{.Region}}" - {{else}} + {{ if .Region }} + region = "{{ .Region }}" + {{ else }} cluster = "{{ .Cluster }}" - {{end}} - label = "{{.Label}}-bucket" + {{ end }} + label = "{{ .Label }}-bucket" } resource "linode_object_storage_key" "foobar" { - label = "{{.Label}}_key" + label = "{{ .Label }}_key" bucket_access { - bucket_name = "{{.Label}}-bucket" + bucket_name = "{{ .Label }}-bucket" {{if .Region }} - region = "{{.Region}}" - {{else}} + region = "{{ .Region }}" + {{ else }} cluster = "{{ .Cluster }}" - {{end}} + {{ end }} permissions = "read_only" } bucket_access { bucket_name = linode_object_storage_bucket.foobar.label - {{if .Region }} + {{ if .Region }} region = linode_object_storage_bucket.foobar.region - {{else}} + {{ else }} cluster = linode_object_storage_bucket.foobar.cluster - {{end}} + {{ end }} permissions = "read_write" } + {{if .Region }} + regions = [ "{{ .Region }}" ] + {{ end }} } {{ end }} \ No newline at end of file diff --git a/linode/placementgroup/resource_test.go b/linode/placementgroup/resource_test.go index d3f5990b7..65dfb5fb3 100644 --- a/linode/placementgroup/resource_test.go +++ b/linode/placementgroup/resource_test.go @@ -27,7 +27,7 @@ func init() { }) var err error - testRegion, err = acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}) + testRegion, err = acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) } diff --git a/linode/placementgroupassignment/resource_test.go b/linode/placementgroupassignment/resource_test.go index 9027d9ad8..5431faddf 100644 --- a/linode/placementgroupassignment/resource_test.go +++ b/linode/placementgroupassignment/resource_test.go @@ -19,7 +19,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/placementgroups/datasource_test.go b/linode/placementgroups/datasource_test.go index 6d36b521e..7a5865b91 100644 --- a/linode/placementgroups/datasource_test.go +++ b/linode/placementgroups/datasource_test.go @@ -21,7 +21,7 @@ func TestAccDataSourcePlacementGroups_basic(t *testing.T) { baseLabel := acctest.RandomWithPrefix("tf-test") - testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}) + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"Placement Group"}, "core") if err != nil { t.Error(fmt.Errorf("failed to get region with PG capability: %w", err)) } diff --git a/linode/rdns/resource_test.go b/linode/rdns/resource_test.go index 45ecc410a..0b0ab9a2a 100644 --- a/linode/rdns/resource_test.go +++ b/linode/rdns/resource_test.go @@ -25,7 +25,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/region/datasource_test.go b/linode/region/datasource_test.go index 95b301842..a379ed68a 100644 --- a/linode/region/datasource_test.go +++ b/linode/region/datasource_test.go @@ -18,7 +18,7 @@ var ( ) func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/vlan/datasource_test.go b/linode/vlan/datasource_test.go index 5a874de44..0143fd516 100644 --- a/linode/vlan/datasource_test.go +++ b/linode/vlan/datasource_test.go @@ -20,7 +20,7 @@ import ( var testRegion string func init() { - region, err := acceptance.GetRandomRegionWithCaps([]string{"vlans"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"vlans"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/volume/resource_test.go b/linode/volume/resource_test.go index 232fcc0a3..c117f4f6b 100644 --- a/linode/volume/resource_test.go +++ b/linode/volume/resource_test.go @@ -24,7 +24,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"Block Storage"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Block Storage"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/volumes/datasource_test.go b/linode/volumes/datasource_test.go index b5f1ccd16..baacaa094 100644 --- a/linode/volumes/datasource_test.go +++ b/linode/volumes/datasource_test.go @@ -22,7 +22,7 @@ func init() { F: sweep, }) - region, err := acceptance.GetRandomRegionWithCaps([]string{"Block Storage"}) + region, err := acceptance.GetRandomRegionWithCaps([]string{"Block Storage"}, "core") if err != nil { log.Fatal(err) } diff --git a/linode/vpc/resource_test.go b/linode/vpc/resource_test.go index 22afc82d8..31a884941 100644 --- a/linode/vpc/resource_test.go +++ b/linode/vpc/resource_test.go @@ -29,7 +29,7 @@ func init() { var err error - testRegion, err = acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + testRegion, err = acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) } diff --git a/linode/vpcs/datasource_test.go b/linode/vpcs/datasource_test.go index f2cc6726e..075e6e55e 100644 --- a/linode/vpcs/datasource_test.go +++ b/linode/vpcs/datasource_test.go @@ -18,7 +18,7 @@ func TestAccDataSourceVPCs_basic_smoke(t *testing.T) { resourceName := "data.linode_vpcs.foobar" vpcLabel := acctest.RandomWithPrefix("tf-test") - testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { t.Error(fmt.Errorf("failed to get region with VPC capability: %w", err)) } @@ -46,7 +46,7 @@ func TestAccDataSourceVPCs_filterByLabel(t *testing.T) { resourceName := "data.linode_vpcs.foobar" vpcLabel := acctest.RandomWithPrefix("tf-test") - testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) } diff --git a/linode/vpcsubnet/resource_test.go b/linode/vpcsubnet/resource_test.go index 58f60b80f..4fea47abc 100644 --- a/linode/vpcsubnet/resource_test.go +++ b/linode/vpcsubnet/resource_test.go @@ -22,7 +22,7 @@ import ( var testRegion string func init() { - r, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + r, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) } diff --git a/linode/vpcsubnets/datasource_test.go b/linode/vpcsubnets/datasource_test.go index 444957a8b..8d1b6f249 100644 --- a/linode/vpcsubnets/datasource_test.go +++ b/linode/vpcsubnets/datasource_test.go @@ -18,7 +18,7 @@ func TestAccDataSourceVPCSubnets_basic_smoke(t *testing.T) { resourceName := "data.linode_vpc_subnets.foobar" vpcLabel := acctest.RandomWithPrefix("tf-test") - testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) } @@ -51,7 +51,7 @@ func TestAccDataSourceVPCSubnets_filterByLabel(t *testing.T) { resourceName := "data.linode_vpc_subnets.foobar" vpcLabel := acctest.RandomWithPrefix("tf-test") - testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}) + testRegion, err := acceptance.GetRandomRegionWithCaps([]string{"VPCs"}, "core") if err != nil { log.Fatal(fmt.Errorf("Error getting region: %s", err)) }