diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..f44a9a603 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "dev", "main", "proj/*" ] + pull_request: + branches: [ "dev", "main", "proj/*" ] + schedule: + - cron: '39 0 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/gosec_pr.yml b/.github/workflows/gosec_pr.yml deleted file mode 100644 index 0ab0c7e15..000000000 --- a/.github/workflows/gosec_pr.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Gosec Scan -on: - pull_request: null - -jobs: - gosec_scan: - runs-on: ubuntu-latest - env: - GO111MODULE: on - steps: - - name: Checkout Source - uses: actions/checkout@v4 - - name: Run Gosec Security Scanner - uses: securego/gosec@master - with: - args: -exclude=G104 ./... diff --git a/.github/workflows/nightly_smoke_tests.yml b/.github/workflows/nightly_smoke_tests.yml index dc1cf1ebe..a4cf26d56 100644 --- a/.github/workflows/nightly_smoke_tests.yml +++ b/.github/workflows/nightly_smoke_tests.yml @@ -41,62 +41,34 @@ jobs: LINODE_TOKEN: ${{ secrets.DX_LINODE_TOKEN }} - name: Notify Slack - if: always() + if: always() && github.repository == 'linode/terraform-provider-linode' uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" - }, - { - "type": "mrkdwn", - "text": "*Branch:*\n`${{ github.ref_name }}`" - } - ] - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" - }, - { - "type": "mrkdwn", - "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" - } - ] - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index aa3a1c8c5..2489c5045 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,4 +1,4 @@ -name: Pull Request +name: CI for Pull Requests on: pull_request jobs: tests: @@ -22,3 +22,28 @@ jobs: run: go mod tidy - name: Fail if changes run: git diff-index --exit-code HEAD + + dependency-review: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: 'Checkout repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + with: + comment-summary-in-pr: on-failure + + gosec_scan: + runs-on: ubuntu-latest + env: + GO111MODULE: on + steps: + - name: Checkout Source + uses: actions/checkout@v4 + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: -exclude=G104 ./... diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 7b247ef33..04b3ea7c9 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -2,7 +2,7 @@ name: Unit Tests on: workflow_dispatch: null push: - pull_request: + jobs: unit_tests: runs-on: ubuntu-latest diff --git a/docs/index.md b/docs/index.md index 1d94bc100..d90f498ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,17 +84,17 @@ This section outlines commonly used provider configuration options. The Linode API CA file path can also be specified using the `LINODE_CA` environment variable. -* `obj_access_key` - (Optional) The access key to be used in [linode_object_storage_bucket](/docs/resources/object_storage_bucket.md) and [linode_object_storage_object](/docs/resources/object_storage_object.md). +* `obj_access_key` - (Optional) The access key to be used in [linode_object_storage_bucket](resources/object_storage_bucket.md) and [linode_object_storage_object](resources/object_storage_object.md). The Object Access Key can also be specified using the `LINODE_OBJ_ACCESS_KEY` shell environment variable. -* `obj_secret_key` - (Optional) The secret key to be used in [linode_object_storage_bucket](/docs/resources/object_storage_bucket.md) and [linode_object_storage_object](/docs/resources/object_storage_object.md). +* `obj_secret_key` - (Optional) The secret key to be used in [linode_object_storage_bucket](resources/object_storage_bucket.md) and [linode_object_storage_object](resources/object_storage_object.md). The Object Secret Key can also be specified using the `LINODE_OBJ_SECRET_KEY` shell environment variable. -* `obj_use_temp_keys` - (Optional) If true, temporary object keys will be created implicitly at apply-time for the [linode_object_storage_bucket](/docs/resources/object_storage_bucket.md) and [linode_object_storage_object](/docs/resources/object_storage_object.md) resource to use. +* `obj_use_temp_keys` - (Optional) If true, temporary object keys will be created implicitly at apply-time for the [linode_object_storage_bucket](resources/object_storage_bucket.md) and [linode_object_storage_object](resources/object_storage_object.md) resource to use. -* `obj_bucket_force_delete` - (Optional) If true, all objects and versions will purged from a [linode_object_storage_bucket](/docs/resources/object_storage_bucket.md) before it is destroyed. +* `obj_bucket_force_delete` - (Optional) If true, all objects and versions will purged from a [linode_object_storage_bucket](resources/object_storage_bucket.md) before it is destroyed. * `skip_instance_ready_poll` - (Optional) Skip waiting for a linode_instance resource to be running. diff --git a/docs/resources/image.md b/docs/resources/image.md index 88ff02066..57665b092 100644 --- a/docs/resources/image.md +++ b/docs/resources/image.md @@ -63,7 +63,6 @@ resource "linode_image" "foobar" { 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"] } ``` @@ -78,7 +77,7 @@ The following arguments are supported: * `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. +* `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. 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. diff --git a/docs/resources/object_storage_bucket.md b/docs/resources/object_storage_bucket.md index c57f69a92..686fa60ee 100644 --- a/docs/resources/object_storage_bucket.md +++ b/docs/resources/object_storage_bucket.md @@ -37,7 +37,7 @@ resource "linode_object_storage_bucket" "mybucket" { access_key = linode_object_storage_key.mykey.access_key secret_key = linode_object_storage_key.mykey.secret_key - cluster = "us-east-1" + region = "us-mia" label = "mybucket" lifecycle_rule { @@ -63,7 +63,7 @@ provider "linode" { resource "linode_object_storage_bucket" "mybucket" { # no need to specify the keys with the resource - cluster = "us-east-1" + region = "us-mia" label = "mybucket" lifecycle_rule { @@ -81,7 +81,7 @@ provider "linode" { resource "linode_object_storage_bucket" "mybucket" { # no need to specify the keys with the resource - cluster = "us-east-1" + region = "us-mia" label = "mybucket" lifecycle_rule { diff --git a/docs/resources/object_storage_object.md b/docs/resources/object_storage_object.md index cb3798d14..5ec88166b 100644 --- a/docs/resources/object_storage_object.md +++ b/docs/resources/object_storage_object.md @@ -15,7 +15,7 @@ Provides a Linode Object Storage Object resource. This can be used to create, mo ```hcl resource "linode_object_storage_object" "object" { bucket = "my-bucket" - cluster = "us-east-1" + region = "us-mia" key = "my-object" secret_key = linode_object_storage_key.my_key.secret_key @@ -31,7 +31,7 @@ resource "linode_object_storage_object" "object" { ```hcl resource "linode_object_storage_object" "object" { bucket = "my-bucket" - cluster = "us-east-1" + region = "us-mia" key = "my-object" secret_key = linode_object_storage_key.my_key.secret_key @@ -55,7 +55,7 @@ provider "linode" { resource "linode_object_storage_object" "object" { # no need to specify the keys with the resource bucket = "my-bucket" - cluster = "us-east-1" + region = "us-mia" key = "my-object" source = pathexpand("~/files/log.txt") } @@ -71,7 +71,7 @@ provider "linode" { resource "linode_object_storage_object" "object" { # no need to specify the keys with the resource bucket = "my-bucket" - cluster = "us-east-1" + region = "us-mia" key = "my-object" source = pathexpand("~/files/log.txt") } diff --git a/go.mod b/go.mod index d36f47053..30f078d5b 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.22.0 toolchain go1.22.5 require ( - github.com/aws/aws-sdk-go-v2 v1.32.5 + github.com/aws/aws-sdk-go-v2 v1.32.7 github.com/aws/aws-sdk-go-v2/config v1.28.5 github.com/aws/aws-sdk-go-v2/credentials v1.17.46 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 - github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0 github.com/aws/smithy-go v1.22.1 - github.com/go-resty/resty/v2 v2.15.3 + github.com/go-resty/resty/v2 v2.16.2 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 @@ -20,34 +20,34 @@ require ( github.com/hashicorp/terraform-plugin-framework-nettypes v0.2.0 github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 github.com/hashicorp/terraform-plugin-framework-timetypes v0.5.0 - github.com/hashicorp/terraform-plugin-framework-validators v0.15.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.16.0 github.com/hashicorp/terraform-plugin-go v0.25.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-mux v0.17.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 - github.com/hashicorp/terraform-plugin-testing v1.10.0 - github.com/linode/linodego v1.43.0 + github.com/hashicorp/terraform-plugin-testing v1.11.0 + github.com/linode/linodego v1.44.1 github.com/linode/linodego/k8s v1.25.2 - github.com/stretchr/testify v1.9.0 - golang.org/x/crypto v0.29.0 - golang.org/x/net v0.31.0 - golang.org/x/sync v0.9.0 + github.com/stretchr/testify v1.10.0 + golang.org/x/crypto v0.32.0 + golang.org/x/net v0.34.0 + golang.org/x/sync v0.10.0 ) require ( github.com/ProtonMail/go-crypto v1.1.0-alpha.2 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect @@ -62,6 +62,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -72,7 +73,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/hc-install v0.9.0 // indirect - github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect github.com/hashicorp/terraform-json v0.23.0 // indirect @@ -101,10 +102,10 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.15.0 // indirect golang.org/x/mod v0.21.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/term v0.26.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.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 diff --git a/go.sum b/go.sum index 84a26796f..3895611a6 100644 --- a/go.sum +++ b/go.sum @@ -9,10 +9,10 @@ github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= -github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 h1:pT3hpW0cOHRJx8Y0DfJUEQuqPild8jRGmSFmBgvydr0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6/go.mod h1:j/I2++U0xX+cr44QjHay4Cvxj6FUbnxrgmqN3H1jTZA= +github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw= +github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc= github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= @@ -21,24 +21,24 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUW github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4 h1:6eKRM6fgeXG4krRO9XKz755vuRhT5UyB9M1W6vjA3JU= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.4/go.mod h1:h0TjcRi+nTob6fksqubKOe+Hra8uqfgmN+vuw4xRwWE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23 h1:1SZBDiRzzs3sNhOMVApyWPduWYGAX0imGy06XiBnCAM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23/go.mod h1:i9TkxgbZmHVh2S0La6CAXtnyFhlCX/pJ0JsOvBAS6Mk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26 h1:GeNJsIFHB+WW5ap2Tec4K6dzcVTsRbsT1Lra46Hv9ME= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.26/go.mod h1:zfgMpwHDXX2WGoG84xG2H+ZlPTkJUU4YUvx2svLQYWo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4 h1:aaPpoG15S2qHkWm4KlEyF01zovK1nW4BBbyXuHNSE90= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4/go.mod h1:eD9gS2EARTKgGr/W5xwgY/ik9z/zqpW+m/xOQbVxrMk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4 h1:E5ZAVOmI2apR8ADb72Q63KqwwwdW1XcMeXIlrZ1Psjg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4/go.mod h1:wezzqVUOVVdk+2Z/JzQT4NxAU0NbhRe5W8pIE72jsWI= -github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0 h1:SwaJ0w0MOp0pBTIKTamLVeTKD+iOWyNJRdJ2KCQRg6Q= -github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0/go.mod h1:TMhLIyRIyoGVlaEMAt+ITMbwskSTpcGsCPDq91/ihY0= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7 h1:tB4tNw83KcajNAzaIMhkhVI2Nt8fAZd5A5ro113FEMY= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.7/go.mod h1:lvpyBGkZ3tZ9iSsUIcC2EWp+0ywa7aK3BLT+FwZi+mQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7 h1:Hi0KGbrnr57bEHWM0bJ1QcBzxLrL/k2DHvGYhb8+W1w= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.7/go.mod h1:wKNgWgExdjjrm4qvfbTorkvocEstaoDl4WCvGfeCy9c= +github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0 h1:SAfh4pNx5LuTafKKWR02Y+hL3A+3TX8cTKG1OIAJaBk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.72.0/go.mod h1:r+xl5yzMk9083rMR+sJ5TYj9Tihvf/l1oxzZXDgGj2Q= github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= @@ -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.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= -github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= +github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= +github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= 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= @@ -97,9 +97,12 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -131,8 +134,8 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6eLhghE= github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= -github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= -github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= @@ -147,8 +150,8 @@ github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 h1:gm5b1kHgFFhaK github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1/go.mod h1:MsjL1sQ9L7wGwzJ5RjcI6FzEMdyoBnw+XK8ZnOvQOLY= 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.15.0 h1:RXMmu7JgpFjnI1a5QjMCBb11usrW2OtAG+iOTIj5c9Y= -github.com/hashicorp/terraform-plugin-framework-validators v0.15.0/go.mod h1:Bh89/hNmqsEWug4/XWKYBwtnw3tbz5BAy1L1OgvbIaY= +github.com/hashicorp/terraform-plugin-framework-validators v0.16.0 h1:O9QqGoYDzQT7lwTXUsZEtgabeWW96zUBh47Smn2lkFA= +github.com/hashicorp/terraform-plugin-framework-validators v0.16.0/go.mod h1:Bh89/hNmqsEWug4/XWKYBwtnw3tbz5BAy1L1OgvbIaY= github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -157,8 +160,8 @@ github.com/hashicorp/terraform-plugin-mux v0.17.0 h1:/J3vv3Ps2ISkbLPiZOLspFcIZ0v github.com/hashicorp/terraform-plugin-mux v0.17.0/go.mod h1:yWuM9U1Jg8DryNfvCp+lH70WcYv6D8aooQxxxIzFDsE= github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 h1:wyKCCtn6pBBL46c1uIIBNUOWlNfYXfXpVo16iDyLp8Y= github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0/go.mod h1:B0Al8NyYVr8Mp/KLwssKXG1RqnTk7FySqSn4fRuLNgw= -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-plugin-testing v1.11.0 h1:MeDT5W3YHbONJt2aPQyaBsgQeAIckwPX41EUHXEn29A= +github.com/hashicorp/terraform-plugin-testing v1.11.0/go.mod h1:WNAHQ3DcgV/0J+B15WTE6hDvxcUdkPPpnB1FR3M910U= 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= @@ -189,8 +192,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.43.0 h1:sGeBB3caZt7vKBoPS5p4AVzmlG4JoqQOdigIibx3egk= -github.com/linode/linodego v1.43.0/go.mod h1:n4TMFu1UVNala+icHqrTEFFaicYSF74cSAUG5zkTwfA= +github.com/linode/linodego v1.44.1 h1:+O1KUjJLe4Y6hVXFgN4+VZh+06JaPTsZHonup/pHPN0= +github.com/linode/linodego v1.44.1/go.mod h1:gbgZweiU1LFyaCKI12wUlwDTeha/JTGruoKj751Ix5Q= 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= @@ -247,8 +250,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -269,8 +272,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk 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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 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= @@ -282,17 +285,17 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.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.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.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= @@ -307,19 +310,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 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= 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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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= diff --git a/linode/framework_provider.go b/linode/framework_provider.go index 3da7bdd5b..ebc82c36a 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -54,7 +54,10 @@ import ( "github.com/linode/terraform-provider-linode/v2/linode/nbs" "github.com/linode/terraform-provider-linode/v2/linode/nbtypes" "github.com/linode/terraform-provider-linode/v2/linode/networkingip" + "github.com/linode/terraform-provider-linode/v2/linode/networkingipassignment" + "github.com/linode/terraform-provider-linode/v2/linode/networkingips" "github.com/linode/terraform-provider-linode/v2/linode/networktransferprices" + "github.com/linode/terraform-provider-linode/v2/linode/obj" "github.com/linode/terraform-provider-linode/v2/linode/objbucket" "github.com/linode/terraform-provider-linode/v2/linode/objcluster" "github.com/linode/terraform-provider-linode/v2/linode/objkey" @@ -111,6 +114,7 @@ func (p *FrameworkProvider) Metadata( resp *provider.MetadataResponse, ) { resp.TypeName = "linode" + resp.Version = p.ProviderVersion } func (p *FrameworkProvider) Schema( @@ -232,6 +236,9 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res volume.NewResource, vpc.NewResource, vpcsubnet.NewResource, + networkingip.NewResource, + networkingipassignment.NewResource, + obj.NewResource, } } @@ -300,5 +307,6 @@ func (p *FrameworkProvider) DataSources(ctx context.Context) []func() datasource placementgroups.NewDataSource, childaccount.NewDataSource, childaccounts.NewDataSource, + networkingips.NewDataSource, } } diff --git a/linode/framework_provider_config.go b/linode/framework_provider_config.go index e9d3f115f..ceb3452a2 100644 --- a/linode/framework_provider_config.go +++ b/linode/framework_provider_config.go @@ -34,14 +34,25 @@ func (fp *FrameworkProvider) Configure( return } + meta.Config = &data + fp.HandleDefaults(&data, &resp.Diagnostics) if resp.Diagnostics.HasError() { return } - fp.InitProvider(ctx, &data, req.TerraformVersion, &resp.Diagnostics, &meta) - if resp.Diagnostics.HasError() { - return + if fp.Meta != nil && fp.Meta.Client != nil { + // Crossplane provider-linode expects to use a single configured instance of the linode client across all invocations + // However, due to how upjet operates, the configureProvider() gets invoked on every resource call. To preserve the client, + // see if the fp.Meta.Client is already initialized, and if so, re-use it. + + tflog.Info(ctx, "Linode client was already configured, re-using..") + meta.Client = fp.Meta.Client + } else { + meta.Client = fp.InitLinodeClient(ctx, &data, req.TerraformVersion, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } } resp.ResourceData = &meta @@ -218,24 +229,12 @@ func (fp *FrameworkProvider) HandleDefaults( } } -func (fp *FrameworkProvider) InitProvider( +func (fp *FrameworkProvider) InitLinodeClient( ctx context.Context, lpm *helper.FrameworkProviderModel, tfVersion string, diags *diag.Diagnostics, - meta *helper.FrameworkProviderMeta, -) { - if fp.Meta != nil && fp.Meta.Client != nil { - // Crossplane provider-linode expects to use a single configured instance of the linode client across all invocations - // However, due to how upjet operates, the configureProvider() gets invoked on every resource call. To preserve the client, - // see if the fp.Meta.Client is already initialized, and if so, re-use it. - - tflog.Info(ctx, "Linode client was already configured, re-using..") - meta.Client = fp.Meta.Client - meta.Config = fp.Meta.Config - return - } - +) *linodego.Client { accessToken := lpm.AccessToken.ValueString() APIURL := lpm.APIURL.ValueString() APIVersion := lpm.APIVersion.ValueString() @@ -261,12 +260,12 @@ func (fp *FrameworkProvider) InitProvider( caPath, err := helper.ExpandPath(lpm.APICAPath.ValueString()) if err != nil { diags.AddError("Failed to expand api_ca_path", err.Error()) - return + return nil } if err := helper.AddRootCAToTransport(caPath, httpTransport); err != nil { diags.AddError("Failed to add root CA to HTTP transport", err.Error()) - return + return nil } } @@ -297,7 +296,7 @@ func (fp *FrameworkProvider) InitProvider( }) if err != nil { diags.AddError("Error occurs when loading linode profile.", err.Error()) - return + return nil } } else { tflog.Info(ctx, "Linode config does not exist, skipping..") @@ -334,8 +333,7 @@ func (fp *FrameworkProvider) InitProvider( helper.ApplyAllRetryConditions(&client) - meta.Config = lpm - meta.Client = &client + return &client } func (fp *FrameworkProvider) terraformUserAgent( diff --git a/linode/helper/conversion.go b/linode/helper/conversion.go index 0e90dff3c..41647ce07 100644 --- a/linode/helper/conversion.go +++ b/linode/helper/conversion.go @@ -49,7 +49,7 @@ func StringAnyMapToTyped[T any](m map[string]any) map[string]T { return result } -func StringAliasSliceToStringSlice[T ~string](obj []T) ([]string, error) { +func StringAliasSliceToStringSlice[T ~string](obj []T) []string { var result []string for _, v := range obj { @@ -57,7 +57,7 @@ func StringAliasSliceToStringSlice[T ~string](obj []T) ([]string, error) { result = append(result, strValue) } - return result, nil + return result } func StringToInt64(s string, diags *diag.Diagnostics) int64 { diff --git a/linode/helper/objects.go b/linode/helper/objects.go index 3a92c23a5..35f8464d0 100644 --- a/linode/helper/objects.go +++ b/linode/helper/objects.go @@ -12,6 +12,7 @@ import ( s3 "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/linode/linodego" @@ -26,6 +27,15 @@ func GetRegionOrCluster(d *schema.ResourceData) (regionOrCluster string) { return } +func FwS3Connection(ctx context.Context, endpoint, accessKey, secretKey string, diags *diag.Diagnostics) *s3.Client { + s3client, err := S3Connection(ctx, endpoint, accessKey, secretKey) + if err != nil { + diags.AddError("Failed to Create S3 Connection", err.Error()) + } + + return s3client +} + func S3Connection(ctx context.Context, endpoint, accessKey, secretKey string) (*s3.Client, error) { tflog.Debug(ctx, "Creating Object Storage client") awsSDKConfig, err := config.LoadDefaultConfig( diff --git a/linode/helper/sdkv2_validators.go b/linode/helper/sdkv2_validators.go index 28d92edfc..9fd837471 100644 --- a/linode/helper/sdkv2_validators.go +++ b/linode/helper/sdkv2_validators.go @@ -3,10 +3,8 @@ package helper import ( "net" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func SDKv2ValidateIPv4Range(i any, path cty.Path) diag.Diagnostics { @@ -34,13 +32,3 @@ func SDKv2ValidateIPv6Range(i any, path cty.Path) diag.Diagnostics { return nil } - -func SDKv2ObjectCannedACLValidator(i any, p cty.Path) diag.Diagnostics { - aclValues, err := StringAliasSliceToStringSlice[s3types.ObjectCannedACL]( - s3types.ObjectCannedACLPrivate.Values(), // this return all acl values, not just private - ) - if err != nil { - return diag.FromErr(err) - } - return validation.ToDiagFunc(validation.StringInSlice(aclValues, true))(i, p) -} diff --git a/linode/instance/helpers.go b/linode/instance/helpers.go index d6fb50eeb..e4f03ccbb 100644 --- a/linode/instance/helpers.go +++ b/linode/instance/helpers.go @@ -35,6 +35,14 @@ func getDeadlineSeconds(ctx context.Context, d *schema.ResourceData) int { return int(duration.Seconds()) } +func setPublicIPAddress(d *schema.ResourceData, ip string) { + d.Set("ip_address", ip) + d.SetConnInfo(map[string]string{ + "type": "ssh", + "host": ip, + }) +} + func createInstanceConfigsFromSet( ctx context.Context, client linodego.Client, diff --git a/linode/instance/resource.go b/linode/instance/resource.go index f836f4f07..d4bf9817a 100644 --- a/linode/instance/resource.go +++ b/linode/instance/resource.go @@ -3,6 +3,7 @@ package instance import ( "context" "fmt" + "slices" "strconv" "time" @@ -92,17 +93,30 @@ func readResource(ctx context.Context, d *schema.ResourceData, meta interface{}) public, private := instanceNetwork.IPv4.Public, instanceNetwork.IPv4.Private if len(public) > 0 { - d.Set("ip_address", public[0].Address) + oldIP, ok := d.GetOk("ip_address") - d.SetConnInfo(map[string]string{ - "type": "ssh", - "host": public[0].Address, - }) + if !ok || !slices.ContainsFunc( + public, func(newIP *linodego.InstanceIP) bool { + return newIP.Address == oldIP.(string) + }, + ) { + setPublicIPAddress(d, public[0].Address) + } } if len(private) > 0 { d.Set("private_ip", true) - d.Set("private_ip_address", private[0].Address) + + oldIP, ok := d.GetOk("private_ip_address") + + if !ok || !slices.ContainsFunc( + private, func(newIP *linodego.InstanceIP) bool { + return newIP.Address == oldIP.(string) + }, + ) { + d.Set("private_ip_address", private[0].Address) + } + } else { d.Set("private_ip", false) } @@ -322,7 +336,7 @@ func createResource(ctx context.Context, d *schema.ResourceData, meta interface{ if private := privateIP(*address); private { d.Set("private_ip_address", address.String()) } else { - d.Set("ip_address", address.String()) + setPublicIPAddress(d, address.String()) } } diff --git a/linode/instance/resource_test.go b/linode/instance/resource_test.go index 57cad23ff..82afbedfe 100644 --- a/linode/instance/resource_test.go +++ b/linode/instance/resource_test.go @@ -109,7 +109,7 @@ func TestAccResourceInstance_basic_smoke(t *testing.T) { ResourceName: resName, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"root_pass", "authorized_keys", "image", "resize_disk", "migration_type", "firewall_id"}, + ImportStateVerifyIgnore: []string{"root_pass", "authorized_keys", "image", "resize_disk", "migration_type", "firewall_id", "capabilities"}, }, }, }) @@ -172,7 +172,7 @@ func TestAccResourceInstance_authorizedUsers(t *testing.T) { ResourceName: resName, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"root_pass", "authorized_users", "image", "resize_disk", "migration_type", "firewall_id"}, + ImportStateVerifyIgnore: []string{"root_pass", "authorized_users", "image", "resize_disk", "migration_type", "firewall_id", "capabilities"}, }, }, }) @@ -2895,14 +2895,12 @@ func checkComputeInstanceDisk(instance *linodego.Instance, label string, size in } func TestAccResourceInstance_withReservedIP(t *testing.T) { - acceptance.OptInTest(t) t.Parallel() var instance linodego.Instance resourceName := "linode_instance.foobar" instanceName := acctest.RandomWithPrefix("tf_test") rootPass := acctest.RandString(16) - reservedIP := "50.116.51.242" // Use a test IP or fetch a real reserved IP resource.Test(t, resource.TestCase{ PreCheck: func() { acceptance.PreCheck(t) }, @@ -2910,12 +2908,11 @@ func TestAccResourceInstance_withReservedIP(t *testing.T) { CheckDestroy: acceptance.CheckInstanceDestroy, Steps: []resource.TestStep{ { - Config: tmpl.WithReservedIP(t, instanceName, acceptance.PublicKeyMaterial, testRegion, rootPass, reservedIP), + Config: tmpl.WithReservedIP(t, instanceName, acceptance.PublicKeyMaterial, testRegion, rootPass), Check: resource.ComposeTestCheckFunc( acceptance.CheckInstanceExists(resourceName, &instance), resource.TestCheckResourceAttr(resourceName, "label", instanceName), resource.TestCheckResourceAttr(resourceName, "ipv4.#", "1"), - resource.TestCheckResourceAttr(resourceName, "ipv4.0", reservedIP), ), }, { @@ -2927,3 +2924,65 @@ func TestAccResourceInstance_withReservedIP(t *testing.T) { }, }) } + +func TestAccResourceInstance_deleteWithReservedIP(t *testing.T) { + t.Parallel() + var instance linodego.Instance + resourceName := "linode_instance.foobar" + testRegion := "us-east" + reservedIP := "" + instanceName := acctest.RandomWithPrefix("tf_test") + ipResourceName := "linode_networking_ip.test" + rootPass := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.WithReservedIP(t, instanceName, acceptance.PublicKeyMaterial, testRegion, rootPass), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckInstanceExists(resourceName, &instance), + resource.TestCheckResourceAttr(resourceName, "label", instanceName), + resource.TestCheckResourceAttr(resourceName, "ipv4.#", "1"), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[ipResourceName] + if !ok { + return fmt.Errorf("Not found: %s", ipResourceName) + } + reservedIP = rs.Primary.Attributes["address"] + return nil + }, + ), + }, + { + Config: tmpl.OnlyReservedIP(t, testRegion), // This config only includes the reserved IP resource + Check: resource.ComposeTestCheckFunc( + func(s *terraform.State) error { + client := acceptance.TestAccProvider.Meta().(*helper.ProviderMeta).Client + + // Check if the instance is deleted + _, err := client.GetInstance(context.Background(), instance.ID) + if err == nil { + return fmt.Errorf("Linode instance %d still exists", instance.ID) + } + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code != 404 { + return fmt.Errorf("Error requesting Linode instance %d: %s", instance.ID, err) + } + + // Check if the Reserved IP still exists and is reserved + ip, err := client.GetIPAddress(context.Background(), reservedIP) + if err != nil { + return fmt.Errorf("Error checking if Reserved IP exists: %s", err) + } + if !ip.Reserved { + return fmt.Errorf("Reserved IP %s is no longer reserved after instance deletion", reservedIP) + } + + return nil + }, + ), + }, + }, + }) +} diff --git a/linode/instance/tmpl/template.go b/linode/instance/tmpl/template.go index f470470a2..4f13cb533 100644 --- a/linode/instance/tmpl/template.go +++ b/linode/instance/tmpl/template.go @@ -1,6 +1,7 @@ package tmpl import ( + "fmt" "testing" "github.com/linode/linodego" @@ -28,7 +29,6 @@ type TemplateData struct { AssignedGroup string DiskEncryption *linodego.InstanceDiskEncryption - IPv4 []string } func Basic(t testing.TB, label, pubKey, region string, rootPass string) string { @@ -743,7 +743,7 @@ func WithPG(t testing.TB, label, region, assignedGroup string, groups []string) }) } -func WithReservedIP(t *testing.T, label, pubKey, region, rootPass string, reservedIP string) string { +func WithReservedIP(t *testing.T, label, pubKey, region, rootPass string) string { generatedConfig := acceptance.ExecuteTemplate(t, "instance_with_reserved_ip", TemplateData{ Label: label, @@ -751,7 +751,17 @@ func WithReservedIP(t *testing.T, label, pubKey, region, rootPass string, reserv Image: acceptance.TestImageLatest, Region: region, RootPass: rootPass, - IPv4: []string{reservedIP}, }) return generatedConfig } + +func OnlyReservedIP(t *testing.T, region string) string { + return fmt.Sprintf(` +resource "linode_networking_ip" "test" { + type = "ipv4" + region = "%s" + public = true + reserved = true +} +`, region) +} diff --git a/linode/instance/tmpl/templates/instance_with_reserved_ip.gotf b/linode/instance/tmpl/templates/instance_with_reserved_ip.gotf index 40331b2dc..a13af9d85 100644 --- a/linode/instance/tmpl/templates/instance_with_reserved_ip.gotf +++ b/linode/instance/tmpl/templates/instance_with_reserved_ip.gotf @@ -2,17 +2,23 @@ {{ template "e2e_test_firewall" . }} +resource "linode_networking_ip" "test" { + type = "ipv4" + region = "{{ .Region }}" + public = true + reserved = true +} + resource "linode_instance" "foobar" { label = "{{ .Label }}" type = "g6-nanode-1" region = "{{ .Region }}" image = "{{ .Image }}" firewall_id = linode_firewall.e2e_test_firewall.id - root_pass = "{{ .RootPass }}" authorized_keys = ["{{ .PubKey }}"] - ipv4 = [{{ range $index, $ip := .IPv4 }}{{ if $index }}, {{ end }}"{{ $ip }}"{{ end }}] + ipv4 = [linode_networking_ip.test.address] } {{ 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 ad2746716..217aae951 100644 --- a/linode/instance/tmpl/templates/private_image.gotf +++ b/linode/instance/tmpl/templates/private_image.gotf @@ -7,7 +7,7 @@ resource "linode_instance" "foobar-orig" { group = "tf_test" type = "g6-nanode-1" region = "{{ .Region }}" - image = "linode/alpine3.19" + image = "linode/alpine3.20" firewall_id = linode_firewall.e2e_test_firewall.id } diff --git a/linode/instanceconfig/tmpl/instance_disk.gotf b/linode/instanceconfig/tmpl/instance_disk.gotf index 00f161ea9..a7f791ab4 100644 --- a/linode/instanceconfig/tmpl/instance_disk.gotf +++ b/linode/instanceconfig/tmpl/instance_disk.gotf @@ -5,7 +5,7 @@ resource "linode_instance_disk" "foobar" { linode_id = linode_instance.foobar.id size = linode_instance.foobar.specs.0.disk - image = "linode/alpine3.18" + image = "linode/alpine3.20" root_pass = "{{ .RootPass }}" } diff --git a/linode/instanceip/framework_resource.go b/linode/instanceip/framework_resource.go index 2ad1fed5a..59ba1ebb4 100644 --- a/linode/instanceip/framework_resource.go +++ b/linode/instanceip/framework_resource.go @@ -153,7 +153,6 @@ func (r *Resource) Read( if resp.Diagnostics.HasError() { return } - ip, err := client.GetInstanceIPAddress(ctx, linodeID, address) if err != nil { if lerr, ok := err.(*linodego.Error); ok && lerr.Code == 404 { diff --git a/linode/instancenetworking/datasource_test.go b/linode/instancenetworking/datasource_test.go index d09925106..0bc61fbc8 100644 --- a/linode/instancenetworking/datasource_test.go +++ b/linode/instancenetworking/datasource_test.go @@ -80,3 +80,34 @@ func TestAccDataSourceInstanceNetworking_vpc(t *testing.T) { }, }) } + +func TestAccDataSourceInstanceNetworking_basicwithReseved(t *testing.T) { + t.Parallel() + + var instance linodego.Instance + + name := acctest.RandomWithPrefix("tf_test") + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.DataBasic_withReservedField(t, name, testRegion), + }, + { + Config: tmpl.DataBasic_withReservedField(t, name, testRegion), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckInstanceExists("linode_instance.foobar", &instance), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv4.0.private.#"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv4.0.public.#"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv4.0.reserved.#"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv4.0.shared.#"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv6.0.global.#"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv6.0.link_local.%"), + resource.TestCheckResourceAttrSet(testInstanceNetworkResName, "ipv6.0.slaac.%"), + ), + }, + }, + }) +} diff --git a/linode/instancenetworking/tmpl/data_basic_reserved.gotf b/linode/instancenetworking/tmpl/data_basic_reserved.gotf new file mode 100644 index 000000000..0081c7edf --- /dev/null +++ b/linode/instancenetworking/tmpl/data_basic_reserved.gotf @@ -0,0 +1,22 @@ +{{ define "instance_networking_data_basic_with_reserved" }} + +resource "linode_instance" "foobar" { + label = "{{.Label}}" + type = "g6-nanode-1" + region = "{{ .Region }}" + image = "linode/debian12" +} + +resource "linode_networking_ip" "reserved_ip" { + region = "{{ .Region }}" + public = true + type = "ipv4" + reserved = true + linode_id = linode_instance.foobar.id +} + +data "linode_instance_networking" "test" { + linode_id = linode_instance.foobar.id +} + +{{ end }} diff --git a/linode/instancenetworking/tmpl/template.go b/linode/instancenetworking/tmpl/template.go index fdbaeda7c..40c4f8934 100644 --- a/linode/instancenetworking/tmpl/template.go +++ b/linode/instancenetworking/tmpl/template.go @@ -30,3 +30,11 @@ func DataVPC(t testing.TB, label, region, subnetIPv4, interfaceIPv4 string) stri InterfaceIPv4: interfaceIPv4, }) } + +func DataBasic_withReservedField(t *testing.T, instanceLabel, region string) string { + return acceptance.ExecuteTemplate(t, + "instance_networking_data_basic_with_reserved", TemplateData{ + Label: instanceLabel, + Region: region, + }) +} diff --git a/linode/instancereservedipassignment/resource_test.go b/linode/instancereservedipassignment/resource_test.go index 499b4bc28..1603e41de 100644 --- a/linode/instancereservedipassignment/resource_test.go +++ b/linode/instancereservedipassignment/resource_test.go @@ -22,27 +22,24 @@ func init() { if err != nil { log.Fatal(err) } - region = "us-east" testRegion = region } func TestAccInstanceIP_addReservedIP(t *testing.T) { - acceptance.OptInTest(t) t.Parallel() var instance linodego.Instance name := acctest.RandomWithPrefix("tf_test") - reservedIP := "50.116.48.7223" // Replace with your actual reserved IP address resource.Test(t, resource.TestCase{ PreCheck: func() { acceptance.PreCheck(t) }, ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, CheckDestroy: acceptance.CheckInstanceDestroy, Steps: []resource.TestStep{ { - Config: tmpl.AddReservedIP(t, name, testRegion, reservedIP), + Config: tmpl.AddReservedIP(t, name, testRegion), Check: resource.ComposeTestCheckFunc( acceptance.CheckInstanceExists("linode_instance.foobar", &instance), - resource.TestCheckResourceAttr(testInstanceIPResName, "address", reservedIP), + resource.TestCheckResourceAttrSet(testInstanceIPResName, "address"), resource.TestCheckResourceAttr(testInstanceIPResName, "public", "true"), resource.TestCheckResourceAttrSet(testInstanceIPResName, "linode_id"), resource.TestCheckResourceAttrSet(testInstanceIPResName, "gateway"), diff --git a/linode/instancereservedipassignment/tmpl/AddReservedIPToInstance.gotf b/linode/instancereservedipassignment/tmpl/AddReservedIPToInstance.gotf index bf6ce2a9e..30ffc6aa3 100644 --- a/linode/instancereservedipassignment/tmpl/AddReservedIPToInstance.gotf +++ b/linode/instancereservedipassignment/tmpl/AddReservedIPToInstance.gotf @@ -11,10 +11,17 @@ resource "linode_instance" "foobar" { firewall_id = linode_firewall.e2e_test_firewall.id } +resource "linode_networking_ip" "test" { + type = "ipv4" + region = "{{ .Region }}" + public = true + reserved = true +} + resource "linode_reserved_ip_assignment" "test" { linode_id = linode_instance.foobar.id public = true - address = "{{ .Address}}" + address = linode_networking_ip.test.address } {{ end }} \ No newline at end of file diff --git a/linode/instancereservedipassignment/tmpl/template.go b/linode/instancereservedipassignment/tmpl/template.go index 2746e7d05..de17ccce1 100644 --- a/linode/instancereservedipassignment/tmpl/template.go +++ b/linode/instancereservedipassignment/tmpl/template.go @@ -10,14 +10,12 @@ type TemplateData struct { Label string ApplyImmediately bool Region string - Address string } -func AddReservedIP(t *testing.T, instanceLabel, region string, address string) string { +func AddReservedIP(t *testing.T, instanceLabel, region string) string { return acceptance.ExecuteTemplate(t, "instance_ip_add_reservedIP", TemplateData{ - Label: instanceLabel, - Region: region, - Address: address, + Label: instanceLabel, + Region: region, }) } diff --git a/linode/nb/framework_resource.go b/linode/nb/framework_resource.go index 807a73865..aa3594aa6 100644 --- a/linode/nb/framework_resource.go +++ b/linode/nb/framework_resource.go @@ -38,7 +38,7 @@ func (r *Resource) Create( req resource.CreateRequest, resp *resource.CreateResponse, ) { - tflog.Debug(ctx, "Create linode_nodebalancer") + tflog.Debug(ctx, "Create "+r.Config.Name) var data NodeBalancerModel client := r.Meta.Client @@ -116,7 +116,7 @@ func (r *Resource) Read( req resource.ReadRequest, resp *resource.ReadResponse, ) { - tflog.Debug(ctx, "Read linode_nodebalancer") + tflog.Debug(ctx, "Read "+r.Config.Name) var data NodeBalancerModel client := r.Meta.Client @@ -177,7 +177,7 @@ func (r *Resource) Update( req resource.UpdateRequest, resp *resource.UpdateResponse, ) { - tflog.Debug(ctx, "Update linode_nodebalancer") + tflog.Debug(ctx, "Update "+r.Config.Name) var plan, state NodeBalancerModel client := r.Meta.Client @@ -260,7 +260,7 @@ func (r *Resource) Delete( req resource.DeleteRequest, resp *resource.DeleteResponse, ) { - tflog.Debug(ctx, "Delete linode_nodebalancer") + tflog.Debug(ctx, "Delete "+r.Config.Name) var data NodeBalancerModel client := r.Meta.Client diff --git a/linode/nbnode/framework_resource.go b/linode/nbnode/framework_resource.go index 1e9b58025..510a1cbea 100644 --- a/linode/nbnode/framework_resource.go +++ b/linode/nbnode/framework_resource.go @@ -32,6 +32,8 @@ type Resource struct { func AddNodeResource(ctx context.Context, node linodego.NodeBalancerNode, resp *resource.CreateResponse, plan ResourceModel) { resp.State.SetAttribute(ctx, path.Root("id"), types.StringValue(strconv.Itoa(node.ID))) + resp.State.SetAttribute(ctx, path.Root("nodebalancer_id"), types.StringValue(strconv.Itoa(node.NodeBalancerID))) + resp.State.SetAttribute(ctx, path.Root("config_id"), types.StringValue(strconv.Itoa(node.ConfigID))) } func (r *Resource) Create( @@ -112,7 +114,7 @@ func (r *Resource) Read( resp.Diagnostics.AddWarning( "The NodeBalancer Node No Longer Exists", fmt.Sprintf( - "Removing Linode Token with ID %v from state because it no longer exists", id, + "Removing NodeBalancer Node with ID %v from state because it no longer exists", id, ), ) resp.State.RemoveResource(ctx) diff --git a/linode/networkingip/datasource_test.go b/linode/networkingip/datasource_test.go index ae4b2e013..1b7f9b924 100644 --- a/linode/networkingip/datasource_test.go +++ b/linode/networkingip/datasource_test.go @@ -49,6 +49,7 @@ func TestAccDataSourceNetworkingIP_basic(t *testing.T) { resource.TestMatchResourceAttr(dataResourceName, "gateway", regexp.MustCompile(`\.1$`)), resource.TestCheckResourceAttr(dataResourceName, "type", "ipv4"), resource.TestCheckResourceAttr(dataResourceName, "public", "true"), + resource.TestCheckResourceAttrSet(dataResourceName, "reserved"), resource.TestCheckResourceAttr(dataResourceName, "prefix", "24"), resource.TestMatchResourceAttr(dataResourceName, "rdns", regexp.MustCompile(`.ip.linodeusercontent.com$`)), ), diff --git a/linode/networkingip/framework_datasource.go b/linode/networkingip/framework_datasource.go index e80ebbd75..4e71407b6 100644 --- a/linode/networkingip/framework_datasource.go +++ b/linode/networkingip/framework_datasource.go @@ -2,7 +2,6 @@ package networkingip import ( "context" - "encoding/json" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -37,10 +36,8 @@ func (data *DataSourceModel) parseIP(ip *linodego.InstanceIP) { data.RDNS = types.StringValue(ip.RDNS) data.LinodeID = types.Int64Value(int64(ip.LinodeID)) data.Region = types.StringValue(ip.Region) - - id, _ := json.Marshal(ip) - - data.ID = types.StringValue(string(id)) + data.Reserved = types.BoolValue(ip.Reserved) + data.ID = types.StringValue(ip.Address) } type DataSourceModel struct { @@ -51,6 +48,7 @@ type DataSourceModel struct { Type types.String `tfsdk:"type"` Public types.Bool `tfsdk:"public"` RDNS types.String `tfsdk:"rdns"` + Reserved types.Bool `tfsdk:"reserved"` LinodeID types.Int64 `tfsdk:"linode_id"` Region types.String `tfsdk:"region"` ID types.String `tfsdk:"id"` @@ -72,10 +70,29 @@ func (d *DataSource) Read( ctx = tflog.SetField(ctx, "address", data.Address.ValueString()) - ip, err := d.Meta.Client.GetIPAddress(ctx, data.Address.ValueString()) + // Workaround: Use GetIPAddresses and filter for the specific address + ips, err := d.Meta.Client.ListIPAddresses(ctx, nil) if err != nil { resp.Diagnostics.AddError( - "Unable to get IP Address: %s", err.Error(), + "Unable to list IP Addresses", + err.Error(), + ) + return + } + + var ip *linodego.InstanceIP + for _, candidate := range ips { + if candidate.Address == data.Address.ValueString() { + ip = &candidate + break + } + } + + // If the IP address is not found, return an error + if ip == nil { + resp.Diagnostics.AddError( + "IP Address Not Found", + "Could not find the specified IP address in the list of retrieved IPs.", ) return } diff --git a/linode/networkingip/framework_datasource_schema.go b/linode/networkingip/framework_datasource_schema.go index 5299994ea..c97a7a891 100644 --- a/linode/networkingip/framework_datasource_schema.go +++ b/linode/networkingip/framework_datasource_schema.go @@ -43,6 +43,11 @@ var frameworkDatasourceSchema = schema.Schema{ Description: "The Region this IP address resides in.", Computed: true, }, + "reserved": schema.BoolAttribute{ + Description: "Whether the IPv4 address should be reserved.", + Computed: true, + }, + "id": schema.StringAttribute{ Description: "A unique identifier for this datasource.", Computed: true, diff --git a/linode/networkingip/framework_resource.go b/linode/networkingip/framework_resource.go new file mode 100644 index 000000000..8c21ed1b8 --- /dev/null +++ b/linode/networkingip/framework_resource.go @@ -0,0 +1,213 @@ +package networkingip + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_networking_ip", + IDType: types.StringType, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Create linode_networking_ip") + var plan NetworkingIPModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + createOpts := linodego.AllocateReserveIPOptions{ + Type: plan.Type.ValueString(), + Public: plan.Public.ValueBool(), + } + + if !plan.LinodeID.IsNull() { + createOpts.LinodeID = int(plan.LinodeID.ValueInt64()) + } + if !plan.Reserved.IsNull() { + createOpts.Reserved = plan.Reserved.ValueBool() + } + if !plan.Region.IsNull() { + createOpts.Region = plan.Region.ValueString() + } + + ip, err := client.AllocateReserveIP(ctx, createOpts) + if err != nil { + resp.Diagnostics.AddError( + "Error creating IP Address", + fmt.Sprintf("Could not create IP address: %s", err), + ) + return + } + + plan.FlattenIPAddress(ctx, ip, false) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Read linode_networking_ip") + var state NetworkingIPModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + // Use ListIPAddresses as a workaround to retrieve the specific private IP address + // since GetIPAddress doesnt retrieve private IP addresses + ips, err := client.ListIPAddresses(ctx, nil) + if err != nil { + resp.Diagnostics.AddError( + "Error reading IP Addresses", + fmt.Sprintf("Could not list IP addresses: %s", err), + ) + return + } + + var foundIP *linodego.InstanceIP + for _, ip := range ips { + if ip.Address == state.ID.ValueString() { + foundIP = &ip + break + } + } + + if foundIP == nil { + // IP address not found; remove the resource from the state + resp.State.RemoveResource(ctx) + return + } + + state.FlattenIPAddress(ctx, foundIP, false) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Update linode_networking_ip") + var plan, state NetworkingIPModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + var reservedValue *bool + if plan.Reserved != state.Reserved { + value := plan.Reserved.ValueBoolPointer() + reservedValue = value + } + + updateOpts := linodego.IPAddressUpdateOptionsV2{ + Reserved: reservedValue, + } + + ip, err := client.UpdateIPAddressV2(ctx, state.Address.ValueString(), updateOpts) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Update IP Address", + fmt.Sprintf("Could not update reserved status of IP address: %s", err), + ) + return + } + + plan.FlattenIPAddress(ctx, ip, false) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Delete linode_networking_ip") + var state NetworkingIPModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + // Regular assigned ephemeral IP address + if !state.Reserved.ValueBool() { + // This is a regular ephemeral IP address + linodeID := helper.FrameworkSafeInt64ToInt(state.LinodeID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Proceed with deleting the IP if it's not the only one + err := client.DeleteInstanceIPAddress(ctx, linodeID, state.Address.ValueString()) + if err != nil { + if lErr, ok := err.(*linodego.Error); (ok && lErr.Code != 404) || !ok { + resp.Diagnostics.AddError( + "Failed to Delete IP", + fmt.Sprintf( + "failed to delete instance (%d) ip (%s): %s", + linodeID, state.Address.ValueString(), err.Error(), + ), + ) + } + } + } else { + // Reserved IP address + // If the IP is currently assigned (reserved but used) + if state.LinodeID.ValueInt64() != 0 { + // It's an assigned reserved IP, we can delete it regardless of being the only IP + linodeID := helper.FrameworkSafeInt64ToInt(state.LinodeID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Delete the reserved IP (this will turn it into an ephemeral IP if it's the only IP) + err := client.DeleteReservedIPAddress(ctx, state.Address.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Delete Assigned Reserved IP", + fmt.Sprintf( + "failed to delete assigned reserved ip (%s) from linode (%d): %s", + state.Address.ValueString(), linodeID, err.Error(), + ), + ) + } + return + } else { + // Reserved IP (unassigned) that needs to be deleted + // If it's a reserved IP but it is not assigned to a Linode, proceed with deletion + err := client.DeleteReservedIPAddress(ctx, state.Address.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Delete Reserved IP", + fmt.Sprintf( + "failed to delete reserved ip (%s): %s", + state.Address.ValueString(), err.Error(), + ), + ) + } + } + } +} diff --git a/linode/networkingip/framework_resource_model.go b/linode/networkingip/framework_resource_model.go new file mode 100644 index 000000000..d5cab63ed --- /dev/null +++ b/linode/networkingip/framework_resource_model.go @@ -0,0 +1,35 @@ +package networkingip + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +type NetworkingIPModel struct { + ID types.String `tfsdk:"id"` + LinodeID types.Int64 `tfsdk:"linode_id"` + Reserved types.Bool `tfsdk:"reserved"` + Region types.String `tfsdk:"region"` + Public types.Bool `tfsdk:"public"` + Address types.String `tfsdk:"address"` + Type types.String `tfsdk:"type"` +} + +func (m *NetworkingIPModel) FlattenIPAddress(ctx context.Context, ip *linodego.InstanceIP, preserveKnown bool) { + m.ID = helper.KeepOrUpdateString(m.ID, ip.Address, preserveKnown) + + if ip.LinodeID != 0 { + m.LinodeID = helper.KeepOrUpdateValue(m.LinodeID, types.Int64Value(int64(ip.LinodeID)), preserveKnown) + } else { + m.LinodeID = helper.KeepOrUpdateValue(m.LinodeID, types.Int64Null(), preserveKnown) + } + + m.Reserved = helper.KeepOrUpdateValue(m.Reserved, types.BoolValue(ip.Reserved), preserveKnown) + m.Region = helper.KeepOrUpdateString(m.Region, ip.Region, preserveKnown) + m.Public = helper.KeepOrUpdateValue(m.Public, types.BoolValue(ip.Public), preserveKnown) + m.Address = helper.KeepOrUpdateString(m.Address, ip.Address, preserveKnown) + m.Type = helper.KeepOrUpdateString(m.Type, string(ip.Type), preserveKnown) +} diff --git a/linode/networkingip/framework_resource_schema.go b/linode/networkingip/framework_resource_schema.go new file mode 100644 index 000000000..ff6930e61 --- /dev/null +++ b/linode/networkingip/framework_resource_schema.go @@ -0,0 +1,70 @@ +package networkingip + +import ( + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" +) + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the IPv4 address.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "linode_id": schema.Int64Attribute{ + Description: "The ID of the Linode to allocate an IPv4 address for. Required when reserved is false or not set.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "reserved": schema.BoolAttribute{ + Description: "Whether the IPv4 address should be reserved.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Description: "The region for the reserved IPv4 address. Required when reserved is true and linode_id is not set.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "public": schema.BoolAttribute{ + Description: "Whether the IPv4 address is public or private.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "address": schema.StringAttribute{ + Description: "The allocated IPv4 address.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "type": schema.StringAttribute{ + Description: "The type of IP address (ipv4).", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("ipv4"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, +} diff --git a/linode/networkingip/resource_test.go b/linode/networkingip/resource_test.go new file mode 100644 index 000000000..a63958d31 --- /dev/null +++ b/linode/networkingip/resource_test.go @@ -0,0 +1,81 @@ +//go:build integration || networkingip + +package networkingip_test + +import ( + "log" + "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/networkingip/tmpl" +) + +func init() { + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func TestAccResourceNetworkingIP_reserved(t *testing.T) { + t.Parallel() + + label := acctest.RandomWithPrefix("tf-test") + + resourceName := "linode_networking_ip.reserved_ip" + instanceResourceName := "linode_instance.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.NetworkingIPReservedAssigned(t, label, testRegion), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "reserved", "true"), + resource.TestCheckResourceAttr(resourceName, "public", "true"), + resource.TestCheckResourceAttr(resourceName, "type", "ipv4"), + resource.TestCheckResourceAttrSet(resourceName, "address"), + resource.TestCheckResourceAttrSet(resourceName, "region"), + resource.TestCheckResourceAttrPair(resourceName, "linode_id", instanceResourceName, "id"), + ), + }, + }, + }) +} + +func TestAccResourceNetworkingIP_reserveToUnreserve(t *testing.T) { + t.Parallel() + + resName := "linode_networking_ip.test_ip" + linodeLabel := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + + Steps: []resource.TestStep{ + { + Config: tmpl.NetworkingIPReserveTest(t, linodeLabel, testRegion, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resName, "reserved", "true"), + ), + }, + { + Config: tmpl.NetworkingIPReserveTest(t, linodeLabel, testRegion, false), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resName, "reserved", "false"), + ), + }, + { + ResourceName: resName, + ImportState: true, + ImportStateVerifyIgnore: []string{"wait_for_available"}, + }, + }, + }) +} diff --git a/linode/networkingip/tmpl/data_basic.gotf b/linode/networkingip/tmpl/data_basic.gotf index 602bdccdf..27acdd8d9 100644 --- a/linode/networkingip/tmpl/data_basic.gotf +++ b/linode/networkingip/tmpl/data_basic.gotf @@ -10,7 +10,7 @@ provider "linode" { resource "linode_instance" "foobar" { label = "{{.Label}}" group = "tf_test" - image = "linode/alpine3.16" + image = "linode/ubuntu24.10" type = "g6-standard-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/networkingip/tmpl/resource_reserved_assigned.gotf b/linode/networkingip/tmpl/resource_reserved_assigned.gotf new file mode 100644 index 000000000..9062950dd --- /dev/null +++ b/linode/networkingip/tmpl/resource_reserved_assigned.gotf @@ -0,0 +1,18 @@ +{{ define "networking_ip_reserved_assigned" }} + +resource "linode_instance" "test" { + label = "{{.Label}}" + group = "tf_test" + type = "g6-nanode-1" + region = "{{ .Region }}" + image = "linode/debian12" +} + +resource "linode_networking_ip" "reserved_ip" { + linode_id = linode_instance.test.id + public = true + type = "ipv4" + reserved = true +} + +{{ end }} \ No newline at end of file diff --git a/linode/networkingip/tmpl/template.go b/linode/networkingip/tmpl/template.go index 84f28951a..c2bbf09c9 100644 --- a/linode/networkingip/tmpl/template.go +++ b/linode/networkingip/tmpl/template.go @@ -7,11 +7,29 @@ import ( ) type TemplateData struct { - Label string - Region string + Label string + Region string + Reserved bool } func DataBasic(t testing.TB, label, region string) string { return acceptance.ExecuteTemplate(t, "networking_ip_data_basic", TemplateData{Label: label, Region: region}) } + +func NetworkingIPReservedAssigned(t *testing.T, label string, region string) string { + return acceptance.ExecuteTemplate(t, + "networking_ip_reserved_assigned", + TemplateData{ + Label: label, + Region: region, + }) +} + +func NetworkingIPReserveTest(t *testing.T, label string, region string, reserved bool) string { + return acceptance.ExecuteTemplate(t, "networking_ip_reserve_test", TemplateData{ + Label: label, + Region: region, + Reserved: reserved, + }) +} diff --git a/linode/networkingip/tmpl/update_reserved_status.gotf b/linode/networkingip/tmpl/update_reserved_status.gotf new file mode 100644 index 000000000..fd65ffa8a --- /dev/null +++ b/linode/networkingip/tmpl/update_reserved_status.gotf @@ -0,0 +1,21 @@ +{{ define "networking_ip_reserve_test" }} + +{{ template "e2e_test_firewall" . }} + +resource "linode_instance" "test_instance" { + label = "{{.Label}}" + group = "tf_test" + image = "linode/debian12" + type = "g6-standard-1" + region = "{{ .Region }}" + firewall_id = linode_firewall.e2e_test_firewall.id +} + +resource "linode_networking_ip" "test_ip" { + public = true + type = "ipv4" + reserved = {{ .Reserved }} + linode_id = linode_instance.test_instance.id +} + +{{ end }} diff --git a/linode/networkingipassignment/framework_resource.go b/linode/networkingipassignment/framework_resource.go new file mode 100644 index 000000000..4715a11f6 --- /dev/null +++ b/linode/networkingipassignment/framework_resource.go @@ -0,0 +1,149 @@ +package networkingipassignment + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_networking_ip_assignment", + IDType: types.StringType, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + tflog.Debug(ctx, "Create linode_networking_assign_ip") + var plan NetworkingIPModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + apiAssignments := make([]linodego.LinodeIPAssignment, len(plan.Assignments)) + for i, assignment := range plan.Assignments { + apiAssignments[i] = linodego.LinodeIPAssignment{ + Address: assignment.Address.ValueString(), + LinodeID: int(assignment.LinodeID.ValueInt64()), + } + } + + assignOpts := linodego.LinodesAssignIPsOptions{ + Region: plan.Region.ValueString(), + Assignments: apiAssignments, + } + + err := client.InstancesAssignIPs(ctx, assignOpts) + if err != nil { + resp.Diagnostics.AddError( + "Error assigning IP Addresses", + fmt.Sprintf("Could not assign IP addresses: %s", err), + ) + return + } + + // Generate a unique ID for this resource + plan.ID = types.StringValue(fmt.Sprintf("%s-%d", plan.Region.ValueString(), len(plan.Assignments))) + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Read linode_networking_assign_ip") + var state NetworkingIPModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + for i, assignment := range state.Assignments { + ip, err := client.GetIPAddress(ctx, assignment.Address.ValueString()) + if err != nil { + if lerr, ok := err.(*linodego.Error); ok && lerr.Code == 404 { + // IP not found, remove it from state + state.Assignments = append(state.Assignments[:i], state.Assignments[i+1:]...) + continue + } + resp.Diagnostics.AddError( + "Error reading IP Address", + fmt.Sprintf("Could not read IP address %s: %s", assignment.Address.ValueString(), err), + ) + return + } + + state.Assignments[i] = AssignmentModel{ + Address: types.StringValue(ip.Address), + LinodeID: types.Int64Value(int64(ip.LinodeID)), + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} + +func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + tflog.Debug(ctx, "Delete linode_networking_assignments") + var state NetworkingIPModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + client := r.Meta.Client + + // Iterate through all assignments and unassign each IP + for _, assignment := range state.Assignments { + linodeID := helper.FrameworkSafeInt64ToInt(assignment.LinodeID.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + ipAddress := assignment.Address.ValueString() + if ipAddress == "" { + resp.Diagnostics.AddWarning( + "Invalid IP Address", + "An IP address in the assignment list is empty or invalid. Skipping.", + ) + continue + } + + // Unassign the IP address from the Linode + err := client.DeleteInstanceIPAddress(ctx, linodeID, ipAddress) + if err != nil { + // Log and continue with the next IP address if an error occurs + resp.Diagnostics.AddError( + "Failed to Unassign IP Address", + fmt.Sprintf("Error unassigning IP address %s from Linode %d: %s", ipAddress, linodeID, err), + ) + continue + } + + tflog.Debug(ctx, fmt.Sprintf("Successfully unassigned IP %s from Linode %d", ipAddress, linodeID)) + } + + resp.State.RemoveResource(ctx) +} diff --git a/linode/networkingipassignment/framework_resource_model.go b/linode/networkingipassignment/framework_resource_model.go new file mode 100644 index 000000000..786636adc --- /dev/null +++ b/linode/networkingipassignment/framework_resource_model.go @@ -0,0 +1,16 @@ +package networkingipassignment + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type NetworkingIPModel struct { + ID types.String `tfsdk:"id"` + Region types.String `tfsdk:"region"` + Assignments []AssignmentModel `tfsdk:"assignments"` +} + +type AssignmentModel struct { + Address types.String `tfsdk:"address"` + LinodeID types.Int64 `tfsdk:"linode_id"` +} diff --git a/linode/networkingipassignment/framework_resource_schema.go b/linode/networkingipassignment/framework_resource_schema.go new file mode 100644 index 000000000..130239a74 --- /dev/null +++ b/linode/networkingipassignment/framework_resource_schema.go @@ -0,0 +1,39 @@ +package networkingipassignment + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the IP assignment operation.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), // Use the state when ID is unknown. + }, + }, + "region": schema.StringAttribute{ + Required: true, + Description: "The region for the IP assignments.", + }, + "assignments": schema.ListAttribute{ + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "address": types.StringType, + "linode_id": types.Int64Type, + }, + }, + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), // Ensure the list uses state when unknown. + listplanmodifier.RequiresReplace(), + }, + }, + }, +} diff --git a/linode/networkingipassignment/resource_test.go b/linode/networkingipassignment/resource_test.go new file mode 100644 index 000000000..ae3d59efe --- /dev/null +++ b/linode/networkingipassignment/resource_test.go @@ -0,0 +1,110 @@ +//go:build integration || networkingipassignment + +package networkingipassignment_test + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" + "github.com/linode/terraform-provider-linode/v2/linode/helper" + "github.com/linode/terraform-provider-linode/v2/linode/networkingipassignment/tmpl" +) + +var testRegion string + +func init() { + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func TestAccResourceNetworkingIPsAssign(t *testing.T) { + t.Parallel() + + resourceName := "linode_networking_ip_assignment.test" + instanceName := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: checkNetworkingIPsAssignDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.NetworkingIPsAssign(t, instanceName, testRegion), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "region"), + resource.TestCheckResourceAttrSet(resourceName, "assignments.#"), + func(*terraform.State) error { + time.Sleep(30 * time.Second) // Add a delay to allow for API propagation + return nil + }, + checkNetworkingIPsAssignExists, + resource.TestCheckResourceAttrSet(resourceName, "assignments.0.linode_id"), + resource.TestCheckResourceAttrSet(resourceName, "assignments.0.address"), + ), + }, + // Removed ImportState step as it's no longer supported + }, + }) +} + +func checkNetworkingIPsAssignExists(s *terraform.State) error { + client := acceptance.TestAccProvider.Meta().(*helper.ProviderMeta).Client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "linode_networking_assign_ip" { + continue + } + + filter := fmt.Sprintf("{\"region\": \"%s\"}", rs.Primary.Attributes["region"]) + ips, err := client.ListIPAddresses(context.Background(), &linodego.ListOptions{Filter: filter}) + if err != nil { + return fmt.Errorf("Error listing IP addresses: %s", err) + } + + assignmentCount := 0 + for _, ip := range ips { + if ip.LinodeID != 0 { + assignmentCount++ + } + } + + if assignmentCount == 0 { + return fmt.Errorf("No IP assignments found") + } + } + + return nil +} + +func checkNetworkingIPsAssignDestroy(s *terraform.State) error { + client := acceptance.TestAccProvider.Meta().(*helper.ProviderMeta).Client + for _, rs := range s.RootModule().Resources { + if rs.Type != "linode_networking_assign_ip" { + continue + } + + ipAddress := rs.Primary.ID // Assuming ID is the address of the IP + _, err := client.GetIPAddress(context.Background(), ipAddress) + if err == nil { + return fmt.Errorf("Networking IPs Assign with id %s still exists", ipAddress) + } + + if apiErr, ok := err.(*linodego.Error); ok && apiErr.Code != 404 { + return fmt.Errorf("Error requesting Networking IPs Assign with id %s", ipAddress) + } + } + + return nil +} diff --git a/linode/networkingipassignment/tmpl/network_ip_assign.gotf b/linode/networkingipassignment/tmpl/network_ip_assign.gotf new file mode 100644 index 000000000..6608a8b38 --- /dev/null +++ b/linode/networkingipassignment/tmpl/network_ip_assign.gotf @@ -0,0 +1,45 @@ +{{ define "networking_ips_assign" }} + +resource "linode_instance" "test1" { + label = "{{ .Label }}-1" + type = "g6-nanode-1" + region = "{{ .Region }}" + image = "linode/alpine3.19" +} + +resource "linode_instance" "test2" { + label = "{{ .Label }}-2" + type = "g6-nanode-1" + region = "{{ .Region }}" + image = "linode/alpine3.19" +} + +resource "linode_networking_ip" "reserved_ip1" { + public = true + type = "ipv4" + region = "{{ .Region }}" + reserved = true +} + +resource "linode_networking_ip" "reserved_ip2" { + public = true + type = "ipv4" + region = "{{ .Region }}" + reserved = true +} + +resource "linode_networking_ip_assignment" "test" { + region = "{{ .Region }}" + assignments = [ + { + linode_id = linode_instance.test1.id + address = linode_networking_ip.reserved_ip1.address + }, + { + linode_id = linode_instance.test2.id + address = linode_networking_ip.reserved_ip2.address + } + ] +} + +{{ end }} \ No newline at end of file diff --git a/linode/networkingipassignment/tmpl/template.go b/linode/networkingipassignment/tmpl/template.go new file mode 100644 index 000000000..740700045 --- /dev/null +++ b/linode/networkingipassignment/tmpl/template.go @@ -0,0 +1,21 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" +) + +type TemplateData struct { + Label string + Region string +} + +func NetworkingIPsAssign(t *testing.T, label string, region string) string { + return acceptance.ExecuteTemplate(t, + "networking_ips_assign", + TemplateData{ + Label: label, + Region: region, + }) +} diff --git a/linode/networkingips/datasource_test.go b/linode/networkingips/datasource_test.go new file mode 100644 index 000000000..7829139b0 --- /dev/null +++ b/linode/networkingips/datasource_test.go @@ -0,0 +1,116 @@ +//go:build integration || networkingip + +package networkingips_test + +import ( + "fmt" + "log" + "regexp" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" + "github.com/linode/terraform-provider-linode/v2/linode/networkingips/tmpl" +) + +var testRegion string + +func init() { + region, err := acceptance.GetRandomRegionWithCaps([]string{"linodes"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region +} + +func TestAccDataSourceNetworkingIP_list(t *testing.T) { + t.Parallel() + + dataResourceName := "data.linode_networking_ips.list" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataList(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.#"), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[dataResourceName] + if !ok { + return fmt.Errorf("resource not found: %s", dataResourceName) + } + + numAddresses, err := strconv.Atoi(rs.Primary.Attributes["ip_addresses.#"]) + if err != nil { + return fmt.Errorf("failed to parse ip_addresses.#: %v", err) + } + + for i := 0; i < numAddresses; i++ { + prefix := fmt.Sprintf("ip_addresses.%d.", i) + + // Check if all required fields are set + if rs.Primary.Attributes[prefix+"gateway"] != "" && + rs.Primary.Attributes[prefix+"rdns"] != "" && + rs.Primary.Attributes[prefix+"address"] != "" && + rs.Primary.Attributes[prefix+"linode_id"] != "" && + rs.Primary.Attributes[prefix+"region"] != "" && + rs.Primary.Attributes[prefix+"type"] != "" && + rs.Primary.Attributes[prefix+"public"] != "" && + rs.Primary.Attributes[prefix+"prefix"] != "" && + rs.Primary.Attributes[prefix+"subnet_mask"] != "" && + rs.Primary.Attributes[prefix+"reserved"] != "" { + + // Perform assertions for the selected IP address + if !regexp.MustCompile(`\.1$`).MatchString(rs.Primary.Attributes[prefix+"gateway"]) { + return fmt.Errorf("attribute %sgateway has invalid value: %s", prefix, rs.Primary.Attributes[prefix+"gateway"]) + } + + if !regexp.MustCompile(`.ip.linodeusercontent.com$`).MatchString(rs.Primary.Attributes[prefix+"rdns"]) { + return fmt.Errorf("attribute %srdns has invalid value: %s", prefix, rs.Primary.Attributes[prefix+"rdns"]) + } + + return nil + } + } + + return fmt.Errorf("no IP address found with all attributes set") + }, + ), + }, + }, + }) +} + +func TestAccDataSourceNetworkingIP_filterReserved(t *testing.T) { + t.Parallel() + + dataResourceName := "data.linode_networking_ips.filtered" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + Steps: []resource.TestStep{ + { + Config: tmpl.DataFilterReserved(t), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.#"), + resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.reserved", "true"), + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.address"), + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.linode_id"), + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.region"), + resource.TestMatchResourceAttr(dataResourceName, "ip_addresses.0.gateway", regexp.MustCompile(`\.1$`)), + resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.type", "ipv4"), + resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.public", "true"), + resource.TestCheckResourceAttr(dataResourceName, "ip_addresses.0.prefix", "24"), + resource.TestMatchResourceAttr(dataResourceName, "ip_addresses.0.rdns", regexp.MustCompile(`.ip.linodeusercontent.com$`)), + resource.TestCheckResourceAttrSet(dataResourceName, "ip_addresses.0.subnet_mask"), + ), + }, + }, + }) +} diff --git a/linode/networkingips/famework_datasource_schema.go b/linode/networkingips/famework_datasource_schema.go new file mode 100644 index 000000000..2a6088b6e --- /dev/null +++ b/linode/networkingips/famework_datasource_schema.go @@ -0,0 +1,82 @@ +package networkingips + +import ( + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/linode/terraform-provider-linode/v2/linode/helper/frameworkfilter" +) + +var filterConfig = frameworkfilter.Config{ + "type": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "region": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "rdns": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "address": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeString}, + "prefix": {APIFilterable: true, TypeFunc: frameworkfilter.FilterTypeInt}, + + "gateway": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "subnet_mask": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "public": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeString}, + "linode_id": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeInt}, + "reserved": {APIFilterable: false, TypeFunc: frameworkfilter.FilterTypeBool}, +} + +var frameworkDatasourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The data source's unique ID.", + Computed: true, + }, + "order": filterConfig.OrderSchema(), + "order_by": filterConfig.OrderBySchema(), + }, + Blocks: map[string]schema.Block{ + "filter": filterConfig.Schema(), + "ip_addresses": schema.ListNestedBlock{ + Description: "The returned list of Images.", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "address": schema.StringAttribute{ + Description: "The IP address.", + Computed: true, + }, + "gateway": schema.StringAttribute{ + Description: "The default gateway for this address.", + Computed: true, + }, + "subnet_mask": schema.StringAttribute{ + Description: "The mask that separates host bits from network bits for this address.", + Computed: true, + }, + "prefix": schema.Int64Attribute{ + Description: "The number of bits set in the subnet mask.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "The type of address this is (ipv4, ipv6, ipv6/pool, ipv6/range).", + Computed: true, + }, + "public": schema.BoolAttribute{ + Description: "Whether this is a public or private IP address.", + Computed: true, + }, + "rdns": schema.StringAttribute{ + Description: "The reverse DNS assigned to this address. For public IPv4 addresses, this will be set to " + + "a default value provided by Linode if not explicitly set.", + Computed: true, + }, + "linode_id": schema.Int64Attribute{ + Description: "The ID of the Linode this address currently belongs to.", + Computed: true, + }, + "region": schema.StringAttribute{ + Description: "The Region this IP address resides in.", + Computed: true, + }, + "reserved": schema.BoolAttribute{ + Computed: true, + Description: "Whether this IP is reserved or not.", + }, + }, + }, + }, + }, +} diff --git a/linode/networkingips/framework_datasource.go b/linode/networkingips/framework_datasource.go new file mode 100644 index 000000000..7ed5a1e82 --- /dev/null +++ b/linode/networkingips/framework_datasource.go @@ -0,0 +1,81 @@ +package networkingips + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +type DataSource struct { + helper.BaseDataSource +} + +func NewDataSource() datasource.DataSource { + return &DataSource{ + BaseDataSource: helper.NewBaseDataSource( + helper.BaseDataSourceConfig{ + Name: "linode_networking_ips", + Schema: &frameworkDatasourceSchema, + }, + ), + } +} + +func (d *DataSource) Read( + ctx context.Context, + req datasource.ReadRequest, + resp *datasource.ReadResponse, +) { + tflog.Debug(ctx, "Read data.linode_networking_ips") + + var data FilterModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + id, diag := filterConfig.GenerateID(data.Filters) + if diag != nil { + resp.Diagnostics.Append(diag) + return + } + data.ID = id + + result, diag := filterConfig.GetAndFilter( + ctx, d.Meta.Client, data.Filters, listFunc, + data.Order, data.OrderBy) + if diag != nil { + resp.Diagnostics.Append(diag) + return + } + + resp.Diagnostics.Append(data.parseIPAddresses(helper.AnySliceToTyped[linodego.InstanceIP](result))...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func listFunc( + ctx context.Context, + client *linodego.Client, + filter string, +) ([]any, error) { + tflog.Trace(ctx, "client.ListIPAddresses(...)", map[string]any{ + "filter": filter, + }) + + images, err := client.ListIPAddresses(ctx, &linodego.ListOptions{ + Filter: filter, + }) + if err != nil { + return nil, err + } + + return helper.TypedSliceToAny(images), nil +} diff --git a/linode/networkingips/framework_datasource_models.go b/linode/networkingips/framework_datasource_models.go new file mode 100644 index 000000000..43a07b971 --- /dev/null +++ b/linode/networkingips/framework_datasource_models.go @@ -0,0 +1,60 @@ +package networkingips + +import ( + "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" +) + +type IPAddressModel struct { + Address types.String `tfsdk:"address"` + Type types.String `tfsdk:"type"` + Region types.String `tfsdk:"region"` + RDNS types.String `tfsdk:"rdns"` + Prefix types.Int64 `tfsdk:"prefix"` + Gateway types.String `tfsdk:"gateway"` + SubnetMask types.String `tfsdk:"subnet_mask"` + Public types.Bool `tfsdk:"public"` + LinodeID types.Int64 `tfsdk:"linode_id"` + Reserved types.Bool `tfsdk:"reserved"` +} + +func (m *IPAddressModel) ParseIP(ip linodego.InstanceIP) { + m.Address = types.StringValue(ip.Address) + m.Type = types.StringValue(string(ip.Type)) + m.Region = types.StringValue(ip.Region) + m.RDNS = types.StringValue(ip.RDNS) + m.Prefix = types.Int64Value(int64(ip.Prefix)) + m.Gateway = types.StringValue(ip.Gateway) + m.SubnetMask = types.StringValue(ip.SubnetMask) + m.Public = types.BoolValue(ip.Public) + m.LinodeID = types.Int64Value(int64(ip.LinodeID)) + m.Reserved = types.BoolValue(ip.Reserved) +} + +// FilterModel describes the Terraform resource data model to match the +// resource schema. +type FilterModel struct { + ID types.String `tfsdk:"id"` + Filters frameworkfilter.FiltersModelType `tfsdk:"filter"` + Order types.String `tfsdk:"order"` + OrderBy types.String `tfsdk:"order_by"` + IPAddresses []IPAddressModel `tfsdk:"ip_addresses"` +} + +func (data *FilterModel) parseIPAddresses( + ips []linodego.InstanceIP, +) diag.Diagnostics { + result := make([]IPAddressModel, len(ips)) + + for i := range ips { + var data IPAddressModel + data.ParseIP(ips[i]) + result[i] = data + } + + data.IPAddresses = result + + return nil +} diff --git a/linode/networkingips/tmpl/data_filter.gotf b/linode/networkingips/tmpl/data_filter.gotf new file mode 100644 index 000000000..57139b64a --- /dev/null +++ b/linode/networkingips/tmpl/data_filter.gotf @@ -0,0 +1,19 @@ +{{ define "networking_ip_data_filtered" }} + +resource "linode_networking_ip" "test" { + type = "ipv4" + region = "us-mia" + reserved = true + public = true +} + +data "linode_networking_ips" "filtered" { + depends_on = [linode_networking_ip.test] + + filter { + name = "reserved" + values = ["true"] + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/networkingips/tmpl/data_list.gotf b/linode/networkingips/tmpl/data_list.gotf new file mode 100644 index 000000000..866041424 --- /dev/null +++ b/linode/networkingips/tmpl/data_list.gotf @@ -0,0 +1,15 @@ +{{ define "networking_ip_data_list" }} + +resource "linode_networking_ip" "test" { + type = "ipv4" + region = "us-mia" + reserved = true + public = true + +} + +data "linode_networking_ips" "list" { + depends_on = [linode_networking_ip.test] +} + +{{ end }} \ No newline at end of file diff --git a/linode/networkingips/tmpl/template.go b/linode/networkingips/tmpl/template.go new file mode 100644 index 000000000..56749df79 --- /dev/null +++ b/linode/networkingips/tmpl/template.go @@ -0,0 +1,20 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" +) + +type TemplateData struct { + Label string + Region string +} + +func DataList(t *testing.T) string { + return acceptance.ExecuteTemplate(t, "networking_ip_data_list", nil) +} + +func DataFilterReserved(t *testing.T) string { + return acceptance.ExecuteTemplate(t, "networking_ip_data_filtered", nil) +} diff --git a/linode/obj/framework_models.go b/linode/obj/framework_models.go new file mode 100644 index 000000000..c849359f2 --- /dev/null +++ b/linode/obj/framework_models.go @@ -0,0 +1,209 @@ +package obj + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + "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" +) + +type BaseModel struct { + Bucket types.String `tfsdk:"bucket"` + Cluster types.String `tfsdk:"cluster"` + Region types.String `tfsdk:"region"` + Key types.String `tfsdk:"key"` + SecreteKey types.String `tfsdk:"secret_key"` + AccessKey types.String `tfsdk:"access_key"` + Content types.String `tfsdk:"content"` + ContentBase64 types.String `tfsdk:"content_base64"` + Source types.String `tfsdk:"source"` + ACL types.String `tfsdk:"acl"` + CacheControl types.String `tfsdk:"cache_control"` + ContentDisposition types.String `tfsdk:"content_disposition"` + ContentEncoding types.String `tfsdk:"content_encoding"` + ContentLanguage types.String `tfsdk:"content_language"` + ContentType types.String `tfsdk:"content_type"` + Endpoint types.String `tfsdk:"endpoint"` + ETag types.String `tfsdk:"etag"` + ForceDestroy types.Bool `tfsdk:"force_destroy"` + Metadata types.Map `tfsdk:"metadata"` + VersionID types.String `tfsdk:"version_id"` + WebsiteRedirect types.String `tfsdk:"website_redirect"` +} + +// TODO: consider merging two models when resource's ID change to int type +type ResourceModel struct { + ID types.String `tfsdk:"id"` + BaseModel +} + +func (data ResourceModel) getObjectBody(diags *diag.Diagnostics) (body *s3manager.ReaderSeekerCloser) { + if !data.Source.IsNull() && !data.Source.IsUnknown() { + sourcePath := data.Source.ValueString() + + file, err := os.Open(filepath.Clean(sourcePath)) + if err != nil { + diags.AddError(fmt.Sprintf("Failed to Open the File at %q", sourcePath), err.Error()) + return + } + + return s3manager.ReadSeekCloser(file) + } + + var contentBytes []byte + var err error + + if !data.ContentBase64.IsNull() && !data.ContentBase64.IsUnknown() { + contentBytes, err = base64.StdEncoding.DecodeString(data.ContentBase64.ValueString()) + if err != nil { + diags.AddError("Failed to Decode the base64 Content", err.Error()) + } + } else if !data.Content.IsNull() && !data.Content.IsUnknown() { + contentBytes = []byte(data.Content.ValueString()) + } + + return s3manager.ReadSeekCloser(bytes.NewReader(contentBytes)) +} + +func (data ResourceModel) GetObjectStorageKeys( + ctx context.Context, + client *linodego.Client, + config *helper.FrameworkProviderModel, + permissions string, + diags *diag.Diagnostics, +) (*ObjectKeys, func()) { + result := &ObjectKeys{} + + result.AccessKey = data.AccessKey.ValueString() + result.SecretKey = data.SecreteKey.ValueString() + + if result.Ok() { + return result, nil + } + + result.AccessKey = config.ObjAccessKey.ValueString() + result.SecretKey = config.ObjSecretKey.ValueString() + + if result.Ok() { + return result, nil + } + + if config.ObjUseTempKeys.ValueBool() { + objKey := fwCreateTempKeys(ctx, client, data.Bucket.ValueString(), data.GetRegionOrCluster(ctx), permissions, diags) + if diags.HasError() { + return nil, nil + } + + result.AccessKey = objKey.AccessKey + result.SecretKey = objKey.SecretKey + + teardownTempKeysCleanUp := func() { cleanUpTempKeys(ctx, client, objKey.ID) } + + return result, teardownTempKeysCleanUp + } + + diags.AddError( + "Keys Not Found", + "`access_key` and `secret_key` are Required but not Configured", + ) + + return nil, nil +} + +func (plan *ResourceModel) ComputeEndpointIfUnknown(ctx context.Context, client *linodego.Client, diags *diag.Diagnostics) { + if !plan.Endpoint.IsUnknown() { + return + } + + bucketName := plan.Bucket.ValueString() + regionOrCluster := plan.GetRegionOrCluster(ctx) + + bucket, err := client.GetObjectStorageBucket(ctx, regionOrCluster, bucketName) + if err != nil { + diags.AddError( + "Failed to Find the Specified Linode ObjectStorageBucket", + err.Error(), + ) + return + } + + plan.Endpoint = types.StringValue( + strings.TrimPrefix(bucket.Hostname, fmt.Sprintf("%s.", bucket.Label)), + ) +} + +func (data *ResourceModel) GenerateObjectStorageObjectID(apply bool, preserveKnown bool) string { + id := fmt.Sprintf("%s/%s", data.Bucket.ValueString(), data.Key.ValueString()) + + if apply { + data.ID = types.StringValue(id) + } + + return id +} + +func (data ResourceModel) GetRegionOrCluster(ctx context.Context) string { + if !data.Region.IsNull() && !data.Region.IsUnknown() { + return data.Region.ValueString() + } + + return data.Cluster.ValueString() +} + +func (data *ResourceModel) FlattenObject( + obj s3.HeadObjectOutput, preserveKnown bool, +) { + data.CacheControl = helper.KeepOrUpdateStringPointer(data.CacheControl, obj.CacheControl, preserveKnown) + data.ContentDisposition = helper.KeepOrUpdateStringPointer(data.ContentDisposition, obj.ContentDisposition, preserveKnown) + data.ContentEncoding = helper.KeepOrUpdateStringPointer(data.ContentEncoding, obj.ContentEncoding, preserveKnown) + data.ContentLanguage = helper.KeepOrUpdateStringPointer(data.ContentLanguage, obj.ContentLanguage, preserveKnown) + data.ContentType = helper.KeepOrUpdateStringPointer(data.ContentType, obj.ContentType, preserveKnown) + data.ETag = helper.KeepOrUpdateStringPointer(data.ETag, getQuotesTrimmedETag(obj), preserveKnown) + data.WebsiteRedirect = helper.KeepOrUpdateStringPointer(data.WebsiteRedirect, obj.WebsiteRedirectLocation, preserveKnown) + data.VersionID = helper.KeepOrUpdateStringPointer(data.VersionID, obj.VersionId, preserveKnown) + data.Metadata = helper.KeepOrUpdateValue(data.Metadata, types.MapValueMust(types.StringType, flattenObjectMetadata(obj.Metadata)), preserveKnown) + data.ContentDisposition = helper.KeepOrUpdateStringPointer(data.ContentDisposition, obj.ContentDisposition, preserveKnown) + + data.GenerateObjectStorageObjectID(true, preserveKnown) +} + +func (data ResourceModel) ETagChanged( + obj s3.HeadObjectOutput, +) bool { + return !data.ETag.Equal(types.StringPointerValue(getQuotesTrimmedETag(obj))) +} + +func (plan *ResourceModel) CopyFrom(state ResourceModel, preserveKnown bool) { + plan.ID = helper.KeepOrUpdateValue(plan.ID, state.ID, preserveKnown) + plan.Bucket = helper.KeepOrUpdateValue(plan.Bucket, state.Bucket, preserveKnown) + plan.Cluster = helper.KeepOrUpdateValue(plan.Cluster, state.Cluster, preserveKnown) + plan.Region = helper.KeepOrUpdateValue(plan.Region, state.Region, preserveKnown) + plan.Key = helper.KeepOrUpdateValue(plan.Key, state.Key, preserveKnown) + plan.SecreteKey = helper.KeepOrUpdateValue(plan.SecreteKey, state.SecreteKey, preserveKnown) + plan.AccessKey = helper.KeepOrUpdateValue(plan.AccessKey, state.AccessKey, preserveKnown) + plan.Content = helper.KeepOrUpdateValue(plan.Content, state.Content, preserveKnown) + plan.ContentBase64 = helper.KeepOrUpdateValue(plan.ContentBase64, state.ContentBase64, preserveKnown) + plan.Source = helper.KeepOrUpdateValue(plan.Source, state.Source, preserveKnown) + plan.ACL = helper.KeepOrUpdateValue(plan.ACL, state.ACL, preserveKnown) + plan.CacheControl = helper.KeepOrUpdateValue(plan.CacheControl, state.CacheControl, preserveKnown) + plan.ContentDisposition = helper.KeepOrUpdateValue(plan.ContentDisposition, state.ContentDisposition, preserveKnown) + plan.ContentEncoding = helper.KeepOrUpdateValue(plan.ContentEncoding, state.ContentEncoding, preserveKnown) + plan.ContentLanguage = helper.KeepOrUpdateValue(plan.ContentLanguage, state.ContentLanguage, preserveKnown) + plan.ContentType = helper.KeepOrUpdateValue(plan.ContentType, state.ContentType, preserveKnown) + plan.Endpoint = helper.KeepOrUpdateValue(plan.Endpoint, state.Endpoint, preserveKnown) + plan.ETag = helper.KeepOrUpdateValue(plan.ETag, state.ETag, preserveKnown) + plan.ForceDestroy = helper.KeepOrUpdateValue(plan.ForceDestroy, state.ForceDestroy, preserveKnown) + plan.Metadata = helper.KeepOrUpdateValue(plan.Metadata, state.Metadata, preserveKnown) + plan.VersionID = helper.KeepOrUpdateValue(plan.VersionID, state.VersionID, preserveKnown) + plan.WebsiteRedirect = helper.KeepOrUpdateValue(plan.WebsiteRedirect, state.WebsiteRedirect, preserveKnown) +} diff --git a/linode/obj/framework_resource.go b/linode/obj/framework_resource.go new file mode 100644 index 000000000..f7d7c38d9 --- /dev/null +++ b/linode/obj/framework_resource.go @@ -0,0 +1,301 @@ +package obj + +import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +const ( + READ_PERMISSION = "read_only" + READ_WRITE_PERMISSION = "read_write" +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_object_storage_object", + IDType: types.StringType, + Schema: &frameworkResourceSchema, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create "+r.Config.Name) + + var plan ResourceModel + client := r.Meta.Client + config := r.Meta.Config + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = populateLogAttributes(ctx, plan) + + plan.ComputeEndpointIfUnknown(ctx, client, &resp.Diagnostics) + + s3client, teardownKeys := getS3ClientFromModel( + ctx, client, config, plan, READ_WRITE_PERMISSION, &resp.Diagnostics, + ) + + if teardownKeys != nil { + defer teardownKeys() + } + + if resp.Diagnostics.HasError() { + return + } + + fwPutObject(ctx, plan, s3client, &resp.Diagnostics) + + // Add resource to TF states earlier to prevent + // dangling resources (resources created but not managed by TF) + AddObjectResource(ctx, resp, plan) + + RefreshObject(ctx, &plan, s3client, &resp.Diagnostics, nil, true) + + // IDs should always be overridden during creation (see #1085) + // TODO: Remove when Crossplane empty string ID issue is resolved + plan.GenerateObjectStorageObjectID(true, false) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func RefreshObject( + ctx context.Context, + data *ResourceModel, + s3client *s3.Client, + diags *diag.Diagnostics, + removeResource func(context.Context), + preserveKnown bool, +) { + tflog.Debug(ctx, "enter RefreshObject") + + if diags.HasError() { + return + } + + headObjectInput := &s3.HeadObjectInput{ + Bucket: data.Bucket.ValueStringPointer(), + Key: data.Key.ValueStringPointer(), + } + + tflog.Debug(ctx, "getting object header", map[string]any{"HeadObjectInput": headObjectInput}) + headOutput, err := s3client.HeadObject(ctx, headObjectInput) + if err != nil { + if helper.IsObjNotFoundErr(err) && removeResource != nil { + removeResource(ctx) + diags.AddWarning( + "Object Not Found", + "couldn't find the bucket or object, removing the object from the TF state", + ) + } + diags.AddError("Failed to Refresh the Object", err.Error()) + } + + data.FlattenObject(*headOutput, preserveKnown) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read "+r.Config.Name) + + client := r.Meta.Client + config := r.Meta.Config + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = populateLogAttributes(ctx, state) + + // TODO: cleanup when Crossplane fixes it + if helper.FrameworkAttemptRemoveResourceForEmptyID(ctx, state.ID, resp) { + return + } + + s3client, teardownKeys := getS3ClientFromModel( + ctx, client, config, state, READ_PERMISSION, &resp.Diagnostics, + ) + + if teardownKeys != nil { + defer teardownKeys() + } + + if resp.Diagnostics.HasError() { + if newDiags := deleteBucketNotFound(resp.Diagnostics); len(newDiags) < len(resp.Diagnostics) { + resp.Diagnostics = newDiags + + resp.Diagnostics.AddWarning( + "The Object No Longer Exists", + fmt.Sprintf( + "Removing Object Storage Object %q from state because it no longer exists", + state.ID.ValueString(), + ), + ) + + resp.State.RemoveResource(ctx) + } + return + } + + RefreshObject(ctx, &state, s3client, &resp.Diagnostics, resp.State.RemoveResource, false) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update "+r.Config.Name) + + var plan, state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = populateLogAttributes(ctx, state) + client := r.Meta.Client + config := r.Meta.Config + + plan.ComputeEndpointIfUnknown(ctx, client, &resp.Diagnostics) + + s3client, teardownKeys := getS3ClientFromModel( + ctx, client, config, plan, READ_WRITE_PERMISSION, &resp.Diagnostics, + ) + + if teardownKeys != nil { + defer teardownKeys() + } + + if resp.Diagnostics.HasError() { + return + } + + if (!plan.ETag.IsUnknown() && !plan.ETag.Equal(state.ETag)) || + !plan.CacheControl.Equal(state.CacheControl) || + !plan.ContentBase64.Equal(state.ContentBase64) || + !plan.ContentDisposition.Equal(state.ContentDisposition) || + !plan.ContentEncoding.Equal(state.ContentEncoding) || + !plan.ContentLanguage.Equal(state.ContentLanguage) || + !plan.ContentType.Equal(state.ContentType) || + !plan.Content.Equal(state.Content) || + !plan.Metadata.Equal(state.Metadata) || + !plan.Source.Equal(state.Source) || + !plan.WebsiteRedirect.Equal(state.WebsiteRedirect) { + + fwPutObject(ctx, plan, s3client, &resp.Diagnostics) + } + + RefreshObject(ctx, &plan, s3client, &resp.Diagnostics, nil, true) + + plan.CopyFrom(state, true) + + // Workaround for Crossplane issue where ID is not + // properly populated in plan + // See TPT-2865 for more details + if plan.ID.ValueString() == "" { + plan.ID = state.ID + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete "+r.Config.Name) + + var state ResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = populateLogAttributes(ctx, state) + + client := r.Meta.Client + config := r.Meta.Config + + s3client, teardownKeys := getS3ClientFromModel( + ctx, client, config, state, READ_WRITE_PERMISSION, &resp.Diagnostics, + ) + + if teardownKeys != nil { + defer teardownKeys() + } + + if resp.Diagnostics.HasError() { + return + } + + force := state.ForceDestroy.ValueBool() + bucket := state.Bucket.ValueString() + key := state.Key.ValueString() + + if !state.VersionID.IsNull() { + tflog.Debug(ctx, "versioning was enabled for this object, deleting all versions and delete markers") + + err := helper.DeleteAllObjectVersionsAndDeleteMarkers(ctx, s3client, bucket, key, force, true) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Delete All Object Versions and Deletion Markers in the Versioned Bucket", + err.Error(), + ) + } + } else { + tflog.Debug(ctx, "versioning was disabled for this object, simply delete the object") + + err := deleteObject(ctx, s3client, bucket, strings.TrimPrefix(key, "/"), "", force) + if err != nil { + resp.Diagnostics.AddError("Failed to Delete the Object", err.Error()) + } + } +} + +func populateLogAttributes(ctx context.Context, model ResourceModel) context.Context { + return helper.SetLogFieldBulk(ctx, map[string]any{ + "bucket": model.Bucket.ValueString(), + "region_or_cluster": model.GetRegionOrCluster(ctx), + "object_key": model.Key.ValueString(), + }) +} diff --git a/linode/obj/framework_resource_schema.go b/linode/obj/framework_resource_schema.go new file mode 100644 index 000000000..0c32ffa9d --- /dev/null +++ b/linode/obj/framework_resource_schema.go @@ -0,0 +1,216 @@ +package obj + +import ( + "context" + "strings" + + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +const ( + REGION_CLUSTER_REQUIRE_REPLACEMENT_FUNC_DESCRIPTION = "Require replacement if region or " + + "cluster has been changed and the change is not a migration from a cluster to " + + "an equivalent region or from a region to an equivalent cluster" +) + +func requireReplacementIfClusterOrRegionSemanticallyChanged(ctx context.Context, sr planmodifier.StringRequest, rrifr *stringplanmodifier.RequiresReplaceIfFuncResponse) { + var regionPlan, clusterPlan, regionState, clusterState types.String + sr.Plan.GetAttribute(ctx, path.Root("cluster"), &clusterPlan) + sr.Plan.GetAttribute(ctx, path.Root("region"), ®ionPlan) + sr.State.GetAttribute(ctx, path.Root("cluster"), &clusterState) + sr.State.GetAttribute(ctx, path.Root("region"), ®ionState) + + if !regionState.IsNull() && regionPlan.IsNull() && !clusterPlan.IsNull() && strings.HasPrefix(clusterPlan.ValueString(), regionState.ValueString()) { + // the region changed to an equivalent cluster + return + } + + if !clusterState.IsNull() && clusterPlan.IsNull() && !regionPlan.IsNull() && strings.HasPrefix(clusterState.ValueString(), regionPlan.ValueString()) { + // the cluster changed to an equivalent region + return + } + + rrifr.RequiresReplace = true +} + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the object.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "bucket": schema.StringAttribute{ + Description: "The target bucket to put this object in.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cluster": schema.StringAttribute{ + Description: "The target cluster that the bucket is in.", + 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, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIf( + requireReplacementIfClusterOrRegionSemanticallyChanged, + REGION_CLUSTER_REQUIRE_REPLACEMENT_FUNC_DESCRIPTION, + REGION_CLUSTER_REQUIRE_REPLACEMENT_FUNC_DESCRIPTION, + ), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.MatchRoot("region")), + }, + }, + "region": schema.StringAttribute{ + Description: "The target region that the bucket is in.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIf( + requireReplacementIfClusterOrRegionSemanticallyChanged, + REGION_CLUSTER_REQUIRE_REPLACEMENT_FUNC_DESCRIPTION, + REGION_CLUSTER_REQUIRE_REPLACEMENT_FUNC_DESCRIPTION, + ), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.MatchRoot("cluster")), + }, + }, + "key": schema.StringAttribute{ + Description: "The name of the uploaded object.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "secret_key": schema.StringAttribute{ + Description: "The REQUIRED S3 secret key with access to the target bucket. " + + "If not specified with the resource, you must provide its value by configuring the obj_secret_key, " + + "or, opting-in generating it implicitly at apply-time using obj_use_temp_keys at provider-level.", + Optional: true, + Sensitive: true, + }, + "access_key": schema.StringAttribute{ + Description: "The REQUIRED S3 access key with access to the target bucket. " + + "If not specified with the resource, you must provide its value by configuring the obj_access_key, " + + "or, opting-in generating it implicitly at apply-time using obj_use_temp_keys at provider-level.", + Optional: true, + }, + "content": schema.StringAttribute{ + Description: "The contents of the Object to upload.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content_base64"), + path.MatchRoot("source"), + ), + }, + }, + "content_base64": schema.StringAttribute{ + Description: "The base64 contents of the Object to upload.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content"), + path.MatchRoot("source"), + ), + }, + }, + "source": schema.StringAttribute{ + Description: "The source file to upload.", + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRoot("content"), + path.MatchRoot("content_base64"), + ), + }, + }, + "acl": schema.StringAttribute{ + Description: "The ACL config given to this object.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString( + string(s3types.ObjectCannedACLPrivate), + ), + Validators: []validator.String{ + stringvalidator.OneOf( + helper.StringAliasSliceToStringSlice( + s3types.ObjectCannedACLPrivate.Values(), + )..., + ), + }, + }, + "cache_control": schema.StringAttribute{ + Description: "This cache_control configuration of this object.", + Optional: true, + }, + "content_disposition": schema.StringAttribute{ + Description: "The content disposition configuration of this object.", + Optional: true, + }, + "content_encoding": schema.StringAttribute{ + Description: "The encoding of the content of this object.", + Optional: true, + }, + "content_language": schema.StringAttribute{ + Description: "The language metadata of this object.", + Optional: true, + }, + "content_type": schema.StringAttribute{ + Description: "The MIME type of the content.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "endpoint": schema.StringAttribute{ + Description: "The endpoint for the bucket used for s3 connections.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "etag": schema.StringAttribute{ + Description: "The specific version of this object.", + Optional: true, + Computed: true, + }, + "force_destroy": schema.BoolAttribute{ + Description: "Whether the object should bypass deletion restrictions.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "metadata": schema.MapAttribute{ + Description: "The metadata of this object", + Optional: true, + Computed: true, + ElementType: types.StringType, + Default: helper.EmptyMapDefault(types.StringType), + }, + "version_id": schema.StringAttribute{ + Description: "The version ID of this object.", + Computed: true, + }, + "website_redirect": schema.StringAttribute{ + Description: "The website redirect location of this object.", + Optional: true, + }, + }, +} diff --git a/linode/obj/helpers.go b/linode/obj/helpers.go index 7048ac49b..308ec156a 100644 --- a/linode/obj/helpers.go +++ b/linode/obj/helpers.go @@ -4,11 +4,20 @@ import ( "context" "fmt" "regexp" + "slices" + "strings" "time" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + sdkv2diag "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/linode/linodego" "github.com/linode/terraform-provider-linode/v2/linode/helper" @@ -19,12 +28,31 @@ type ObjectKeys struct { SecretKey string } -func populateLogAttributes(ctx context.Context, d *schema.ResourceData) context.Context { - return helper.SetLogFieldBulk(ctx, map[string]any{ - "bucket": d.Get("bucket"), - "cluster": d.Get("cluster"), - "object_key": d.Get("key"), - }) +func getS3ClientFromModel( + ctx context.Context, + client *linodego.Client, + config *helper.FrameworkProviderModel, + data ResourceModel, + permission string, + diags *diag.Diagnostics, +) (*s3.Client, func()) { + keys, teardownKeys := data.GetObjectStorageKeys(ctx, client, config, permission, diags) + if diags.HasError() { + return nil, teardownKeys + } + + s3client := helper.FwS3Connection( + ctx, + data.Endpoint.ValueString(), + keys.AccessKey, + keys.SecretKey, + diags, + ) + if diags.HasError() { + return nil, teardownKeys + } + + return s3client, teardownKeys } // getObjKeysFromProvider gets obj_access_key and obj_secret_key from provider configuration. @@ -36,7 +64,7 @@ func getObjKeysFromProvider( keys.AccessKey = config.ObjAccessKey keys.SecretKey = config.ObjSecretKey - return keys, checkObjKeysConfigured(keys) + return keys, keys.Ok() } func isCluster(regionOrCluster string) bool { @@ -45,14 +73,55 @@ func isCluster(regionOrCluster string) bool { return re.MatchString(regionOrCluster) } +// fwCreateTempKeys creates temporary Object Storage Keys to use. +// The temporary keys are scoped only to the target cluster and bucket with limited permissions. +// Keys only exist for the duration of the apply time. +func fwCreateTempKeys( + ctx context.Context, + client *linodego.Client, + bucket, regionOrCluster, permissions string, + diags *diag.Diagnostics, +) *linodego.ObjectStorageKey { + tflog.Debug(ctx, "Create temporary object storage access keys implicitly.") + + tempBucketAccess := linodego.ObjectStorageKeyBucketAccess{ + BucketName: bucket, + Permissions: permissions, + } + + if isCluster(regionOrCluster) { + tflog.Warn(ctx, "Cluster is deprecated for Linode Object Storage service, please consider switch to using region.") + tempBucketAccess.Cluster = regionOrCluster + } else { + tflog.Info(ctx, fmt.Sprintf("%q Is Region", regionOrCluster)) + tempBucketAccess.Region = regionOrCluster + } + + createOpts := linodego.ObjectStorageKeyCreateOptions{ + Label: fmt.Sprintf("temp_%s_%v", bucket, time.Now().Unix()), + BucketAccess: &[]linodego.ObjectStorageKeyBucketAccess{tempBucketAccess}, + } + + tflog.Debug(ctx, "client.CreateObjectStorageKey(...)", map[string]interface{}{ + "options": createOpts, + }) + + keys, err := client.CreateObjectStorageKey(ctx, createOpts) + if err != nil { + diags.AddError("Failed to Create Object Storage Key", err.Error()) + } + + return keys +} + // createTempKeys creates temporary Object Storage Keys to use. // The temporary keys are scoped only to the target cluster and bucket with limited permissions. // Keys only exist for the duration of the apply time. func createTempKeys( ctx context.Context, - client linodego.Client, + client *linodego.Client, bucket, regionOrCluster, permissions string, -) (*linodego.ObjectStorageKey, diag.Diagnostics) { +) (*linodego.ObjectStorageKey, sdkv2diag.Diagnostics) { tflog.Debug(ctx, "Create temporary object storage access keys implicitly.") tempBucketAccess := linodego.ObjectStorageKeyBucketAccess{ @@ -78,21 +147,21 @@ func createTempKeys( keys, err := client.CreateObjectStorageKey(ctx, createOpts) if err != nil { - return nil, diag.FromErr(err) + return nil, sdkv2diag.FromErr(err) } return keys, nil } // checkObjKeysConfigured checks whether AccessKey and SecretKey both exist. -func checkObjKeysConfigured(keys ObjectKeys) bool { +func (keys ObjectKeys) Ok() bool { return keys.AccessKey != "" && keys.SecretKey != "" } // cleanUpTempKeys deleted the temporarily created object keys. func cleanUpTempKeys( ctx context.Context, - client linodego.Client, + client *linodego.Client, keyId int, ) { tflog.Trace(ctx, "Clean up temporary keys: client.DeleteObjectStorageKey(...)", map[string]interface{}{ @@ -116,7 +185,7 @@ func GetObjKeys( config *helper.Config, client linodego.Client, bucket, regionOrCluster, permission string, -) (ObjectKeys, diag.Diagnostics, func()) { +) (ObjectKeys, sdkv2diag.Diagnostics, func()) { var teardownTempKeysCleanUp func() = nil objKeys := ObjectKeys{ @@ -124,21 +193,21 @@ func GetObjKeys( SecretKey: d.Get("secret_key").(string), } - if !checkObjKeysConfigured(objKeys) { + if !objKeys.Ok() { // If object keys don't exist in the resource configuration, firstly look for the keys from provider configuration if providerKeys, ok := getObjKeysFromProvider(objKeys, config); ok { objKeys = providerKeys } else if config.ObjUseTempKeys { // Implicitly create temporary object storage keys - keys, diag := createTempKeys(ctx, client, bucket, regionOrCluster, permission) + keys, diag := createTempKeys(ctx, &client, bucket, regionOrCluster, permission) if diag != nil { return objKeys, diag, nil } objKeys.AccessKey = keys.AccessKey objKeys.SecretKey = keys.SecretKey - teardownTempKeysCleanUp = func() { cleanUpTempKeys(ctx, client, keys.ID) } + teardownTempKeysCleanUp = func() { cleanUpTempKeys(ctx, &client, keys.ID) } } else { - return objKeys, diag.Errorf( + return objKeys, sdkv2diag.Errorf( "access_key and secret_key are required.", ), nil } @@ -152,7 +221,8 @@ func putObjectWithRetries( s3client *s3.Client, putInput *s3.PutObjectInput, retryDuration time.Duration, -) error { + diags *diag.Diagnostics, +) { tflog.Debug(ctx, "Attempting to put object with retries") ticker := time.NewTicker(retryDuration) @@ -169,19 +239,120 @@ func putObjectWithRetries( tflog.Debug(ctx, fmt.Sprintf( "Failed to put Bucket (%v) Object (%v): %s. Retrying...", - putInput.Bucket, - putInput.Key, + aws.ToString(putInput.Bucket), + aws.ToString(putInput.Key), err.Error(), ), ) continue } - return nil + return case <-ctx.Done(): // The timeout for this context will implicitly be handled by Terraform - return fmt.Errorf("failed to put the object: %s", ctx.Err()) + diags.AddError("Failed to Put the Object", ctx.Err().Error()) + return + } + } +} + +func getQuotesTrimmedETag( + obj s3.HeadObjectOutput, +) *string { + if obj.ETag != nil { + result := strings.Trim(*obj.ETag, `"`) + return &result + } + return nil +} + +func deleteObject(ctx context.Context, client *s3.Client, bucket, key, version string, force bool) error { + tflog.Debug(ctx, "deleting the object key") + deleteObjectInput := &s3.DeleteObjectInput{ + Bucket: &bucket, + Key: &key, + BypassGovernanceRetention: aws.Bool(force), + } + if version != "" { + deleteObjectInput.VersionId = &version + } + + tflog.Debug(ctx, "client.DeleteObject(...)", map[string]any{"options": deleteObjectInput}) + _, err := client.DeleteObject(ctx, deleteObjectInput) + if err != nil { + msg := fmt.Sprintf("failed to delete object version (%s): %s", version, err) + tflog.Error(ctx, msg) + if !helper.IsObjNotFoundErr(err) { + return fmt.Errorf("%s: %w", msg, err) } } + return nil +} + +func flattenObjectMetadata(metadata map[string]string) map[string]attr.Value { + metadataObject := make(map[string]attr.Value, len(metadata)) + for key, value := range metadata { + key := strings.ToLower(key) + metadataObject[key] = types.StringValue(value) + } + + return metadataObject +} + +func deleteBucketNotFound(diags diag.Diagnostics) diag.Diagnostics { + return slices.DeleteFunc(diags, func(d diag.Diagnostic) bool { + return strings.Contains(d.Detail(), "Bucket not found") + }) +} + +func AddObjectResource( + ctx context.Context, + resp *resource.CreateResponse, + plan ResourceModel, +) { + plan.GenerateObjectStorageObjectID(true, true) + resp.State.SetAttribute(ctx, path.Root("bucket"), plan.Bucket) + resp.State.SetAttribute(ctx, path.Root("key"), plan.Key) + resp.State.SetAttribute(ctx, path.Root("cluster"), plan.Cluster) + resp.State.SetAttribute(ctx, path.Root("region"), plan.Region) +} + +func fwPutObject( + ctx context.Context, + data ResourceModel, + s3client *s3.Client, + diags *diag.Diagnostics, +) { + tflog.Debug(ctx, "getting object body from resource data") + + body := data.getObjectBody(diags) + if diags.HasError() { + return + } + defer body.Close() + + putInput := &s3.PutObjectInput{ + Body: body, + Bucket: data.Bucket.ValueStringPointer(), + Key: data.Key.ValueStringPointer(), + + ACL: s3types.ObjectCannedACL(data.ACL.ValueString()), + CacheControl: data.CacheControl.ValueStringPointer(), + ContentDisposition: data.ContentDisposition.ValueStringPointer(), + ContentEncoding: data.ContentEncoding.ValueStringPointer(), + ContentLanguage: data.ContentLanguage.ValueStringPointer(), + ContentType: data.ContentType.ValueStringPointer(), + WebsiteRedirectLocation: data.WebsiteRedirect.ValueStringPointer(), + } + + if len(data.Metadata.Elements()) > 0 { + data.Metadata.ElementsAs(ctx, &putInput.Metadata, false) + tflog.Debug(ctx, fmt.Sprintf("got Metadata: %v", putInput.Metadata)) + } + + putObjectWithRetries(ctx, s3client, putInput, time.Second*5, diags) + if diags.HasError() { + return + } } diff --git a/linode/obj/resource.go b/linode/obj/resource.go deleted file mode 100644 index 805eeca69..000000000 --- a/linode/obj/resource.go +++ /dev/null @@ -1,354 +0,0 @@ -package obj - -import ( - "bytes" - "context" - "encoding/base64" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - - "github.com/hashicorp/terraform-plugin-log/tflog" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/linode/terraform-provider-linode/v2/linode/helper" -) - -func Resource() *schema.Resource { - return &schema.Resource{ - Schema: resourceSchema, - - ReadContext: readResource, - CreateContext: createResource, - UpdateContext: updateResource, - DeleteContext: deleteResource, - - CustomizeDiff: diffResource, - } -} - -func createResource(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - ctx = populateLogAttributes(ctx, d) - tflog.Debug(ctx, "creating linode_object_storage_object") - return putObject(ctx, d, meta) -} - -func readResource(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - ctx = populateLogAttributes(ctx, d) - tflog.Debug(ctx, "reading linode_object_storage_object") - - config := meta.(*helper.ProviderMeta).Config - client := meta.(*helper.ProviderMeta).Client - regionOrCluster := helper.GetRegionOrCluster(d) - - bucket := d.Get("bucket").(string) - key := d.Get("key").(string) - - objKeys, diags, teardownKeysCleanUp := GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_only") - if len(diags) > 0 { - // Check if the error is due to the bucket being not found - for _, diag := range diags { - errMsg := diag.Summary - if helper.IsBucketNotFoundErrorMsg(errMsg) { - 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) - } - - headObjectInput := &s3.HeadObjectInput{ - Bucket: &bucket, - Key: &key, - } - tflog.Debug(ctx, "getting object header", map[string]any{"HeadObjectInput": headObjectInput}) - headOutput, err := s3client.HeadObject( - ctx, - headObjectInput, - ) - if err != nil { - if helper.IsObjNotFoundErr(err) { - d.SetId("") - tflog.Warn(ctx, - "couldn't find the bucket or object, "+ - "removing the object from the TF state") - return nil - } - return diag.FromErr(err) - } - - d.Set("cache_control", headOutput.CacheControl) - d.Set("content_disposition", headOutput.ContentDisposition) - d.Set("content_encoding", headOutput.ContentEncoding) - d.Set("content_language", headOutput.ContentLanguage) - d.Set("content_type", headOutput.ContentType) - d.Set("etag", strings.Trim(helper.StringValue(headOutput.ETag), `"`)) - d.Set("website_redirect", headOutput.WebsiteRedirectLocation) - d.Set("version_id", headOutput.VersionId) - d.Set("metadata", flattenObjectMetadata(headOutput.Metadata)) - - // Compute s3 endpoint when it's not configured by the user - if _, ok := d.GetOk("endpoint"); !ok { - tflog.Debug(ctx, "'endpoint' wasn't configured, computing it from cluster name") - endpoint, err := helper.ComputeS3Endpoint(ctx, d, meta) - if err != nil { - return diag.Errorf("failed to compute object storage endpoint: %s", err) - } - tflog.Debug(ctx, fmt.Sprintf("computed endpoint: '%s'", endpoint)) - d.Set("endpoint", endpoint) - } - - return nil -} - -func updateResource(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - ctx = populateLogAttributes(ctx, d) - tflog.Debug(ctx, "updating linode_object_storage_object") - if d.HasChanges("cache_control", "content_base64", "content_disposition", - "content_encoding", "content_language", "content_type", "content", - "etag", "metadata", "source", "website_redirect") { - tflog.Debug(ctx, "detected qualified change(s), calling 'putObject'") - return putObject(ctx, d, meta) - } - - regionOrCluster := helper.GetRegionOrCluster(d) - bucket := d.Get("bucket").(string) - key := d.Get("key").(string) - acl := s3types.ObjectCannedACL(d.Get("acl").(string)) - - if d.HasChange("acl") { - config := meta.(*helper.ProviderMeta).Config - client := meta.(*helper.ProviderMeta).Client - - objKeys, diags, teardownKeysCleanUp := GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_write") - if diags != 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) - } - - aclPutInput := &s3.PutObjectAclInput{ - Bucket: &bucket, - Key: &key, - ACL: acl, - } - tflog.Debug( - ctx, - "detected ACL change in TF files, updating it on the cloud", - map[string]any{"PutObjectAclInput": aclPutInput}, - ) - - _, err = s3client.PutObjectAcl(ctx, aclPutInput) - if err != nil { - return diag.Errorf("failed to put Bucket (%s) Object (%s) ACL: %s", bucket, key, err) - } - } - - return readResource(ctx, d, meta) -} - -func deleteResource(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - ctx = populateLogAttributes(ctx, d) - tflog.Debug(ctx, "deleting linode_object_storage_object") - - config := meta.(*helper.ProviderMeta).Config - client := meta.(*helper.ProviderMeta).Client - regionOrCluster := helper.GetRegionOrCluster(d) - bucket := d.Get("bucket").(string) - key := d.Get("key").(string) - force := d.Get("force_destroy").(bool) - - objKeys, diags, teardownKeysCleanUp := GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_write") - if diags != 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) - } - - if _, ok := d.GetOk("version_id"); ok { - tflog.Debug(ctx, "versioning was enabled for this object, deleting all versions and delete markers") - return diag.FromErr( - helper.DeleteAllObjectVersionsAndDeleteMarkers(ctx, s3client, bucket, key, force, true), - ) - } - tflog.Debug(ctx, "versioning was disabled for this object, simply delete the object") - return diag.FromErr(deleteObject(ctx, s3client, bucket, strings.TrimPrefix(key, "/"), "", force)) -} - -func diffResource( - ctx context.Context, d *schema.ResourceDiff, meta any, -) error { - if d.HasChange("etag") { - tflog.Debug(ctx, "'etag' has been changed, computing new 'version_id'") - d.SetNewComputed("version_id") - } - return nil -} - -// putObject builds the object from spec and puts it in the -// specified bucket via the *schema.ResourceData, then it calls -// readResource. -func putObject(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - tflog.Debug(ctx, "entered 'putObject' function") - - config := meta.(*helper.ProviderMeta).Config - client := meta.(*helper.ProviderMeta).Client - regionOrCluster := helper.GetRegionOrCluster(d) - bucket := d.Get("bucket").(string) - key := d.Get("key").(string) - - objKeys, diags, teardownKeysCleanUp := GetObjKeys(ctx, d, config, client, bucket, regionOrCluster, "read_write") - if diags != 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) - } - - tflog.Debug(ctx, "getting object body from resource data") - body, err := objectBodyFromResourceData(d) - if err != nil { - return diag.FromErr(err) - } - defer body.Close() - - nilOrValue := func(s string) *string { - if s == "" { - return nil - } - return &s - } - - putInput := &s3.PutObjectInput{ - Bucket: &bucket, - Key: &key, - Body: &body, - - CacheControl: nilOrValue(d.Get("cache_control").(string)), - ContentDisposition: nilOrValue(d.Get("content_disposition").(string)), - ContentEncoding: nilOrValue(d.Get("content_encoding").(string)), - ContentLanguage: nilOrValue(d.Get("content_language").(string)), - ContentType: nilOrValue(d.Get("content_type").(string)), - WebsiteRedirectLocation: nilOrValue(d.Get("website_redirect").(string)), - } - - if acl := nilOrValue(d.Get("acl").(string)); acl != nil { - putInput.ACL = s3types.ObjectCannedACL(*acl) - } - - if metadata, ok := d.GetOk("metadata"); ok { - putInput.Metadata = expandObjectMetadata(metadata.(map[string]any)) - tflog.Debug(ctx, fmt.Sprintf("got Metadata: %v", putInput.Metadata)) - } - - errs := putObjectWithRetries(ctx, s3client, putInput, time.Second*5) - if errs != nil { - return diag.Errorf("failed to put Bucket (%s) Object (%s): %s", bucket, key, errs) - } - - d.SetId(helper.BuildObjectStorageObjectID(d)) - - return readResource(ctx, d, meta) -} - -func deleteObject(ctx context.Context, client *s3.Client, bucket, key, version string, force bool) error { - tflog.Debug(ctx, "deleting the object key") - deleteObjectInput := &s3.DeleteObjectInput{ - Bucket: &bucket, - Key: &key, - BypassGovernanceRetention: aws.Bool(force), - } - if version != "" { - deleteObjectInput.VersionId = &version - } - - tflog.Debug(ctx, "client.DeleteObject(...)", map[string]any{"options": deleteObjectInput}) - _, err := client.DeleteObject(ctx, deleteObjectInput) - if err != nil { - msg := fmt.Sprintf("failed to delete object version (%s): %s", version, err) - tflog.Error(ctx, msg) - if !helper.IsObjNotFoundErr(err) { - return fmt.Errorf("%s: %w", msg, err) - } - } - return nil -} - -func objectBodyFromResourceData(d *schema.ResourceData) (body s3manager.ReaderSeekerCloser, err error) { - if source, ok := d.GetOk("source"); ok { - sourceFilePath := source.(string) - - file, err := os.Open(filepath.Clean(sourceFilePath)) - if err != nil { - return s3manager.ReaderSeekerCloser{}, err - } - return *s3manager.ReadSeekCloser(file), err - } - - var contentBytes []byte - if encodedContent, ok := d.GetOk("content_base64"); ok { - contentBytes, err = base64.StdEncoding.DecodeString(encodedContent.(string)) - } else { - content := d.Get("content").(string) - contentBytes = []byte(content) - } - - body = *s3manager.ReadSeekCloser(bytes.NewReader(contentBytes)) - return -} - -func expandObjectMetadata(metadata map[string]any) map[string]string { - metadataMap := make(map[string]string, len(metadata)) - for key, value := range metadata { - metadataMap[key] = value.(string) - } - return metadataMap -} - -func flattenObjectMetadata(metadata map[string]string) map[string]string { - metadataObject := make(map[string]string, len(metadata)) - for key, value := range metadata { - key := strings.ToLower(key) - metadataObject[key] = value - } - - return metadataObject -} diff --git a/linode/obj/schema_resource.go b/linode/obj/schema_resource.go deleted file mode 100644 index b21fbdfca..000000000 --- a/linode/obj/schema_resource.go +++ /dev/null @@ -1,136 +0,0 @@ -package obj - -import ( - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/linode/terraform-provider-linode/v2/linode/helper" -) - -var resourceSchema = map[string]*schema.Schema{ - "bucket": { - Type: schema.TypeString, - Description: "The target bucket to put this object in.", - Required: true, - ForceNew: true, - }, - "cluster": { - 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"}, - }, - "region": { - Type: schema.TypeString, - Description: "The target region that the bucket is in.", - Optional: true, - ForceNew: true, - ExactlyOneOf: []string{"cluster", "region"}, - }, - "key": { - Type: schema.TypeString, - Description: "The name of the uploaded object.", - Required: true, - ForceNew: true, - }, - "secret_key": { - Type: schema.TypeString, - Description: "The REQUIRED S3 secret key with access to the target bucket. " + - "If not specified with the resource, you must provide its value by configuring the obj_secret_key, " + - "or, opting-in generating it implicitly at apply-time using obj_use_temp_keys at provider-level.", - Optional: true, - Sensitive: true, - }, - "access_key": { - Type: schema.TypeString, - Description: "The REQUIRED S3 access key with access to the target bucket. " + - "If not specified with the resource, you must provide its value by configuring the obj_access_key, " + - "or, opting-in generating it implicitly at apply-time using obj_use_temp_keys at provider-level.", - Optional: true, - }, - "content": { - Type: schema.TypeString, - Description: "The contents of the Object to upload.", - Optional: true, - ExactlyOneOf: []string{"content", "content_base64", "source"}, - }, - "content_base64": { - Type: schema.TypeString, - Description: "The base64 contents of the Object to upload.", - Optional: true, - }, - "source": { - Type: schema.TypeString, - Description: "The source file to upload.", - Optional: true, - }, - "acl": { - Type: schema.TypeString, - Description: "The ACL config given to this object.", - Default: s3types.ObjectCannedACLPrivate, - ValidateDiagFunc: helper.SDKv2ObjectCannedACLValidator, - Optional: true, - }, - "cache_control": { - Type: schema.TypeString, - Description: "This cache_control configuration of this object.", - Optional: true, - }, - "content_disposition": { - Type: schema.TypeString, - Description: "The content disposition configuration of this object.", - Optional: true, - }, - "content_encoding": { - Type: schema.TypeString, - Description: "The encoding of the content of this object.", - Optional: true, - }, - "content_language": { - Type: schema.TypeString, - Description: "The language metadata of this object.", - Optional: true, - }, - "content_type": { - Type: schema.TypeString, - Description: "The MIME type of the content.", - Optional: true, - Computed: true, - }, - "endpoint": { - Type: schema.TypeString, - Description: "The endpoint for the bucket used for s3 connections.", - Computed: true, - Optional: true, - }, - "etag": { - Type: schema.TypeString, - Description: "The specific version of this object.", - Optional: true, - Computed: true, - }, - "force_destroy": { - Type: schema.TypeBool, - Description: "Whether the object should bypass deletion restrictions.", - Optional: true, - Default: false, - }, - "metadata": { - Type: schema.TypeMap, - Description: "The metadata of this object", - Optional: true, - Elem: &schema.Schema{Type: schema.TypeString}, - }, - "version_id": { - Type: schema.TypeString, - Description: "The version ID of this object.", - Computed: true, - }, - "website_redirect": { - Type: schema.TypeString, - Description: "The website redirect location of this object.", - Optional: true, - }, -} diff --git a/linode/obj/tmpl/creds_configed.gotf b/linode/obj/tmpl/creds_configed.gotf index 45b8b083a..04775d970 100644 --- a/linode/obj/tmpl/creds_configed.gotf +++ b/linode/obj/tmpl/creds_configed.gotf @@ -13,13 +13,13 @@ resource "linode_object_storage_object" "creds_configed" { provider = linode.creds_configed bucket = linode_object_storage_bucket.foobar.label - {{if .Region }} + {{ if .Region }} region = "{{.Region}}" - {{else}} + {{ else }} cluster = "{{ .Cluster }}" - {{end}} + {{ end }} key = "test_creds_configed" - content = "{{.Content}}" + content = "{{ .Content }}" } {{ end }} \ No newline at end of file diff --git a/linode/objbucket/schema_resource.go b/linode/objbucket/schema_resource.go index 6119346e5..c390c2ad7 100644 --- a/linode/objbucket/schema_resource.go +++ b/linode/objbucket/schema_resource.go @@ -57,7 +57,7 @@ var resourceSchema = map[string]*schema.Schema{ Type: schema.TypeBool, Description: "If true, the bucket will be created with CORS enabled for all origins.", Optional: true, - Default: true, + Computed: true, }, "lifecycle_rule": { Type: schema.TypeList, diff --git a/linode/objkey/framework_model.go b/linode/objkey/framework_model.go index c23563811..7f8d46810 100644 --- a/linode/objkey/framework_model.go +++ b/linode/objkey/framework_model.go @@ -2,6 +2,7 @@ package objkey import ( "context" + "slices" "strconv" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -73,11 +74,19 @@ func (plan ResourceModel) GetCreateOptions(ctx context.Context) (opts linodego.O return } -func getObjectStorageKeyRegionIDs(regions []linodego.ObjectStorageKeyRegion) []string { +func getObjectStorageKeyRegionIDsSet(regions []linodego.ObjectStorageKeyRegion) []string { regionIDs := make([]string, len(regions)) for i, r := range regions { regionIDs[i] = r.ID } + + // Deduplicate regions + // + // Considering migrating to `someMap.Keys()` when upgrading to Go 1.23 + // https://pkg.go.dev/maps@master#Keys + slices.Sort(regionIDs) + regionIDs = slices.Compact(regionIDs) + return regionIDs } @@ -107,7 +116,7 @@ func (rm *ResourceModel) FlattenObjectStorageKey( rm.SecretKey = helper.KeepOrUpdateString(rm.SecretKey, key.SecretKey, preserveKnown) } - newRegions := getObjectStorageKeyRegionIDs(key.Regions) + newRegions := getObjectStorageKeyRegionIDsSet(key.Regions) rm.Regions = helper.KeepOrUpdateStringSet(rm.Regions, newRegions, preserveKnown, diags) rm.BucketAccess = FlattenBucketAccessEntries(key.BucketAccess, rm.BucketAccess, preserveKnown) diff --git a/linode/provider.go b/linode/provider.go index 0bd899a11..7ffed6b32 100644 --- a/linode/provider.go +++ b/linode/provider.go @@ -20,7 +20,6 @@ import ( "github.com/linode/terraform-provider-linode/v2/linode/instance" "github.com/linode/terraform-provider-linode/v2/linode/instanceconfig" "github.com/linode/terraform-provider-linode/v2/linode/lke" - "github.com/linode/terraform-provider-linode/v2/linode/obj" "github.com/linode/terraform-provider-linode/v2/linode/objbucket" "github.com/linode/terraform-provider-linode/v2/linode/user" ) @@ -155,7 +154,6 @@ func Provider() *schema.Provider { "linode_instance_config": instanceconfig.Resource(), "linode_lke_cluster": lke.Resource(), "linode_object_storage_bucket": objbucket.Resource(), - "linode_object_storage_object": obj.Resource(), "linode_user": user.Resource(), }, } diff --git a/linode/rdns/tmpl/basic.gotf b/linode/rdns/tmpl/basic.gotf index 5c7fd3d31..7cd19bfff 100644 --- a/linode/rdns/tmpl/basic.gotf +++ b/linode/rdns/tmpl/basic.gotf @@ -5,7 +5,7 @@ resource "linode_instance" "foobar" { label = "{{.Label}}" group = "tf_test" - image = "linode/alpine3.18" + image = "linode/alpine3.20" type = "g6-standard-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/rdns/tmpl/changed.gotf b/linode/rdns/tmpl/changed.gotf index 062197fb4..100731bd4 100644 --- a/linode/rdns/tmpl/changed.gotf +++ b/linode/rdns/tmpl/changed.gotf @@ -5,7 +5,7 @@ resource "linode_instance" "foobar" { label = "{{.Label}}" group = "tf_test" - image = "linode/alpine3.18" + image = "linode/alpine3.20" type = "g6-standard-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/rdns/tmpl/deleted.gotf b/linode/rdns/tmpl/deleted.gotf index eeec87070..c0b158365 100644 --- a/linode/rdns/tmpl/deleted.gotf +++ b/linode/rdns/tmpl/deleted.gotf @@ -5,7 +5,7 @@ resource "linode_instance" "foobar" { label = "{{.Label}}" group = "tf_test" - image = "linode/alpine3.18" + image = "linode/alpine3.20" type = "g6-standard-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/rdns/tmpl/with_timeout.gotf b/linode/rdns/tmpl/with_timeout.gotf index 469d59d51..9ed99789f 100644 --- a/linode/rdns/tmpl/with_timeout.gotf +++ b/linode/rdns/tmpl/with_timeout.gotf @@ -4,7 +4,7 @@ resource "linode_instance" "foobar" { label = "{{.Label}}" - image = "linode/alpine3.19" + image = "linode/alpine3.20" type = "g6-nanode-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/rdns/tmpl/with_timeout_updated.gotf b/linode/rdns/tmpl/with_timeout_updated.gotf index 37bd313a8..dc29408c9 100644 --- a/linode/rdns/tmpl/with_timeout_updated.gotf +++ b/linode/rdns/tmpl/with_timeout_updated.gotf @@ -4,7 +4,7 @@ resource "linode_instance" "foobar" { label = "{{.Label}}" - image = "linode/alpine3.19" + image = "linode/alpine3.20" type = "g6-nanode-1" region = "{{ .Region }}" firewall_id = linode_firewall.e2e_test_firewall.id diff --git a/linode/token/framework_resource_schema.go b/linode/token/framework_resource_schema.go index 7b95542e2..b0c85c605 100644 --- a/linode/token/framework_resource_schema.go +++ b/linode/token/framework_resource_schema.go @@ -2,7 +2,6 @@ package token import ( "context" - "time" "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -72,10 +71,9 @@ var frameworkResourceSchema = schema.Schema{ sr planmodifier.StringRequest, rrifr *stringplanmodifier.RequiresReplaceIfFuncResponse, ) { - rrifr.RequiresReplace = !helper.CompareTimeStrings( + rrifr.RequiresReplace = !helper.CompareRFC3339TimeStrings( sr.PlanValue.ValueString(), sr.StateValue.ValueString(), - time.RFC3339, ) }, RequireReplacementWhenScopesChangedDescription, diff --git a/linode/vpcsubnet/resource_test.go b/linode/vpcsubnet/resource_test.go index 4fea47abc..ce2c2f99f 100644 --- a/linode/vpcsubnet/resource_test.go +++ b/linode/vpcsubnet/resource_test.go @@ -93,6 +93,8 @@ func TestAccResourceVPCSubnet_update(t *testing.T) { ImportState: true, ImportStateVerify: true, ImportStateIdFunc: resourceImportStateID, + // TODO:: remove when API response for updated timestamp is consistent + ImportStateVerifyIgnore: []string{"updated"}, }, }, })