diff --git a/.github/images/logo-full.svg b/.github/images/logo-full.svg new file mode 100644 index 0000000..c1e5864 --- /dev/null +++ b/.github/images/logo-full.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..95bc764 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Go Lint and Format + +on: + pull_request: + push: + branches: + - "main" + - "master" + +env: + GO_VERSION: stable + GOLANGCI_LINT_VERSION: v1.60 + +jobs: + golangci-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - name: golangci-lint Action + uses: golangci/golangci-lint-action@v6 + with: + args: --timeout=3m + + terraform-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: hashicorp/setup-terraform@v3.1.2 + - name: Terraform Accepatance Tests + run: | + DFCLOUD_API_KEY=${{ secrets.DFCLOUD_API_KEY }} make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bea4ce8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +# Terraform Provider release workflow. +name: Release + +# This GitHub action creates a release when a tag that matches the pattern +# "v*" (e.g. v0.1.0) is created. +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: "The version of the release" + type: string + required: false + +# Releases need permissions to read and write the repository contents. +# GitHub considers creating releases and uploading assets as writing contents. +permissions: + contents: write + +env: + GO_VERSION: stable + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + cache: true + go-version: ${{ env.GO_VERSION }} + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + id: import_gpg + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + args: release --clean + env: + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + VERSION: ${{ github.event.inputs.version || github.ref }} + diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..b105516 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,64 @@ +# Visit https://goreleaser.com for documentation on how to customize this +# behavior. +version: 2 +before: + hooks: + # this is just an example and not a requirement for provider building/publishing + - go mod tidy +builds: +- env: + # goreleaser does not work with CGO, it could also complicate + # usage by users in CI/CD systems like HCP Terraform where + # they are unable to install libraries. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + goos: + - freebsd + - windows + - linux + - darwin + goarch: + - amd64 + - '386' + - arm + - arm64 + ignore: + - goos: darwin + goarch: '386' + binary: '{{ .ProjectName }}_v{{ .Version }}' +archives: +- format: zip + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' +checksum: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 +signs: + - artifacts: checksum + args: + # if you are using this in a GitHub action or some other automated pipeline, you + # need to pass the batch flag to indicate its not interactive. + - "--batch" + - "--local-user" + - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" +release: + github: + owner: dragonflydb + name: terraform-provider-dfcloud + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + # If you want to manually examine the release before its live, uncomment this line: + # draft: true +changelog: + disable: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b440d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PLUGIN_NAME := terraform-provider-dfcloud +PLUGIN_PATH := ./bin/ + +build: + @echo "Building Terraform plugin..." + goreleaser build --single-target --skip=validate --clean --snapshot + +install-tfplugindocs: + @echo "Installing tfplugindocs tool..." + @go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.19.4 + +docs: install-tfplugindocs + @echo "Updating documentation..." + @tfplugindocs generate --provider-name dfcloud + +.PHONY: install-tfplugindocs install-goreleaser docs + +test: + @echo "Run acceptance tests against the provider" + TF_ACC=true go test ./... $(CLI_ARGS) + +update-terraformrc: + @echo 'provider_installation {\n dev_overrides {\n "registry.terraform.io/dragonflydb/dfcloud" = "$(PWD)/bin"\n "registry.terraform.io/hashicorp/aws" = "$(PWD)/bin"\n }\n\n # For all other providers, install them directly from their origin provider\n # registries as normal. If you omit this, Terraform will _only_ use\n # the dev_overrides block, and so no other providers will be available.\n direct {}\n}' > ~/.terraformrc + +.PHONY: build install update-terraformrc diff --git a/README.md b/README.md index cbf6468..3977f6c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # terraform-provider-dfcloud +

+ + Dragonfly + +

