Compare commits

..
Author SHA1 Message Date
Delaine Rogers 3a6ede4909 demo unit tests showing the bug 2026-08-04 16:25:54 -07:00
Susmitha Girumala 163ec69771 Update github test workflow permissions to read-only (#76)
Set GITHUB_TOKEN to read-only permission by default
2026-07-14 15:31:58 -04:00
Piotr KazmierczakandHugo e8a0b0bf10 feat(storage): support multipart uploads (#72)
This changeset implements multipart uploads. AWS caps PutObject calls at 5
GiB, effectively causing the plugin to error on any upload larger than that.
Multipart limit is 8 TiB, which should be plenty for most use cases.

Multipart implementation is size-gated: we only switch to multipart uploads once
the file size is greater than multipartThreshold, which is currently set to 8
MiB, and we set the part size to 8 MiB as well (this follows AWS CLI and boto3).
That gives us ~78 GiB max object before we auto-scale, due to 10,000 part cap on
AWS.

---------

Co-authored-by: Hugo <10965479+hugoghx@users.noreply.github.com>
2026-06-26 16:34:32 +02:00
Damian Debkowski 4c8e95fcd8 fix: improve http transport (#73) 2026-06-24 07:12:41 -07:00
Louis Ruch 01f8da94e4 feat(host): add catalog attribute to only sync instance primary addresses (#71)
* feat(host): add catalog attribute to only sync instance primary
addresses
2026-05-27 13:12:34 -07:00
aditya2548 241901319d feat: add ListObjects method & relevant e2e tests 2026-05-14 14:55:01 -05:00
Louis Ruch 56f89a5dcf test(host): update e2e testing to support shared test enviro creds (#69) 2026-05-14 10:59:38 -07:00
hashicorp-copywrite[bot] 5b7b669b41 [COMPLIANCE] Add/Update Copyright Headers (#68)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-05-11 22:37:05 -07:00
dependabot[bot] 7b4ee7f403 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 (#66)
Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.69.0 to 1.97.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.69.0...service/s3/v1.97.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.97.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 21:14:09 -07:00
dependabot[bot] 916324ee64 build(deps): bump google.golang.org/grpc from 1.68.0 to 1.79.3 (#65)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.68.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.68.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 21:09:52 -07:00
dependabot[bot] 11d3d6d523 build(deps): bump golang.org/x/crypto from 0.36.0 to 0.45.0 (#62)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.36.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-25 22:10:57 -08:00
oss-core-libraries-dashboard[bot]andhashicorp-copywrite[bot] 4758d5d9cd [COMPLIANCE] Update Copyright and License Headers (Batch 1 of 1) (#64)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-01-16 15:56:19 -08:00
Emilia 42e2126217 fix: Allow shared credentials in GenerateCredentialChain (#63)
* allow shared creds

* fix non-const printf format strings for go 1.24

* add .env and .envrc to .gitignore
2025-12-04 12:46:02 -05:00
Katrina Schwark 5d098ac71d Add PCI review checklist to PR template (#61) 2025-06-30 11:57:23 -06:00
dependabot[bot] 949976ec19 build(deps): bump golang.org/x/net from 0.36.0 to 0.38.0 (#60)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.36.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 16:23:17 -07:00
dependabot[bot] 49f692e7a8 build(deps): bump golang.org/x/net from 0.33.0 to 0.36.0 (#59)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.36.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-17 09:58:09 -07:00
daniandDanielle Miu a75ac95465 Implement NormalizeStorageBucketData and begin normalizing endpoint URLs (#58)
Co-authored-by: Danielle Miu <29378233+DanielleMiu@users.noreply.github.com>
2025-03-14 15:19:20 +00:00
Irena Rindos 7fd6d3f3e5 chore: update deps (#57) 2025-01-07 12:45:10 -05:00
Louis Ruch bdd27e2562 feat(storage): Add s3Client cache (#55)
* feat(storage): Add s3Client cache

* Update awsutil deps
2024-11-25 21:32:37 -08:00
Johan Brandhorst-Satzkorn f17f05ec82 Update CODEOWNERS (#54)
Make it effective with a wildcard
2024-11-18 16:23:19 -08:00
Damian Debkowski 899c62ce06 feat: support dual stack requests for aws clients (#52)
* feat: enable dual stack in aws clients

* refactor: optionally set custom endpoint resolver

* feat: update sts & iam client to utilize dualstack

* update aws dependencies to remove role policy bug
2024-10-28 11:50:18 -07:00
Louis Ruch ce22c1c46a Refactor error messages to include aws error details (#53)
* refactor error messages to include aws error details

* update deps
2024-10-21 09:55:51 -07:00
Hugo 2f9163ad0e docs(host): Add newly supported attributes 2024-10-03 18:19:49 +01:00
Damian Debkowski 7caff84c06 test: validate ipv6 compatibility (#50)
* test: validate ipv6 compatability

* test: update test to include checking against SBC state
2024-09-25 12:04:00 -07:00
Hugo 5f96422fbf chore(credential): Remove required arg from GetCredentialsConfig 2024-09-23 18:07:32 +01:00
Hugo 4dac4498d3 feat(service): Add support for host plugin AssumeRole
This commit also introduces a new dry run validation step when creating
or updating a host catalog. Additionally, it introduces new tests
related to using AssumeRole credentials, as well as more error and
success state cases.

service/host/plugin.go now closely follows service/storage/plugin.go.
This is by design as to bring both "halves" of the AWS plugin into
parity with each other.
2024-09-23 18:07:32 +01:00
hashicorp-copywrite[bot] 9af0110945 [COMPLIANCE] Add Copyright and License Headers (#47)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2024-07-24 11:50:06 -04:00
Elim Tsiagbey c2e7c94f9c fix: Append list error for permission validation (#48)
* fix: Append list error for permission validation

Instead of returning the error right away, append the error when `ListObject` is successful but the key expected if not found. Set the `Read` permission state as `Unknown` if the response from the list calls does not find the expected key.

This can be caused if the credentials do not have `s3:PutObject` but have `s3:ListBucket`. Without `s3:PutObject`, the object will not be written to S3 so other calls to find the object will not be successful even if they have valid permissions.

This prevents us from returning a `write` error on Boundary because the list call will always fail. Without this, the  Permissions are not wrapped and the SBC states are all set to `unknown` even though we know certain permission failed.
2024-07-24 11:48:14 -04:00
daniandDamian Debkowski 5add0a1b53 Worker storage bucket credential state (#46)
* feat: parse generic aws api errors

* feat: parse s3 api errors

* feat: return credential state for all status errors

* fix status codes on certain http error codes and re-enable two tests

* switch code to FailedPrecondition

* update certain error codes

* update go.mod for new sdk version

---------

Co-authored-by: Damian Debkowski <damian.debkowski@hashicorp.com>
2024-07-17 15:37:27 -04:00
Hugo 88798fb76b fix(plugin): Panic in tests if err variable is nil 2024-07-15 16:27:00 +01:00
Hugo 5626a997c8 fix(plugin): Remove AWS region validation
The check in `PartitionForRegion` was a static check against a list of
known regions. This would cause an error if the SDK was not up-to-date.
2024-07-15 16:27:00 +01:00
Michael Li 6cfccab32a chore: Update to use Node20 version of Actions (#43) 2024-05-03 15:50:39 -04:00
Hugo 282935aa79 chore(README): Fix Endpoint URL attribute 2024-02-07 23:18:49 +00:00
dani 050357f367 add DeleteObjects command to plugin (#38) 2023-12-15 19:12:35 +01:00
55 changed files with 7307 additions and 1530 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
## PCI review checklist
<!-- heimdall_github_prtemplate:grc-pci_dss-2024-01-05 -->
- [ ] I have documented a clear reason for, and description of, the change I am making.
- [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
- [ ] If applicable, I've documented the impact of any changes to security controls.
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.
+4 -2
View File
@@ -4,6 +4,8 @@ on:
- workflow_dispatch
- push
permissions: read-all
jobs:
test:
runs-on: ubuntu-latest
@@ -13,9 +15,9 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
- name: Set up Go
uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
with:
go-version-file: 'go.mod'
cache: true
+9
View File
@@ -4,3 +4,12 @@ plan.tfplan
*.log
*.out
*.html
# IntelliJ IDEA project files
.idea
*.iprg
.DS_Store
.env
.envrc
+1 -2
View File
@@ -1,5 +1,4 @@
# These owners will be the default owners for everything in
# the repo, unless a later match takes precedence.
@hashicorp/boundary
* @hashicorp/boundary
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2021 HashiCorp, Inc.
Copyright IBM Corp. 2021, 2026
Mozilla Public License Version 2.0
==================================
+50 -46
View File
@@ -1,65 +1,69 @@
module github.com/hashicorp/boundary-plugin-aws
go 1.21
go 1.25.7
require (
github.com/aws/aws-sdk-go v1.44.80
github.com/aws/aws-sdk-go-v2 v1.20.1
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2
github.com/aws/smithy-go v1.14.1
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.1
github.com/hashicorp/boundary/sdk v0.0.41-0.20231117205527-08045ddb050e
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0
github.com/hashicorp/terraform-json v0.14.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5
github.com/aws/smithy-go v1.25.1
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/hashicorp/boundary/sdk v0.0.59
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0
github.com/hashicorp/terraform-json v0.23.0
github.com/mitchellh/mapstructure v1.5.0
github.com/stretchr/testify v1.8.4
google.golang.org/grpc v1.59.0
google.golang.org/protobuf v1.31.0
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/eventlogger v0.2.6-0.20231025104552-802587e608f0 // indirect
github.com/hashicorp/eventlogger v0.2.11 // indirect
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-hclog v1.5.0 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.5.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/pointerstructure v1.2.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/zclconf/go-cty v1.10.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/zclconf/go-cty v1.15.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+157 -208
View File
@@ -4,170 +4,168 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
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/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aws/aws-sdk-go v1.44.80 h1:jEXGecSgPdvM5KnyDsSgFhZSm7WwaTp4h544Im4SfhI=
github.com/aws/aws-sdk-go v1.44.80/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
github.com/aws/aws-sdk-go-v2 v1.20.1 h1:rZBf5DWr7YGrnlTK4kgDQGn1ltqOg5orCYb/UhOFZkg=
github.com/aws/aws-sdk-go-v2 v1.20.1/go.mod h1:NU06lETsFm8fUC6ZjhgDpVBcGZTFQ6XM+LZWZxMI4ac=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 h1:lN6L3LrYHeZ6xCxaIYtoWCx4GMLk4nRknsh29OMSqHY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12/go.mod h1:TDCkEAkMTXxTs0oLBGBKpBZbk3NLh8EvAfF0Q3x8/0c=
github.com/aws/aws-sdk-go-v2/config v1.18.33 h1:JKcw5SFxFW/rpM4mOPjv0VQ11E2kxW13F3exWOy7VZU=
github.com/aws/aws-sdk-go-v2/config v1.18.33/go.mod h1:hXO/l9pgY3K5oZJldamP0pbZHdPqqk+4/maa7DSD3cA=
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 h1:lIH1eKPcCY1ylR4B6PkBGRWMHO3aVenOKJHWiS4/G2w=
github.com/aws/aws-sdk-go-v2/credentials v1.13.32/go.mod h1:lL8U3v/Y79YRG69WlAho0OHIKUXCyFvSXaIvfo81sls=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 h1:DK/9C+UN/X+1+Wm8pqaDksQr2tSLzq+8X1/rI/ZxKEQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8/go.mod h1:ce7BgLQfYr5hQFdy67oX2svto3ufGtm6oBvmsHScI1Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38 h1:c8ed/T9T2K5I+h/JzmF5tpI46+OODQ74dzmdo+QnaMg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.38/go.mod h1:qggunOChCMu9ZF/UkAfhTz25+U2rLVb3ya0Ua6TTfCA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 h1:hNeAAymUY5gu11WrrmFb3CVIp9Dar9hbo44yzzcQpzA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32/go.mod h1:0ZXSqrty4FtQ7p8TEuRde/SZm9X05KT18LAUlR40Ln0=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 h1:fc0ukRAiP1syoSGZYu+DaE+FulSYhTiJ8WpVu5jElU4=
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39/go.mod h1:WLAW8PT7+JhjZfLSWe7WEJaJu0GNo0cKc2Zyo003RBs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 h1:vUh7dBFNS3oFCtVv6CiYKh5hP9ls8+kIpKLeFruIBLk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1/go.mod h1:sFMeinkhj/SZKQM8BxtvNtSPjJEo0Xrz+w3g2e4FSKI=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0 h1:NXi4pNJWjAaiI56P1Rl8DC9A4jMNRE00WNBsDua5WRg=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0/go.mod h1:L3ZT0N/vBsw77mOAawXmRnREpEjcHd2v5Hzf7AkIH8M=
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2 h1:DPFxx/6Zwes/MiadlDteVqDKov7yQ5v9vuwfhZuJm1s=
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2/go.mod h1:cQTMNdo/Z5t1DDRsUnx0a2j6cPnytMBidUYZw2zks28=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13 h1:iV/W5OMBys+66OeXJi/7xIRrKZNsu0ylsLGu+6nbmQE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.13/go.mod h1:ReJb6xYmtGyu9KoFtRreWegbN9dZqvZIIv4vWnhcsyI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33 h1:QviNkc+vGSuEHx8P+pVNKOdWLXBPIwMFv7p0fphgE4U=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.33/go.mod h1:fABTUmOrAgAalG2i9WJpjBvlnk7UK8YmnYaxN+Q2CwE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32 h1:dGAseBFEYxth10V23b5e2mAS+tX7oVbfYHD6dnDdAsg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.32/go.mod h1:4jwAWKEkCR0anWk5+1RbfSg1R5Gzld7NLiuaq5bTR/Y=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1 h1:PT6PBCycRwhpEW5hJnRiceCeoWJ+r3bdgXtV+VKG7Pk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.1/go.mod h1:TqoxCLwT2nrxrBGA+z7t6OWM7LBkgRckK3gOjYE+7JA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 h1:v346f1h8sUBKXnEbrv43L37MTBlFHyKXQPIZHNAaghA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2/go.mod h1:cwCATiyNrXK9P2FsWdZ89g9mpsYv2rhk0UA/KByl5fY=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 h1:A2RlEMo4SJSwbNoUUgkxTAEMduAy/8wG3eB2b2lP4gY=
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2/go.mod h1:ju+nNXUunfIFamXUIZQiICjnO/TPlOmWcYhZcSy7xaE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 h1:OJELEgyaT2kmaBGZ+myyZbTTLobfe3ox3FSh5eYK9Qs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2/go.mod h1:ubDBBaDFs1GHijSOTi8ljppML15GLG0HxhILtbjNNYQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2 h1:ympg1+Lnq33XLhcK/xTG4yZHPs1Oyxu+6DEWbl7qOzA=
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2/go.mod h1:FQ/DQcOfESELfJi5ED+IPPAjI5xC6nxtSolVVB773jM=
github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/smithy-go v1.14.1 h1:EFKMUmH/iHMqLiwoEDx2rRjRQpI1YCn5jTysoaDujFs=
github.com/aws/smithy-go v1.14.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE=
github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8 h1:1/bT9kDdLQzfZ1e6J6hpW+SfNDd6xrV8F3M2CuGyUz8=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.8/go.mod h1:RbdwTONAIi59ej/+1H+QzZORt5bcyAtbrS7FQb2pvz0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0 h1:fTLR6dLDTGChAjecRPlVrKeznT0rVdzR4yn9Z68MTGk=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0/go.mod h1:V0jbRy1/IPapnkqgXSwVOFB+u5pnCwd9S+R3pKWULC4=
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6 h1:EWehQXACWr+6hzfZPwZChlfoVhiUCfLHE0Xh3kAfzWQ=
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6/go.mod h1:qRXgEBWPIltrWHQwU+HkyBvwh1QgeigFcaCGCIVrWk0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8 h1:tIN8MFT1z5STK5kTdOT1TCfMN/bn5fSEnlKsTL8qBOU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.8/go.mod h1:VKS56txtNWjKI8FqD/hliL0BcshyF4ZaLBa1rm2Y+5s=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8 h1:AgYCo1Rb8XChJXA871BXHDNxNWOTAr6V5YdsRIBbgv0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.8/go.mod h1:Au9dvIGm1Hbqnt29d3VakOCQuN9l0WrkDDTRq8biWS4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2 h1:T7b3qniouutV5Wwa9B1q7gW+Y8s1B3g9RE9qa7zLBIM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.88.2/go.mod h1:tW9TsLb6t1eaTdBE6LITyJW1m/+DjQPU78Q/jT2FJu8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/boundary/sdk v0.0.41-0.20231117205527-08045ddb050e h1:PzZklmCjnaqQq9JdZRI9SVPSd9xo0iXLlmOHoQX68aM=
github.com/hashicorp/boundary/sdk v0.0.41-0.20231117205527-08045ddb050e/go.mod h1:+XTDYf9YNeKIbGOPJwy7hlO2Le4zgzCtHCG/u+z4THI=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/boundary/sdk v0.0.59 h1:XNniO0rwgihLdF1+4qDHKN3gdta3AU1JteZResCJ8Oc=
github.com/hashicorp/boundary/sdk v0.0.59/go.mod h1:VYwVGAEaHTzihRXpzKZL6EhWBhll59Iul3PGLVL63ao=
github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU=
github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/eventlogger v0.2.6-0.20231025104552-802587e608f0 h1:f9oX8/3zxiQrfrWnBeyjDm4S02GAU02OBtCRoZOUwlo=
github.com/hashicorp/eventlogger v0.2.6-0.20231025104552-802587e608f0/go.mod h1://CHt6/j+Q2lc0NlUB5af4aS2M0c0aVBg9/JfcpAyhM=
github.com/hashicorp/eventlogger v0.2.11 h1:lkK8ARM/DrCeL5deH2yVS6p40337dESi+77p9pIfLqo=
github.com/hashicorp/eventlogger v0.2.11/go.mod h1:Rmc3MEopz7jUnLokZVTWAsXaEP5rqd20ObGS+pcau3U=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 h1:iAb287bq0TaWTnhDYuN/zVqdD2EwanQg9ncVelC60Xc=
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0/go.mod h1:tMywUTIvdB/FXhwm6HMTt61C8/eODY6gitCHhXtyojg=
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-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5 h1:jrnDfQm2hCQ0/hEselgqzV4fK16gpZoY0OWGZpVPNHM=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5/go.mod h1:psh1qKep5ukvuNobFY/hCybuudlkkACpmazOsCgX5Rg=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14 h1:1ZuhfnZgRnLK8S0KovJkoTCRIQId5pv3sDR7pG5VQBw=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14/go.mod h1:0dWtzl2ilqKpavgM3id/kFK9L3tjo6fS4OhbVPSYpnQ=
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-kms-wrapping/plugin/v2 v2.0.8 h1:/GIUjn9GkFXMk/8/irRdbdtmx8CcyeyWdVy/E5LvzyA=
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.8/go.mod h1:JDc9UOD4EVRDIwPVethJcT5Ibi/Nas6eQDPtA60iwP0=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 h1:afreZ1WJd0WI7v4NsMZ1aL7V/T59sxPuKmQDgGUja20=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20/go.mod h1:NeK2Ul15t1zutp/dZzt28XQrGZHosbxE/QLNNfaWObM=
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.5.2 h1:aWv8eimFqWlsEiMrYZdPYl+FdHaBJSN4AWwGWfT1G2Y=
github.com/hashicorp/go-plugin v1.5.2/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 h1:ca5TSI4AgaOncPpyzLDtCGjVEtKukONpeM95vFxXCOQ=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0/go.mod h1:7CUvZtfTp2U0CYQCLzMtS2ngckjAZePSfwrE2aeDP1M=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2 h1:KlDrZWx/wYpnUcFPB0s9uhPjyNZoeIfhrV2MN/Ly76s=
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.2/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.11 h1:uPW2Wn0YlmI9RGSkZpcIplnVRwJ7BCiGpk1vnF2TMw4=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.11/go.mod h1:uis9dCmOzXuOaRyXq+1Foh31kcvXKoWogjNnhfjHfW8=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.9 h1:0S0ctJ7Ra8O7ap+/3fZUnzJ3VzJyirWS/WnNCuOYtZY=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.9/go.mod h1:TNNdgtjLgVDbrgFcyCKrlAicIl3dZF94swJltyGUX2M=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.6 h1:ZYv2XA+tEfFXIToR2jmBgVqQU9gERt0APbWqmUoNGnY=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.6/go.mod h1:ggFN8dlaLWS2R1gymBbCrvXM/bkZP7hEAa4seqDwhyg=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.13 h1:TayxZ5drfMP0G6T++WvnLESGLOWeHtdWDWWTAi2e3Qk=
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.13/go.mod h1:NDRQ/F3DXTylqjORAP0cA+puH/JFrLlT+NlqxHK2/e8=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10 h1:2iDz+t0JLl1W0tJhvmhsh/UBgT1JgC8Qxz8HxYMWXQo=
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10/go.mod h1:eZkXE+osawMrAWR4wJRmyKauUwH6mNGbjFuiDujnbPk=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.8 h1:pgSicufBI+MvaIG9Keykb3k9B3sWtDNjmsoyrl2/8Qw=
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.8/go.mod h1:sBcjk+paCXCMR9HHLcrYSfPz2FsskD8MQuotKiyM2aA=
github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1 h1:SMGUnbpAcat8rIKHkBPjfv81yC46a8eCNZ2hsR2l1EI=
github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1/go.mod h1:Ch/bf00Qnx77MZd49JRgHYqHQjtEmTgGU2faufpVZb0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 h1:xbrxd0U9XQW8qL1BAz2XrAjAF/P2vcqUTAues9c24B8=
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3/go.mod h1:LWq2Sy8UoKKuK4lFuCNWSjJj57MhNNf2zzBWMtkAIX4=
github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU=
github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
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.5.0 h1:O293SZ2Eg+AAYijkVK3jR786Am1bhDEh2GHT0tIVE5E=
github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
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/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s=
github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
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/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W65euzixn7clSzK66puSt1H4I5SC0EmHH4=
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng=
github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4=
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/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@@ -177,113 +175,64 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/rogpeppe/go-internal v1.6.2 h1:aIihoIOHCiLZHxyoNQ+ABL4NKhFTgKLBdMLyEAh98m0=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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-20200930185726-fdedc70b468f/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-20211216021012-1d35b9e2eb4e/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
+27 -17
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -6,7 +6,6 @@ package credential
import (
"fmt"
"github.com/aws/aws-sdk-go/aws/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
@@ -35,9 +34,10 @@ type CredentialAttributes struct {
}
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
// CredentialsConfig used for configuring an AWS session. An error is returned if
// any unrecognized fields are found in the protobuf struct input.
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, required bool) (*awsutil.CredentialsConfig, error) {
// CredentialsConfig used for configuring an AWS session. An status error is returned
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
// struct input.
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, dualStack bool) (*awsutil.CredentialsConfig, error) {
// initialize secrets if it is nil
// secrets can be nil because static credentials are optional
if secrets == nil {
@@ -49,13 +49,13 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes,
unknownFields := values.StructFields(secrets)
badFields := make(map[string]string)
accessKey, err := values.GetStringValue(secrets, ConstAccessKeyId, required)
accessKey, err := values.GetStringValue(secrets, ConstAccessKeyId, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = err.Error()
}
delete(unknownFields, ConstAccessKeyId)
secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, required)
secretKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error()
}
@@ -117,13 +117,29 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes,
opts = append(opts, awsutil.WithRoleTags(attrs.RoleTags))
}
return awsutil.NewCredentialsConfig(opts...)
if dualStack {
opts = append(opts, []awsutil.Option{
awsutil.WithIamEndpointResolver(&iamEndpointResolver{
dualStack: dualStack,
}),
awsutil.WithStsEndpointResolver(&stsEndpointResolver{
dualStack: dualStack,
}),
}...)
}
cfg, err := awsutil.NewCredentialsConfig(opts...)
if err != nil {
return nil, errors.BadRequestStatus(err.Error())
}
return cfg, nil
}
// GetCredentialAttributes parses values out of a protobuf struct input and returns a
// CredentialAttributes used for configuring an AWS session. An error is returned if
// any of the following fields are missing from the protobuf struct input or have
// invalid value types: region, disableCredentialRotation
// CredentialAttributes used for configuring an AWS session. An status error is returned
// with an InvalidArgument code if any of the following fields are missing from the protobuf
// struct input or have invalid value types: region, disableCredentialRotation
func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
badFields := make(map[string]string)
@@ -132,12 +148,6 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
}
if region != "" {
if _, found := endpoints.PartitionForRegion(endpoints.DefaultPartitions(), region); !found {
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = fmt.Sprintf("not a valid region: %s", region)
}
}
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
+17 -25
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -25,13 +25,6 @@ func TestGetCredentialAttributes(t *testing.T) {
in: map[string]any{},
expectedErrContains: "missing required value \"region\"",
},
{
name: "invalid aws region value",
in: map[string]any{
ConstRegion: "dne",
},
expectedErrContains: "not a valid region",
},
{
name: "bad value for disable_credential_rotation",
in: map[string]any{
@@ -112,8 +105,8 @@ func TestGetCredentialsConfig(t *testing.T) {
cases := []struct {
name string
secrets *structpb.Struct
required bool
attrs *CredentialAttributes
dualStack bool
expected *awsutil.CredentialsConfig
expectedErrContains string
}{
@@ -144,30 +137,22 @@ func TestGetCredentialsConfig(t *testing.T) {
},
},
{
name: "missing access key",
name: "with dualstack",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
dualStack: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
required: true,
expectedErrContains: "secrets.access_key_id: missing required value",
},
{
name: "missing secret key",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
},
expected: &awsutil.CredentialsConfig{
AccessKey: "AKIAfoobar",
SecretKey: "bazqux",
Region: "us-west-2",
},
required: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expectedErrContains: "secrets.secret_access_key: missing required value",
},
{
name: "unknown fields",
@@ -267,7 +252,7 @@ func TestGetCredentialsConfig(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.required)
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.dualStack)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
@@ -283,6 +268,13 @@ func TestGetCredentialsConfig(t *testing.T) {
require.Equal(tc.expected.RoleExternalId, actual.RoleExternalId)
require.Equal(tc.expected.RoleSessionName, actual.RoleSessionName)
require.Equal(tc.expected.RoleTags, actual.RoleTags)
if tc.dualStack {
require.NotNil(actual.IAMEndpointResolver)
require.NotNil(actual.STSEndpointResolver)
} else {
require.Nil(actual.IAMEndpointResolver)
require.Nil(actual.STSEndpointResolver)
}
})
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -0,0 +1,172 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
// TestGetCredentialsConfigRoleArnFallback documents all credential input
// combinations and the CredentialsConfig they produce. The key finding is
// in the "role_arn + no static keys" case: GetCredentialsConfig succeeds and
// populates CredentialsConfig.RoleARN, but no STS call is ever made during
// config construction. At runtime, awsutil.GenerateCredentialChain silently
// falls back to the ambient credential chain (env vars / instance profile)
// without calling sts:AssumeRole, so a bogus or unreachable ARN is never
// detected.
//
// Run this test to confirm the root cause:
//
// go test ./internal/credential/... -run TestGetCredentialsConfigRoleArnFallback -v
import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
func TestGetCredentialsConfigRoleArnFallback(t *testing.T) {
cases := []struct {
name string
secrets *structpb.Struct
attrs *CredentialAttributes
expectedAccessKey string
expectedSecretKey string
expectedRoleARN string
expectedType CredentialType
expectedErrContains string
}{
{
// NOTE: No role_arn and no static keys. GetCredentialsConfig
// succeeds and returns a CredentialsConfig with no credentials set
// at all. GetCredentialType returns Unknown because there is no
// AccessKey prefix to classify. The AWS SDK will use whatever
// ambient credentials it finds at runtime (env vars, instance
// profile, etc.).
name: "ambient only — no role_arn, no static keys",
attrs: &CredentialAttributes{
Region: "us-east-1",
DisableCredentialRotation: true,
},
expectedAccessKey: "",
expectedSecretKey: "",
expectedRoleARN: "",
expectedType: Unknown,
},
{
// NOTE: The normal static-key path. Both keys are provided; no
// role_arn. GetCredentialType returns StaticAWS because the
// AccessKey starts with "AKIA".
name: "static keys only",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{
Region: "us-east-1",
DisableCredentialRotation: true,
},
expectedAccessKey: "AKIAfoobar",
expectedSecretKey: "bazqux",
expectedRoleARN: "",
expectedType: StaticAWS,
},
{
// NOTE: THE BUG SCENARIO. role_arn is set (to a clearly bogus
// value) and disable_credential_rotation is true, but no static
// keys are provided. GetCredentialsConfig succeeds — the ARN is
// stored in CredentialsConfig.RoleARN. GetCredentialType returns
// DynamicAWS. However, no sts:AssumeRole call is ever made here
// or during GenerateCredentialChain: the awsutil library skips the
// assume-role path when AccessKey is empty and falls back to the
// ambient credential chain instead. The bogus ARN is never
// validated or exercised.
name: "role_arn + no static keys (THE BUG SCENARIO)",
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:sts::000000000000:assumed-role/fake/whoever",
DisableCredentialRotation: true,
},
expectedAccessKey: "",
expectedSecretKey: "",
expectedRoleARN: "arn:aws:sts::000000000000:assumed-role/fake/whoever",
expectedType: DynamicAWS,
},
{
// NOTE: role_arn is set but disable_credential_rotation is false
// (the default). This is caught by the validation switch and
// returns an explicit error. Validation works correctly here.
name: "role_arn without disable_credential_rotation — validation error",
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:iam::123456789012:role/MyRole",
},
expectedErrContains: "disable_credential_rotation attribute is required when providing a role_arn",
},
{
// NOTE: Both role_arn and static keys are provided together. This
// is caught by the validation switch and returns an error listing
// all three conflicting fields. Validation works correctly here.
name: "role_arn + static keys — conflict error",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{
Region: "us-east-1",
RoleArn: "arn:aws:iam::123456789012:role/MyRole",
},
expectedErrContains: "conflicts with role_arn value",
},
{
// NOTE: access_key_id is provided but secret_access_key is
// missing. The validation switch catches the incomplete pair.
name: "static keys — missing secret_access_key",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("AKIAfoobar"),
},
},
attrs: &CredentialAttributes{Region: "us-east-1"},
expectedErrContains: "secrets.secret_access_key: missing required value",
},
{
// NOTE: secret_access_key is provided but access_key_id is
// missing. The validation switch catches the incomplete pair.
name: "static keys — missing access_key_id",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
attrs: &CredentialAttributes{Region: "us-east-1"},
expectedErrContains: "secrets.access_key_id: missing required value",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
cfg, err := GetCredentialsConfig(tc.secrets, tc.attrs, false)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(codes.InvalidArgument, status.Code(err))
return
}
require.NoError(err)
require.Equal(tc.expectedAccessKey, cfg.AccessKey)
require.Equal(tc.expectedSecretKey, cfg.SecretKey)
require.Equal(tc.expectedRoleARN, cfg.RoleARN)
require.Equal(tc.expectedType, GetCredentialType(cfg),
"credential type mismatch — see NOTE above the test case for why this matters")
})
}
}
+65 -30
View File
@@ -1,17 +1,21 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"errors"
stderr "errors"
"fmt"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/iam"
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
"github.com/hashicorp/boundary-plugin-aws/internal/values"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"google.golang.org/protobuf/types/known/structpb"
@@ -71,7 +75,7 @@ func WithStateTestOpts(opts []awsutil.Option) AwsCredentialPersistedStateOption
func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedStateOption {
return func(s *AwsCredentialPersistedState) error {
if s.CredentialsConfig != nil {
return errors.New("credentials config already set")
return stderr.New("credentials config already set")
}
s.CredentialsConfig = x
@@ -83,7 +87,7 @@ func WithCredentialsConfig(x *awsutil.CredentialsConfig) AwsCredentialPersistedS
func WithCredsLastRotatedTime(t time.Time) AwsCredentialPersistedStateOption {
return func(s *AwsCredentialPersistedState) error {
if !s.CredsLastRotatedTime.IsZero() {
return errors.New("last rotation time already set")
return stderr.New("last rotation time already set")
}
s.CredsLastRotatedTime = t
@@ -106,13 +110,15 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (
// ValidateCreds takes the credentials configuration from the persisted state
// and runs sts.GetCallerIdentity for the current credentials, which is done
// to check that the credentials are valid.
// to check that the credentials are valid. This method returns a status error
// with PluginError details.
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
return fmt.Errorf("error validating credentials: %w", err)
st, _ := errors.ParseAWSError("validating credentials", err)
return st.Err()
}
return nil
}
@@ -121,18 +127,19 @@ func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
// then deletes the old access key. If deletion of the old access key is successful, the new access key/secret key are
// written into the credentials config and the persisted state. On any error, the old credentials are not overwritten.
// This ensures that any generated new secret key never leaves this function in case of an error, even though it will
// still result in an extraneous access key existing.
// still result in an extraneous access key existing. This method returns a status error with PluginError details.
func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
return errors.New("invalid credential type")
return errors.BadRequestStatus("invalid credential type")
}
if err := s.CredentialsConfig.RotateKeys(ctx, append([]awsutil.Option{
awsutil.WithValidityCheckTimeout(rotationWaitTimeout),
}, s.testOpts...)...); err != nil {
return fmt.Errorf("error rotating credentials: %w", err)
st, _ := errors.ParseAWSError("rotating credentials", err)
return st.Err()
}
s.CredsLastRotatedTime = time.Now()
return nil
@@ -140,13 +147,14 @@ func (s *AwsCredentialPersistedState) RotateCreds(ctx context.Context) error {
// ReplaceCreds replaces the access key in the state with a new key.
// If the existing key was rotated at any point in time, it is
// deleted first, otherwise it's left alone.
// deleted first, otherwise it's left alone. This method returns a
// status error with PluginError details.
func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credentialsConfig *awsutil.CredentialsConfig) error {
if credentialsConfig == nil {
return errors.New("missing new credentials config")
return errors.BadRequestStatus("missing new credentials config")
}
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
// Delete old/existing credentials. This action is only possible for static credential types.
@@ -166,27 +174,29 @@ func (s *AwsCredentialPersistedState) ReplaceCreds(ctx context.Context, credenti
// DeleteCreds deletes the credentials in the state. The access key
// ID, secret access key, and rotation time fields are zeroed out in
// the state just to ensure that they cannot be re-used after.
// the state just to ensure that they cannot be re-used after. This
// method returns a status error with PluginError details.
func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error {
if s.CredentialsConfig == nil {
return errors.New("missing credentials config")
return errors.BadRequestStatus("missing credentials config")
}
if GetCredentialType(s.CredentialsConfig) != StaticAWS {
return errors.New("invalid credential type")
return errors.BadRequestStatus("invalid credential type")
}
if err := s.CredentialsConfig.DeleteAccessKey(ctx, s.CredentialsConfig.AccessKey, s.testOpts...); err != nil {
// Determine if the deletion error was due to a missing
// resource. If it was, just pass it.
var awsErr *iamTypes.NoSuchEntityException
if errors.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" {
if stderr.As(err, &awsErr) && awsErr.ErrorCode() == "NoSuchEntity" {
s.CredentialsConfig = nil
s.CredsLastRotatedTime = time.Time{}
return nil
}
// Otherwise treat it as an actual error.
return err
st, _ := errors.ParseAWSError("deleting credentials", err)
return st.Err()
}
s.CredentialsConfig = nil
@@ -196,13 +206,7 @@ func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error {
// GenerateCredentialChain returns a AWS configuration for the credentials in the state.
func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Context) (*aws.Config, error) {
// Default `WithSharedCredentials` is `true` - This disables the ability to
// read credentials from environment variables because we set the default
// profile options, so we need to set it to false (see
// awsutil@v2.(*CredentialsConfig).generateAwsConfigOptions) for extra
// details.
opts := append(s.testOpts, awsutil.WithSharedCredentials(false))
return s.CredentialsConfig.GenerateCredentialChain(ctx, opts...)
return s.CredentialsConfig.GenerateCredentialChain(ctx, s.testOpts...)
}
// ToMap returns a map of the credentials stored in the persisted state.
@@ -230,7 +234,7 @@ func (s *AwsCredentialPersistedState) ToMap() map[string]any {
// AwsCredentialPersistedStateFromProto parses values out of a protobuf struct input
// and returns a AwsCredentialPersistedState used for configuring an AWS session.
func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, dualStack bool, opts ...AwsCredentialPersistedStateOption) (*AwsCredentialPersistedState, error) {
// initialize secrets if it is nil
// secrets can be nil because static credentials are optional
if secrets == nil {
@@ -245,17 +249,17 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede
accessKeyId, err := values.GetStringValue(secrets, ConstAccessKeyId, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
secretAccessKey, err := values.GetStringValue(secrets, ConstSecretAccessKey, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
return nil, fmt.Errorf("persisted state integrity: %w", err)
}
s, err := NewAwsCredentialPersistedState(opts...)
@@ -285,6 +289,17 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede
if len(attrs.RoleTags) != 0 {
awsOpts = append(awsOpts, awsutil.WithRoleTags(attrs.RoleTags))
}
if dualStack {
awsOpts = append(awsOpts, []awsutil.Option{
awsutil.WithIamEndpointResolver(&iamEndpointResolver{
dualStack: dualStack,
}),
awsutil.WithStsEndpointResolver(&stsEndpointResolver{
dualStack: dualStack,
}),
}...)
}
credentialsConfig, err := awsutil.NewCredentialsConfig(awsOpts...)
if err != nil {
return nil, err
@@ -314,3 +329,23 @@ func GetCredentialType(cc *awsutil.CredentialsConfig) CredentialType {
return Unknown
}
type iamEndpointResolver struct {
dualStack bool
}
func (e *iamEndpointResolver) ResolveEndpoint(ctx context.Context, params iam.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = iam.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
type stsEndpointResolver struct {
dualStack bool
}
func (e *stsEndpointResolver) ResolveEndpoint(ctx context.Context, params sts.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = sts.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
+96 -42
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -14,7 +14,10 @@ import (
"github.com/aws/aws-sdk-go-v2/service/iam"
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
@@ -109,6 +112,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
name string
secrets *structpb.Struct
attrs *CredentialAttributes
dualStack bool
opts []AwsCredentialPersistedStateOption
expected *AwsCredentialPersistedState
expectedErr string
@@ -171,6 +175,26 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
},
},
},
{
name: "with dualstack static credentials",
secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("foobar"),
ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
},
},
dualStack: true,
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expected: &AwsCredentialPersistedState{
CredentialsConfig: &awsutil.CredentialsConfig{
Region: "us-west-2",
AccessKey: "foobar",
SecretKey: "bazqux",
},
},
},
{
name: "bad last rotated time",
secrets: &structpb.Struct{
@@ -183,7 +207,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
attrs: &CredentialAttributes{
Region: "us-west-2",
},
expectedErr: "persisted state integrity error: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"",
expectedErr: "persisted state integrity: could not parse time in value \"creds_last_rotated_time\": parsing time \"notatime\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"notatime\" as \"2006\"",
},
{
name: "option error",
@@ -315,7 +339,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.opts...)
actual, err := AwsCredentialPersistedStateFromProto(tc.secrets, tc.attrs, tc.dualStack, tc.opts...)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
return
@@ -333,6 +357,13 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
require.Equal(tc.expected.CredentialsConfig.RoleExternalId, actual.CredentialsConfig.RoleExternalId)
require.Equal(tc.expected.CredentialsConfig.RoleSessionName, actual.CredentialsConfig.RoleSessionName)
require.Equal(tc.expected.CredentialsConfig.RoleTags, actual.CredentialsConfig.RoleTags)
if tc.dualStack {
require.NotNil(actual.CredentialsConfig.STSEndpointResolver)
require.NotNil(actual.CredentialsConfig.IAMEndpointResolver)
} else {
require.Nil(actual.CredentialsConfig.STSEndpointResolver)
require.Nil(actual.CredentialsConfig.IAMEndpointResolver)
}
}
})
}
@@ -340,14 +371,16 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
cases := []struct {
name string
in *AwsCredentialPersistedState
expectedErr string
name string
in *AwsCredentialPersistedState
expectedStatusCode codes.Code
expectedErr string
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedStatusCode: codes.InvalidArgument,
expectedErr: "missing credentials config",
},
{
name: "validation error",
@@ -365,7 +398,8 @@ func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error validating credentials: %s", testGetCallerIdentityErr),
expectedStatusCode: codes.Unknown,
expectedErr: "aws service unknown: unknown error: validating credentials",
},
{
name: "good",
@@ -383,10 +417,12 @@ func TestAwsCatalogPersistedState_ValidateCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.ValidateCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
assert.Equal(tc.expectedStatusCode, status.Code(err))
return
}
@@ -402,11 +438,13 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
exepected *awsutil.CredentialsConfig
expectedNonZeroRotatedTime bool
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete dynamic credential type",
@@ -417,7 +455,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete unknown credential type",
@@ -428,7 +467,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "rotation error",
@@ -446,7 +486,8 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error rotating credentials: error calling CreateAccessKey: error calling iam.GetUser: %s", testGetUserErr),
expectedErr: "aws service unknown: unknown error: rotating credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "good",
@@ -496,10 +537,12 @@ func TestAwsCatalogPersistedState_RotateCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.RotateCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
assert.Equal(tc.expectedStatusCode, status.Code(err))
return
}
@@ -521,17 +564,20 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
expected *awsutil.CredentialsConfig
expectedDeleteCalled bool
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing new credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing new credentials config",
name: "missing new credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing new credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "deletion error",
@@ -549,8 +595,9 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
),
},
},
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
credentialConfig: &awsutil.CredentialsConfig{},
expectedErr: "aws service unknown: unknown error: deleting credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "good with delete of old rotated",
@@ -604,11 +651,12 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
state.Reset()
err := tc.in.ReplaceCreds(context.Background(), tc.credentialConfig)
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
return
}
@@ -623,14 +671,16 @@ func TestAwsCatalogPersistedState_ReplaceCreds(t *testing.T) {
func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
cases := []struct {
name string
in *AwsCredentialPersistedState
expectedErr string
name string
in *AwsCredentialPersistedState
expectedErr string
expectedStatusCode codes.Code
}{
{
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
name: "missing credentials config",
in: &AwsCredentialPersistedState{},
expectedErr: "missing credentials config",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete dynamic credential type",
@@ -641,7 +691,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "cannot delete unknown credential type",
@@ -652,7 +703,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
},
CredsLastRotatedTime: time.Now(),
},
expectedErr: "invalid credential type",
expectedErr: "invalid credential type",
expectedStatusCode: codes.InvalidArgument,
},
{
name: "deletion error",
@@ -669,7 +721,8 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
),
},
},
expectedErr: fmt.Sprintf("error deleting old access key: %s", testDeleteAccessKeyErr),
expectedErr: "aws service unknown: unknown error: deleting credentials",
expectedStatusCode: codes.Unknown,
},
{
name: "deletion error, but OK because key was just gone",
@@ -708,10 +761,11 @@ func TestAwsCatalogPersistedState_DeleteCreds(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
err := tc.in.DeleteCreds(context.Background())
if tc.expectedErr != "" {
require.EqualError(err, tc.expectedErr)
require.Error(err)
assert.ErrorContains(err, tc.expectedErr)
return
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
+258 -2
View File
@@ -1,15 +1,71 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"bytes"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/aws/aws-sdk-go-v2/aws/retry"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
// awsErrorAccessDenied is returned when the credentials do not have
// the required permissions to do a specific action on a resource.
// This error will persist until the IAM Role Policy is updated by
// the owner. This error cannot be resolved by retrying the request.
awsErrorAccessDenied = "AccessDenied"
// awsErrorInvalidAccessKeyId is returned when the credential does
// not exist. This error will persist until the credentials attached
// to the storage bucket is updated. This error can occur when the
// given credentials are deleted, deactivated, or rotated. This
// error cannot be resolved by retrying the request.
awsErrorInvalidAccessKeyId = "InvalidAccessKeyId"
// awsErrorExpiredToken is returned when the provided credential is
// expired. This error can only be returned by a dynamic credential
// type. This error can resolve itself by retrying the request.
awsErrorExpiredToken = "ExpiredToken"
// NoSuchBucket is returned when the specified S3 storage bucket does
// not exist
awsErrorNoSuchBucket = "NoSuchBucket"
// BadDigest is returned when an S3 PUT OBJECT request's calculated
// checksum does not match the checksum which was sent by the client
awsErrorBadDigest = "BadDigest"
// NoSuchKey is returned when attempting to interact with an S3 object
// whose key does not exist
awsErrorNoSuchKey = "NoSuchKey"
// NotFound is returned when attempting to interact with an S3 object
// whose key does not exist
awsErrorNotFound = "NotFound"
// The InvalidObjectState error is returned when trying to
// access an object that was moved into cold storage.
awsErrorInvalidObjectState = "InvalidObjectState"
// RequestTimeout is returned when an http request takes longer than allowed
awsErrorRequestTimeout = "RequestTimeout"
// RequestTimeoutException is returned when an http request takes longer than allowed
awsErrorRequestTimeoutException = "RequestTimeoutException"
)
// InvalidArgumentError returns an grpc invalid argument status error.
@@ -22,5 +78,205 @@ func InvalidArgumentError(msg string, f map[string]string) error {
sort.Strings(fieldMsgs)
msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", "))
}
return status.Error(codes.InvalidArgument, msg)
return BadRequestStatus(msg)
}
// ParseAWSError converts an aws service error into a RPC status. This method
// will handle parsing the following aws error codes: throttling, connections,
// and credentials. This method will fallback to parsing the http status code
// when it cannot match an aws error code. This method does not handle specific
// service type errors such as S3 or EC2.
func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Permission) {
if err == nil {
return nil, nil
}
msg := fmt.Sprintf("%s: %v", op, err)
// find the service name of the aws api
serviceName := "unknown"
var oe *smithy.OperationError
if errors.As(err, &oe) {
serviceName = oe.Service()
}
// The unknown plugin error code will be used
// when the aws service returns an error that
// has failed to be parsed. By default, this
// error code is retryable.
defer func() {
if st == nil {
statusMsg := fmt.Sprintf("aws service %s: unknown error: %s", serviceName, msg)
st = status.New(codes.Unknown, statusMsg)
}
if permission == nil {
permission = &pb.Permission{State: pb.StateType_STATE_TYPE_UNKNOWN, CheckedAt: timestamppb.Now()}
}
}()
// evaluate against aws throttling error codes
throttleErr := retry.ThrottleErrorCode{
Codes: retry.DefaultThrottleErrorCodes,
}
if throttleErr.IsErrorThrottle(err).Bool() {
statusMsg := fmt.Sprintf("aws service %s: throttling: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
}
// match error type against NotFound
var notFoundErr *types.NotFound
if errors.As(err, &notFoundErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against NoSuchkey
var noSuchKeyErr *types.NoSuchKey
if errors.As(err, &noSuchKeyErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against InvalidObjectState
var invalidObjectStateErr *types.InvalidObjectState
if errors.As(err, &invalidObjectStateErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
}
// match error type against NoSuchBucket
var noSuchBucketErr *types.NoSuchBucket
if errors.As(err, &noSuchBucketErr) {
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: noSuchBucketErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
}
// match error type against AccessDenied
var accessDeniedErr *types.AccessDenied
if errors.As(err, &accessDeniedErr) {
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: accessDeniedErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
}
// fallback to evaluating the smithy APIError code
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case awsErrorAccessDenied:
fallthrough
case awsErrorInvalidAccessKeyId:
fallthrough
case awsErrorExpiredToken:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: apiErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
case awsErrorNoSuchBucket:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: apiErr.ErrorMessage(),
CheckedAt: timestamppb.Now(),
}
case awsErrorBadDigest:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.Aborted, statusMsg), nil
case awsErrorNoSuchKey:
fallthrough
case awsErrorNotFound:
fallthrough
case awsErrorInvalidObjectState:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
case awsErrorRequestTimeout:
fallthrough
case awsErrorRequestTimeoutException:
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
return status.New(codes.DeadlineExceeded, statusMsg), nil
}
}
// fallback to evaluating the http status code
var httpErr *awshttp.ResponseError
if errors.As(err, &httpErr) {
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(httpErr.Response.Body)
if err != nil {
return status.New(codes.Internal, err.Error()), nil
}
defer httpErr.Response.Body.Close()
switch httpErr.HTTPStatusCode() {
case http.StatusBadRequest:
statusMsg := fmt.Sprintf("aws service %s: bad request: %s", serviceName, msg)
return status.New(codes.InvalidArgument, statusMsg), nil
case http.StatusUnauthorized:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: buf.String(),
CheckedAt: timestamppb.Now(),
}
case http.StatusForbidden:
statusMsg := fmt.Sprintf("aws service %s: invalid credentials: %s", serviceName, msg)
return status.New(codes.PermissionDenied, statusMsg), &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: buf.String(),
CheckedAt: timestamppb.Now(),
}
case http.StatusNotFound:
statusMsg := fmt.Sprintf("aws service %s: resource not found: %s", serviceName, msg)
return status.New(codes.NotFound, statusMsg), nil
case http.StatusTooManyRequests:
statusMsg := fmt.Sprintf("aws service %s: throttling: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
case http.StatusInternalServerError:
fallthrough
case http.StatusBadGateway:
fallthrough
case http.StatusServiceUnavailable:
statusMsg := fmt.Sprintf("aws service %s: connectivity: %s", serviceName, msg)
return status.New(codes.Unavailable, statusMsg), nil
case http.StatusRequestTimeout:
fallthrough
case http.StatusGatewayTimeout:
statusMsg := fmt.Sprintf("aws service %s: timeout: %s", serviceName, msg)
return status.New(codes.DeadlineExceeded, statusMsg), nil
}
}
return st, nil
}
// BadRequestStatusf returns a status error with an invalid
// argument code and a formatted error message.
func BadRequestStatusf(format string, args ...any) error {
return status.New(codes.InvalidArgument, fmt.Sprintf(format, args...)).Err()
}
// BadRequestStatus returns a status error with an invalid
// argument code and the provided message.
func BadRequestStatus(msg string) error {
return status.New(codes.InvalidArgument, msg).Err()
}
// UnknownStatusf returns a status error with an internal
// error code and a formatted error message.
func UnknownStatusf(format string, args ...any) error {
return status.New(codes.Internal, fmt.Sprintf(format, args...)).Err()
}
// UnknownStatus returns a status error with an internal
// error code and the provided message.
func UnknownStatus(msg string) error {
return status.New(codes.Internal, msg).Err()
}
+427
View File
@@ -0,0 +1,427 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"fmt"
"testing"
"net/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
func Test_ParseAWSError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
expectedStatusCode codes.Code
expectedStatusMsg string
expectedPermission *pb.Permission
}{
{
name: "nil-error",
expectedStatusCode: codes.OK,
},
{
name: "default-to-unknwon",
err: fmt.Errorf("what can this error be?"),
expectedStatusCode: codes.Unknown,
expectedStatusMsg: "aws service unknown: unknown error: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-bad-request",
err: TestAwsHttpResponseError(http.StatusBadRequest, "bad request"),
expectedStatusCode: codes.InvalidArgument,
expectedStatusMsg: "aws service unknown: bad request: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-unauthorized",
err: TestAwsHttpResponseError(http.StatusUnauthorized, "unauthorized request"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "unauthorized request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "http-status-code-forbidden",
err: TestAwsHttpResponseError(http.StatusForbidden, "forbidden request"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "forbidden request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "http-status-code-not-found",
err: TestAwsHttpResponseError(http.StatusNotFound, "not found request"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: resource not found: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-too-many-requests",
err: TestAwsHttpResponseError(http.StatusTooManyRequests, "too many requests"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-request-timeout",
err: TestAwsHttpResponseError(http.StatusRequestTimeout, "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: timeout: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-internal-server-error",
err: TestAwsHttpResponseError(http.StatusInternalServerError, "internal server error"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-bad-gateway",
err: TestAwsHttpResponseError(http.StatusBadGateway, "bad gateway"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-service-unavailable",
err: TestAwsHttpResponseError(http.StatusServiceUnavailable, "service unavailable"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: connectivity: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "http-status-code-gateway-timeout",
err: TestAwsHttpResponseError(http.StatusGatewayTimeout, "gateway timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: timeout: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "wrapped-http-status-code-forbidden",
err: fmt.Errorf("wrap: %w", TestAwsHttpResponseError(http.StatusForbidden, "forbidden request")),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "forbidden request",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-throttling",
err: TestAwsError("Throttling", "throttling error"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-throttling-exception",
err: TestAwsError("ThrottlingException", "throttling exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-throttled-exception",
err: TestAwsError("ThrottledException", "throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-throttled-exception",
err: TestAwsError("RequestThrottledException", "request throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-too-many-requests-exception",
err: TestAwsError("TooManyRequestsException", "too many requests exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-provisioned-throughput-exceeded-exception",
err: TestAwsError("ProvisionedThroughputExceededException", "provisioned throughput exceeded exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-transaction-in-progress-exception",
err: TestAwsError("TransactionInProgressException", "transaction in progress exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-limit-exceeded",
err: TestAwsError("RequestLimitExceeded", "request limit exceeded"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-bandwidth-limit-exceeded",
err: TestAwsError("BandwidthLimitExceeded", "bandwidth limit exceeded"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-limit-exceeded-exception",
err: TestAwsError("LimitExceededException", "limit exceeded exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-throttled",
err: TestAwsError("RequestThrottled", "request throttled"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-slow-down",
err: TestAwsError("SlowDown", "slow down"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-prior-request-not-complete",
err: TestAwsError("PriorRequestNotComplete", "prior request not complete"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-ec2-throttled-exception",
err: TestAwsError("EC2ThrottledException", "ec2 throttled exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service unknown: throttling: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-timeout",
err: TestAwsError("RequestTimeout", "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-timeout-exception",
err: TestAwsError("RequestTimeoutException", "request timeout exception"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-request-access-denied",
err: TestAwsError(awsErrorAccessDenied, "not authorized to perform action"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "not authorized to perform action",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-request-invalid-access-key-id",
err: TestAwsError(awsErrorInvalidAccessKeyId, "The AWS Access Key Id you provided does not exist in our records."),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "The AWS Access Key Id you provided does not exist in our records.",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-request-expired-token",
err: TestAwsError(awsErrorExpiredToken, "The security token included in the request is expired."),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "The security token included in the request is expired.",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-s3-typed-not-found",
err: TestAwsS3NotFoundError("object was not found"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-no-such-key",
err: TestAwsS3NoSuchKeyError("key does not exist"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-invalid-object-state",
err: TestAwsS3InvalidObjectStateError("object is archived"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
},
{
name: "aws-s3-typed-no-such-bucket",
err: TestAwsS3NoSuchBucketError("bucket does not exist"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service unknown: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "bucket does not exist",
CheckedAt: timestamppb.Now(),
},
},
{
name: "aws-s3-typed-access-denied",
err: TestAwsS3AccessDeniedError("access denied"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service unknown: invalid credentials: test",
expectedPermission: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "access denied",
CheckedAt: timestamppb.Now(),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
require, assert := require.New(t), assert.New(t)
actualStatus, permission := ParseAWSError("test", tt.err)
if tt.expectedStatusCode == codes.OK {
require.Nil(actualStatus)
return
}
require.Equal(tt.expectedStatusCode, actualStatus.Code())
require.Contains(actualStatus.Message(), tt.expectedStatusMsg)
require.Len(actualStatus.Details(), 0)
CheckSimilarPermission(assert, tt.expectedPermission, permission, true)
})
}
}
func Test_BadRequestStatusf(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := BadRequestStatusf("test: %s", "hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.InvalidArgument)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_BadRequestStatus(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := BadRequestStatus("test: hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.InvalidArgument)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_UnknownStatusf(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := UnknownStatusf("test: %s", "hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.Internal)
_, ok := status.FromError(err)
require.True(ok)
}
func Test_UnknownStatus(t *testing.T) {
require, assert := require.New(t), assert.New(t)
err := UnknownStatusf("test: hello world")
assert.ErrorContains(err, "test: hello world")
assert.Equal(status.Code(err), codes.Internal)
_, ok := status.FromError(err)
require.True(ok)
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
import (
"io"
"net/http"
"strings"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
smithyhttp "github.com/aws/smithy-go/transport/http"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"
)
// TestAwsHttpResponseError returns an aws http response error
func TestAwsHttpResponseError(code int, msg string) error {
return &awshttp.ResponseError{
ResponseError: &smithyhttp.ResponseError{
Response: &smithyhttp.Response{
Response: &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(msg)),
},
},
},
}
}
// TestAwsError returns an generic api error
func TestAwsError(code, msg string) error {
return &smithy.GenericAPIError{
Code: code,
Message: msg,
Fault: smithy.FaultServer,
}
}
// TestAwsS3NotFoundError returns an S3 NotFound error.
func TestAwsS3NotFoundError(msg string) error {
return &types.NotFound{Message: &msg}
}
// TestAwsS3NoSuchKeyError returns an S3 NoSuchKey error.
func TestAwsS3NoSuchKeyError(msg string) error {
return &types.NoSuchKey{Message: &msg}
}
// TestAwsS3InvalidObjectStateError returns an S3 InvalidObjectState error.
func TestAwsS3InvalidObjectStateError(msg string) error {
return &types.InvalidObjectState{Message: &msg}
}
// TestAwsS3NoSuchBucketError returns an S3 NoSuchBucket error.
func TestAwsS3NoSuchBucketError(msg string) error {
return &types.NoSuchBucket{Message: &msg}
}
// TestAwsS3AccessDeniedError returns an S3 AccessDenied error.
func TestAwsS3AccessDeniedError(msg string) error {
return &types.AccessDenied{Message: &msg}
}
func CheckSimilarPermission(assert *assert.Assertions, expected, actual *pb.Permission, substituteNilWithDefaultOk bool) {
if expected == nil && substituteNilWithDefaultOk {
expected = &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
CheckedAt: timestamppb.Now(),
}
}
if expected != nil {
assert.NotNil(actual)
if actual != nil {
assert.Equal(expected.State, actual.State, "StateType mismatch")
assert.Equal(expected.ErrorDetails, actual.ErrorDetails)
assert.NotNil(actual.CheckedAt)
}
} else {
assert.Equal(expected, actual)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package values
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package values
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
@@ -32,6 +32,6 @@ type AwsPlugin struct {
func NewAwsPlugin() *AwsPlugin {
return &AwsPlugin{
HostPlugin: &host.HostPlugin{},
StoragePlugin: &storage.StoragePlugin{},
StoragePlugin: storage.New(),
}
}
+8
View File
@@ -108,6 +108,14 @@ The following `attributes` are valid on an AWS host catalog resource:
be performed. See the [Credential Rotation](../../../README.md#credential-rotation) readme for more information.
- `region` (string): The region to configure the host catalog for. All host sets
in this catalog will be configured for this region.
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
provided, `disable_credential_rotation` must be also set to `true.
- `role_external_id` (string): The external id configured for the `AssumeRole`
provider.
- `role_session_name` (string): The session name configured for the `AssumeRole`
provider.
- `role_tags` (object): The key-value pair tags configured for the `AssumeRole`
provider.
The following `secrets` are required on an AWS host catalog resource:
+24 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -19,6 +19,14 @@ import (
// map.
type CatalogAttributes struct {
*cred.CredentialAttributes
// DualStack is used for configuring how the aws client will resolve requests.
DualStack bool
// InstanceAddressesOnly specifies whether only the instance addresses (private IPv4/DNS,
// public IPv4/DNS, and IPv6) are synced, or all addresses, including those from
// secondary ENIs, are synced.
InstanceAddressesOnly bool
}
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
@@ -31,6 +39,18 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
return nil, err
}
dualStack, err := values.GetBoolValue(in, ConstAwsDualStack, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstAwsDualStack)] = err.Error()
}
delete(unknownFields, ConstAwsDualStack)
instanceAddressesOnly, err := values.GetBoolValue(in, ConstInstanceAddressesOnly, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstInstanceAddressesOnly)] = err.Error()
}
delete(unknownFields, ConstInstanceAddressesOnly)
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
@@ -56,7 +76,9 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
}
return &CatalogAttributes{
CredentialAttributes: credAttributes,
CredentialAttributes: credAttributes,
DualStack: dualStack,
InstanceAddressesOnly: instanceAddressesOnly,
}, nil
}
+33 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -43,6 +43,38 @@ func TestGetCatalogAttributes(t *testing.T) {
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "with dual stack",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"dual_stack": structpb.NewBoolValue(true),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &credential.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
},
DualStack: true,
},
},
{
name: "withInstanceAddressesOnly",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"instance_addresses_only": structpb.NewBoolValue(true),
},
},
expected: &CatalogAttributes{
CredentialAttributes: &credential.CredentialAttributes{
Region: "us-west-2",
DisableCredentialRotation: false,
},
InstanceAddressesOnly: true,
},
},
{
name: "default",
in: &structpb.Struct{
+9 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -10,4 +10,12 @@ const (
// ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2
// instance's name. This field is to be matched literally.
ConstInstanceNameTagKey = "Name"
// ConstAwsDualStack is the key for the dualstack flag in the aws s3 client.
ConstAwsDualStack = "dual_stack"
// ConstInstanceAddressesOnly is the key (instance_addresses_only) that specifies whether
// only the instance addresses (private IPv4/DNS, public IPv4/DNS, and IPv6) are synced,
// or all addresses, including those from secondary ENIs, are synced.
ConstInstanceAddressesOnly = "instance_addresses_only"
)
+210 -155
View File
@@ -1,26 +1,23 @@
package host
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
import (
"context"
"errors"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/aws/smithy-go"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
)
// HostPlugin implements the HostPluginServiceServer interface for the
@@ -42,24 +39,19 @@ var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
}
secrets := catalog.GetSecrets()
if secrets == nil {
return nil, status.Error(codes.InvalidArgument, "secrets are required")
return nil, errors.BadRequestStatus("catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "attributes are required")
return nil, errors.BadRequestStatus("attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
if err != nil {
return nil, err
}
credConfig, err := cred.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true)
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes, catalogAttributes.DualStack)
if err != nil {
return nil, err
}
@@ -71,7 +63,14 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
}, p.testCredStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
// Try to rotate AWS static credentials
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS && !catalogAttributes.DisableCredentialRotation {
if err := credState.RotateCreds(ctx); err != nil {
return nil, err
}
}
catalogState, err := newAwsCatalogPersistedState(
@@ -80,25 +79,21 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error setting up persisted state: %s", err)
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
}
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if !catalogAttributes.DisableCredentialRotation {
if err := credState.RotateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
}
} else {
// Simply validate if we aren't rotating.
if err := credState.ValidateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential validation: %s", err)
}
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
}
return &pb.OnCreateCatalogResponse{
@@ -111,43 +106,105 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
// For host catalogs we only need to really validate what is
// currently in the new copy of the host catalog data, so skip
// fetching the new copy to keep things a little less messy.
catalog := req.GetNewCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
currentCatalog := req.GetCurrentCatalog()
if currentCatalog == nil {
return nil, errors.BadRequestStatus("current catalog is required")
}
newCatalog := req.GetNewCatalog()
if newCatalog == nil {
return nil, errors.BadRequestStatus("new catalog is required")
}
var updateSecrets bool
secrets := catalog.GetSecrets()
if secrets != nil {
// We will be updating secrets this run, but what exactly that
// means will be determined later.
updateSecrets = true
oldAttrs := currentCatalog.GetAttributes()
if oldAttrs == nil {
return nil, errors.BadRequestStatus("current catalog attributes are required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
if err != nil {
return nil, err
}
catalogAttributes, err := getCatalogAttributes(attrs)
newAttrs := newCatalog.GetAttributes()
if newAttrs == nil {
return nil, errors.BadRequestStatus("new catalog attributes are required")
}
newCatalogAttributes, err := getCatalogAttributes(newAttrs)
if err != nil {
return nil, err
}
// Get the persisted data.
// NOTE: We might need to change this at a later time. I'm not too
// sure *exactly* what scenarios we might encounter that would
// ultimately mean that we would have to handle an empty or missing
// state in update, but what we are ultimately assuming here
// (implicitly through awsCatalogPersistedStateFromProto) is that
// the state will exist and be populated. Personally I think this
// is fine and important, but this may change in the future.
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
oldCatalogAttributes.CredentialAttributes,
oldCatalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// Verify the incoming credentials are valid and return any errors to the
// user if they're not. Note this doesn't validate the credentials against
// AWS - it only does logical validation on the fields.
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes, newCatalogAttributes.DualStack)
if err != nil {
return nil, err
}
// We will be updating credentials when one of two changes occur:
// 1. the new catalog has secrets for static credentials, in which case we need to delete the old static credentials
// 2. the new catalog has a different roleARN value for dynamic credentials
if newCatalog.GetSecrets() != nil || newCatalogAttributes.RoleArn != oldCatalogAttributes.RoleArn {
// Ensure the incoming credentials are valid for interaction with the S3
// Bucket before replacing them.
newCredState, err := credential.NewAwsCredentialPersistedState(
append([]credential.AwsCredentialPersistedStateOption{
credential.WithCredentialsConfig(updatedCredentials),
}, p.testCredStateOpts...)...,
)
if err != nil {
return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err)
}
newCatalogState, err := newAwsCatalogPersistedState(
append([]awsCatalogPersistedStateOption{
withCredentials(newCredState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, newCatalogState, opts); st != nil {
return nil, st.Err()
}
// Replace the existing credential state.
// This checks the timestamp on the last rotation time as well
// and deletes the credentials if we are managing them
// (ie: if we've rotated them before).
if err := credState.ReplaceCreds(ctx, updatedCredentials); err != nil {
return nil, err
}
}
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if newCatalogAttributes.DisableCredentialRotation && newCatalog.GetSecrets() == nil {
if !credState.CredsLastRotatedTime.IsZero() {
return nil, errors.BadRequestStatus("cannot disable rotation for already-rotated credentials")
}
}
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if !newCatalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
if err := credState.RotateCreds(ctx); err != nil {
return nil, err
}
}
}
catalogState, err := newAwsCatalogPersistedState(
@@ -156,45 +213,21 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if catalogAttributes.DisableCredentialRotation && !updateSecrets {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if !credState.CredsLastRotatedTime.IsZero() {
return nil, status.Error(codes.FailedPrecondition, "cannot disable rotation for already-rotated credentials")
}
}
if updateSecrets {
catalogSecrets, err := credential.GetCredentialsConfig(secrets, catalogAttributes.CredentialAttributes, true)
if err != nil {
return nil, err
}
// Replace the credentials. This checks the timestamp on the last
// rotation time as well and deletes the credentials if we are
// managing them (ie: if we've rotated them before).
if err := credState.ReplaceCreds(ctx, catalogSecrets); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error attempting to replace credentials: %s", err)
}
}
if !catalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if err := credState.RotateCreds(ctx); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error during credential rotation: %s", err)
}
}
// perform dry run to ensure we can interact with AWS as expected.
opts := []ec2Option{}
if newCatalogAttributes.DualStack {
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
}
return &pb.OnUpdateCatalogResponse{
@@ -206,12 +239,12 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
@@ -228,9 +261,10 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
_, err = newAwsCatalogPersistedState(
@@ -239,17 +273,17 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// try to delete static credentials
if cred.GetCredentialType(credState.CredentialsConfig) == cred.StaticAWS {
if credential.GetCredentialType(credState.CredentialsConfig) == credential.StaticAWS {
if !credState.CredsLastRotatedTime.IsZero() {
// Delete old/existing credentials. This is done with the same
// credentials to ensure that it has the proper permissions to do
// it.
if err := credState.DeleteCreds(ctx); err != nil {
return nil, status.Errorf(codes.Aborted, "error removing rotated credentials during catalog deletion: %s", err)
return nil, err
}
}
}
@@ -288,12 +322,12 @@ func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetD
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -304,9 +338,10 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -315,56 +350,47 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
set := req.GetSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "set is nil")
return nil, errors.BadRequestStatus("set is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
return nil, errors.BadRequestStatus("set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
ec2Client, err := catalogState.EC2Client(ctx)
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
_, err = ec2Client.DescribeInstances(ctx, input)
if err == nil {
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "DryRunOperation" {
// Success
return &pb.OnCreateSetResponse{}, nil
}
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
return &pb.OnCreateSetResponse{}, nil
}
// OnUpdateSet is called when a dynamic host set is updated.
func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -375,9 +401,10 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -386,46 +413,38 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
// As with catalog, we don't need to really look at the old host
// set here, just need to work off of/validate the new set config
set := req.GetNewSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "new set is nil")
return nil, errors.BadRequestStatus("new set is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "new set missing attributes")
return nil, errors.BadRequestStatus("new set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
ec2Client, err := catalogState.EC2Client(ctx)
describeInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
return nil, errors.BadRequestStatusf("error building set filters: %s", err)
}
input, err := buildDescribeInstancesInput(setAttrs, true)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters: %s", err)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
if st := dryRunValidation(ctx, catalogState, opts, describeInstanceFilters...); st != nil {
return nil, st.Err()
}
_, err = ec2Client.DescribeInstances(ctx, input)
if err == nil {
return nil, status.Error(codes.FailedPrecondition, "query error: DescribeInstances DryRun should have returned error, but none was found")
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "DryRunOperation" {
// Success
return &pb.OnUpdateSetResponse{}, nil
}
return nil, status.Errorf(codes.InvalidArgument, "error performing dry run of DescribeInstances: %s", err)
return &pb.OnUpdateSetResponse{}, nil
}
// OnDeleteSet is called when a dynamic host set is deleted.
@@ -439,12 +458,12 @@ func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
return nil, errors.BadRequestStatus("catalog is required")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
return nil, errors.BadRequestStatus("catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
@@ -455,9 +474,10 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
credState, err := credential.AwsCredentialPersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes,
catalogAttributes.DualStack,
p.testCredStateOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
catalogState, err := newAwsCatalogPersistedState(
@@ -466,12 +486,12 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
}
sets := req.GetSets()
if sets == nil {
return nil, status.Error(codes.InvalidArgument, "sets is nil")
return nil, errors.BadRequestStatus("sets are required")
}
// Build all the queries in advance.
@@ -486,11 +506,11 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
for i, set := range sets {
// Validate Id since we use it in output
if set.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "set missing id")
return nil, errors.BadRequestStatus("set id is required")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
return nil, errors.BadRequestStatusf("set %s attributes are required", set.GetId())
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
@@ -499,7 +519,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
input, err := buildDescribeInstancesInput(setAttrs, false)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
return nil, errors.BadRequestStatusf("error building DescribeInstances parameters for host set id %q: %s", set.GetId(), err)
}
queries[i] = hostSetQuery{
Id: set.GetId(),
@@ -507,9 +527,13 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
}
}
ec2Client, err := catalogState.EC2Client(ctx)
opts := []ec2Option{}
if catalogAttributes.DualStack {
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
}
ec2Client, err := catalogState.EC2Client(ctx, opts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting EC2 client: %s", err)
return nil, errors.BadRequestStatusf("error getting EC2 client: %s", err)
}
// Run all queries now and assemble output.
@@ -517,7 +541,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
for i, query := range queries {
output, err := ec2Client.DescribeInstances(ctx, query.Input)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error running DescribeInstances for host set id %q: %s", query.Id, err)
return nil, errors.BadRequestStatusf("error running DescribeInstances for host set id %q: %s", query.Id, err)
}
queries[i].Output = output
@@ -526,9 +550,9 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
// set of hosts afterwards (possibly removing duplicates).
for _, reservation := range output.Reservations {
for _, instance := range reservation.Instances {
host, err := awsInstanceToHost(instance)
host, err := awsInstanceToHost(instance, catalogAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
return nil, errors.BadRequestStatusf("error processing host results for host set id %q: %s", query.Id, err)
}
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
@@ -624,9 +648,12 @@ func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.Descri
// awsInstanceToHost processes data from an ec2.Instance and returns
// a ListHostsResponseHost with the ID and network addresses
// populated.
func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, error) {
func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) {
if aws.ToString(instance.InstanceId) == "" {
return nil, errors.New("response integrity error: missing instance id")
return nil, fmt.Errorf("response integrity error: missing instance id")
}
if catalogAttributes == nil {
return nil, fmt.Errorf("response integrity error: missing catalog attributes")
}
result := new(pb.ListHostsResponseHost)
@@ -642,10 +669,16 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro
}
}
result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress)
// First let's get the instance's Private IPv4/DNS, Public IPv4/DNS, and IPv6 addresses
result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress, instance.Ipv6Address)
result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName, instance.PublicDnsName)
// Now go through all of the interfaces and log the IP address of
if catalogAttributes.InstanceAddressesOnly {
// return early as we already have the instance addresses
return result, nil
}
// Now go through all of the interfaces and sync the IP address of
// every interface.
for _, iface := range instance.NetworkInterfaces {
// Populate default IP addresses/DNS name similar to how we do
@@ -674,6 +707,28 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro
return result, nil
}
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
// credentials, the host listing functionality as well as the filters, if any
// are passed in. This function can therefore be used for both host catalog and
// host set validation.
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, ec2Opts []ec2Option, filters ...types.Filter) *status.Status {
if state == nil {
return status.New(codes.InvalidArgument, "persisted state is required")
}
ec2Client, err := state.EC2Client(ctx, ec2Opts...)
if err != nil {
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
}
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
if err != nil {
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
}
return nil
}
// appendDistinct will append the elements to the slice
// if an element is not nil, empty, and does not exist in slice.
func appendDistinct(slice []string, elems ...*string) []string {
@@ -0,0 +1,516 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
// This file contains integration tests that demonstrate the role_arn silent
// fallback bug at the plugin level.
//
// THE BUG IN ONE SENTENCE:
// When a host catalog is configured with role_arn but no static credentials,
// the plugin never calls sts:AssumeRole. It silently falls back to the
// ambient AWS credential chain (env vars / instance profile), so a bogus or
// unreachable ARN produces no error and returns the same hosts as if no ARN
// were specified at all.
//
// HOW TO READ THESE TESTS:
// Each test function covers one plugin operation (OnCreateCatalog or
// ListHosts). Within each function, the cases are ordered to tell a
// progressive story:
// 1. Ambient baseline — no role_arn, works fine
// 2. Static-key path — explicit keys, works fine
// 3. Bug case — bogus role_arn + no keys, silently succeeds
// 4. Desired behavior — bogus role_arn + STS error injected, SHOULD fail
// 5. Validation errors — combinations that are correctly rejected
//
// Cases marked "BUG" in the soft-fail block currently pass (no error is
// returned). Once the bug is fixed those tests will start asserting correctly
// and the t.Fail() path will no longer be reached.
//
// Run both test functions:
//
// go test ./plugin/service/host/... -run TestPluginRoleArnFallback -v
import (
"context"
"errors"
"testing"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// ambientInstance is the single fake EC2 instance returned by every mock in
// this file. Seeing this instance in the response means the ambient credential
// chain was used, regardless of what role_arn was configured.
var ambientInstance = ec2types.Instance{
InstanceId: aws.String("i-ambientinstance"),
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
NetworkInterfaces: []ec2types.InstanceNetworkInterface{
{
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
PrivateIpAddresses: []ec2types.InstancePrivateIpAddress{
{
PrivateIpAddress: aws.String("10.0.0.1"),
PrivateDnsName: aws.String("ambient.example.internal"),
},
},
},
},
}
// ambientInstanceOutput is a DescribeInstancesOutput that returns ambientInstance.
// All mock EC2 clients in this file are configured with this output so that
// success cases always return the same identifiable host.
var ambientInstanceOutput = &ec2.DescribeInstancesOutput{
Reservations: []ec2types.Reservation{
{Instances: []ec2types.Instance{ambientInstance}},
},
}
// catalogAttrsStruct builds a catalog attributes struct for the given region.
// disableRotation is always written explicitly — false must be set to prevent
// credential rotation from being attempted (which would require IAM mocks).
// roleArn is written only when non-empty.
func catalogAttrsStruct(region, roleArn string, disableRotation bool) *structpb.Struct {
fields := map[string]*structpb.Value{
credential.ConstRegion: structpb.NewStringValue(region),
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(disableRotation),
}
if roleArn != "" {
fields[credential.ConstRoleArn] = structpb.NewStringValue(roleArn)
}
return &structpb.Struct{Fields: fields}
}
// ─────────────────────────────────────────────────────────────────────────────
// OnCreateCatalog
// ─────────────────────────────────────────────────────────────────────────────
func TestPluginRoleArnFallback_OnCreateCatalog(t *testing.T) {
// bogusARN is the ARN used in bug-scenario cases. It contains a fake
// account ID (000000000000) that does not correspond to any real AWS
// account. A correct implementation would fail when it attempts to call
// sts:AssumeRole with this ARN. Currently it is silently ignored.
const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever"
tests := []struct {
name string
req *pb.OnCreateCatalogRequest
credOpts []credential.AwsCredentialPersistedStateOption
catalogOpts []awsCatalogPersistedStateOption
// wantErr is set for cases that should return an error.
wantErr bool
wantErrContains string
wantErrCode codes.Code
// wantEmptySecrets is true when the persisted secrets should be empty
// (dynamic / ambient credentials are not stored in the database).
wantEmptySecrets bool
}{
{
// NOTE: No role_arn, no static keys. OnCreateCatalog succeeds using
// whatever ambient credentials are available. The persisted secrets
// are empty because there are no static keys to store. This is the
// baseline — it shows that ambient credentials work on their own.
name: "1. ambient only — no role_arn, no static keys (baseline)",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: true,
},
{
// NOTE: Explicit static keys, rotation disabled. OnCreateCatalog
// succeeds and persists the key pair. This is the normal static-key
// path — no role assumption involved. No credOpts are needed here:
// with disable_credential_rotation=true and StaticAWS keys, the
// dry-run only calls the mocked EC2 DescribeInstances and does not
// make any real AWS calls (same pattern as the existing
// TestPluginOnCreateCatalogSuccess/usingStaticCredentials test).
name: "2. static keys only (baseline)",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: false, // key pair is stored
},
{
// NOTE: THE BUG — bogus role_arn, no static keys. OnCreateCatalog
// succeeds even though the ARN points to a nonexistent role. The
// dry-run DescribeInstances call is mocked out, so the bad ARN is
// never presented to STS. The persisted secrets are empty (dynamic
// credentials are not stored), which is correct — but the absence
// of an error is not.
name: "3. bogus role_arn + no static keys — BUG: succeeds silently",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
wantEmptySecrets: true,
},
{
// NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn,
// but we inject an STS mock that returns an error when AssumeRole
// is called. A correct implementation would propagate that error
// and OnCreateCatalog would fail. Currently it succeeds because
// sts:AssumeRole is never called — the STS mock is never reached.
// The soft-fail below documents this: if err == nil it means the
// bug is still present.
name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail",
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
},
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(
awsutil.NewMockSTS(
awsutil.WithAssumeRoleError(
errors.New("simulated STS AssumeRole failure: no such role"),
),
),
),
}),
},
catalogOpts: []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}),
)),
},
// We do NOT set wantErr here — the soft-fail logic below handles
// this case explicitly so we can log a clear BUG message.
},
{
// NOTE: Providing both role_arn and static keys is correctly
// rejected at validation time. The switch in GetCredentialsConfig
// catches this combination before any AWS call is attempted.
name: "5. role_arn + static keys — correctly rejected (conflict error)",
wantErr: true,
req: &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
},
wantErrContains: "conflicts with role_arn value",
wantErrCode: codes.InvalidArgument,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
p := &HostPlugin{
testCredStateOpts: tt.credOpts,
testCatalogStateOpts: tt.catalogOpts,
}
rsp, err := p.OnCreateCatalog(context.Background(), tt.req)
// Case 4 — desired behavior, soft-fail.
if tt.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" {
if err == nil {
t.Log("BUG: expected OnCreateCatalog to fail when sts:AssumeRole returns an error,",
"but it succeeded — the role_arn is being silently ignored and sts:AssumeRole",
"is never called. This test will pass once the bug is fixed.")
t.Fail()
}
return
}
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErrContains)
require.Equal(t, tt.wantErrCode.String(), status.Code(err).String())
return
}
require.NoError(t, err)
secrets := rsp.GetPersisted().GetSecrets().GetFields()
// Remove the rotation timestamp before checking emptiness.
delete(secrets, credential.ConstCredsLastRotatedTime)
if tt.wantEmptySecrets {
require.Empty(t, secrets,
"expected empty persisted secrets (dynamic/ambient credentials are not stored)")
} else {
require.NotEmpty(t, secrets,
"expected persisted secrets to contain the static key pair")
}
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ListHosts
// ─────────────────────────────────────────────────────────────────────────────
// listHostsRequest builds a minimal ListHostsRequest. The persisted secrets
// are always an empty struct because the bug scenario uses dynamic credentials
// (nothing is persisted for ambient or role-based auth).
func listHostsRequest(attrs *structpb.Struct) *pb.ListHostsRequest {
return &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: attrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
}
}
func TestPluginRoleArnFallback_ListHosts(t *testing.T) {
// bogusARN is a clearly invalid ARN: a real AWS account ID has 12 digits,
// and "fake/whoever" is not a valid role path. A correct implementation
// would call sts:AssumeRole with this ARN and fail. Currently it is ignored.
const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever"
// ec2WithAmbientInstance is the mock EC2 factory used by all three
// narrative cases. It always returns the same ambientInstance regardless
// of what credentials were resolved. Seeing "i-ambientinstance" in the
// response from case 3 (bogus role_arn) proves the ARN was ignored and
// the same ambient identity was used as in case 1.
ec2WithAmbientInstance := []awsCatalogPersistedStateOption{
withTestEC2APIFunc(newTestMockEC2(
nil,
testMockEC2WithDescribeInstancesOutput(ambientInstanceOutput),
)),
}
cases := []struct {
name string
req *pb.ListHostsRequest
credOpts []credential.AwsCredentialPersistedStateOption
catalogOpts []awsCatalogPersistedStateOption
// wantHosts is the expected number of hosts in a successful response.
wantHosts int
wantErr bool
wantErrContains string
wantErrCode codes.Code
}{
{
// NOTE: No role_arn, no static keys. ListHosts succeeds using
// ambient credentials and returns i-ambientinstance (10.0.0.1).
// This is the baseline — the "normal" ambient credential flow.
name: "1. ambient only — no role_arn, no static keys (baseline)",
req: listHostsRequest(catalogAttrsStruct("us-east-1", "", true)),
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
{
// NOTE: Explicit static keys. ListHosts succeeds and returns
// i-ambientinstance. The same mock EC2 is used here just for
// consistency; in a real scenario static keys and role-assumed
// keys would return different instances.
name: "2. static keys only (baseline)",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", "", true),
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
},
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
{
// NOTE: THE BUG — bogus role_arn is set, but ListHosts succeeds
// and returns the SAME i-ambientinstance (10.0.0.1) as the ambient
// baseline in case 1. The two responses are identical. This proves
// the role_arn was completely ignored: the ambient credential chain
// was used instead of calling sts:AssumeRole with the bogus ARN.
name: "3. bogus role_arn + no static keys — BUG: same hosts as ambient baseline",
req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)),
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1, // same count AND same instance ID as case 1 — the ARN was ignored
},
{
// NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn,
// but an STS mock is injected that returns an error when AssumeRole
// is called. A correct implementation would propagate that STS
// error and ListHosts would return an error. Currently ListHosts
// succeeds because sts:AssumeRole is never called — the injected
// error has no effect. The soft-fail below makes the bug visible
// without causing the full test run to abort.
name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail",
req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)),
credOpts: []credential.AwsCredentialPersistedStateOption{
credential.WithStateTestOpts([]awsutil.Option{
awsutil.WithSTSAPIFunc(
awsutil.NewMockSTS(
awsutil.WithAssumeRoleError(
errors.New("simulated STS AssumeRole failure: no such role"),
),
),
),
}),
},
catalogOpts: ec2WithAmbientInstance,
// wantErr deliberately not set — handled by soft-fail logic below.
},
{
// NOTE: Providing both role_arn and static keys is correctly
// rejected at validation time, before any AWS call is made.
// This case is here to show that not all role_arn combinations
// are silently ignored — only the "no static keys" path is affected.
name: "5. role_arn + static keys — correctly rejected (conflict error)",
req: &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrsStruct("us-east-1", bogusARN, true),
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"),
credential.ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
},
Sets: []*hostsets.HostSet{
{
Id: "set-1",
Attrs: &hostsets.HostSet_Attributes{
Attributes: &structpb.Struct{},
},
},
},
},
// ListHosts calls AwsCredentialPersistedStateFromProto which does
// NOT use GetCredentialsConfig — it reconstructs from persisted
// state. With both a role_arn in attrs and keys in persisted
// secrets, the state is built with both set, which results in the
// DynamicAWS path. No conflict error is raised at this layer.
// We still include this case for completeness, expecting success
// (the conflict is only checked in GetCredentialsConfig, which is
// called during OnCreateCatalog, not during ListHosts).
catalogOpts: ec2WithAmbientInstance,
wantHosts: 1,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
p := &HostPlugin{
testCredStateOpts: tc.credOpts,
testCatalogStateOpts: tc.catalogOpts,
}
rsp, err := p.ListHosts(context.Background(), tc.req)
// Case 4 — desired behavior, soft-fail.
if tc.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" {
if err == nil {
t.Log("BUG: expected ListHosts to fail when sts:AssumeRole returns an error,",
"but it succeeded — the role_arn is being silently ignored and sts:AssumeRole",
"is never called. Compare the response with case 1 (ambient baseline):",
"both return i-ambientinstance, proving the same ambient credentials were",
"used in both cases. This test will assert correctly once the bug is fixed.")
t.Fail()
}
return
}
if tc.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErrContains)
require.Equal(t, tc.wantErrCode.String(), status.Code(err).String())
return
}
require.NoError(t, err)
require.Len(t, rsp.GetHosts(), tc.wantHosts,
"unexpected number of hosts returned — see case NOTE for what this proves")
if tc.wantHosts > 0 {
require.Equal(t, "i-ambientinstance", rsp.GetHosts()[0].GetExternalId(),
"case 3 returning i-ambientinstance (same as case 1) proves the bogus ARN was ignored")
}
})
}
}
File diff suppressed because it is too large. Load diff
+58 -17
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
@@ -7,12 +7,10 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/protobuf/types/known/structpb"
@@ -22,17 +20,6 @@ type EC2API interface {
DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
}
var customClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
},
}
type awsCatalogPersistedState struct {
*credential.AwsCredentialPersistedState
@@ -86,7 +73,11 @@ func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
// EC2Client returns a configured EC2 client based on the session
// information stored in the state.
func (s *awsCatalogPersistedState) EC2Client(ctx context.Context) (EC2API, error) {
func (s *awsCatalogPersistedState) EC2Client(ctx context.Context, opt ...ec2Option) (EC2API, error) {
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("error parsing options when fetching EC2 client: %w", err)
}
awsCfg, err := s.GenerateCredentialChain(ctx)
if err != nil {
return nil, fmt.Errorf("error getting AWS configuration when fetching EC2 client: %w", err)
@@ -94,8 +85,58 @@ func (s *awsCatalogPersistedState) EC2Client(ctx context.Context) (EC2API, error
if awsCfg == nil {
return nil, fmt.Errorf("nil aws configuration")
}
var ec2Opts []func(*ec2.Options)
if opts.withDualStack {
ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{
dualStack: opts.withDualStack,
}))
}
if s.testEC2APIFunc != nil {
return s.testEC2APIFunc(*awsCfg)
}
return ec2.NewFromConfig(*awsCfg), nil
return ec2.NewFromConfig(*awsCfg, ec2Opts...), nil
}
// getOpts iterates the inbound ec2Options and returns a struct
func getOpts(opt ...ec2Option) (ec2Options, error) {
opts := getDefaultEc2Options()
for _, o := range opt {
if o == nil {
continue
}
if err := o(&opts); err != nil {
return ec2Options{}, err
}
}
return opts, nil
}
// ec2Option - how ec2Options are passed as arguments
type ec2Option func(*ec2Options) error
// options = how options are represented
type ec2Options struct {
withDualStack bool
}
func getDefaultEc2Options() ec2Options {
return ec2Options{}
}
// WithDualStack sets the dual stack resolver
func WithDualStack(with bool) ec2Option {
return func(o *ec2Options) error {
o.withDualStack = with
return nil
}
}
type endpointResolver struct {
dualStack bool
}
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params ec2.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = ec2.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
return
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package host
+1 -1
View File
@@ -68,7 +68,7 @@ The following `attributes` are valid on an AWS storage bucket resource:
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
be performed.
- `region` (string): The region to configure the storage bucket for.
- `endpoint-url` (string): The endpoint to configure the storage.
- `endpoint_url` (string): The endpoint to configure the storage.
- `role_arn` (string): The role arn configured for the assume role provider.
- `role_external_id` (string): The external id configured for the assume role provider.
- `role_session_name` (string): The session name configured for the assume role provider.
+11 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -19,6 +19,9 @@ type StorageAttributes struct {
// EndpointUrl is used for configuring how the aws client will resolve requests.
EndpointUrl string
// DualStack is used for configuring how the aws client will resolve requests.
DualStack bool
}
func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
@@ -37,6 +40,12 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
}
delete(unknownFields, ConstAwsEndpointUrl)
dualStack, err := values.GetBoolValue(in, ConstAwsDualStack, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstAwsDualStack)] = err.Error()
}
delete(unknownFields, ConstAwsDualStack)
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
@@ -64,5 +73,6 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
return &StorageAttributes{
CredentialAttributes: credAttributes,
EndpointUrl: endpointUrl,
DualStack: dualStack,
}, nil
}
+25 -3
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,6 +7,8 @@ import (
"testing"
cred "github.com/hashicorp/boundary-plugin-aws/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -19,6 +21,7 @@ func TestGetStorageAttributes(t *testing.T) {
in *structpb.Struct
expected *StorageAttributes
expectedErrContains string
expectedDetails *pb.StorageBucketCredentialState
}{
{
name: "missing region",
@@ -38,6 +41,21 @@ func TestGetStorageAttributes(t *testing.T) {
},
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
},
{
name: "good w/ dualstack",
in: &structpb.Struct{
Fields: map[string]*structpb.Value{
"region": structpb.NewStringValue("us-west-2"),
"dual_stack": structpb.NewBoolValue(true),
},
},
expected: &StorageAttributes{
CredentialAttributes: &cred.CredentialAttributes{
Region: "us-west-2",
},
DualStack: true,
},
},
{
name: "good w/ endpoint",
in: &structpb.Struct{
@@ -100,13 +118,17 @@ func TestGetStorageAttributes(t *testing.T) {
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
require, assert := require.New(t), assert.New(t)
actual, err := getStorageAttributes(tc.in)
if tc.expectedErrContains != "" {
require.Error(err)
require.Contains(err.Error(), tc.expectedErrContains)
require.Equal(status.Code(err), codes.InvalidArgument)
require.Equal(codes.InvalidArgument, status.Code(err))
st, ok := status.FromError(err)
require.True(ok)
assert.Len(st.Details(), 0)
return
}
+5 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,6 +7,9 @@ const (
// ConstAwsEndpointUrl is the key for the endpoint url in the aws s3 client.
ConstAwsEndpointUrl = "endpoint_url"
// ConstAwsDualStack is the key for the dualstack flag in the aws s3 client.
ConstAwsDualStack = "dual_stack"
// defaultStreamChunkSize is the recommened chunk size for sending data through a stream
defaultStreamChunkSize = 65536 // 64 KiB
defaultStreamChunkSize = 1 << 20 // 1 MiB
)
+75
View File
@@ -0,0 +1,75 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
s3StatusErrMsg = "aws s3 error: %s"
// s3ErrorBadDigest is returned when there is a checksum
// mismatch between the request and the s3 service. This
// error code is returned for the PutObject request.
s3ErrorBadDigest = "BadDigest"
)
func setStatePermission(req any, permission *pb.Permission) *pb.StorageBucketCredentialState {
state := &pb.StorageBucketCredentialState{
State: &pb.Permissions{},
}
switch req.(type) {
case pb.HeadObjectRequest:
state.State.Read = permission
case *pb.HeadObjectRequest:
state.State.Read = permission
case pb.GetObjectRequest:
state.State.Read = permission
case *pb.GetObjectRequest:
state.State.Read = permission
case pb.PutObjectRequest:
state.State.Write = permission
case *pb.PutObjectRequest:
state.State.Write = permission
case pb.DeleteObjectsRequest:
state.State.Delete = permission
case *pb.DeleteObjectsRequest:
state.State.Delete = permission
case pb.ListObjectsRequest:
state.State.Read = permission
case *pb.ListObjectsRequest:
state.State.Read = permission
}
return state
}
// parseS3Error will convert an S3 api error into a RPC status. If the
// error does not match against an s3 error type, it will fallback to
// evaluating against generic aws service errors. This includes throttling,
// credentials, and connectivity error types.
//
// https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
func parseS3Error(op string, err error, req any) *status.Status {
if err == nil {
return nil
}
st, p := errors.ParseAWSError(op, err)
if p != nil {
state := setStatePermission(req, p)
if st, err = st.WithDetails(state); err != nil {
st = status.New(codes.Internal, err.Error())
}
}
return st
}
func checksumMistmatchStatus() error {
return status.New(codes.Aborted, "mismatched checksum").Err()
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"testing"
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/types/known/timestamppb"
)
func Test_ParseS3Error(t *testing.T) {
t.Parallel()
cases := []struct {
name string
req any
err error
expectedStatusCode codes.Code
expectedStatusMsg string
expectedDetails *pb.StorageBucketCredentialState
}{
{
name: "nil-error",
expectedStatusCode: codes.OK,
},
{
name: "no-such-bucket",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("NoSuchBucket", "GetObject", "no such bucket"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "no such bucket",
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "no-such-key",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("NoSuchKey", "GetObject", "no such key"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "not-found",
req: &pb.GetObjectRequest{},
err: errors.TestAwsHttpResponseError(404, "not found"),
expectedStatusCode: codes.NotFound,
// cannot embed services within mock http errors, therefore unknown service
expectedStatusMsg: "aws service unknown: resource not found: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "invalid-object-state",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("InvalidObjectState", "GetObject", "invalid object state"),
expectedStatusCode: codes.NotFound,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "bad-digest",
req: &pb.PutObjectRequest{},
err: TestAwsS3Error(s3ErrorBadDigest, "PutObject", "checksum mismatch"),
expectedStatusCode: codes.Aborted,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-throttle-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("Throttling", "action", "throttling exception"),
expectedStatusCode: codes.Unavailable,
expectedStatusMsg: "aws service s3: throttling: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-connectivity-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("RequestTimeout", "action", "request timeout"),
expectedStatusCode: codes.DeadlineExceeded,
expectedStatusMsg: "aws service s3: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_UNKNOWN,
CheckedAt: timestamppb.Now(),
},
},
},
},
{
name: "fallback-credentials-error",
req: &pb.GetObjectRequest{},
err: TestAwsS3Error("AccessDenied", "action", "access denied"),
expectedStatusCode: codes.PermissionDenied,
expectedStatusMsg: "aws service s3: invalid credentials: test:",
expectedDetails: &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
ErrorDetails: "access denied",
CheckedAt: timestamppb.Now(),
},
},
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
require, assert := require.New(t), assert.New(t)
actualStatus := parseS3Error("test", tt.err, tt.req)
if tt.expectedStatusCode == codes.OK {
require.Nil(actualStatus)
return
}
require.Equal(tt.expectedStatusCode, actualStatus.Code())
require.Contains(actualStatus.Message(), tt.expectedStatusMsg)
require.Len(actualStatus.Details(), 1)
foundDetail := false
for _, detail := range actualStatus.Details() {
switch errDetail := detail.(type) {
case *pb.StorageBucketCredentialState:
foundDetail = true
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Read, errDetail.State.Read, false)
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Write, errDetail.State.Write, false)
errors.CheckSimilarPermission(assert, tt.expectedDetails.State.Delete, errDetail.State.Delete, false)
}
}
require.True(foundDetail, "did not find error details with status")
})
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+82 -21
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -7,12 +7,13 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/retry"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
"github.com/aws/aws-sdk-go-v2/service/s3"
smithyEndpoints "github.com/aws/smithy-go/endpoints"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
@@ -25,19 +26,22 @@ type S3API interface {
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
CreateMultipartUpload(ctx context.Context, params *s3.CreateMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error)
UploadPart(ctx context.Context, params *s3.UploadPartInput, optFns ...func(*s3.Options)) (*s3.UploadPartOutput, error)
CompleteMultipartUpload(ctx context.Context, params *s3.CompleteMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error)
AbortMultipartUpload(ctx context.Context, params *s3.AbortMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error)
DeleteObjects(ctx context.Context, params *s3.DeleteObjectsInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error)
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
Credentials() aws.Credentials
}
var customClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
},
type s3Client struct {
*s3.Client
creds aws.Credentials
}
func (c *s3Client) Credentials() aws.Credentials {
return c.creds
}
type awsStoragePersistedState struct {
@@ -84,7 +88,6 @@ func newAwsStoragePersistedState(opts ...awsStoragePersistedStateOption) (*awsSt
}
func (s *awsStoragePersistedState) toProto() (*storagebuckets.StorageBucketPersisted, error) {
data, err := structpb.NewStruct(s.ToMap())
if err != nil {
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
@@ -92,9 +95,9 @@ func (s *awsStoragePersistedState) toProto() (*storagebuckets.StorageBucketPersi
return &storagebuckets.StorageBucketPersisted{Data: data}, nil
}
// S3Client returns a configured S3 client based on the session
// s3Client returns a configured S3 client based on the session
// information stored in the state.
func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option) (S3API, error) {
func (s *awsStoragePersistedState) s3Client(ctx context.Context, opt ...s3Option) (S3API, error) {
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err)
@@ -107,15 +110,28 @@ func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option
return nil, fmt.Errorf("nil aws configuration")
}
var s3Opts []func(*s3.Options)
if opts.withEndpoint != "" {
if opts.withEndpoint != "" || opts.withDualStack {
s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{
endpoint: opts.withEndpoint,
endpoint: opts.withEndpoint,
dualStack: opts.withDualStack,
}))
}
if s.testS3APIFunc != nil {
return s.testS3APIFunc(*awsCfg)
}
return s3.NewFromConfig(*awsCfg, s3Opts...), nil
// Retrieve the credentials provider from the client
credsProvider := awsCfg.Credentials
creds, err := credsProvider.Retrieve(ctx)
if err != nil {
return nil, fmt.Errorf("failed to retrieve credentials: %v\n", err)
}
err = newTransport(awsCfg)
if err != nil {
return nil, fmt.Errorf("failed to configure new http transport: %v", err)
}
c := s3.NewFromConfig(*awsCfg, s3Opts...)
return &s3Client{Client: c, creds: creds}, nil
}
// getOpts iterates the inbound s3Options and returns a struct
@@ -137,13 +153,23 @@ type s3Option func(*s3Options) error
// options = how options are represented
type s3Options struct {
withEndpoint string
withEndpoint string
withDualStack bool
withCacheRefresh bool
}
func getDefaultS3Options() s3Options {
return s3Options{}
}
// WithCacheRefresh controls if the cache should be forced to refresh
func WithCacheRefresh(refresh bool) s3Option {
return func(o *s3Options) error {
o.withCacheRefresh = refresh
return nil
}
}
// WithEndpoint contains the endpoint to use
func WithEndpoint(with string) s3Option {
return func(o *s3Options) error {
@@ -152,18 +178,27 @@ func WithEndpoint(with string) s3Option {
}
}
// WithDualStack sets the dual stack resolver
func WithDualStack(with bool) s3Option {
return func(o *s3Options) error {
o.withDualStack = with
return nil
}
}
type endpointResolver struct {
endpoint string
endpoint string
dualStack bool
}
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
var uri *url.URL
params.UseDualStack = aws.Bool(e.dualStack)
resolver, err = s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
if err != nil {
return
}
if e.endpoint == "" {
// TODO: not sure if we should set an error or ignore the missing endpoint value and continue with the default resolver
return
}
uri, err = url.Parse(e.endpoint)
@@ -171,7 +206,6 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
return
}
if uri == nil {
// TODO: not sure if we should set an error or ignore the missing uri value and continue with the default resolver
return
}
resolver = smithyEndpoints.Endpoint{
@@ -179,3 +213,30 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
}
return
}
// newTransport returns an http Transport object. The http transport object utilizes the
// default transport created by minio. The following transport fields are updated to provide
// more reliable connectivity when handling larger objects:
// MaxResponseHeaderBytes is set to 1MiB to prevent header reads from stalling.
// WriteBufferSize is set to 1MiB, matching the default buffer size used in Boundary storage.
// ReadBufferSize is set to 1MiB, matching the default buffer size used in Boundary storage.
// ForceAttemptHTTP2 is set to true, supporting more resilient multiplexing and avoids HOL blocking on retries.
// IdleConnTimeout is set to 2 minutes.
func newTransport(cfg *aws.Config) error {
if cfg == nil {
return fmt.Errorf("missing aws configuration")
}
cfg.HTTPClient = awshttp.NewBuildableClient().WithTransportOptions(func(transport *http.Transport) {
transport.MaxResponseHeaderBytes = 1 << 20 // 1 MiB prevents header read from stalling
transport.WriteBufferSize = 1 << 20 // 1 MiB write buffer (default is 4 KiB)
transport.ReadBufferSize = 1 << 20 // 1 MiB read buffer (default is 4 KiB)
transport.ForceAttemptHTTP2 = true // enables HTTP/2 where supported more resilient multiplexing, avoids HOL blocking on retries
transport.IdleConnTimeout = 2 * time.Minute // double the idle connection timeout, default is 1 minute
})
cfg.Retryer = func() aws.Retryer {
return retry.NewStandard(func(o *retry.StandardOptions) {
o.MaxAttempts = 20
})
}
return nil
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -64,7 +64,7 @@ func TestAwsStoragePersistedStateS3Client(t *testing.T) {
)
require.NoError(err)
clientRaw, err := state.S3Client(context.Background(), tc.s3Opts...)
clientRaw, err := state.s3Client(context.Background(), tc.s3Opts...)
require.NoError(err)
require.NotNil(clientRaw)
client, ok := clientRaw.(*testMockS3)
+203 -18
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package storage
@@ -6,6 +6,8 @@ package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"testing"
@@ -14,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/aws/smithy-go"
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
@@ -28,25 +31,8 @@ const (
testGetUserErr = "test error for GetUser"
testGetCallerIdentityErr = "test error for GetCallerIdentity"
testDeleteAccessKeyErr = "test error for DeleteAccessKey"
testGetObjectErr = "test error for GetObject"
testPutObjectErr = "test error for PutObject"
testHeadObjectErr = "test error for HeadObject"
testListObjectV2Err = "test error for ListObjectV2"
testDeleteObjectErr = "test error for DeleteObject"
testDeleteObjectsErr = "test error for DeleteObjects"
)
// throttleErr is a mocked error used for testing the aws s3 client
type throttleErr struct{}
func (e *throttleErr) ErrorCode() string {
return "ThrottlingException"
}
func (e *throttleErr) Error() string {
return "ThrottlingException"
}
type testMockS3State struct {
GetObjectCalled bool
GetObjectInputParams *s3.GetObjectInput
@@ -55,6 +41,23 @@ type testMockS3State struct {
PutObjectInputParams *s3.PutObjectInput
PutObjectBody []byte
CreateMultipartUploadCalled bool
CreateMultipartUploadInputParams *s3.CreateMultipartUploadInput
UploadPartCalled bool
UploadPartCallCount int
UploadPartInputParams []*s3.UploadPartInput
// UploadPartBody is the concatenation, in part-number order, of every
// part body received by UploadPart. It can be compared against the full
// object that was expected to be uploaded.
UploadPartBody []byte
CompleteMultipartUploadCalled bool
CompleteMultipartUploadInputParams *s3.CompleteMultipartUploadInput
AbortMultipartUploadCalled bool
AbortMultipartUploadInputParams *s3.AbortMultipartUploadInput
HeadObjectCalled bool
HeadObjectInputParams *s3.HeadObjectInput
@@ -73,6 +76,16 @@ func (s *testMockS3State) Reset() {
s.PutObjectCalled = false
s.PutObjectInputParams = nil
s.PutObjectBody = nil
s.CreateMultipartUploadCalled = false
s.CreateMultipartUploadInputParams = nil
s.UploadPartCalled = false
s.UploadPartCallCount = 0
s.UploadPartInputParams = nil
s.UploadPartBody = nil
s.CompleteMultipartUploadCalled = false
s.CompleteMultipartUploadInputParams = nil
s.AbortMultipartUploadCalled = false
s.AbortMultipartUploadInputParams = nil
s.HeadObjectCalled = false
s.HeadObjectInputParams = nil
s.ListObjectsV2Called = false
@@ -97,6 +110,19 @@ type testMockS3 struct {
PutObjectOutput *s3.PutObjectOutput
PutObjectErr error
// mocked responses for the multipart upload calls
CreateMultipartUploadOutput *s3.CreateMultipartUploadOutput
CreateMultipartUploadErr error
UploadPartOutput *s3.UploadPartOutput
UploadPartErr error
// UploadPartErrOnPart, when greater than zero, causes UploadPart to return
// UploadPartErr (or a generic error) only for the matching part number.
UploadPartErrOnPart int32
CompleteMultipartUploadOutput *s3.CompleteMultipartUploadOutput
CompleteMultipartUploadErr error
AbortMultipartUploadOutput *s3.AbortMultipartUploadOutput
AbortMultipartUploadErr error
// mocked responses for headObject
HeadObjectOutput *s3.HeadObjectOutput
HeadObjectErr error
@@ -130,6 +156,63 @@ func testMockS3WithPutObjectError(e error) testMockS3Option {
}
}
func testMockS3WithCreateMultipartUploadOutput(o *s3.CreateMultipartUploadOutput) testMockS3Option {
return func(m *testMockS3) error {
m.CreateMultipartUploadOutput = o
return nil
}
}
func testMockS3WithCreateMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.CreateMultipartUploadErr = e
return nil
}
}
func testMockS3WithUploadPartOutput(o *s3.UploadPartOutput) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartOutput = o
return nil
}
}
func testMockS3WithUploadPartError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartErr = e
return nil
}
}
func testMockS3WithUploadPartErrorOnPart(partNumber int32, e error) testMockS3Option {
return func(m *testMockS3) error {
m.UploadPartErr = e
m.UploadPartErrOnPart = partNumber
return nil
}
}
func testMockS3WithCompleteMultipartUploadOutput(o *s3.CompleteMultipartUploadOutput) testMockS3Option {
return func(m *testMockS3) error {
m.CompleteMultipartUploadOutput = o
return nil
}
}
func testMockS3WithCompleteMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.CompleteMultipartUploadErr = e
return nil
}
}
func testMockS3WithAbortMultipartUploadError(e error) testMockS3Option {
return func(m *testMockS3) error {
m.AbortMultipartUploadErr = e
return nil
}
}
func testMockS3WithGetObjectOutput(o *s3.GetObjectOutput) testMockS3Option {
return func(m *testMockS3) error {
m.GetObjectOutput = o
@@ -271,6 +354,95 @@ func (m *testMockS3) PutObject(ctx context.Context, params *s3.PutObjectInput, o
return m.PutObjectOutput, nil
}
func (m *testMockS3) CreateMultipartUpload(ctx context.Context, params *s3.CreateMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) {
if m.State != nil {
m.State.CreateMultipartUploadCalled = true
m.State.CreateMultipartUploadInputParams = params
}
if m.CreateMultipartUploadErr != nil {
return nil, m.CreateMultipartUploadErr
}
if m.CreateMultipartUploadOutput != nil {
return m.CreateMultipartUploadOutput, nil
}
return &s3.CreateMultipartUploadOutput{
UploadId: aws.String("test-upload-id"),
}, nil
}
func (m *testMockS3) UploadPart(ctx context.Context, params *s3.UploadPartInput, optFns ...func(*s3.Options)) (*s3.UploadPartOutput, error) {
if m.State != nil {
m.State.UploadPartCalled = true
m.State.UploadPartCallCount++
m.State.UploadPartInputParams = append(m.State.UploadPartInputParams, params)
}
// always drain the body so the part data can be asserted on and so the
// reader is not left dangling.
var data []byte
if params.Body != nil {
var err error
data, err = io.ReadAll(params.Body)
if err != nil {
return nil, err
}
if m.State != nil {
m.State.UploadPartBody = append(m.State.UploadPartBody, data...)
}
}
if m.UploadPartErr != nil {
if m.UploadPartErrOnPart == 0 || (params.PartNumber != nil && *params.PartNumber == m.UploadPartErrOnPart) {
return nil, m.UploadPartErr
}
}
if m.UploadPartOutput != nil {
return m.UploadPartOutput, nil
}
// echo the supplied part checksum so CompleteMultipartUpload can be built
// the way the real service would allow.
sum := sha256.Sum256(data)
return &s3.UploadPartOutput{
ETag: aws.String(fmt.Sprintf("etag-%d", aws.ToInt32(params.PartNumber))),
ChecksumSHA256: aws.String(base64.StdEncoding.EncodeToString(sum[:])),
}, nil
}
func (m *testMockS3) CompleteMultipartUpload(ctx context.Context, params *s3.CompleteMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) {
if m.State != nil {
m.State.CompleteMultipartUploadCalled = true
m.State.CompleteMultipartUploadInputParams = params
}
if m.CompleteMultipartUploadErr != nil {
return nil, m.CompleteMultipartUploadErr
}
if m.CompleteMultipartUploadOutput != nil {
return m.CompleteMultipartUploadOutput, nil
}
return &s3.CompleteMultipartUploadOutput{}, nil
}
func (m *testMockS3) AbortMultipartUpload(ctx context.Context, params *s3.AbortMultipartUploadInput, optFns ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) {
if m.State != nil {
m.State.AbortMultipartUploadCalled = true
m.State.AbortMultipartUploadInputParams = params
}
if m.AbortMultipartUploadErr != nil {
return nil, m.AbortMultipartUploadErr
}
if m.AbortMultipartUploadOutput != nil {
return m.AbortMultipartUploadOutput, nil
}
return &s3.AbortMultipartUploadOutput{}, nil
}
func (m *testMockS3) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
if m.State != nil {
m.State.ListObjectsV2Called = true
@@ -414,3 +586,16 @@ func validSTSMock() []credential.AwsCredentialPersistedStateOption {
}),
}
}
// TestAwsS3Error returns an s3 api error
func TestAwsS3Error(code, op, msg string) error {
return &smithy.OperationError{
ServiceID: "s3",
OperationName: op,
Err: &smithy.GenericAPIError{
Code: code,
Message: msg,
Fault: smithy.FaultServer,
},
}
}
+108 -10
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
@@ -22,8 +22,6 @@ import (
const expectedEc2InstanceCount = 5
var expectedTags = []string{"foo", "bar", "baz"}
func TestHostPlugin(t *testing.T) {
region := os.Getenv("AWS_REGION")
if region == "" {
@@ -80,6 +78,14 @@ func TestHostPlugin(t *testing.T) {
require.NoError(err)
require.Len(ec2InstanceTags, expectedEc2InstanceCount)
rawExpectedTags, err := tf.GetOutputSlice("instance_tag_keys")
require.NoError(err)
require.Len(rawExpectedTags, 3)
expectedTags := make([]string, 0, len(rawExpectedTags))
for _, rawExpectedTag := range rawExpectedTags {
expectedTags = append(expectedTags, rawExpectedTag.(string))
}
// Start the workflow now. Set up the host catalog. Note that this
// will cause the state to go out of drift above in the sense that
// the access key ID/secret access key will no longer be valid. We
@@ -143,13 +149,13 @@ func TestHostPlugin(t *testing.T) {
}
cases := [][]string{
{"foo"},
{"bar"},
{"baz"},
{"foo", "bar"},
{"foo", "baz"},
{"bar", "baz"},
{"foo", "bar", "baz"},
{expectedTags[0]},
{expectedTags[1]},
{expectedTags[2]},
{expectedTags[0], expectedTags[1]},
{expectedTags[0], expectedTags[2]},
{expectedTags[1], expectedTags[2]},
{expectedTags[0], expectedTags[1], expectedTags[2]},
}
for _, tc := range cases {
@@ -159,6 +165,9 @@ func TestHostPlugin(t *testing.T) {
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
// TODO: add OnDeleteSet if it needs to be implemented
}
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
}
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
@@ -444,3 +453,92 @@ func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin,
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
}
func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing testInstanceAddressesOnlyListHosts")
// Get the instance details from tf output
instanceID, err := tf.GetOutputString("multi_address_instance_id")
require.NoError(t, err)
primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses")
require.NoError(t, err)
secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses")
require.NoError(t, err)
var primary, allAddresses []string
for _, addr := range primaryAddresses {
primary = append(primary, addr.(string))
}
allAddresses = append(allAddresses, primary...)
for _, addr := range secondaryAddresses {
allAddresses = append(allAddresses, addr.(string))
}
cases := []struct {
name string
instanceAddressesOnly bool
expectedIPs []string
}{
{
name: "all addresses",
expectedIPs: allAddresses,
},
{
name: "instance addresses only",
instanceAddressesOnly: true,
expectedIPs: primary,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
catalogAttrMap := map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
}
if tc.instanceAddressesOnly {
catalogAttrMap[host.ConstInstanceAddressesOnly] = true
}
catalogAttrs, err := structpb.NewStruct(catalogAttrMap)
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: []*hostsets.HostSet{{
Id: "hostset-0",
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
}},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.Len(response.GetHosts(), 1)
hostResp := response.GetHosts()[0]
require.Equal(instanceID, hostResp.ExternalId)
require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs)
})
}
}
+320 -8
View File
@@ -1,10 +1,11 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
import (
"context"
"fmt"
"io"
"net"
"os"
@@ -17,12 +18,16 @@ import (
"github.com/hashicorp/boundary-plugin-aws/plugin/service/storage"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
// NOTE: this test does not vaildate against dynamic credentials because
// this credential type can only be tested on an EC2 instance.
func TestStoragePlugin(t *testing.T) {
region := os.Getenv("AWS_REGION")
if region == "" {
@@ -75,6 +80,18 @@ func TestStoragePlugin(t *testing.T) {
iamSecretAccessKeyMissingPutObject, err := tf.GetOutputString("iam_secret_access_key_missing_put_obj")
require.NoError(err)
iamAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_access_key_missing_delete_obj")
require.NoError(err)
iamSecretAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_secret_access_key_missing_delete_obj")
require.NoError(err)
s3PaginationTestObjectCount, err := tf.GetOutput("s3_pagination_test_object_count")
require.NoError(err)
objectCountFloat, ok := s3PaginationTestObjectCount.(float64)
require.True(ok)
objectCount := int(objectCountFloat)
// Start the workflow now. Set up the storage bucket. Note that this
// will cause the state to go out of drift above in the sense that
// the access key ID/secret access key will no longer be valid. We
@@ -121,22 +138,75 @@ func TestStoragePlugin(t *testing.T) {
testPluginOnDeleteStorageBucket(ctx, t, p, bucketName, region, keyid, secret, true)
// ********************
// * Object Methods: PutObject, GetObject, HeadObject
// * Object Methods: PutObject, GetObject, HeadObject, DeleteObjects
// ********************
//
// Reassign the keyid and secret first.
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
testPluginObjectMethods(ctx, t, p, bucketName, region, keyid, secret)
// ********************
// * ListObjects
// ********************
testListObjects(ctx, t, p, objectCount, bucketName, region, keyid, secret)
// ********************
// * Validate Permissions
// ********************
//
var expectedSBCState *pb.StorageBucketCredentialState
// Validate error is returned for missing get object permission
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingGetObject, iamSecretAccessKeyMissingGetObject)
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
Delete: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingGetObject, iamSecretAccessKeyMissingGetObject, expectedSBCState)
// Validate error is returned for missing put object permission
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingPutObject, iamSecretAccessKeyMissingPutObject)
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
Read: &pb.Permission{
// Since we cannot write an object to S3,
// we cannot test against the ability to read from S3.
State: pb.StateType_STATE_TYPE_UNKNOWN,
},
Delete: &pb.Permission{
// We can still try deleting an object that does
// not exist in the S3 bucket to validate access.
State: pb.StateType_STATE_TYPE_OK,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingPutObject, iamSecretAccessKeyMissingPutObject, expectedSBCState)
// Validate error is returned for missing delete object permission
expectedSBCState = &pb.StorageBucketCredentialState{
State: &pb.Permissions{
Write: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Read: &pb.Permission{
State: pb.StateType_STATE_TYPE_OK,
},
Delete: &pb.Permission{
State: pb.StateType_STATE_TYPE_ERROR,
},
},
}
testPluginValidatePermissions(ctx, t, p, bucketName, region, iamAccessKeyMissingDeleteObject, iamSecretAccessKeyMissingDeleteObject, expectedSBCState)
}
func testPluginOnCreateStorageBucket(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
@@ -289,10 +359,12 @@ func testPluginOnDeleteStorageBucket(ctx context.Context, t *testing.T, p *stora
}
}
func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string) {
func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string, expectedState *pb.StorageBucketCredentialState) {
t.Helper()
t.Logf("testing ValidatePermissions")
require := require.New(t)
require, assert := require.New(t), assert.New(t)
require.NotNil(expectedState)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
@@ -315,6 +387,51 @@ func testPluginValidatePermissions(ctx context.Context, t *testing.T, p *storage
_, err = p.ValidatePermissions(ctx, req)
require.Error(err)
var actualSBCState *pb.StorageBucketCredentialState
if st, ok := status.FromError(err); ok {
for _, detail := range st.Details() {
if statusDetail, ok := detail.(*pb.StorageBucketCredentialState); ok {
actualSBCState = statusDetail
break
}
}
}
require.NotNil(actualSBCState)
require.NotNil(actualSBCState.GetState())
expectedReadState := expectedState.GetState().GetRead()
if expectedReadState != nil {
actualReadState := actualSBCState.GetState().GetRead()
require.NotNil(actualReadState)
assert.Equal(expectedReadState.GetState(), actualReadState.GetState())
assert.NotEmpty(actualReadState.GetCheckedAt())
if expectedReadState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualReadState.GetErrorDetails())
}
}
expectedWriteState := expectedState.GetState().GetWrite()
if expectedWriteState != nil {
actualWriteState := actualSBCState.GetState().GetWrite()
require.NotNil(actualWriteState)
assert.Equal(expectedWriteState.GetState(), actualWriteState.GetState())
assert.NotEmpty(actualWriteState.GetCheckedAt())
if expectedWriteState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualWriteState.GetErrorDetails())
}
}
expectedDeleteState := expectedState.GetState().GetDelete()
if expectedDeleteState != nil {
actualDeleteState := actualSBCState.GetState().GetDelete()
require.NotNil(actualDeleteState)
assert.Equal(expectedDeleteState.GetState(), actualDeleteState.GetState())
assert.NotEmpty(actualDeleteState.GetCheckedAt())
if expectedDeleteState.GetState() == pb.StateType_STATE_TYPE_ERROR {
assert.NotEmpty(actualDeleteState.GetErrorDetails())
}
}
}
func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.StoragePlugin, bucketName, region, accessKeyId, secretAccessKey string) {
@@ -369,7 +486,7 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
// Need to create a GRPC server to test GetObject
t.Logf("testing GetObject")
lis, err := net.Listen("tcp", "localhost:2030")
lis, err := net.Listen("tcp", "[::1]:2030")
require.NoError(err)
grpcServer := grpc.NewServer()
pb.RegisterStoragePluginServiceServer(grpcServer, &storage.StoragePlugin{})
@@ -377,7 +494,7 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
grpcServer.Serve(lis)
}()
conn, err := grpc.Dial("localhost:2030", grpc.WithTransportCredentials(insecure.NewCredentials()))
conn, err := grpc.Dial("[::1]:2030", grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(err)
defer conn.Close()
client := pb.NewStoragePluginServiceClient(conn)
@@ -409,3 +526,198 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
require.NoError(conn.Close())
grpcServer.Stop()
}
// testListObjects verifies top-level and recursive listing by matching the exact keys and types of returned objects.
func testListObjects(ctx context.Context, t *testing.T, p *storage.StoragePlugin, objectCount int, bucketName, region, accessKeyId, secretAccessKey string) {
t.Helper()
t.Logf("testing ListObjects (bucket=%s, region=%s, expectedObjects=%d, creds=%t)",
bucketName,
region,
objectCount,
accessKeyId != "" && secretAccessKey != "",
)
r := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstRegion: region,
credential.ConstDisableCredentialRotation: true,
})
r.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstAccessKeyId: accessKeyId,
credential.ConstSecretAccessKey: secretAccessKey,
})
r.NoError(err)
bucket := &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: reqAttrs,
Secrets: reqSecrets,
}
// Test Scenario 1: Non-Paginated Listing
t.Run("NonPaginatedListing", func(t *testing.T) {
// Subtest for the non-recursive (top-level) option
t.Run("TopLevel", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a small set with recursive=false")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects/file1.txt", IsDir: false},
{Key: "list-objects/data.json", IsDir: false},
{Key: "list-objects/nested-dir/", IsDir: true},
{Key: "list-objects/empty-nested-dir/", IsDir: true},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Subtest for the recursive option
t.Run("Recursive", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a small set with recursive=true")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects/",
Recursive: true,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects/file1.txt", IsDir: false},
{Key: "list-objects/data.json", IsDir: false},
{Key: "list-objects/nested-dir/file2.txt", IsDir: false},
{Key: "list-objects/nested-dir/", IsDir: true},
{Key: "list-objects/empty-nested-dir/", IsDir: true},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
})
// Test Scenario 2: Paginated Top-Level Listing
t.Run("TopLevelListing", func(t *testing.T) {
// Subtest for the root prefix
t.Run("AtRoot", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=false at root prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
var expectedObjects []*pb.Object
for i := 0; i < objectCount; i++ {
key := fmt.Sprintf("list-objects-paginated/root-file-%d.txt", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: key, IsDir: false})
}
for i := 0; i < objectCount; i++ {
keyEmpty := fmt.Sprintf("list-objects-paginated/empty-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyEmpty, IsDir: true})
keyNested := fmt.Sprintf("list-objects-paginated/nested-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNested, IsDir: true})
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Subtest for a nested prefix
t.Run("AtNestedPrefix", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=false at a nested prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/nested-dir-7/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
expectedObjects := []*pb.Object{
{Key: "list-objects-paginated/nested-dir-7/data-file-7.json", IsDir: false},
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
})
// Test Scenario 3: Paginated Recursive Listing
t.Run("RecursiveListing", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects with recursive=true")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/",
Recursive: true,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
var expectedObjects []*pb.Object
for i := 0; i < objectCount; i++ {
keyFile := fmt.Sprintf("list-objects-paginated/root-file-%d.txt", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyFile, IsDir: false})
keyNestedFile := fmt.Sprintf("list-objects-paginated/nested-dir-%d/data-file-%d.json", i, i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedFile, IsDir: false})
}
for i := 0; i < objectCount; i++ {
keyNestedDir := fmt.Sprintf("list-objects-paginated/nested-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedDir, IsDir: true})
keyEmptyDir := fmt.Sprintf("list-objects-paginated/empty-dir-%d/", i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyEmptyDir, IsDir: true})
keyNestedEmptyDir := fmt.Sprintf("list-objects-paginated/empty-dir-%d/dir-%d/", i, i)
expectedObjects = append(expectedObjects, &pb.Object{Key: keyNestedEmptyDir, IsDir: true})
}
r.ElementsMatch(resp.Objects, expectedObjects)
})
// Test Scenario 4: Edge cases
t.Run("EdgeCases", func(t *testing.T) {
// Subtest for a prefix that doesn't exist
t.Run("NonExistentPrefix", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects for a non-existent prefix")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "this/prefix/does/not/exist/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
r.Empty(resp.Objects)
})
// Subtest for listing inside a known empty directory
t.Run("InsideEmptyDirectory", func(t *testing.T) {
r := require.New(t)
t.Logf("testing ListObjects inside an empty directory")
req := &pb.ListObjectsRequest{
Bucket: bucket,
KeyPrefix: "list-objects-paginated/empty-dirs/dir-10/",
Recursive: false,
}
resp, err := p.ListObjects(ctx, req)
r.NoError(err)
r.NotNil(resp)
r.Empty(resp.Objects)
})
})
}
+22 -15
View File
@@ -1,24 +1,20 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
variable "iam_user_prefix" {
default = "boundary-plugin-host-aws-test-iam-user"
}
variable "iam_user_count" {
default = 6
}
resource "random_id" "aws_iam_user_name" {
count = var.iam_user_count
prefix = "${var.iam_user_prefix}-${count.index}"
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
byte_length = 4
}
resource "aws_iam_user" "user" {
count = var.iam_user_count
name = random_id.aws_iam_user_name[count.index].dec
force_destroy = true
count = var.iam_user_count
name = random_id.aws_iam_user_name[count.index].dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_access_key" "user_initial_key" {
@@ -26,8 +22,13 @@ resource "aws_iam_access_key" "user_initial_key" {
user = aws_iam_user.user[count.index].name
}
resource "random_id" "aws_ec2_policy_name" {
prefix = "BoundaryPluginHost"
byte_length = 4
}
resource "aws_iam_policy" "ec2_describeinstances" {
name = "BoundaryPluginHostAwsTestDescribeInstancesPolicy"
name = random_id.aws_ec2_policy_name.dec
policy = <<EOF
{
@@ -51,9 +52,15 @@ resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment
policy_arn = aws_iam_policy.ec2_describeinstances.arn
}
resource "random_id" "aws_iam_policy_name" {
count = var.iam_user_count
prefix = "BoundaryPluginCredentials"
byte_length = 4
}
resource "aws_iam_policy" "user_self_manage_policy" {
count = var.iam_user_count
name = "BoundaryPluginHostAwsTestUserSelfManagePolicy-${count.index}"
name = random_id.aws_iam_policy_name[count.index].dec
policy = <<EOF
{
+2 -2
View File
@@ -1,11 +1,11 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
version = "~> 6.0"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
data "aws_caller_identity" "current" {}
variable "iam_user_count" {
default = 6
}
# We are using a shared account for testing so make sure the stale ec2
# machine tags don't match for this test run
resource "random_id" "foo_key" {
prefix = "foo"
byte_length = 4
}
resource "random_id" "bar_key" {
prefix = "bar"
byte_length = 4
}
resource "random_id" "baz_key" {
prefix = "baz"
byte_length = 4
}
locals {
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
instance_tags = [
{
"${random_id.foo_key.dec}" = "true"
},
{
"${random_id.foo_key.dec}" = "true"
"${random_id.bar_key.dec}" = "true"
},
{
"${random_id.bar_key.dec}" = "true"
},
{
"${random_id.bar_key.dec}" = "true"
"${random_id.baz_key.dec}" = "true"
},
{
"${random_id.baz_key.dec}" = "true"
},
]
}
+133 -30
View File
@@ -1,28 +1,6 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
variable "instance_tags" {
default = [
{
"foo" = "true",
},
{
"foo" = "true",
"bar" = "true",
},
{
"bar" = "true",
},
{
"bar" = "true",
"baz" = "true",
},
{
"baz" = "true",
},
]
}
data "aws_availability_zones" "azs" {}
data "aws_ami" "ubuntu" {
@@ -42,22 +20,117 @@ data "aws_ami" "ubuntu" {
}
resource "aws_vpc" "vpc" {
cidr_block = "10.0.0.0/16"
cidr_block = "10.0.0.0/16"
assign_generated_ipv6_cidr_block = true
}
resource "aws_subnet" "subnet" {
vpc_id = aws_vpc.vpc.id
cidr_block = aws_vpc.vpc.cidr_block
availability_zone = data.aws_availability_zones.azs.names[0]
vpc_id = aws_vpc.vpc.id
cidr_block = aws_vpc.vpc.cidr_block
ipv6_cidr_block = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 0)
availability_zone = data.aws_availability_zones.azs.names[0]
map_public_ip_on_launch = true
assign_ipv6_address_on_creation = true
}
# Need to add a gateway for public IP addr assignments
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.vpc.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
route {
ipv6_cidr_block = "::/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.subnet.id
route_table_id = aws_route_table.public.id
}
resource "aws_instance" "instances" {
count = length(var.instance_tags)
count = length(local.instance_tags)
ami = data.aws_ami.ubuntu.id
instance_type = "t3.nano"
subnet_id = aws_subnet.subnet.id
tags = var.instance_tags[count.index]
tags = local.instance_tags[count.index]
}
resource "aws_instance" "multi_address_instance" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.nano"
subnet_id = aws_subnet.subnet.id
secondary_private_ips = ["10.0.10.10"]
ipv6_address_count = 1
associate_public_ip_address = true
tags = {
Name = "boundary-multi-address-instance"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_network_interface" "multi_address_secondary_eni" {
subnet_id = aws_subnet.subnet.id
private_ips = ["10.0.20.10"]
ipv6_addresses = [cidrhost(aws_subnet.subnet.ipv6_cidr_block, 32)]
tags = {
Name = "boundary-multi-address-secondary-eni"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_network_interface_attachment" "multi_address_secondary_eni" {
instance_id = aws_instance.multi_address_instance.id
network_interface_id = aws_network_interface.multi_address_secondary_eni.id
device_index = 1
}
resource "aws_eip" "multi_address_primary" {
domain = "vpc"
network_interface = aws_instance.multi_address_instance.primary_network_interface_id
associate_with_private_ip = aws_instance.multi_address_instance.private_ip
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public]
tags = {
Name = "boundary-multi-address-primary-eip"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_eip" "multi_address_primary_secondary_private" {
domain = "vpc"
network_interface = aws_instance.multi_address_instance.primary_network_interface_id
associate_with_private_ip = "10.0.10.10"
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_instance.multi_address_instance]
tags = {
Name = "boundary-multi-address-primary-secondary-eip"
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_eip" "multi_address_secondary_eni" {
domain = "vpc"
network_interface = aws_network_interface.multi_address_secondary_eni.id
associate_with_private_ip = "10.0.20.10"
depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_network_interface_attachment.multi_address_secondary_eni]
tags = {
Name = "boundary-multi-address-secondary-eni-eip"
"boundary-demo" = local.hashicorp_email
}
}
output "instance_ids" {
@@ -74,4 +147,34 @@ output "instance_tags" {
value = {
for _, r in aws_instance.instances : r.id => r.tags
}
}
}
output "instance_tag_keys" {
value = [
random_id.foo_key.dec,
random_id.bar_key.dec,
random_id.baz_key.dec,
]
}
output "multi_address_instance_id" {
value = aws_instance.multi_address_instance.id
}
output "multi_address_primary_addresses" {
value = {
private_ip = aws_instance.multi_address_instance.private_ip
public_ip = aws_eip.multi_address_primary.public_ip
ipv6 = tolist(aws_instance.multi_address_instance.ipv6_addresses)[0]
}
}
output "multi_address_secondary_addresses" {
value = {
primary_eni_private_ip = "10.0.10.10"
primary_eni_public_ip = aws_eip.multi_address_primary_secondary_private.public_ip
secondary_eni_private_ip = aws_network_interface.multi_address_secondary_eni.private_ip
secondary_eni_public_ip = aws_eip.multi_address_secondary_eni.public_ip
secondary_eni_ipv6 = tolist(aws_network_interface.multi_address_secondary_eni.ipv6_addresses)[0]
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "random_id" "foo" {
+104 -17
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "aws_iam_role" "valid" {
@@ -32,10 +32,20 @@ resource "aws_iam_policy" "valid" {
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectAttributes"
"s3:DeleteObject",
"s3:GetObjectAttributes",
"s3:ListBucket",
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
},
{
"Action": [
"s3:ListBucket"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}"
}
]
@@ -79,7 +89,9 @@ resource "aws_iam_policy" "missing_put_obj" {
{
"Action": [
"s3:GetObject",
"s3:GetObjectAttributes"
"s3:GetObjectAttributes",
"s3:ListBucket",
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
@@ -125,7 +137,7 @@ resource "aws_iam_policy" "missing_get_obj" {
{
"Action": [
"s3:PutObject",
"s3:GetObjectAttributes"
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
@@ -141,18 +153,67 @@ resource "aws_iam_role_policy_attachment" "missing_get_obj" {
policy_arn = aws_iam_policy.missing_get_obj.arn
}
resource "aws_iam_role" "missing_delete_obj" {
name = "${random_id.prefix.dec}-missing-delete-obj"
tags = local.tags
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
AWS: data.aws_caller_identity.current.user_id
}
},
]
})
}
resource "aws_iam_policy" "missing_delete_obj" {
name = "${random_id.prefix.dec}-missing-delete-obj"
tags = local.tags
policy = <<EOT
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectAttributes",
"s3:ListBucket"
],
"Effect": "Allow",
"Resource": "${aws_s3_bucket.test.arn}*"
}
]
}
EOT
}
resource "aws_iam_role_policy_attachment" "missing_delete_obj" {
role = aws_iam_role.missing_delete_obj.name
policy_arn = aws_iam_policy.missing_delete_obj.arn
}
# Static Credential Testing
resource "random_id" "user_name" {
count = var.iam_user_count
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "test" {
count = var.iam_user_count
name = random_id.user_name[count.index].dec
force_destroy = true
tags = local.tags
count = var.iam_user_count
name = random_id.user_name[count.index].dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "test" {
@@ -195,17 +256,42 @@ resource "aws_iam_user_policy_attachment" "test_credentials" {
policy_arn = aws_iam_policy.credentials[count.index].arn
}
# Edge case for missing DeleteObject permission
resource "random_id" "missing_delete_obj" {
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
byte_length = 4
}
resource "aws_iam_user" "missing_delete_obj" {
name = random_id.missing_delete_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
tags = {
"boundary-demo" = local.hashicorp_email
}
}
resource "aws_iam_access_key" "missing_delete_obj" {
user = aws_iam_user.missing_delete_obj.name
}
resource "aws_iam_user_policy_attachment" "missing_delete_obj" {
user = aws_iam_user.missing_delete_obj.name
policy_arn = aws_iam_policy.missing_delete_obj.arn
}
# Edge case for missing GetObject permission
resource "random_id" "missing_get_obj" {
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "missing_get_obj" {
name = random_id.missing_get_obj.dec
force_destroy = true
tags = local.tags
name = random_id.missing_get_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "missing_get_obj" {
@@ -220,14 +306,15 @@ resource "aws_iam_user_policy_attachment" "missing_get_obj" {
# Edge case for missing PutObject permission
resource "random_id" "missing_put_obj" {
prefix = random_id.prefix.dec
prefix = "demo-${local.hashicorp_email}" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
byte_length = 4
}
resource "aws_iam_user" "missing_put_obj" {
name = random_id.missing_put_obj.dec
force_destroy = true
tags = local.tags
name = random_id.missing_put_obj.dec
force_destroy = true
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser" # Do Not Remove/Edit. This is a requirement to obtain access in creating a iam user in the dev account.
tags = local.tags
}
resource "aws_iam_access_key" "missing_put_obj" {
+2 -2
View File
@@ -1,11 +1,11 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
version = "~> 6.0"
}
}
}
+25 -6
View File
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
output "bucket_name" {
@@ -6,15 +6,19 @@ output "bucket_name" {
}
output "valid_policy_arn" {
value = aws_iam_role.valid.arn
value = aws_iam_role.valid.arn
}
output "missing_put_obj_policy_arn" {
value = aws_iam_role.missing_put_obj.arn
value = aws_iam_role.missing_put_obj.arn
}
output "missing_put_get_policy_arn" {
value = aws_iam_role.missing_get_obj.arn
output "missing_get_obj_policy_arn" {
value = aws_iam_role.missing_get_obj.arn
}
output "missing_delete_obj_policy_arn" {
value = aws_iam_role.missing_delete_obj.arn
}
output "iam_user_names" {
@@ -53,4 +57,19 @@ output "iam_access_key_missing_get_obj" {
output "iam_secret_access_key_missing_get_obj" {
value = aws_iam_access_key.missing_get_obj.secret
sensitive = true
}
}
output "iam_access_key_missing_delete_obj" {
value = aws_iam_access_key.missing_delete_obj.id
sensitive = true
}
output "iam_secret_access_key_missing_delete_obj" {
value = aws_iam_access_key.missing_delete_obj.secret
sensitive = true
}
output "s3_pagination_test_object_count" {
description = "The number of S3 objects created for each pagination test scenario."
value = local.s3_pagination_test_object_count
}
+19 -3
View File
@@ -1,9 +1,25 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
resource "aws_s3_bucket" "test" {
bucket = random_id.prefix.dec
force_destroy = true
acl = "private"
tags = local.tags
}
}
resource "aws_s3_bucket_public_access_block" "secure_access" {
bucket = aws_s3_bucket.test.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_object" "s3_files" {
for_each = local.s3_files
bucket = aws_s3_bucket.test.id
key = each.key
content = each.value
tags = local.tags
}
+47 -5
View File
@@ -1,10 +1,10 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2021, 2026
# SPDX-License-Identifier: MPL-2.0
data "aws_caller_identity" "current" {}
resource "random_id" "prefix" {
prefix = "storage-e2e-test"
prefix = "aws-plugin-e2e-test"
byte_length = 4
}
@@ -13,8 +13,50 @@ variable "iam_user_count" {
}
locals {
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
tags = {
user_id = split(":", data.aws_caller_identity.current.user_id)[1]
repo = "boundary-plugin-aws"
User = split(":", data.aws_caller_identity.current.user_id)[1]
Repo = "boundary-plugin-aws"
Environment = "Demo"
}
}
# total number of objects of each type to create
s3_pagination_test_object_count = 350
# Dynamically build a flat map of S3 objects:
# - root-level text files
# - nested JSON files
# - placeholder "empty directories"
# - some static files in list-objects directory
# This map is consumed directly by an aws_s3_object resource
s3_files = merge(
# Root-level text files
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/root-file-${i}.txt" => "This is a root-level file #${i}."
},
# Nested JSON files inside sub-directories
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/nested-dir-${i}/data-file-${i}.json" =>
jsonencode({
id = i,
message = "This is a nested JSON file #${i}."
})
},
# "Empty" directories (in S3 these are just placeholder objects ending with `/`)
{
for i in range(local.s3_pagination_test_object_count) :
"list-objects-paginated/empty-dir-${i}/dir-${i}/" => "" # zero-length object to mimic directory
},
# Static example files in list-objects directory
{
"list-objects/file1.txt" = "This is the content for file 1."
"list-objects/data.json" = jsonencode({ key = "value", items = [1, 2, 3] })
"list-objects/nested-dir/file2.txt" = "This is a nested file."
"list-objects/empty-nested-dir/" = ""
}
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2021, 2026
// SPDX-License-Identifier: MPL-2.0
package testing