+ Dragonfly Cloud terraform provider is a terraform provider for managing resources in [Dragonfly Cloud](https://dragonflydb.cloud/). This provider is currently in *beta*. Feel free to try it out and provide feedback through issues. diff --git a/docs/resources/datastore.md b/docs/resources/datastore.md index 95ab489..0446722 100644 --- a/docs/resources/datastore.md +++ b/docs/resources/datastore.md @@ -3,12 +3,12 @@ page_title: "dfcloud_datastore Resource - dfcloud" subcategory: "" description: |- - Manages a Dragonfly datastore resource. + Manages a Dragonfly datastore. --- # dfcloud_datastore (Resource) -Manages a Dragonfly datastore resource. +Manages a Dragonfly datastore. @@ -30,7 +30,7 @@ Manages a Dragonfly datastore resource. - `addr` (String) The address of the datastore. - `created_at` (Number) The timestamp when the datastore was created. -- `id` (String) The ID of the datastore. +- `datastore_id` (String) The ID of the datastore. - `password` (String, Sensitive) The password for the datastore. diff --git a/docs/resources/network.md b/docs/resources/network.md index 19503e5..488bd3c 100644 --- a/docs/resources/network.md +++ b/docs/resources/network.md @@ -26,7 +26,7 @@ Manages a Dragonfly network. - `created_at` (Number) The timestamp when the network was created. - `id` (String) The ID of the network. - `status` (String) The status of the network. -- `vpc` (Attributes) The VPC information for the network. (see [below for nested schema](#nestedatt--vpc)) +- `vpc` (Object) The VPC information for the network. (see [below for nested schema](#nestedatt--vpc)) ### Nested Schema for `location` @@ -42,5 +42,5 @@ Required: Read-Only: -- `account_id` (String) The account ID of the VPC. -- `resource_id` (String) The resource ID of the VPC. +- `account_id` (String) +- `resource_id` (String) diff --git a/examples/network/main.tf b/examples/network/main.tf new file mode 100644 index 0000000..c98862b --- /dev/null +++ b/examples/network/main.tf @@ -0,0 +1,62 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "5.50.0" + } + + dfcloud = { + source = "registry.terraform.io/dragonflydb/dfcloud" + } + } +} + +provider "aws" { + # Configuration options +} + +provider "dfcloud" { + # Configuration options +} + +data "aws_caller_identity" "current" {} + +# client VPC +resource "aws_vpc" "client" { + cidr_block = "10.0.0.0/16" + + tags = { + Name = "client" + } +} + +# private network +resource "dfcloud_network" "network" { + name = "network" + location = { + region = "us-east-1" + provider = "aws" + } + cidr_block = "192.168.0.0/16" +} + +# private connection +resource "dfcloud_connection" "connection" { + depends_on = [aws_vpc.client, dfcloud_network.network] + + name = "connection" + peer = { + account_id = data.aws_caller_identity.current.account_id + region = "us-east-1" + vpc_id = aws_vpc.client.id + } + + network_id = dfcloud_network.network.id +} + +resource "aws_vpc_peering_connection_accepter" "accepter" { + depends_on = [dfcloud_connection.connection] + + vpc_peering_connection_id = dfcloud_connection.connection.peer_connection_id + auto_accept = true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..534473e --- /dev/null +++ b/go.mod @@ -0,0 +1,65 @@ +module github.com/dragonflydb/terraform-provider-dfcloud + +go 1.22.0 + +toolchain go1.22.10 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.8.0 + github.com/hashicorp/terraform-plugin-go v0.25.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 + github.com/hashicorp/terraform-plugin-testing v1.11.0 + github.com/samber/lo v1.47.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/cloudflare/circl v1.3.7 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/hc-install v0.9.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 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.15.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/grpc v1.67.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc663b0 --- /dev/null +++ b/go.sum @@ -0,0 +1,220 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= +github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +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/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= +github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +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/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= +github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6eLhghE= +github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= +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= +github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= +github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= +github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= +github.com/hashicorp/terraform-plugin-framework v1.8.0 h1:P07qy8RKLcoBkCrY2RHJer5AEvJnDuXomBgou6fD8kI= +github.com/hashicorp/terraform-plugin-framework v1.8.0/go.mod h1:/CpTukO88PcL/62noU7cuyaSJ4Rsim+A/pa+3rUVufY= +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= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +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.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= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= +github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= +github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +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-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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/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/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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/provider/connection.go b/internal/provider/connection.go new file mode 100644 index 0000000..ec77da3 --- /dev/null +++ b/internal/provider/connection.go @@ -0,0 +1,191 @@ +package provider + +import ( + "context" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/dragonflydb/terraform-provider-dfcloud/internal/resource_model" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type ConnectionResource struct { + client *dfcloud.Client +} + +func NewConnectionResource() resource.Resource { + return &ConnectionResource{} +} + +func (r *ConnectionResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "dfcloud_connection" +} + +func (r *ConnectionResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a Dragonfly network connection.", + Attributes: map[string]schema.Attribute{ + "connection_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the connection.", + Computed: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "The status of the connection.", + Computed: true, + }, + "status_detail": schema.StringAttribute{ + MarkdownDescription: "Additional details about the connection status.", + Computed: true, + }, + "peer_connection_id": schema.StringAttribute{ + MarkdownDescription: "The underlying cloud provider connection ID.", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the connection.", + Required: true, + }, + "network_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the network to connect to.", + Required: true, + }, + "peer": schema.SingleNestedAttribute{ + MarkdownDescription: "The VPC to connect to.", + Required: true, + Attributes: map[string]schema.Attribute{ + "account_id": schema.StringAttribute{ + MarkdownDescription: "The account ID of the target VPC.", + Required: true, + }, + "vpc_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the target VPC.", + Required: true, + }, + "region": schema.StringAttribute{ + MarkdownDescription: "The region of the target VPC.", + Optional: true, + }, + }, + }, + }, + } +} + +func (r *ConnectionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*dfcloud.Client) + if !ok { + resp.Diagnostics.AddError("failed to get provider", "failed to get provider") + } + + r.client = client +} + +func (r *ConnectionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var state resource_model.Connection + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + resp.Diagnostics.AddError("failed to get plan into state", "failed to get plan into state") + } + + connConfig := resource_model.IntoConnectionConfig(state) + respConn, err := r.client.CreateConnection(ctx, connConfig) + if err != nil { + resp.Diagnostics.AddError("failed to create connection", err.Error()) + return + } + + // wait until VPC IDs are created + waitForConnectionStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + respConn, err = resource_model.WaitUntilConnectionStatus(waitForConnectionStatusCtx, r.client, respConn.ID, dfcloud.ConnectionStatusInactive) + if err != nil { + resp.Diagnostics.AddError("failed to wait for connection", err.Error()) + return + } + + state.ConnectionID = types.StringValue(respConn.ID) + state.Status = types.StringValue(string(respConn.Status)) + state.StatusDetail = types.StringValue(respConn.StatusDetail) + state.PeerConnID = types.StringValue(respConn.PeerConnectionID) + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *ConnectionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state resource_model.Connection + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + respConn, err := r.client.GetConnection(ctx, state.ConnectionID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed to read connection", err.Error()) + return + } + + if respConn.Status == dfcloud.ConnectionStatusDeleted { + resp.State.RemoveResource(ctx) + return + } + + state.ConnectionID = types.StringValue(respConn.ID) + state.Status = types.StringValue(string(respConn.Status)) + state.StatusDetail = types.StringValue(respConn.StatusDetail) + state.PeerConnID = types.StringValue(respConn.PeerConnectionID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *ConnectionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Connections can't be updated + resp.Diagnostics.AddError( + "Updating a Connection is not supported", + "Updating a Connection is not supported", + ) +} + +func (r *ConnectionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *resource_model.Connection + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteConnection(ctx, state.ConnectionID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed to delete connection", err.Error()) + } + + // wait until connection is deleted + waitForConnectionStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + _, err = resource_model.WaitUntilConnectionStatus(waitForConnectionStatusCtx, r.client, state.ConnectionID.ValueString(), dfcloud.ConnectionStatusDeleted) + if err != nil { + resp.Diagnostics.AddError("failed to wait for connection", err.Error()) + } + +} + +func (r *ConnectionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + connection, err := r.client.GetConnection(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("failed to get network", err.Error()) + return + } + + state := resource_model.FromConnectionConfig(connection) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +var ( + _ resource.Resource = &ConnectionResource{} + _ resource.ResourceWithImportState = &ConnectionResource{} +) diff --git a/internal/provider/datastore.go b/internal/provider/datastore.go new file mode 100644 index 0000000..bd618e6 --- /dev/null +++ b/internal/provider/datastore.go @@ -0,0 +1,323 @@ +package provider + +import ( + "context" + "fmt" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/dragonflydb/terraform-provider-dfcloud/internal/resource_model" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +// NewDatastoreResource is a helper function to simplify the provider implementation. +func NewDatastoreResource() resource.Resource { + return &datastoreResource{} +} + +// datastoreResource is the resource implementation. +type datastoreResource struct { + client *dfcloud.Client +} + +// Metadata returns the resource type name. +func (r *datastoreResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "datastore" +} + +// Schema defines the schema for the resource. +func (r *datastoreResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a Dragonfly datastore resource.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The ID of the datastore.", + Computed: true, + }, + "created_at": schema.Int64Attribute{ + MarkdownDescription: "The timestamp when the datastore was created.", + Computed: true, + }, + "password": schema.StringAttribute{ + MarkdownDescription: "The password for the datastore.", + Computed: true, + Sensitive: true, + }, + "addr": schema.StringAttribute{ + MarkdownDescription: "The address of the datastore.", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the datastore.", + Required: true, + }, + "location": schema.SingleNestedAttribute{ + MarkdownDescription: "The location configuration for the datastore.", + Required: true, + Attributes: map[string]schema.Attribute{ + "provider": schema.StringAttribute{ + MarkdownDescription: "The provider for the datastore location.", + Required: true, + }, + "region": schema.StringAttribute{ + MarkdownDescription: "The region for the datastore location.", + Required: true, + }, + "availability_zones": schema.ListAttribute{ + MarkdownDescription: "The availability zones for the datastore location.", + ElementType: types.StringType, + Optional: true, + Computed: true, + }, + }, + }, + "tier": schema.SingleNestedAttribute{ + MarkdownDescription: "The tier configuration for the datastore.", + Required: true, + Attributes: map[string]schema.Attribute{ + "max_memory_bytes": schema.Int64Attribute{ + MarkdownDescription: "The maximum memory (in bytes) for the datastore.", + Required: true, + }, + "performance_tier": schema.StringAttribute{ + MarkdownDescription: "The performance tier for the datastore.", + Required: true, + }, + "replicas": schema.Int64Attribute{ + MarkdownDescription: "The number of replicas for the datastore.", + Optional: true, + }, + }, + }, + "network_id": schema.StringAttribute{ + MarkdownDescription: "The ID of the network the datastore should be placed into.", + Optional: true, + }, + "dragonfly": schema.SingleNestedAttribute{ + MarkdownDescription: "Dragonfly-specific configuration.", + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "cache_mode": schema.BoolAttribute{ + MarkdownDescription: "Enable cache mode for memory management.", + Optional: true, + Computed: true, + }, + "bullmq": schema.BoolAttribute{ + MarkdownDescription: "Enable BullMQ compatibility.", + Optional: true, + Computed: true, + }, + "tls": schema.BoolAttribute{ + MarkdownDescription: "Enable TLS.", + Optional: true, + Computed: true, + }, + "sidekiq": schema.BoolAttribute{ + MarkdownDescription: "Enable Sidekiq compatibility.", + Optional: true, + Computed: true, + }, + "memcached": schema.BoolAttribute{ + MarkdownDescription: "Enable Memcached protocol.", + Optional: true, + Computed: true, + }, + "acl_rules": schema.ListAttribute{ + MarkdownDescription: "List of ACL rules.", + ElementType: types.StringType, + Optional: true, + Computed: true, + }, + }, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *datastoreResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*dfcloud.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Provider Data Type", + fmt.Sprintf("Expected *dfcloud.Client, got: %T", req.ProviderData), + ) + return + } + + r.client = client +} + +// Create a new resource. +func (r *datastoreResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resource_model.Datastore + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + datastore := resource_model.IntoDatastoreConfig(plan) + if datastore == nil { + resp.Diagnostics.AddError("Configuration Error", "Failed to create datastore configuration") + return + } + + respDatastore, err := r.client.CreateDatastore(ctx, &datastore.Config) + if err != nil { + resp.Diagnostics.AddError("Error Creating Datastore", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + respDatastore, err = resource_model.WaitForDatastoreStatus(ctx, r.client, respDatastore.ID, dfcloud.DatastoreStatusActive) + if err != nil { + resp.Diagnostics.AddError("Error Creating Datastore", err.Error()) + return + } + + tflog.Info(ctx, "created datastore", map[string]interface{}{ + "datastore_id": respDatastore.ID, + "status": respDatastore.Status, + }) + + plan.FromConfig(respDatastore) + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +// Read resource information. +func (r *datastoreResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state resource_model.Datastore + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + respDatastore, err := r.client.GetDatastore(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Reading Datastore", err.Error()) + return + } + + if respDatastore.Status == dfcloud.DatastoreStatusDeleted { + resp.State.RemoveResource(ctx) + return + } + + tflog.Info(ctx, "read datastore", map[string]interface{}{ + "datastore_id": respDatastore.ID, + "status": respDatastore.Status, + }) + + state.FromConfig(respDatastore) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +// Update resource information. +func (r *datastoreResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var state resource_model.Datastore + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // retreive datastore to check if it is active + respDatastore, err := r.client.GetDatastore(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Updating Datastore", err.Error()) + } + + if respDatastore.Status == dfcloud.DatastoreStatusUpdating || respDatastore.Status == dfcloud.DatastoreStatusPending || respDatastore.Status == dfcloud.DatastoreStatusDeleting { + resp.Diagnostics.AddError("Error Reading Datastore", "Datastore is not active") + } + + var plan resource_model.Datastore + diags = req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + datastore := resource_model.IntoDatastoreConfig(plan) + respDatastore, err = r.client.UpdateDatastore(ctx, state.ID.ValueString(), &datastore.Config) + if err != nil { + resp.Diagnostics.AddError("Error Updating Datastore", err.Error()) + return + } + + waitForDatastoreStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + respDatastore, err = resource_model.WaitForDatastoreStatus(waitForDatastoreStatusCtx, r.client, respDatastore.ID, dfcloud.DatastoreStatusActive) + if err != nil { + resp.Diagnostics.AddError("Error Waiting for Datastore Update", err.Error()) + return + } + + tflog.Info(ctx, "updated datastore", map[string]interface{}{ + "datastore_id": respDatastore.ID, + "status": respDatastore.Status, + }) + + plan.FromConfig(respDatastore) + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *datastoreResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state resource_model.Datastore + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteDatastore(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Deleting Datastore", err.Error()) + } + + waitForDatastoreStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + _, err = resource_model.WaitForDatastoreStatus(waitForDatastoreStatusCtx, r.client, state.ID.ValueString(), dfcloud.DatastoreStatusDeleted) + if err != nil { + resp.Diagnostics.AddError("Error Deleting Datastore", err.Error()) + } + + tflog.Info(ctx, "deleted datastore", map[string]interface{}{ + "datastore_id": state.ID.ValueString(), + }) +} + +// ImportState imports the resource state from an external system. +func (r *datastoreResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + datastore, err := r.client.GetDatastore(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("Error Importing Datastore", err.Error()) + return + } + + var plan resource_model.Datastore + plan.FromConfig(datastore) + diags := resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +var ( + _ resource.Resource = &datastoreResource{} + _ resource.ResourceWithConfigure = &datastoreResource{} + _ resource.ResourceWithImportState = &datastoreResource{} +) diff --git a/internal/provider/datastore_test.go b/internal/provider/datastore_test.go new file mode 100644 index 0000000..0f68d2a --- /dev/null +++ b/internal/provider/datastore_test.go @@ -0,0 +1,109 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func testCheckDatastoreExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no datastore ID is set") + } + + _, err := testClient().GetDatastore(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching datastore with ID %s: %s", rs.Primary.ID, err) + } + + return nil + } +} + +func testCheckDatastoreDestroy(s *terraform.State) error { + client := testClient() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "dfcloud_datastore" { + continue + } + + ds, err := client.GetDatastore(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching datastore with ID %s: %s", rs.Primary.ID, err) + } + + if ds.Status != dfcloud.DatastoreStatusDeleting && ds.Status != dfcloud.DatastoreStatusDeleted { + return fmt.Errorf("datastore still exists") + } + } + + return nil +} + +func TestAcc_DatastoreResource(t *testing.T) { + name := "tf-test-" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckDatastoreDestroy, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccDatastoreResourceConfig(name), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckDatastoreExists("dfcloud_datastore.test"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "name", name), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "location.provider", "aws"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "location.region", "us-east-1"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "tier.performance_tier", "dev"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "tier.max_memory_bytes", "3000000000"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "tier.replicas", "1"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "dragonfly.cache_mode", "false"), + resource.TestCheckResourceAttr("dfcloud_datastore.test", "dragonfly.tls", "false"), + resource.TestCheckResourceAttrSet("dfcloud_datastore.test", "id"), + resource.TestCheckResourceAttrSet("dfcloud_datastore.test", "addr"), + resource.TestCheckResourceAttrSet("dfcloud_datastore.test", "created_at"), + resource.TestCheckResourceAttrSet("dfcloud_datastore.test", "password"), + ), + }, + // Import State + { + ResourceName: "dfcloud_datastore.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccDatastoreResourceConfig(name string) string { + return fmt.Sprintf(` +resource "dfcloud_datastore" "test" { + name = %[1]q + + location = { + provider = "aws" + region = "us-east-1" + } + + tier = { + max_memory_bytes = 3000000000 # 3GB + performance_tier = "dev" + replicas = 1 + } +} +`, name) +} diff --git a/internal/provider/network.go b/internal/provider/network.go new file mode 100644 index 0000000..58513bc --- /dev/null +++ b/internal/provider/network.go @@ -0,0 +1,189 @@ +package provider + +import ( + "context" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/dragonflydb/terraform-provider-dfcloud/internal/resource_model" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +type NetworkResource struct { + client *dfcloud.Client +} + +func NewNetworkResource() resource.Resource { + return &NetworkResource{} +} + +func (r *NetworkResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "dfcloud_network" +} + +func (r *NetworkResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a Dragonfly network.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The ID of the network.", + Computed: true, + }, + "created_at": schema.Int64Attribute{ + MarkdownDescription: "The timestamp when the network was created.", + Computed: true, + }, + "status": schema.StringAttribute{ + MarkdownDescription: "The status of the network.", + Computed: true, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the network.", + Required: true, + }, + "location": schema.SingleNestedAttribute{ + MarkdownDescription: "The location configuration for the network.", + Required: true, + Attributes: map[string]schema.Attribute{ + "provider": schema.StringAttribute{ + MarkdownDescription: "The provider for the network location.", + Required: true, + }, + "region": schema.StringAttribute{ + MarkdownDescription: "The region for the network location.", + Required: true, + }, + }, + }, + "cidr_block": schema.StringAttribute{ + MarkdownDescription: "The CIDR block for the network.", + Required: true, + }, + "vpc": schema.SingleNestedAttribute{ + MarkdownDescription: "The VPC information for the network.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "resource_id": schema.StringAttribute{ + MarkdownDescription: "The resource ID of the VPC.", + Computed: true, + }, + "account_id": schema.StringAttribute{ + MarkdownDescription: "The account ID of the VPC.", + Computed: true, + }, + }, + }, + }, + } +} + +func (r *NetworkResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*dfcloud.Client) + if !ok { + resp.Diagnostics.AddError("failed to get provider", "failed to get provider") + } + + r.client = client +} + +func (r *NetworkResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var state resource_model.Network + resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + resp.Diagnostics.AddError("failed to get plan into state", "failed to get plan into state") + } + + networkConfig := resource_model.IntoNetworkConfig(state) + respNetwork, err := r.client.CreateNetwork(ctx, networkConfig) + if err != nil { + resp.Diagnostics.AddError("failed to create network", err.Error()) + return + } + + // wait until VPC IDs are created + waitForNetworkStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + respNetwork, err = resource_model.WaitUntilNetworkStatus(waitForNetworkStatusCtx, r.client, respNetwork.ID, dfcloud.NetworkStatusActive) + if err != nil { + resp.Diagnostics.AddError("failed to wait for network", err.Error()) + return + } + + state = *resource_model.FromNetworkConfig(respNetwork) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *NetworkResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state resource_model.Network + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + waitForNetworkStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + respNetwork, err := resource_model.WaitUntilNetworkStatus(waitForNetworkStatusCtx, r.client, state.Id.ValueString(), dfcloud.NetworkStatusActive) + if err != nil { + resp.Diagnostics.AddError("failed to wait for network", err.Error()) + return + } + + if respNetwork.Status == dfcloud.NetworkStatusDeleted { + resp.State.RemoveResource(ctx) + return + } + + state = *resource_model.FromNetworkConfig(respNetwork) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *NetworkResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // no updates allowed for networks + resp.Diagnostics.AddError( + "Updating a Network is not supported", + "Updating a Network is not supported", + ) +} + +func (r *NetworkResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *resource_model.Network + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteNetwork(ctx, state.Id.ValueString()) + if err != nil { + resp.Diagnostics.AddError("failed to delete network", err.Error()) + } + + // wait until network is deleted + waitForNetworkStatusCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + _, err = resource_model.WaitUntilNetworkStatus(waitForNetworkStatusCtx, r.client, state.Id.ValueString(), dfcloud.NetworkStatusDeleted) + if err != nil { + resp.Diagnostics.AddError("failed to wait for network deletion", err.Error()) + } +} + +func (r *NetworkResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + network, err := r.client.GetNetwork(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("failed to get network", err.Error()) + return + } + + state := resource_model.FromNetworkConfig(network) + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +var ( + _ resource.Resource = &NetworkResource{} + _ resource.ResourceWithImportState = &NetworkResource{} +) diff --git a/internal/provider/network_test.go b/internal/provider/network_test.go new file mode 100644 index 0000000..4581c6c --- /dev/null +++ b/internal/provider/network_test.go @@ -0,0 +1,102 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func testCheckNetworkExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no network ID is set") + } + + _, err := testClient().GetNetwork(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching network with ID %s: %s", rs.Primary.ID, err) + } + + return nil + } +} + +func testCheckNetworkDestroy(s *terraform.State) error { + client := testClient() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "dfcloud_network" { + continue + } + + network, err := client.GetNetwork(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching network with ID %s: %s", rs.Primary.ID, err) + } + + if network.Status != dfcloud.NetworkStatusDeleting && network.Status != dfcloud.NetworkStatusDeleted { + return fmt.Errorf("network still exists") + } + } + + return nil +} + +func TestAcc_NetworkResource(t *testing.T) { + name := "tf-test-" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testCheckNetworkDestroy, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccNetworkResourceConfig(name), + Check: resource.ComposeAggregateTestCheckFunc( + testCheckNetworkExists("dfcloud_network.test"), + resource.TestCheckResourceAttr("dfcloud_network.test", "name", name), + resource.TestCheckResourceAttr("dfcloud_network.test", "location.provider", "aws"), + resource.TestCheckResourceAttr("dfcloud_network.test", "location.region", "us-east-1"), + resource.TestCheckResourceAttr("dfcloud_network.test", "cidr_block", "10.0.0.0/16"), + resource.TestCheckResourceAttrSet("dfcloud_network.test", "id"), + resource.TestCheckResourceAttrSet("dfcloud_network.test", "created_at"), + resource.TestCheckResourceAttrSet("dfcloud_network.test", "status"), + resource.TestCheckResourceAttrSet("dfcloud_network.test", "vpc.resource_id"), + resource.TestCheckResourceAttrSet("dfcloud_network.test", "vpc.account_id"), + ), + }, + // Import State + { + ResourceName: "dfcloud_network.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccNetworkResourceConfig(name string) string { + return fmt.Sprintf(` +resource "dfcloud_network" "test" { + name = %[1]q + + location = { + provider = "aws" + region = "us-east-1" + } + + cidr_block = "10.0.0.0/16" +} +`, name) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..8b0f495 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,97 @@ +package provider + +import ( + "context" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type DragonflyDBCloudProvider struct { + version string +} + +type ProviderSchema struct { + ApiKey types.String `tfsdk:"api_key"` + ApiHost types.String `tfsdk:"api_host"` +} + +func NewDragonflyDBCloudProvider(version string) func() provider.Provider { + return func() provider.Provider { + return &DragonflyDBCloudProvider{ + version: version, + } + } +} + +func (p DragonflyDBCloudProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "dfcloud_" + resp.Version = p.version +} + +func (p DragonflyDBCloudProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "api_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "Dragonfly Cloud API key. This can also be set via the DFCLOUD_API_KEY environment variable.", + }, + "api_host": schema.StringAttribute{ + Optional: true, + Description: "The URL of the Dragonfly Cloud API.", + }, + }, + Description: `The Dragonfly Cloud provider is used to interact with resources supported by Dragonfly Cloud. + +The provider needs to be configured with the proper credentials before it can be used.`, + } +} + +func (p DragonflyDBCloudProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config ProviderSchema + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + var options []dfcloud.ClientOption + if config.ApiKey.ValueString() != "" { + options = append(options, dfcloud.WithAPIKey(config.ApiKey.ValueString())) + + } else { + options = append(options, dfcloud.WithAPIKeyFromEnv()) + } + + if config.ApiHost.ValueString() != "" { + options = append(options, dfcloud.WithAPIHost(config.ApiHost.ValueString())) + } + + client, err := dfcloud.NewClient(options...) + if err != nil { + resp.Diagnostics.AddError("failed to create client", err.Error()) + return + } + + resp.ResourceData = client +} + +func (p DragonflyDBCloudProvider) DataSources(ctx context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + // Provider specific implementation + } +} + +func (p DragonflyDBCloudProvider) Resources(ctx context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewDatastoreResource, + NewNetworkResource, + NewConnectionResource, + } +} + +var _ provider.Provider = &DragonflyDBCloudProvider{} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..c1f6c7d --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,36 @@ +package provider + +import ( + "os" + "testing" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ + "dfcloud": providerserver.NewProtocol6WithError(NewDragonflyDBCloudProvider("dev")()), +} + +func testAccPreCheck(t *testing.T) { + if os.Getenv("DFCLOUD_API_KEY") == "" { + t.Fatalf("DFCLOUD_API_KEY environment variable must be set for acceptance tests") + } +} + +var tc *dfcloud.Client + +func testClient() *dfcloud.Client { + if tc == nil { + var options []dfcloud.ClientOption + + options = append(options, dfcloud.WithAPIKeyFromEnv()) + + client, _ := dfcloud.NewClient(options...) + + tc = client + } + + return tc +} diff --git a/internal/resource_model/connection.go b/internal/resource_model/connection.go new file mode 100644 index 0000000..b6231b5 --- /dev/null +++ b/internal/resource_model/connection.go @@ -0,0 +1,77 @@ +package resource_model + +import ( + "context" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type Connection struct { + ConnectionID types.String `tfsdk:"connection_id"` + Name types.String `tfsdk:"name"` + NetworkID types.String `tfsdk:"network_id"` + Peer *PeerConfigModel `tfsdk:"peer"` + Status types.String `tfsdk:"status"` + StatusDetail types.String `tfsdk:"status_detail"` + PeerConnID types.String `tfsdk:"peer_connection_id"` +} + +type PeerConfigModel struct { + AccountID types.String `tfsdk:"account_id"` + VPCID types.String `tfsdk:"vpc_id"` + Region types.String `tfsdk:"region"` +} + +func IntoPeerConfig(in PeerConfigModel) dfcloud.PeerConfig { + return dfcloud.PeerConfig{ + AccountID: in.AccountID.ValueString(), + VPCID: in.VPCID.ValueString(), + Region: in.Region.ValueString(), + } +} + +func IntoConnectionConfig(in Connection) *dfcloud.ConnectionConfig { + return &dfcloud.ConnectionConfig{ + Name: in.Name.ValueString(), + NetworkID: in.NetworkID.ValueString(), + Peer: IntoPeerConfig(*in.Peer), + } +} + +func FromConnectionConfig(in *dfcloud.Connection) *Connection { + return &Connection{ + ConnectionID: types.StringValue(in.ID), + Name: types.StringValue(in.Config.Name), + NetworkID: types.StringValue(in.Config.NetworkID), + Peer: &PeerConfigModel{ + AccountID: types.StringValue(in.Config.Peer.AccountID), + VPCID: types.StringValue(in.Config.Peer.VPCID), + Region: types.StringValue(in.Config.Peer.Region), + }, + Status: types.StringValue(string(in.Status)), + StatusDetail: types.StringValue(in.StatusDetail), + PeerConnID: types.StringValue(in.PeerConnectionID), + } +} + +func WaitUntilConnectionStatus(ctx context.Context, client *dfcloud.Client, id string, status dfcloud.ConnectionStatus) (*dfcloud.Connection, error) { + for { + conn, err := client.GetConnection(ctx, id) + if err != nil { + return nil, err + } + + if conn.Status == status { + return conn, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(5 * time.Second): + + } + } +} diff --git a/internal/resource_model/datastore.go b/internal/resource_model/datastore.go new file mode 100644 index 0000000..af8ac13 --- /dev/null +++ b/internal/resource_model/datastore.go @@ -0,0 +1,158 @@ +package resource_model + +import ( + "context" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/samber/lo" +) + +// Datastore maps the resource schema data. +type Datastore struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + NetworkId types.String `tfsdk:"network_id"` + Location DatastoreLocation `tfsdk:"location"` + Tier DatastoreTier `tfsdk:"tier"` + Dragonfly types.Object `tfsdk:"dragonfly"` + CreatedAt types.Int64 `tfsdk:"created_at"` + Password types.String `tfsdk:"password"` + Addr types.String `tfsdk:"addr"` +} + +type DatastoreLocation struct { + Provider types.String `tfsdk:"provider"` + Region types.String `tfsdk:"region"` + AvailabilityZones types.List `tfsdk:"availability_zones"` +} + +type DatastoreTier struct { + Memory types.Int64 `tfsdk:"max_memory_bytes"` + PerformanceTier types.String `tfsdk:"performance_tier"` + Replicas types.Int64 `tfsdk:"replicas"` +} + +func (d *Datastore) FromConfig(in *dfcloud.Datastore) { + d.ID = types.StringValue(in.ID) + d.Name = types.StringValue(in.Config.Name) + d.NetworkId = types.StringNull() + d.CreatedAt = types.Int64Value(in.CreatedAt) + d.Location.Provider = types.StringValue(string(in.Config.Location.Provider)) + d.Location.Region = types.StringValue(in.Config.Location.Region) + d.Location.AvailabilityZones, _ = types.ListValueFrom(context.Background(), types.StringType, in.Config.Location.AvailabilityZones) + d.Addr = types.StringValue(in.Addr) + d.Password = types.StringValue(in.Key) + d.Tier.Memory = types.Int64Value(int64(in.Config.Tier.Memory)) + d.Tier.PerformanceTier = types.StringValue(string(in.Config.Tier.PerformanceTier)) + d.Tier.Replicas = types.Int64Value(int64(*in.Config.Tier.Replicas)) + + aclRules, _ := types.ListValueFrom(context.Background(), types.StringType, in.Config.Dragonfly.AclRules) + d.Dragonfly = types.ObjectValueMust(map[string]attr.Type{ + "cache_mode": types.BoolType, + "tls": types.BoolType, + "bullmq": types.BoolType, + "sidekiq": types.BoolType, + "memcached": types.BoolType, + "acl_rules": types.ListType{ElemType: types.StringType}, + }, map[string]attr.Value{ + "cache_mode": types.BoolPointerValue(in.Config.Dragonfly.CacheMode), + "tls": types.BoolPointerValue(in.Config.Dragonfly.TLS), + "bullmq": types.BoolPointerValue(in.Config.Dragonfly.BullMQ), + "sidekiq": types.BoolPointerValue(in.Config.Dragonfly.Sidekiq), + "memcached": types.BoolPointerValue(in.Config.Dragonfly.Memcached), + "acl_rules": aclRules, + }) + + if in.Config.NetworkID != "" { + d.NetworkId = types.StringValue(in.Config.NetworkID) + } + +} + +func IntoDatastoreConfig(in Datastore) *dfcloud.Datastore { + var zones []string + for _, z := range in.Location.AvailabilityZones.Elements() { + zones = append(zones, z.String()) + } + + datastore := &dfcloud.Datastore{ + ID: in.ID.ValueString(), + Config: dfcloud.DatastoreConfig{ + Name: in.Name.ValueString(), + Location: dfcloud.DatastoreLocation{ + Provider: dfcloud.CloudProvider(in.Location.Provider.ValueString()), + Region: in.Location.Region.ValueString(), + AvailabilityZones: zones, + }, + Tier: dfcloud.DatastoreTier{ + Memory: uint64(in.Tier.Memory.ValueInt64()), + PerformanceTier: dfcloud.PerformanceTier(in.Tier.PerformanceTier.ValueString()), + Replicas: lo.ToPtr(int(in.Tier.Replicas.ValueInt64())), + }, + }, + } + + if !in.NetworkId.IsNull() { + datastore.Config.NetworkID = in.NetworkId.ValueString() + } + + if in.Dragonfly.IsNull() { + in.Dragonfly = types.ObjectValueMust(map[string]attr.Type{ + "cache_mode": types.BoolType, + "tls": types.BoolType, + "bullmq": types.BoolType, + "sidekiq": types.BoolType, + "memcached": types.BoolType, + "acl_rules": types.ListType{ElemType: types.StringType}, + }, map[string]attr.Value{}) + } + + if in.Dragonfly.Attributes()["cache_mode"] != nil { + datastore.Config.Dragonfly.CacheMode = lo.ToPtr(in.Dragonfly.Attributes()["cache_mode"].(types.Bool).ValueBool()) + } + if in.Dragonfly.Attributes()["tls"] != nil { + datastore.Config.Dragonfly.TLS = lo.ToPtr(in.Dragonfly.Attributes()["tls"].(types.Bool).ValueBool()) + } + if in.Dragonfly.Attributes()["bullmq"] != nil { + datastore.Config.Dragonfly.BullMQ = lo.ToPtr(in.Dragonfly.Attributes()["bullmq"].(types.Bool).ValueBool()) + } + if in.Dragonfly.Attributes()["sidekiq"] != nil { + datastore.Config.Dragonfly.Sidekiq = lo.ToPtr(in.Dragonfly.Attributes()["sidekiq"].(types.Bool).ValueBool()) + } + if in.Dragonfly.Attributes()["memcached"] != nil { + datastore.Config.Dragonfly.Memcached = lo.ToPtr(in.Dragonfly.Attributes()["memcached"].(types.Bool).ValueBool()) + } + + if in.Dragonfly.Attributes()["acl_rules"] != nil { + var rules dfcloud.AclRuleArray + for _, rule := range in.Dragonfly.Attributes()["acl_rules"].(types.List).Elements() { + rules = append(rules, rule.String()) + } + datastore.Config.Dragonfly.AclRules = &rules + } + + return datastore +} + +// Helper function to wait for datastore to become active. +func WaitForDatastoreStatus(ctx context.Context, client *dfcloud.Client, id string, status dfcloud.DatastoreStatus) (*dfcloud.Datastore, error) { + for { + datastore, err := client.GetDatastore(ctx, id) + if err != nil { + return nil, err + } + + if datastore.Status == status { + return datastore, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(5 * time.Second): + } + } +} diff --git a/internal/resource_model/network.go b/internal/resource_model/network.go new file mode 100644 index 0000000..1d39cc1 --- /dev/null +++ b/internal/resource_model/network.go @@ -0,0 +1,79 @@ +package resource_model + +import ( + "context" + "time" + + dfcloud "github.com/dragonflydb/terraform-provider-dfcloud/internal/sdk" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type NetworkLocation struct { + Provider types.String `tfsdk:"provider"` + Region types.String `tfsdk:"region"` +} + +type Network struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Location *NetworkLocation `tfsdk:"location"` + CidrBlock types.String `tfsdk:"cidr_block"` + CreatedAt types.Int64 `tfsdk:"created_at"` + Status types.String `tfsdk:"status"` + Vpc types.Object `tfsdk:"vpc"` +} + +func IntoNetworkConfig(in Network) *dfcloud.NetworkConfig { + return &dfcloud.NetworkConfig{ + Name: in.Name.ValueString(), + Location: dfcloud.NetworkLocation{ + Provider: dfcloud.CloudProvider(in.Location.Provider.ValueString()), + Region: in.Location.Region.ValueString(), + }, + CIDRBlock: in.CidrBlock.ValueString(), + } +} + +func FromNetworkConfig(in *dfcloud.Network) *Network { + return &Network{ + Id: types.StringValue(in.ID), + Name: types.StringValue(in.Name), + Location: &NetworkLocation{ + Provider: types.StringValue(string(in.Location.Provider)), + Region: types.StringValue(in.Location.Region), + }, + CidrBlock: types.StringValue(in.CIDRBlock), + CreatedAt: types.Int64Value(in.CreatedAt), + Status: types.StringValue(string(in.Status)), + Vpc: types.ObjectValueMust( + map[string]attr.Type{ + "resource_id": types.StringType, + "account_id": types.StringType, + }, + map[string]attr.Value{ + "resource_id": types.StringValue(in.VPC.ResourceID), + "account_id": types.StringValue(in.VPC.AccountID), + }, + ), + } +} + +func WaitUntilNetworkStatus(ctx context.Context, client *dfcloud.Client, id string, status dfcloud.NetworkStatus) (*dfcloud.Network, error) { + for { + network, err := client.GetNetwork(ctx, id) + if err != nil { + return nil, err + } + + if network.Status == status { + return network, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(5 * time.Second): + } + } +} diff --git a/internal/sdk/client.go b/internal/sdk/client.go new file mode 100644 index 0000000..6c7aaa8 --- /dev/null +++ b/internal/sdk/client.go @@ -0,0 +1,336 @@ +package sdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" +) + +type errorResponse struct { + Error string `json:"error"` +} + +type clientOptions struct { + apiKey string + apiHost string + timeout time.Duration +} + +type ClientOption interface { + apply(*clientOptions) +} + +type apiKeyOption string + +func (o apiKeyOption) apply(opts *clientOptions) { + opts.apiKey = string(o) +} + +// WithAPIKey configures the client to authenticate with Dragonfly cloud using +// the given API key. +// +// You can get an API key from the Dragonfly cloud dashboard. +func WithAPIKey(key string) ClientOption { + return apiKeyOption(key) +} + +// WithAPIKeyFromEnv is a shortcut for calling [WithAPIKey] with the +// value of the DFCLOUD_API_KEY environment variable. +func WithAPIKeyFromEnv() ClientOption { + return WithAPIKey(os.Getenv("DFCLOUD_API_KEY")) +} + +type timeoutOption time.Duration + +func (o timeoutOption) apply(opts *clientOptions) { + opts.timeout = time.Duration(o) +} + +// WithTimeout configures the client request timeout. +func WithTimeout(timeout time.Duration) ClientOption { + return timeoutOption(timeout) +} + +type apiHostOption string + +func (o apiHostOption) apply(opts *clientOptions) { + opts.apiHost = string(o) +} + +// WithAPIHost configures the client to use the given API URL. +func WithAPIHost(url string) ClientOption { + return apiHostOption(url) +} + +// Client represents a REST client for the Dragonfly cloud API. +type Client struct { + apiKey string + apiHost string + + httpClient *http.Client +} + +// NewClient creates a Dragonfly cloud client. +// +// The client options must include either [WithAPIKey] or [WithAPIKeyFromEnv] +// to authenticate with Dragonfly cloud. +func NewClient(opts ...ClientOption) (*Client, error) { + options := clientOptions{ + timeout: time.Second * 15, + } + for _, o := range opts { + o.apply(&options) + } + + if options.apiKey == "" { + return nil, fmt.Errorf("missing api key") + } + + if options.apiHost == "" { + // use default + options.apiHost = "api.dragonflydb.cloud" + } + + return &Client{ + apiKey: options.apiKey, + httpClient: &http.Client{ + Timeout: options.timeout, + }, + apiHost: options.apiHost, + }, nil +} + +func (c *Client) GetDatastore(ctx context.Context, id string) (*Datastore, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/datastores/"+id, nil) + if err != nil { + return nil, err + } + defer r.Close() + + var datastore *Datastore + if err := json.NewDecoder(r).Decode(&datastore); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return datastore, nil +} + +func (c *Client) CreateDatastore(ctx context.Context, config *DatastoreConfig) (*Datastore, error) { + b, _ := json.Marshal(&config) + + r, err := c.request(ctx, http.MethodPost, "/v1/datastores", b) + if err != nil { + return nil, err + } + defer r.Close() + + var datastore Datastore + if err := json.NewDecoder(r).Decode(&datastore); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &datastore, nil +} + +func (c *Client) UpdateDatastore(ctx context.Context, id string, config *DatastoreConfig) (*Datastore, error) { + b, _ := json.Marshal(&config) + + r, err := c.request(ctx, http.MethodPut, "/v1/datastores/"+id, b) + if err != nil { + return nil, err + } + defer r.Close() + + var datastore Datastore + if err := json.NewDecoder(r).Decode(&datastore); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &datastore, nil +} + +// ListDatastores lists all the customers datastores. +func (c *Client) ListDatastores(ctx context.Context) ([]*Datastore, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/datastores", nil) + if err != nil { + return nil, err + } + defer r.Close() + + var datastores []*Datastore + if err := json.NewDecoder(r).Decode(&datastores); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return datastores, nil +} + +func (c *Client) DeleteDatastore(ctx context.Context, id string) error { + r, err := c.request(ctx, http.MethodDelete, "/v1/datastores/"+id, nil) + if err != nil { + return err + } + defer r.Close() + + return nil +} + +func (c *Client) GetNetwork(ctx context.Context, id string) (*Network, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/networks/"+id, nil) + if err != nil { + return nil, err + } + defer r.Close() + + var network *Network + if err := json.NewDecoder(r).Decode(&network); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return network, nil +} + +func (c *Client) CreateNetwork(ctx context.Context, config *NetworkConfig) (*Network, error) { + b, _ := json.Marshal(&config) + + r, err := c.request(ctx, http.MethodPost, "/v1/networks", b) + if err != nil { + return nil, err + } + defer r.Close() + + var network Network + if err := json.NewDecoder(r).Decode(&network); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &network, nil +} + +// ListNetworks lists all the customers networks. +func (c *Client) ListNetworks(ctx context.Context) ([]*Network, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/networks", nil) + if err != nil { + return nil, err + } + defer r.Close() + + var networks []*Network + if err := json.NewDecoder(r).Decode(&networks); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return networks, nil +} + +func (c *Client) DeleteNetwork(ctx context.Context, id string) error { + r, err := c.request(ctx, http.MethodDelete, "/v1/networks/"+id, nil) + if err != nil { + return err + } + defer r.Close() + + return nil +} + +func (c *Client) GetConnection(ctx context.Context, id string) (*Connection, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/connections/"+id, nil) + if err != nil { + return nil, err + } + defer r.Close() + + var conn *Connection + if err := json.NewDecoder(r).Decode(&conn); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return conn, nil +} + +func (c *Client) CreateConnection(ctx context.Context, config *ConnectionConfig) (*Connection, error) { + b, _ := json.Marshal(&config) + + r, err := c.request(ctx, http.MethodPost, "/v1/connections", b) + if err != nil { + return nil, err + } + defer r.Close() + + var conn Connection + if err := json.NewDecoder(r).Decode(&conn); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &conn, nil +} + +// ListConnection lists all the customers connections. +func (c *Client) ListConnections(ctx context.Context) ([]*Connection, error) { + r, err := c.request(ctx, http.MethodGet, "/v1/connections", nil) + if err != nil { + return nil, err + } + defer r.Close() + + var conns []*Connection + if err := json.NewDecoder(r).Decode(&conns); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return conns, nil +} + +func (c *Client) DeleteConnection(ctx context.Context, id string) error { + r, err := c.request(ctx, http.MethodDelete, "/v1/connections/"+id, nil) + if err != nil { + return err + } + defer r.Close() + + return nil +} + +func (c *Client) request( + ctx context.Context, + method string, + path string, + body []byte, +) (io.ReadCloser, error) { + url := &url.URL{ + Scheme: "https", + Host: c.apiHost, + Path: path, + } + + var b io.Reader + if body != nil { + b = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url.String(), b) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.apiKey)) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest && + resp.StatusCode < http.StatusInternalServerError { + var errResp errorResponse + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + return nil, fmt.Errorf("bad status: %d", resp.StatusCode) + } + return nil, fmt.Errorf("bad status: %d: %s", resp.StatusCode, errResp.Error) + } + return nil, fmt.Errorf("bad status: %d", resp.StatusCode) + } + + return resp.Body, nil +} diff --git a/internal/sdk/connection.go b/internal/sdk/connection.go new file mode 100644 index 0000000..0a081c6 --- /dev/null +++ b/internal/sdk/connection.go @@ -0,0 +1,61 @@ +package sdk + +// NetworkStatus represents the current status of the connection. +type ConnectionStatus string + +const ( + // ConnectionStatusPending is set when the user has requested and it is + // being asynchronously setup. + ConnectionStatusPending ConnectionStatus = "pending" + // ConnectionStatusActive indicates the peer connection has been + // established. + ConnectionStatusActive ConnectionStatus = "active" + // ConnectionStatusInactive indicates the peer connection has not yet been + // approved on the customers account. + ConnectionStatusInactive ConnectionStatus = "inactive" + // ConnectionStatusIrrecoverable indicates the peer connection was deleted + // from the customers account. + ConnectionStatusIrrecoverable ConnectionStatus = "irrecoverable" + // ConnectionStatusDeleting is set when the user has requested the + // connection to be deleted and it is being asynchronously deprovisioned. + ConnectionStatusDeleting ConnectionStatus = "deleting" + // ConnectionStatusDeleting is set when the network has been deprovisioned. + ConnectionStatusDeleted ConnectionStatus = "deleted" + // ConnectionStatusFailed indicates the peer connection was requested but + // could not be connected. + ConnectionStatusFailed ConnectionStatus = "failed" +) + +// PeerConfig describes the VPC to connect to. +type PeerConfig struct { + // AccountID is the account ID of the target VPC. + AccountID string `json:"account_id"` + // CIDRBlock is the CIDR block of the target VPC. + CIDRBlock string `json:"cidr_block"` + // VPCID is the ID of the target VPC. + VPCID string `json:"vpc_id"` + // Region is the region of the target VPC. Only specify if the target VPC + // is in a different region to the network your connecting to. + Region string `json:"region,omitempty"` +} + +type ConnectionConfig struct { + Name string `json:"name"` + NetworkID string `json:"network_id"` + Peer PeerConfig `json:"peer"` +} + +// Connection represents a network peer-connection. +type Connection struct { + ID string `json:"connection_id"` + + Status ConnectionStatus `json:"status"` + + // StatusDetail provides more information on the status of the connection. + StatusDetail string `json:"status_detail,omitempty"` + + // PeerConnectionID is the underlying cloud provider peer connection ID. + PeerConnectionID string `json:"peer_connection_id"` + + Config *ConnectionConfig `json:"connection_config"` +} diff --git a/internal/sdk/datastore.go b/internal/sdk/datastore.go new file mode 100644 index 0000000..b445d78 --- /dev/null +++ b/internal/sdk/datastore.go @@ -0,0 +1,145 @@ +package sdk + +type CloudProvider string + +const ( + CloudProviderAWS CloudProvider = "aws" + CloudProviderGCP CloudProvider = "gcp" + CloudProviderAzure CloudProvider = "azure" +) + + +// DatastoreLocation represents where the datastore should be provisioned. +type DatastoreLocation struct { + Provider CloudProvider `json:"provider"` + Region string `json:"region"` + // AvailabilityZones indicates which availability zones the datastore + // should use in priority order. + AvailabilityZones []string `json:"availability_zones"` +} + +type PerformanceTier string +type AclRuleArray []string + +const ( + PerformanceTierDev PerformanceTier = "dev" + PerformanceTierStandard PerformanceTier = "standard" + PerformanceTierEnhanced PerformanceTier = "enhanced" +) + +var PerformanceTiers = []PerformanceTier{ + PerformanceTierDev, + PerformanceTierStandard, + PerformanceTierEnhanced, +} + +func PerformanceTiersString() []string { + var ss []string + for _, tier := range PerformanceTiers { + ss = append(ss, string(tier)) + } + return ss +} + +type DatastoreTier struct { + // Memory is the maximum number of bytes Dragonfly can consume. + Memory uint64 `json:"max_memory_bytes"` + // PerformanceTier determines number of CPUs provisioned relative to + // memory. + PerformanceTier PerformanceTier `json:"performance_tier"` + + // Replicas is the number of Dragonfly replicas (not including the master). + Replicas *int `json:"replicas"` +} + +type DatastoreDragonflyConfig struct { + CacheMode *bool `json:"cache_mode"` + TLS *bool `json:"tls"` + BullMQ *bool `json:"bullmq"` + Sidekiq *bool `json:"sidekiq"` + Memcached *bool `json:"memcached"` + AclRules *AclRuleArray `json:"acl_rules"` +} + +// DatastoreConfig contains the datastores configurable fields. +type DatastoreConfig struct { + Name string `json:"name"` + // NetworkID is an optional ID of a dedicated network to provision the + // datastore in. + NetworkID string `json:"network_id"` + Location DatastoreLocation `json:"location"` + Tier DatastoreTier `json:"tier"` + // Dragonfly contains the Dragonfly node configuration. + Dragonfly DatastoreDragonflyConfig `json:"dragonfly"` + + BackupPolicy BackupPolicy `json:"backup_policy" mapstructure:"backup_policy"` + + Restore RestoreBackup `json:"restore"` + + DisablePasskey bool `json:"disable_passkey"` +} + +type RestoreBackup struct { + // Backup contains the ID of the backup to restore. + BackupId string `json:"backup_id"` + // Loaded denotes if the backup is loaded onto the datastore + Loaded bool `json:"loaded"` +} + +type BackupPolicy struct { + Enabled *bool `json:"enabled"` + Retention int `json:"retention,omitempty"` + EveryHour *bool `json:"every_hour,omitempty"` + EveryDay *bool `json:"every_day,omitempty"` + Hours []int `json:"hours,omitempty"` + WeekDays []int `json:"weekdays,omitempty"` +} + +type DatastoreDashboard struct { + // URL contains the datastores public Grafana dashboard URL. + URL string `json:"url"` +} + +// DatastoreStatus represents the current status of the datastore. +type DatastoreStatus string + +const ( + // DatastoreStatusPending is set when the user has requested the datastore + // and it is being asynchronously provisioned. + DatastoreStatusPending DatastoreStatus = "pending" + // DatastoreStatusUpdating is set when a user has requested an update and + // it is being asynchronously provisioned. + DatastoreStatusUpdating DatastoreStatus = "updating" + // DatastoreStatusRestoring is set when a user has requested a backup + // that is being asyncronously restored. + DatastoreStatusRestoring DatastoreStatus = "restoring" + // DatastoreStatusActive is set when the datastore has been provisioned and + // is usable. + DatastoreStatusActive DatastoreStatus = "active" + // DatastoreStatusDeleting is set when the user has requested the datastore + // to be deleted and it is being asynchronously deprovisioned. + DatastoreStatusDeleting DatastoreStatus = "deleting" + // DatastoreStatusDeleted is set when the datastore has been deprovisioned. + DatastoreStatusDeleted DatastoreStatus = "deleted" +) + +type Datastore struct { + // ID is a unique identifier for the datastore. + ID string `json:"datastore_id"` + + Status DatastoreStatus `json:"status"` + + CreatedAt int64 `json:"created_at" mapstructure:"created_at"` + + // Key is the Dragonfly key to configure when connecting to your + // datastore. + Key string `json:"password"` + + // Addr is the hostname and port of your datastore. + Addr string `json:"addr"` + + // Dashboard contains details on the datastores public Grafana dashboard. + Dashboard *DatastoreDashboard `json:"dashboard"` + + Config DatastoreConfig `json:"config"` +} diff --git a/internal/sdk/network.go b/internal/sdk/network.go new file mode 100644 index 0000000..570978a --- /dev/null +++ b/internal/sdk/network.go @@ -0,0 +1,54 @@ +package sdk + +// NetworkStatus represents the current status of the network. +type NetworkStatus string + +const ( + // NetworkStatusPending is set when the user has requested the network + // and it is being asynchronously provisioned. + NetworkStatusPending NetworkStatus = "pending" + // DatastoreStatusActive is set when the network has been provisioned. + NetworkStatusActive NetworkStatus = "active" + // DatastoreStatusActive is set when the network was requested but could + // not be provisioned. + NetworkStatusFailed NetworkStatus = "failed" + // NetworkStatusDeleting is set when the user has requested the network to + // be deleted and it is being asynchronously deprovisioned. + NetworkStatusDeleting NetworkStatus = "deleting" + // NetworkStatusDeleted is set when the network has been deprovisioned. + NetworkStatusDeleted NetworkStatus = "deleted" +) + +// NetworkLocation represents where the network should be provisioned. +type NetworkLocation struct { + Provider CloudProvider `json:"provider"` + Region string `json:"region"` +} + +type NetworkVPC struct { + // ResourceID is the ID of the VPC. + ResourceID string `json:"resource_id"` + // AccountID is the Dragonfly Cloud account ID that owns the VPC resource. + // This is required to setup peering connections. + AccountID string `json:"account_id"` +} + +type NetworkConfig struct { + Name string `json:"name"` + Location NetworkLocation `json:"location"` + CIDRBlock string `json:"cidr_block"` +} + +type Network struct { + ID string `json:"network_id"` + + Status NetworkStatus `json:"status"` + + CreatedAt int64 `json:"created_at"` + + // VPC contains details on the networks provisioned VPC. This is required + // to setup VPC peering. + VPC *NetworkVPC `json:"vpc"` + + *NetworkConfig +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a0ad7f7 --- /dev/null +++ b/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/dragonflydb/terraform-provider-dfcloud/internal/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" +) + +var ( + // version is the version of the provider. + // This is set at compile time using -ldflags. + version string = "dev" +) + +func main() { + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := providerserver.ServeOpts{ + Address: "registry.terraform.io/dragonflydb/dfcloud", + Debug: debug, + } + + err := providerserver.Serve(context.Background(), provider.NewDragonflyDBCloudProvider(version), opts) + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/terraform-registry-manifest.json b/terraform-registry-manifest.json new file mode 100644 index 0000000..6308a03 --- /dev/null +++ b/terraform-registry-manifest.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": [ + "6.0" + ] + } +}