Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362967bbf5 | ||
|
|
aff3deb152 | ||
|
|
5dd23e6848 | ||
|
|
c56a31be2d | ||
|
|
11c036e28b | ||
|
|
69c24e0ade | ||
|
|
e87d7be2d1 | ||
|
|
77484a3fc9 | ||
|
|
1ad95dc698 | ||
|
|
e484c09479 | ||
|
|
4126a0dbfc | ||
|
|
09dd7f8a4d | ||
|
|
4c4a901d33 | ||
|
|
163ec69771 | ||
|
|
e8a0b0bf10 | ||
|
|
4c8e95fcd8 | ||
|
|
01f8da94e4 | ||
|
|
241901319d | ||
|
|
56f89a5dcf | ||
|
|
5b7b669b41 | ||
|
|
7b4ee7f403 | ||
|
|
916324ee64 | ||
|
|
11d3d6d523 | ||
|
|
4758d5d9cd | ||
|
|
42e2126217 | ||
|
|
5d098ac71d | ||
|
|
949976ec19 | ||
|
|
49f692e7a8 | ||
|
|
a75ac95465 | ||
|
|
7fd6d3f3e5 | ||
|
|
bdd27e2562 | ||
|
|
f17f05ec82 | ||
|
|
899c62ce06 |
No files matched your search
@@ -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,6 +4,8 @@ on:
|
|||||||
- workflow_dispatch
|
- workflow_dispatch
|
||||||
- push
|
- push
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,3 +8,8 @@ plan.tfplan
|
|||||||
# IntelliJ IDEA project files
|
# IntelliJ IDEA project files
|
||||||
.idea
|
.idea
|
||||||
*.iprg
|
*.iprg
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
# These owners will be the default owners for everything in
|
# These owners will be the default owners for everything in
|
||||||
# the repo, unless a later match takes precedence.
|
# the repo, unless a later match takes precedence.
|
||||||
|
|
||||||
@hashicorp/boundary
|
* @hashicorp/boundary
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2021 HashiCorp, Inc.
|
Copyright IBM Corp. 2021, 2026
|
||||||
|
|
||||||
Mozilla Public License Version 2.0
|
Mozilla Public License Version 2.0
|
||||||
==================================
|
==================================
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ Credential rotation can be turned off by setting the
|
|||||||
This plugin will attempt to assume a role when a `role_arn` is supplied through the
|
This plugin will attempt to assume a role when a `role_arn` is supplied through the
|
||||||
`attributes` object. More information about assume an AWS role can be found [here](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). This feature only works when the plugin is running on a self managed Boundary [worker](https://developer.hashicorp.com/boundary/tutorials/hcp-administration/hcp-manage-workers).
|
`attributes` object. More information about assume an AWS role can be found [here](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). This feature only works when the plugin is running on a self managed Boundary [worker](https://developer.hashicorp.com/boundary/tutorials/hcp-administration/hcp-manage-workers).
|
||||||
|
|
||||||
|
To discover hosts across multiple AWS accounts, supply a `target_role_arn` in addition to `role_arn`. The plugin uses the principal credentials to assume the target role via a two-hop `AssumeRole` chain, enabling cross-account host discovery from a single worker. See [Attributes and Secrets](plugin/service/host/README.md#attributes-and-secrets) for the full list of `target_*` attributes.
|
||||||
|
|
||||||
### Environment Credentials
|
### Environment Credentials
|
||||||
|
|
||||||
This plugin will attempt to retrieve credentials from environment variables. More
|
This plugin will attempt to retrieve credentials from environment variables. More
|
||||||
|
|||||||
@@ -1,66 +1,69 @@
|
|||||||
module github.com/hashicorp/boundary-plugin-aws
|
module github.com/hashicorp/boundary-plugin-aws
|
||||||
|
|
||||||
go 1.21
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.20.1
|
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.46
|
||||||
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2
|
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2
|
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.21.2
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||||
github.com/aws/smithy-go v1.14.1
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5
|
||||||
github.com/google/go-cmp v0.6.0
|
github.com/aws/smithy-go v1.25.1
|
||||||
github.com/google/uuid v1.4.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/hashicorp/boundary/sdk v0.0.43-0.20240717182311-a20aae98794a
|
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-multierror v1.1.1
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0
|
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3
|
||||||
github.com/hashicorp/terraform-json v0.14.0
|
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/mitchellh/mapstructure v1.5.0
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.11.1
|
||||||
google.golang.org/grpc v1.61.0
|
google.golang.org/grpc v1.83.2
|
||||||
google.golang.org/protobuf v1.35.1
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.12 // indirect
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.18.33 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.8 // 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.1.38 // 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.4.32 // 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.3.39 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.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.9.13 // 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.1.33 // 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.9.32 // 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.15.1 // 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.13.2 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 // 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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fatih/color v1.15.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.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/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-hclog v1.5.0 // indirect
|
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||||
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14 // 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-uuid v1.0.3 // indirect
|
||||||
github.com/hashicorp/go-version v1.5.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/pointerstructure v1.2.1 // indirect
|
github.com/mitchellh/pointerstructure v1.2.1 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/zclconf/go-cty v1.10.0 // indirect
|
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||||
golang.org/x/crypto v0.28.0 // indirect
|
github.com/zclconf/go-cty v1.15.0 // indirect
|
||||||
golang.org/x/net v0.30.0 // indirect
|
golang.org/x/crypto v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
@@ -4,168 +4,146 @@ 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/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 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
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/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||||
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
|
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 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
|
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.20.1 h1:rZBf5DWr7YGrnlTK4kgDQGn1ltqOg5orCYb/UhOFZkg=
|
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||||
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.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||||
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.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||||
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.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.18.33 h1:JKcw5SFxFW/rpM4mOPjv0VQ11E2kxW13F3exWOy7VZU=
|
github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
|
||||||
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.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.13.32 h1:lIH1eKPcCY1ylR4B6PkBGRWMHO3aVenOKJHWiS4/G2w=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
|
||||||
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.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
|
||||||
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.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
|
||||||
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.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||||
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.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||||
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/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||||
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.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||||
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/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.32 h1:hNeAAymUY5gu11WrrmFb3CVIp9Dar9hbo44yzzcQpzA=
|
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/endpoints/v2 v2.4.32/go.mod h1:0ZXSqrty4FtQ7p8TEuRde/SZm9X05KT18LAUlR40Ln0=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39 h1:fc0ukRAiP1syoSGZYu+DaE+FulSYhTiJ8WpVu5jElU4=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.39/go.mod h1:WLAW8PT7+JhjZfLSWe7WEJaJu0GNo0cKc2Zyo003RBs=
|
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0 h1:fTLR6dLDTGChAjecRPlVrKeznT0rVdzR4yn9Z68MTGk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.1 h1:vUh7dBFNS3oFCtVv6CiYKh5hP9ls8+kIpKLeFruIBLk=
|
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/internal/v4a v1.1.1/go.mod h1:sFMeinkhj/SZKQM8BxtvNtSPjJEo0Xrz+w3g2e4FSKI=
|
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6 h1:EWehQXACWr+6hzfZPwZChlfoVhiUCfLHE0Xh3kAfzWQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.99.0 h1:NXi4pNJWjAaiI56P1Rl8DC9A4jMNRE00WNBsDua5WRg=
|
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/ec2 v1.99.0/go.mod h1:L3ZT0N/vBsw77mOAawXmRnREpEjcHd2v5Hzf7AkIH8M=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||||
github.com/aws/aws-sdk-go-v2/service/iam v1.22.2 h1:DPFxx/6Zwes/MiadlDteVqDKov7yQ5v9vuwfhZuJm1s=
|
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/iam v1.22.2/go.mod h1:cQTMNdo/Z5t1DDRsUnx0a2j6cPnytMBidUYZw2zks28=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||||
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/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||||
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/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||||
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/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||||
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/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||||
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/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||||
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/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
|
||||||
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/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||||
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/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
|
||||||
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/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.38.2 h1:v346f1h8sUBKXnEbrv43L37MTBlFHyKXQPIZHNAaghA=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
|
||||||
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/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.13.2 h1:A2RlEMo4SJSwbNoUUgkxTAEMduAy/8wG3eB2b2lP4gY=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4=
|
||||||
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/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2 h1:OJELEgyaT2kmaBGZ+myyZbTTLobfe3ox3FSh5eYK9Qs=
|
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.2/go.mod h1:ubDBBaDFs1GHijSOTi8ljppML15GLG0HxhILtbjNNYQ=
|
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
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/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
||||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/hashicorp/boundary/sdk v0.0.59 h1:XNniO0rwgihLdF1+4qDHKN3gdta3AU1JteZResCJ8Oc=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/hashicorp/boundary/sdk v0.0.59/go.mod h1:VYwVGAEaHTzihRXpzKZL6EhWBhll59Iul3PGLVL63ao=
|
||||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU=
|
||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU=
|
||||||
github.com/hashicorp/boundary/sdk v0.0.43-0.20240717182311-a20aae98794a h1:SwVze6sYE5o+J9qdcgj29auY1k6O8oSo9DC2+2Gb9rw=
|
|
||||||
github.com/hashicorp/boundary/sdk v0.0.43-0.20240717182311-a20aae98794a/go.mod h1:9iOT7kDM6mYcSkKxNuZlv8rP7U5BG1kXoevjLLL8lNQ=
|
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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.11 h1:lkK8ARM/DrCeL5deH2yVS6p40337dESi+77p9pIfLqo=
|
||||||
github.com/hashicorp/eventlogger v0.2.6-0.20231025104552-802587e608f0/go.mod h1://CHt6/j+Q2lc0NlUB5af4aS2M0c0aVBg9/JfcpAyhM=
|
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 h1:iAb287bq0TaWTnhDYuN/zVqdD2EwanQg9ncVelC60Xc=
|
||||||
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0/go.mod h1:tMywUTIvdB/FXhwm6HMTt61C8/eODY6gitCHhXtyojg=
|
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 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
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.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
github.com/hashicorp/go-hclog v1.6.3/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.8 h1:/GIUjn9GkFXMk/8/irRdbdtmx8CcyeyWdVy/E5LvzyA=
|
||||||
github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5/go.mod h1:psh1qKep5ukvuNobFY/hCybuudlkkACpmazOsCgX5Rg=
|
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.14 h1:1ZuhfnZgRnLK8S0KovJkoTCRIQId5pv3sDR7pG5VQBw=
|
github.com/hashicorp/go-kms-wrapping/v2 v2.0.20 h1:afreZ1WJd0WI7v4NsMZ1aL7V/T59sxPuKmQDgGUja20=
|
||||||
github.com/hashicorp/go-kms-wrapping/v2 v2.0.14/go.mod h1:0dWtzl2ilqKpavgM3id/kFK9L3tjo6fS4OhbVPSYpnQ=
|
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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
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.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA=
|
||||||
github.com/hashicorp/go-plugin v1.5.2/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4=
|
github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0 h1:ca5TSI4AgaOncPpyzLDtCGjVEtKukONpeM95vFxXCOQ=
|
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3 h1:mLe7IMysLSedhuu00W7AXsbfb90AA7UKfZmw3OkJ8n4=
|
||||||
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.0.0/go.mod h1:7CUvZtfTp2U0CYQCLzMtS2ngckjAZePSfwrE2aeDP1M=
|
github.com/hashicorp/go-secure-stdlib/awsutil/v2 v2.1.3/go.mod h1:6+rVulOPNCQbL3Xv2iLCqM0JmU2WO2wRzP1C6hBKeB8=
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw=
|
|
||||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng=
|
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/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.13 h1:TayxZ5drfMP0G6T++WvnLESGLOWeHtdWDWWTAi2e3Qk=
|
||||||
github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.11/go.mod h1:uis9dCmOzXuOaRyXq+1Foh31kcvXKoWogjNnhfjHfW8=
|
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.9 h1:0S0ctJ7Ra8O7ap+/3fZUnzJ3VzJyirWS/WnNCuOYtZY=
|
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10 h1:2iDz+t0JLl1W0tJhvmhsh/UBgT1JgC8Qxz8HxYMWXQo=
|
||||||
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.9/go.mod h1:TNNdgtjLgVDbrgFcyCKrlAicIl3dZF94swJltyGUX2M=
|
github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.10/go.mod h1:eZkXE+osawMrAWR4wJRmyKauUwH6mNGbjFuiDujnbPk=
|
||||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
|
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||||
github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.6 h1:ZYv2XA+tEfFXIToR2jmBgVqQU9gERt0APbWqmUoNGnY=
|
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.6/go.mod h1:ggFN8dlaLWS2R1gymBbCrvXM/bkZP7hEAa4seqDwhyg=
|
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 h1:SMGUnbpAcat8rIKHkBPjfv81yC46a8eCNZ2hsR2l1EI=
|
||||||
github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1/go.mod h1:Ch/bf00Qnx77MZd49JRgHYqHQjtEmTgGU2faufpVZb0=
|
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 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
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 h1:xbrxd0U9XQW8qL1BAz2XrAjAF/P2vcqUTAues9c24B8=
|
||||||
github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3/go.mod h1:LWq2Sy8UoKKuK4lFuCNWSjJj57MhNNf2zzBWMtkAIX4=
|
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.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||||
github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI=
|
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
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.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
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.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
|
||||||
github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM=
|
github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
|
||||||
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
|
||||||
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
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 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
|
||||||
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
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 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
|
||||||
github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
|
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 h1:E87tDTVS5W65euzixn7clSzK66puSt1H4I5SC0EmHH4=
|
||||||
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00=
|
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.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.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.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
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.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.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
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/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.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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
@@ -179,107 +157,60 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
|
||||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||||
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
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 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
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 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
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 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
|
||||||
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
|
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/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.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=
|
||||||
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
|
github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||||
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
|
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||||
github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0=
|
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||||
github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
|
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||||
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8=
|
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||||
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||||
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
|
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
|
||||||
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=
|
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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-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-20210630005230-0f9fa26af87c/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-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
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 v0.0.0-20240116215550-a9fa1716bcac h1:ZL/Teoy/ZGnzyrqK/Optxxp2pmVh+fmJ97slxSRyzUg=
|
|
||||||
google.golang.org/genproto v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe h1:0poefMBYvYbs7g5UkjS6HcxBPaTRAmznle9jnxYoAI8=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe h1:bQnxqljG/wqi4NTXu2+DJ3n7APcEA882QZ1JvhQAq9o=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s=
|
|
||||||
google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0=
|
|
||||||
google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
|
||||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 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 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
@@ -37,7 +37,7 @@ type CredentialAttributes struct {
|
|||||||
// CredentialsConfig used for configuring an AWS session. An status error is returned
|
// 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
|
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
|
||||||
// struct input.
|
// struct input.
|
||||||
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*awsutil.CredentialsConfig, error) {
|
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes, dualStack bool) (*awsutil.CredentialsConfig, error) {
|
||||||
// initialize secrets if it is nil
|
// initialize secrets if it is nil
|
||||||
// secrets can be nil because static credentials are optional
|
// secrets can be nil because static credentials are optional
|
||||||
if secrets == nil {
|
if secrets == nil {
|
||||||
@@ -117,6 +117,17 @@ func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes)
|
|||||||
opts = append(opts, awsutil.WithRoleTags(attrs.RoleTags))
|
opts = append(opts, awsutil.WithRoleTags(attrs.RoleTags))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dualStack {
|
||||||
|
opts = append(opts, []awsutil.Option{
|
||||||
|
awsutil.WithIamEndpointResolver(&iamEndpointResolver{
|
||||||
|
dualStack: dualStack,
|
||||||
|
}),
|
||||||
|
awsutil.WithStsEndpointResolver(&stsEndpointResolver{
|
||||||
|
dualStack: dualStack,
|
||||||
|
}),
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := awsutil.NewCredentialsConfig(opts...)
|
cfg, err := awsutil.NewCredentialsConfig(opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
return nil, errors.BadRequestStatus(err.Error())
|
||||||
@@ -175,3 +186,63 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
|
|||||||
RoleTags: roleTags,
|
RoleTags: roleTags,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTargetCredentialAttributes parses values out of a protobuf struct input and returns a CredentialAttributes used
|
||||||
|
// for configuring a second AWS session. target_role_arn is required when parsing target credentials.
|
||||||
|
// A status error is returned with an InvalidArgument code when target account attributes are missing
|
||||||
|
// required values or have invalid value types.
|
||||||
|
func GetTargetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
|
||||||
|
badFields := make(map[string]string)
|
||||||
|
|
||||||
|
// Parse the role ARN of the target account to AssumeRole into.
|
||||||
|
targetRoleArn, err := values.GetStringValue(in, ConstTargetRoleArn, true)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleArn)] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// try parsing target_region, fallback to region, adds target_region to bad fields only when malformed since not required
|
||||||
|
targetRegion, err := values.GetStringValue(in, ConstTargetRegion, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstTargetRegion)] = err.Error()
|
||||||
|
}
|
||||||
|
if targetRegion == "" {
|
||||||
|
targetRegion, err = values.GetStringValue(in, ConstRegion, true) // region is required for processing
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the remaining fields are optional and only raise errors when malformed
|
||||||
|
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRoleExternalId, err := values.GetStringValue(in, ConstTargetRoleExternalId, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleExternalId)] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRoleSessionName, err := values.GetStringValue(in, ConstTargetRoleSessionName, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleSessionName)] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRoleTags, err := values.GetMapStringString(in, ConstTargetRoleTags, false)
|
||||||
|
if err != nil {
|
||||||
|
badFields[fmt.Sprintf("attributes.%s", ConstTargetRoleTags)] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(badFields) > 0 {
|
||||||
|
return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CredentialAttributes{
|
||||||
|
Region: targetRegion,
|
||||||
|
DisableCredentialRotation: disableCredentialRotation,
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
RoleExternalId: targetRoleExternalId,
|
||||||
|
RoleSessionName: targetRoleSessionName,
|
||||||
|
RoleTags: targetRoleTags,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
@@ -101,11 +101,238 @@ func TestGetCredentialAttributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTargetCredentialAttributes(t *testing.T) {
|
||||||
|
const targetRoleArn = "arn:aws:iam::222222222222:role/Target"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in map[string]any
|
||||||
|
expected *CredentialAttributes
|
||||||
|
expectedErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing target_role_arn",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing target_role_arn and region",
|
||||||
|
in: map[string]any{},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty target_role_arn",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: "",
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_role_arn",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: true,
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: unexpected type for value \"target_role_arn\": want string, got bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_region without target_role_arn",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRegion: "eu-west-1",
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other target fields without target_role_arn",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleExternalId: "ext",
|
||||||
|
ConstTargetRoleSessionName: "sess",
|
||||||
|
ConstTargetRoleTags: map[string]any{"k": "v"},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_role_arn uses region when target_region omitted",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_role_arn without target_region or region",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expectedErrContains: "missing required value \"region\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_region overrides region",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRegion: "eu-west-1",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "eu-west-1",
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty target_region falls back to region",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRegion: "",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all optional target fields",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstDisableCredentialRotation: true,
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRegion: "eu-west-1",
|
||||||
|
ConstTargetRoleExternalId: "target-ext",
|
||||||
|
ConstTargetRoleSessionName: "target-sess",
|
||||||
|
ConstTargetRoleTags: map[string]any{"env": "target"},
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "eu-west-1",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
RoleExternalId: "target-ext",
|
||||||
|
RoleSessionName: "target-sess",
|
||||||
|
RoleTags: map[string]string{"env": "target"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "optional target fields omitted do not copy principal role fields",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstRoleArn: "arn:aws:iam::111111111111:role/Principal",
|
||||||
|
ConstRoleExternalId: "principal-ext",
|
||||||
|
ConstRoleSessionName: "principal-sess",
|
||||||
|
ConstRoleTags: map[string]any{"env": "principal"},
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disable_credential_rotation false is copied",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstDisableCredentialRotation: false,
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
expected: &CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: false,
|
||||||
|
RoleArn: targetRoleArn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_region",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRegion: true,
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_region: unexpected type for value \"target_region\": want string, got bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_role_external_id",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRoleExternalId: true,
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_external_id: unexpected type for value \"target_role_external_id\": want string, got bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_role_session_name",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRoleSessionName: true,
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_session_name: unexpected type for value \"target_role_session_name\": want string, got bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_role_tags",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRoleTags: true,
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_tags: unexpected type for value \"target_role_tags\": want map[string]string, got bool",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type target_role_tag value",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstTargetRoleTags: map[string]any{
|
||||||
|
"env": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: `unexpected type for value in map["env"]: want string, got bool`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong type disable_credential_rotation",
|
||||||
|
in: map[string]any{
|
||||||
|
ConstRegion: "us-west-2",
|
||||||
|
ConstTargetRoleArn: targetRoleArn,
|
||||||
|
ConstDisableCredentialRotation: "sure",
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.disable_credential_rotation: unexpected type for value \"disable_credential_rotation\": want bool, got string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
input, err := structpb.NewStruct(tc.in)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
actual, err := GetTargetCredentialAttributes(input)
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
require.Nil(actual)
|
||||||
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
require.Equal(codes.InvalidArgument, status.Code(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.Equal(tc.expected, actual)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetCredentialsConfig(t *testing.T) {
|
func TestGetCredentialsConfig(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
secrets *structpb.Struct
|
secrets *structpb.Struct
|
||||||
attrs *CredentialAttributes
|
attrs *CredentialAttributes
|
||||||
|
dualStack bool
|
||||||
expected *awsutil.CredentialsConfig
|
expected *awsutil.CredentialsConfig
|
||||||
expectedErrContains string
|
expectedErrContains string
|
||||||
}{
|
}{
|
||||||
@@ -135,6 +362,24 @@ func TestGetCredentialsConfig(t *testing.T) {
|
|||||||
Region: "us-west-2",
|
Region: "us-west-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
expected: &awsutil.CredentialsConfig{
|
||||||
|
AccessKey: "AKIAfoobar",
|
||||||
|
SecretKey: "bazqux",
|
||||||
|
Region: "us-west-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unknown fields",
|
name: "unknown fields",
|
||||||
secrets: &structpb.Struct{
|
secrets: &structpb.Struct{
|
||||||
@@ -233,7 +478,7 @@ func TestGetCredentialsConfig(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
|
|
||||||
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs)
|
actual, err := GetCredentialsConfig(tc.secrets, tc.attrs, tc.dualStack)
|
||||||
if tc.expectedErrContains != "" {
|
if tc.expectedErrContains != "" {
|
||||||
require.Error(err)
|
require.Error(err)
|
||||||
require.Contains(err.Error(), tc.expectedErrContains)
|
require.Contains(err.Error(), tc.expectedErrContains)
|
||||||
@@ -249,6 +494,13 @@ func TestGetCredentialsConfig(t *testing.T) {
|
|||||||
require.Equal(tc.expected.RoleExternalId, actual.RoleExternalId)
|
require.Equal(tc.expected.RoleExternalId, actual.RoleExternalId)
|
||||||
require.Equal(tc.expected.RoleSessionName, actual.RoleSessionName)
|
require.Equal(tc.expected.RoleSessionName, actual.RoleSessionName)
|
||||||
require.Equal(tc.expected.RoleTags, actual.RoleTags)
|
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,13 +1,13 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ConstAccessKey is the key for the region in the aws credentials.
|
// ConstRegion is the key for the region in the aws credentials.
|
||||||
ConstRegion = "region"
|
ConstRegion = "region"
|
||||||
|
|
||||||
// ConstAccessKey is the key for the access key id in the aws credentials.
|
// ConstAccessKeyId is the key for the access key id in the aws credentials.
|
||||||
ConstAccessKeyId = "access_key_id"
|
ConstAccessKeyId = "access_key_id"
|
||||||
|
|
||||||
// ConstSecretAccessKey is the key for the secret access key in the aws credentials.
|
// ConstSecretAccessKey is the key for the secret access key in the aws credentials.
|
||||||
@@ -30,4 +30,19 @@ const (
|
|||||||
|
|
||||||
// ConstRoleTags is the key for the tags used for assuming a IAM role.
|
// ConstRoleTags is the key for the tags used for assuming a IAM role.
|
||||||
ConstRoleTags = "role_tags"
|
ConstRoleTags = "role_tags"
|
||||||
|
|
||||||
|
// ConstTargetRegion is the key for the region of the target account in the aws credentials.
|
||||||
|
ConstTargetRegion = "target_region"
|
||||||
|
|
||||||
|
// ConstTargetRoleArn is the key for assuming a IAM role for the target account.
|
||||||
|
ConstTargetRoleArn = "target_role_arn"
|
||||||
|
|
||||||
|
// ConstTargetRoleExternalId is the key for the external id used for assuming a IAM role for the target account.
|
||||||
|
ConstTargetRoleExternalId = "target_role_external_id"
|
||||||
|
|
||||||
|
// ConstTargetRoleSessionName is the key for the session name used for assuming a IAM role of the target account.
|
||||||
|
ConstTargetRoleSessionName = "target_role_session_name"
|
||||||
|
|
||||||
|
// ConstTargetRoleTags is the key for the tags used for assuming a IAM role of the target account.
|
||||||
|
ConstTargetRoleTags = "target_role_tags"
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
@@ -11,7 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"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"
|
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/errors"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/values"
|
"github.com/hashicorp/boundary-plugin-aws/internal/values"
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
||||||
@@ -109,10 +112,35 @@ func NewAwsCredentialPersistedState(opts ...AwsCredentialPersistedStateOption) (
|
|||||||
// and runs sts.GetCallerIdentity for the current credentials, which is done
|
// and runs sts.GetCallerIdentity for the current credentials, which is done
|
||||||
// to check that the credentials are valid. This method returns a status error
|
// to check that the credentials are valid. This method returns a status error
|
||||||
// with PluginError details.
|
// with PluginError details.
|
||||||
|
//
|
||||||
|
// When RoleARN is set and no static key is present (ambient role path),
|
||||||
|
// credentials are eagerly retrieved before calling GetCallerIdentity. This
|
||||||
|
// forces sts:AssumeRole to be called immediately so that any role errors
|
||||||
|
// (e.g. no such role, access denied) surface here rather than being deferred
|
||||||
|
// to the first real AWS API call.
|
||||||
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
|
func (s *AwsCredentialPersistedState) ValidateCreds(ctx context.Context) error {
|
||||||
if s.CredentialsConfig == nil {
|
if s.CredentialsConfig == nil {
|
||||||
return errors.BadRequestStatus("missing credentials config")
|
return errors.BadRequestStatus("missing credentials config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the ambient role path (RoleARN set, no static key), eagerly resolve
|
||||||
|
// the credential chain so that sts:AssumeRole is called now. Without this,
|
||||||
|
// the AssumeRoleProvider inside the chain is lazy and GetCallerIdentity
|
||||||
|
// would succeed even with an invalid role ARN (it uses the mock/ambient
|
||||||
|
// identity directly rather than the assumed one).
|
||||||
|
cc := s.CredentialsConfig
|
||||||
|
if cc.RoleARN != "" && cc.AccessKey == "" {
|
||||||
|
cfg, err := s.GenerateCredentialChain(ctx)
|
||||||
|
if err != nil {
|
||||||
|
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||||
|
return st.Err()
|
||||||
|
}
|
||||||
|
if _, err := cfg.Credentials.Retrieve(ctx); err != nil {
|
||||||
|
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||||
|
return st.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
|
if _, err := s.CredentialsConfig.GetCallerIdentity(ctx, s.testOpts...); err != nil {
|
||||||
st, _ := errors.ParseAWSError("validating credentials", err)
|
st, _ := errors.ParseAWSError("validating credentials", err)
|
||||||
return st.Err()
|
return st.Err()
|
||||||
@@ -203,13 +231,7 @@ func (s *AwsCredentialPersistedState) DeleteCreds(ctx context.Context) error {
|
|||||||
|
|
||||||
// GenerateCredentialChain returns a AWS configuration for the credentials in the state.
|
// GenerateCredentialChain returns a AWS configuration for the credentials in the state.
|
||||||
func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Context) (*aws.Config, error) {
|
func (s *AwsCredentialPersistedState) GenerateCredentialChain(ctx context.Context) (*aws.Config, error) {
|
||||||
// Default `WithSharedCredentials` is `true` - This disables the ability to
|
return s.CredentialsConfig.GenerateCredentialChain(ctx, s.testOpts...)
|
||||||
// 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...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap returns a map of the credentials stored in the persisted state.
|
// ToMap returns a map of the credentials stored in the persisted state.
|
||||||
@@ -237,7 +259,7 @@ func (s *AwsCredentialPersistedState) ToMap() map[string]any {
|
|||||||
|
|
||||||
// AwsCredentialPersistedStateFromProto parses values out of a protobuf struct input
|
// AwsCredentialPersistedStateFromProto parses values out of a protobuf struct input
|
||||||
// and returns a AwsCredentialPersistedState used for configuring an AWS session.
|
// 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
|
// initialize secrets if it is nil
|
||||||
// secrets can be nil because static credentials are optional
|
// secrets can be nil because static credentials are optional
|
||||||
if secrets == nil {
|
if secrets == nil {
|
||||||
@@ -292,6 +314,17 @@ func AwsCredentialPersistedStateFromProto(secrets *structpb.Struct, attrs *Crede
|
|||||||
if len(attrs.RoleTags) != 0 {
|
if len(attrs.RoleTags) != 0 {
|
||||||
awsOpts = append(awsOpts, awsutil.WithRoleTags(attrs.RoleTags))
|
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...)
|
credentialsConfig, err := awsutil.NewCredentialsConfig(awsOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -321,3 +354,23 @@ func GetCredentialType(cc *awsutil.CredentialsConfig) CredentialType {
|
|||||||
|
|
||||||
return Unknown
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
@@ -112,6 +112,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
secrets *structpb.Struct
|
secrets *structpb.Struct
|
||||||
attrs *CredentialAttributes
|
attrs *CredentialAttributes
|
||||||
|
dualStack bool
|
||||||
opts []AwsCredentialPersistedStateOption
|
opts []AwsCredentialPersistedStateOption
|
||||||
expected *AwsCredentialPersistedState
|
expected *AwsCredentialPersistedState
|
||||||
expectedErr string
|
expectedErr string
|
||||||
@@ -174,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",
|
name: "bad last rotated time",
|
||||||
secrets: &structpb.Struct{
|
secrets: &structpb.Struct{
|
||||||
@@ -318,7 +339,7 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require := require.New(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 != "" {
|
if tc.expectedErr != "" {
|
||||||
require.EqualError(err, tc.expectedErr)
|
require.EqualError(err, tc.expectedErr)
|
||||||
return
|
return
|
||||||
@@ -336,6 +357,13 @@ func TestAwsCredentialPersistedStateFromProto(t *testing.T) {
|
|||||||
require.Equal(tc.expected.CredentialsConfig.RoleExternalId, actual.CredentialsConfig.RoleExternalId)
|
require.Equal(tc.expected.CredentialsConfig.RoleExternalId, actual.CredentialsConfig.RoleExternalId)
|
||||||
require.Equal(tc.expected.CredentialsConfig.RoleSessionName, actual.CredentialsConfig.RoleSessionName)
|
require.Equal(tc.expected.CredentialsConfig.RoleSessionName, actual.CredentialsConfig.RoleSessionName)
|
||||||
require.Equal(tc.expected.CredentialsConfig.RoleTags, actual.CredentialsConfig.RoleTags)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package credential
|
package credential
|
||||||
|
|||||||
+98
-10
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package errors
|
package errors
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws/retry"
|
"github.com/aws/aws-sdk-go-v2/aws/retry"
|
||||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
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"
|
"github.com/aws/smithy-go"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -28,6 +29,11 @@ const (
|
|||||||
// the owner. This error cannot be resolved by retrying the request.
|
// the owner. This error cannot be resolved by retrying the request.
|
||||||
awsErrorAccessDenied = "AccessDenied"
|
awsErrorAccessDenied = "AccessDenied"
|
||||||
|
|
||||||
|
// awsErrorUnauthorizedOperation is returned by EC2 when the caller is not
|
||||||
|
// authorized to perform the operation. This includes DryRun permission
|
||||||
|
// checks that fail (the counterpart to DryRunOperation success).
|
||||||
|
awsErrorUnauthorizedOperation = "UnauthorizedOperation"
|
||||||
|
|
||||||
// awsErrorInvalidAccessKeyId is returned when the credential does
|
// awsErrorInvalidAccessKeyId is returned when the credential does
|
||||||
// not exist. This error will persist until the credentials attached
|
// not exist. This error will persist until the credentials attached
|
||||||
// to the storage bucket is updated. This error can occur when the
|
// to the storage bucket is updated. This error can occur when the
|
||||||
@@ -52,6 +58,10 @@ const (
|
|||||||
// whose key does not exist
|
// whose key does not exist
|
||||||
awsErrorNoSuchKey = "NoSuchKey"
|
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
|
// The InvalidObjectState error is returned when trying to
|
||||||
// access an object that was moved into cold storage.
|
// access an object that was moved into cold storage.
|
||||||
awsErrorInvalidObjectState = "InvalidObjectState"
|
awsErrorInvalidObjectState = "InvalidObjectState"
|
||||||
@@ -61,8 +71,26 @@ const (
|
|||||||
|
|
||||||
// RequestTimeoutException is returned when an http request takes longer than allowed
|
// RequestTimeoutException is returned when an http request takes longer than allowed
|
||||||
awsErrorRequestTimeoutException = "RequestTimeoutException"
|
awsErrorRequestTimeoutException = "RequestTimeoutException"
|
||||||
|
|
||||||
|
// dryRunOperationErrorCode is returned by AWS when a DryRun request would have
|
||||||
|
// succeeded. It is a success signal disguised as an API error (typically HTTP 412).
|
||||||
|
dryRunOperationErrorCode = "DryRunOperation"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IsDryRunSuccess reports whether err is the AWS DryRunOperation success signal.
|
||||||
|
// A true result means the dry-run validated credentials, permissions, and parameters;
|
||||||
|
// it must not be treated as a failure.
|
||||||
|
func IsDryRunSuccess(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var apiErr smithy.APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return apiErr.ErrorCode() == dryRunOperationErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
// InvalidArgumentError returns an grpc invalid argument status error.
|
// InvalidArgumentError returns an grpc invalid argument status error.
|
||||||
func InvalidArgumentError(msg string, f map[string]string) error {
|
func InvalidArgumentError(msg string, f map[string]string) error {
|
||||||
var fieldMsgs []string
|
var fieldMsgs []string
|
||||||
@@ -118,12 +146,57 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
|
|||||||
return status.New(codes.Unavailable, statusMsg), nil
|
return status.New(codes.Unavailable, statusMsg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse some specific aws error codes that we have special cred states for
|
// match error type against NotFound
|
||||||
|
var notFoundErr *types.NotFound
|
||||||
|
if errors.As(err, ¬FoundErr) {
|
||||||
|
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
|
var apiErr smithy.APIError
|
||||||
if errors.As(err, &apiErr) {
|
if errors.As(err, &apiErr) {
|
||||||
switch apiErr.ErrorCode() {
|
switch apiErr.ErrorCode() {
|
||||||
case awsErrorAccessDenied:
|
case awsErrorAccessDenied:
|
||||||
fallthrough
|
fallthrough
|
||||||
|
case awsErrorUnauthorizedOperation:
|
||||||
|
fallthrough
|
||||||
case awsErrorInvalidAccessKeyId:
|
case awsErrorInvalidAccessKeyId:
|
||||||
fallthrough
|
fallthrough
|
||||||
case awsErrorExpiredToken:
|
case awsErrorExpiredToken:
|
||||||
@@ -145,6 +218,8 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
|
|||||||
return status.New(codes.Aborted, statusMsg), nil
|
return status.New(codes.Aborted, statusMsg), nil
|
||||||
case awsErrorNoSuchKey:
|
case awsErrorNoSuchKey:
|
||||||
fallthrough
|
fallthrough
|
||||||
|
case awsErrorNotFound:
|
||||||
|
fallthrough
|
||||||
case awsErrorInvalidObjectState:
|
case awsErrorInvalidObjectState:
|
||||||
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
|
statusMsg := fmt.Sprintf("aws service %s: %s", serviceName, msg)
|
||||||
return status.New(codes.NotFound, statusMsg), nil
|
return status.New(codes.NotFound, statusMsg), nil
|
||||||
@@ -156,8 +231,9 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// default to evaluating the http status code
|
// fallback to evaluating the http status code
|
||||||
if httpErr, ok := err.(*awshttp.ResponseError); ok {
|
var httpErr *awshttp.ResponseError
|
||||||
|
if errors.As(err, &httpErr) {
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
_, err = buf.ReadFrom(httpErr.Response.Body)
|
_, err = buf.ReadFrom(httpErr.Response.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -206,14 +282,26 @@ func ParseAWSError(op string, err error) (st *status.Status, permission *pb.Perm
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BadRequestStatus returns a status error with an invalid
|
// BadRequestStatusf returns a status error with an invalid
|
||||||
// argument code
|
// argument code and a formatted error message.
|
||||||
func BadRequestStatus(format string, args ...any) error {
|
func BadRequestStatusf(format string, args ...any) error {
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf(format, args...)).Err()
|
return status.New(codes.InvalidArgument, fmt.Sprintf(format, args...)).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnknownStatus returns a status error with an internal
|
// BadRequestStatus returns a status error with an invalid
|
||||||
// error code
|
// argument code and the provided message.
|
||||||
func UnknownStatus(format string, args ...any) error {
|
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()
|
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()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package errors
|
package errors
|
||||||
@@ -134,6 +134,17 @@ func Test_ParseAWSError(t *testing.T) {
|
|||||||
State: pb.StateType_STATE_TYPE_UNKNOWN,
|
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",
|
name: "aws-throttling",
|
||||||
err: TestAwsError("Throttling", "throttling error"),
|
err: TestAwsError("Throttling", "throttling error"),
|
||||||
@@ -289,6 +300,17 @@ func Test_ParseAWSError(t *testing.T) {
|
|||||||
CheckedAt: timestamppb.Now(),
|
CheckedAt: timestamppb.Now(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "aws-request-unauthorized-operation",
|
||||||
|
err: TestAwsError(awsErrorUnauthorizedOperation, "You are not authorized to perform this operation."),
|
||||||
|
expectedStatusCode: codes.PermissionDenied,
|
||||||
|
expectedStatusMsg: "aws service unknown: invalid credentials: test",
|
||||||
|
expectedPermission: &pb.Permission{
|
||||||
|
State: pb.StateType_STATE_TYPE_ERROR,
|
||||||
|
ErrorDetails: "You are not authorized to perform this operation.",
|
||||||
|
CheckedAt: timestamppb.Now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "aws-request-invalid-access-key-id",
|
name: "aws-request-invalid-access-key-id",
|
||||||
err: TestAwsError(awsErrorInvalidAccessKeyId, "The AWS Access Key Id you provided does not exist in our records."),
|
err: TestAwsError(awsErrorInvalidAccessKeyId, "The AWS Access Key Id you provided does not exist in our records."),
|
||||||
@@ -311,6 +333,55 @@ func Test_ParseAWSError(t *testing.T) {
|
|||||||
CheckedAt: timestamppb.Now(),
|
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 {
|
for _, tt := range cases {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -330,18 +401,76 @@ func Test_ParseAWSError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_BadRequestStatus(t *testing.T) {
|
func Test_IsDryRunSuccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain error",
|
||||||
|
err: fmt.Errorf("not an aws error"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other api error",
|
||||||
|
err: TestAwsError(awsErrorAccessDenied, "not authorized"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dry run operation",
|
||||||
|
err: TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped dry run operation",
|
||||||
|
err: fmt.Errorf("describe instances: %w", TestAwsError(dryRunOperationErrorCode, "Request would have succeeded, but DryRun flag is set")),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, IsDryRunSuccess(tt.err))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BadRequestStatusf(t *testing.T) {
|
||||||
require, assert := require.New(t), assert.New(t)
|
require, assert := require.New(t), assert.New(t)
|
||||||
err := BadRequestStatus("test: %s", "hello world")
|
err := BadRequestStatusf("test: %s", "hello world")
|
||||||
assert.ErrorContains(err, "test: hello world")
|
assert.ErrorContains(err, "test: hello world")
|
||||||
assert.Equal(status.Code(err), codes.InvalidArgument)
|
assert.Equal(status.Code(err), codes.InvalidArgument)
|
||||||
_, ok := status.FromError(err)
|
_, ok := status.FromError(err)
|
||||||
require.True(ok)
|
require.True(ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_UnknownStatus(t *testing.T) {
|
func Test_BadRequestStatus(t *testing.T) {
|
||||||
require, assert := require.New(t), assert.New(t)
|
require, assert := require.New(t), assert.New(t)
|
||||||
err := UnknownStatus("test: %s", "hello world")
|
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.ErrorContains(err, "test: hello world")
|
||||||
assert.Equal(status.Code(err), codes.Internal)
|
assert.Equal(status.Code(err), codes.Internal)
|
||||||
_, ok := status.FromError(err)
|
_, ok := status.FromError(err)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package errors
|
package errors
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
|
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"
|
"github.com/aws/smithy-go"
|
||||||
smithyhttp "github.com/aws/smithy-go/transport/http"
|
smithyhttp "github.com/aws/smithy-go/transport/http"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
@@ -39,6 +40,31 @@ func TestAwsError(code, msg string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func CheckSimilarPermission(assert *assert.Assertions, expected, actual *pb.Permission, substituteNilWithDefaultOk bool) {
|
||||||
if expected == nil && substituteNilWithDefaultOk {
|
if expected == nil && substituteNilWithDefaultOk {
|
||||||
expected = &pb.Permission{
|
expected = &pb.Permission{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package values
|
package values
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package values
|
package values
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package plugin
|
package plugin
|
||||||
@@ -32,6 +32,6 @@ type AwsPlugin struct {
|
|||||||
func NewAwsPlugin() *AwsPlugin {
|
func NewAwsPlugin() *AwsPlugin {
|
||||||
return &AwsPlugin{
|
return &AwsPlugin{
|
||||||
HostPlugin: &host.HostPlugin{},
|
HostPlugin: &host.HostPlugin{},
|
||||||
StoragePlugin: &storage.StoragePlugin{},
|
StoragePlugin: storage.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,13 +109,23 @@ The following `attributes` are valid on an AWS host catalog resource:
|
|||||||
- `region` (string): The region to configure the host catalog for. All host sets
|
- `region` (string): The region to configure the host catalog for. All host sets
|
||||||
in this catalog will be configured for this region.
|
in this catalog will be configured for this region.
|
||||||
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
|
- `role_arn` (string): The AWS Role ARN used for `AssumeRole` authentication. If
|
||||||
provided, `disable_credential_rotation` must be also set to `true.
|
provided, `disable_credential_rotation` must be also set to `true`.
|
||||||
- `role_external_id` (string): The external id configured for the `AssumeRole`
|
- `role_external_id` (string): The external ID configured for the principal `AssumeRole`
|
||||||
provider.
|
provider.
|
||||||
- `role_session_name` (string): The session name configured for the `AssumeRole`
|
- `role_session_name` (string): The session name configured for the principal `AssumeRole`
|
||||||
provider.
|
provider.
|
||||||
- `role_tags` (object): The key-value pair tags configured for the `AssumeRole`
|
- `role_tags` (object): The key-value pair tags configured for the principal `AssumeRole`
|
||||||
provider.
|
provider.
|
||||||
|
- `target_role_arn` (string): The AWS role ARN of the target IAM role to assume for
|
||||||
|
cross-account discovery. Required if any other `target_*` attributes are configured.
|
||||||
|
- `target_region` (string): The region where the target hosts reside. Defaults to the
|
||||||
|
principal account `region` if omitted.
|
||||||
|
- `target_role_external_id` (string): The external ID configured for the target
|
||||||
|
`AssumeRole` provider.
|
||||||
|
- `target_role_session_name` (string): The session name configured for the target
|
||||||
|
`AssumeRole` provider.
|
||||||
|
- `target_role_tags` (object): The key-value pair tags configured for the target
|
||||||
|
`AssumeRole` provider.
|
||||||
|
|
||||||
The following `secrets` are required on an AWS host catalog resource:
|
The following `secrets` are required on an AWS host catalog resource:
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
@@ -19,6 +19,17 @@ import (
|
|||||||
// map.
|
// map.
|
||||||
type CatalogAttributes struct {
|
type CatalogAttributes struct {
|
||||||
*cred.CredentialAttributes
|
*cred.CredentialAttributes
|
||||||
|
|
||||||
|
// TargetAccount optionally holds the credentials used to AssumeRole into a second, affiliated account when provided.
|
||||||
|
TargetAccount *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) {
|
func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||||
@@ -31,6 +42,26 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var targetCredAttributes *cred.CredentialAttributes
|
||||||
|
if hasTargetAccountAttributes(in) {
|
||||||
|
targetCredAttributes, err = cred.GetTargetCredentialAttributes(in)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
for s := range unknownFields {
|
||||||
switch s {
|
switch s {
|
||||||
// Ignore knownFields from CredentialAttributes
|
// Ignore knownFields from CredentialAttributes
|
||||||
@@ -46,6 +77,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
|||||||
continue
|
continue
|
||||||
case cred.ConstRoleTags:
|
case cred.ConstRoleTags:
|
||||||
continue
|
continue
|
||||||
|
case cred.ConstTargetRegion:
|
||||||
|
continue
|
||||||
|
case cred.ConstTargetRoleArn:
|
||||||
|
continue
|
||||||
|
case cred.ConstTargetRoleExternalId:
|
||||||
|
continue
|
||||||
|
case cred.ConstTargetRoleSessionName:
|
||||||
|
continue
|
||||||
|
case cred.ConstTargetRoleTags:
|
||||||
|
continue
|
||||||
default:
|
default:
|
||||||
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
||||||
}
|
}
|
||||||
@@ -56,7 +97,10 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &CatalogAttributes{
|
return &CatalogAttributes{
|
||||||
CredentialAttributes: credAttributes,
|
CredentialAttributes: credAttributes,
|
||||||
|
TargetAccount: targetCredAttributes,
|
||||||
|
DualStack: dualStack,
|
||||||
|
InstanceAddressesOnly: instanceAddressesOnly,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,3 +143,19 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
|||||||
|
|
||||||
return &setAttrs, nil
|
return &setAttrs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasTargetAccountAttributes(in *structpb.Struct) bool {
|
||||||
|
fields := in.GetFields()
|
||||||
|
|
||||||
|
_, hasTargetRegion := fields[cred.ConstTargetRegion]
|
||||||
|
_, hasTargetRoleArn := fields[cred.ConstTargetRoleArn]
|
||||||
|
_, hasTargetRoleExternalID := fields[cred.ConstTargetRoleExternalId]
|
||||||
|
_, hasTargetRoleSessionName := fields[cred.ConstTargetRoleSessionName]
|
||||||
|
_, hasTargetRoleTags := fields[cred.ConstTargetRoleTags]
|
||||||
|
|
||||||
|
return hasTargetRegion ||
|
||||||
|
hasTargetRoleArn ||
|
||||||
|
hasTargetRoleExternalID ||
|
||||||
|
hasTargetRoleSessionName ||
|
||||||
|
hasTargetRoleTags
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
@@ -43,6 +43,38 @@ func TestGetCatalogAttributes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
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",
|
name: "default",
|
||||||
in: &structpb.Struct{
|
in: &structpb.Struct{
|
||||||
@@ -86,6 +118,170 @@ func TestGetCatalogAttributes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "target_region without target_role_arn",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"target_region": structpb.NewStringValue("eu-west-1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target role field without target_role_arn",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"target_role_external_id": structpb.NewStringValue("target-ext"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other target role fields without target_role_arn",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"target_role_session_name": structpb.NewStringValue("target-session"),
|
||||||
|
"target_role_tags": structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"env": structpb.NewStringValue("target"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty target_role_arn with target_region",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"target_role_arn": structpb.NewStringValue(""),
|
||||||
|
"target_region": structpb.NewStringValue("eu-west-1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.target_role_arn: value \"target_role_arn\" cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_role_arn without target_region uses region; principal sts fields are not copied",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"disable_credential_rotation": structpb.NewBoolValue(true),
|
||||||
|
"role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"),
|
||||||
|
"role_external_id": structpb.NewStringValue("principal-ext"),
|
||||||
|
"role_session_name": structpb.NewStringValue("principal-sess"),
|
||||||
|
"role_tags": structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"env": structpb.NewStringValue("principal"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &CatalogAttributes{
|
||||||
|
CredentialAttributes: &cred.CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
RoleArn: "arn:aws:iam::111111111111:role/Principal",
|
||||||
|
RoleExternalId: "principal-ext",
|
||||||
|
RoleSessionName: "principal-sess",
|
||||||
|
RoleTags: map[string]string{
|
||||||
|
"env": "principal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TargetAccount: &cred.CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
RoleArn: "arn:aws:iam::222222222222:role/Target",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target attributes are independent of principal",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"disable_credential_rotation": structpb.NewBoolValue(true),
|
||||||
|
"role_arn": structpb.NewStringValue("arn:aws:iam::111111111111:role/Principal"),
|
||||||
|
"role_external_id": structpb.NewStringValue("principal-ext"),
|
||||||
|
"role_session_name": structpb.NewStringValue("principal-sess"),
|
||||||
|
"role_tags": structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"env": structpb.NewStringValue("principal"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||||
|
"target_region": structpb.NewStringValue("eu-west-1"),
|
||||||
|
"target_role_external_id": structpb.NewStringValue("target-ext"),
|
||||||
|
"target_role_session_name": structpb.NewStringValue("target-sess"),
|
||||||
|
"target_role_tags": structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"env": structpb.NewStringValue("target"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &CatalogAttributes{
|
||||||
|
CredentialAttributes: &cred.CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
RoleArn: "arn:aws:iam::111111111111:role/Principal",
|
||||||
|
RoleExternalId: "principal-ext",
|
||||||
|
RoleSessionName: "principal-sess",
|
||||||
|
RoleTags: map[string]string{
|
||||||
|
"env": "principal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TargetAccount: &cred.CredentialAttributes{
|
||||||
|
Region: "eu-west-1",
|
||||||
|
DisableCredentialRotation: true,
|
||||||
|
RoleArn: "arn:aws:iam::222222222222:role/Target",
|
||||||
|
RoleExternalId: "target-ext",
|
||||||
|
RoleSessionName: "target-sess",
|
||||||
|
RoleTags: map[string]string{
|
||||||
|
"env": "target",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catalog dual_stack and instance_addresses_only with target",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"dual_stack": structpb.NewBoolValue(true),
|
||||||
|
"instance_addresses_only": structpb.NewBoolValue(true),
|
||||||
|
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &CatalogAttributes{
|
||||||
|
CredentialAttributes: &credential.CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: false,
|
||||||
|
},
|
||||||
|
TargetAccount: &cred.CredentialAttributes{
|
||||||
|
Region: "us-west-2",
|
||||||
|
DisableCredentialRotation: false,
|
||||||
|
RoleArn: "arn:aws:iam::222222222222:role/Target",
|
||||||
|
},
|
||||||
|
DualStack: true,
|
||||||
|
InstanceAddressesOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown field with target_role_arn",
|
||||||
|
in: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"region": structpb.NewStringValue("us-west-2"),
|
||||||
|
"target_role_arn": structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||||
|
"foo": structpb.NewBoolValue(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes.foo: unrecognized field",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
@@ -10,4 +10,12 @@ const (
|
|||||||
// ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2
|
// ConstInstanceNameTagKey denotes the special AWS tag key that contains an EC2
|
||||||
// instance's name. This field is to be matched literally.
|
// instance's name. This field is to be matched literally.
|
||||||
ConstInstanceNameTagKey = "Name"
|
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"
|
||||||
)
|
)
|
||||||
+275
-73
@@ -1,6 +1,6 @@
|
|||||||
package host
|
package host
|
||||||
|
|
||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,8 +9,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
||||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||||
|
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
@@ -32,6 +35,16 @@ type HostPlugin struct {
|
|||||||
testCatalogStateOpts []awsCatalogPersistedStateOption
|
testCatalogStateOpts []awsCatalogPersistedStateOption
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type hostSetQuery struct {
|
||||||
|
Id string
|
||||||
|
Input *ec2.DescribeInstancesInput
|
||||||
|
Output *ec2.DescribeInstancesOutput
|
||||||
|
OutputHosts []*pb.ListHostsResponseHost
|
||||||
|
}
|
||||||
|
|
||||||
|
const errBuildDescribeInstancesInput = "error building DescribeInstances input: %s"
|
||||||
|
const errDryRunFailed = "error performing DescribeInstances dry run: %s"
|
||||||
|
|
||||||
// Ensure that we are implementing HostPluginServiceServer
|
// Ensure that we are implementing HostPluginServiceServer
|
||||||
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes)
|
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes, catalogAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -63,7 +76,7 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to rotate AWS static credentials
|
// Try to rotate AWS static credentials
|
||||||
@@ -73,23 +86,44 @@ func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate assumed-role credentials at catalog-creation time so that an
|
||||||
|
// invalid role_arn is rejected immediately rather than silently falling
|
||||||
|
// back to ambient credentials.
|
||||||
|
if credential.GetCredentialType(credState.CredentialsConfig) == credential.DynamicAWS {
|
||||||
|
if err := credState.ValidateCreds(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
catalogState, err := newAwsCatalogPersistedState(
|
||||||
append([]awsCatalogPersistedStateOption{
|
append([]awsCatalogPersistedStateOption{
|
||||||
withCredentials(credState),
|
withCredentials(credState),
|
||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with AWS as expected.
|
opts := []ec2Option{}
|
||||||
if st := dryRunValidation(ctx, catalogState); st != nil {
|
if catalogAttributes.DualStack {
|
||||||
return nil, st.Err()
|
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
||||||
|
}
|
||||||
|
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
|
||||||
|
}
|
||||||
|
// Run a DescribeInstances request with DryRun set to true to ensure
|
||||||
|
// we can interact with AWS resources as expected.
|
||||||
|
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
persistedProto, err := catalogState.toProto()
|
persistedProto, err := catalogState.toProto()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.OnCreateCatalogResponse{
|
return &pb.OnCreateCatalogResponse{
|
||||||
@@ -131,15 +165,16 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
|
|||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetSecrets(),
|
req.GetPersisted().GetSecrets(),
|
||||||
oldCatalogAttributes.CredentialAttributes,
|
oldCatalogAttributes.CredentialAttributes,
|
||||||
|
oldCatalogAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("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
|
// 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
|
// user if they're not. Note this doesn't validate the credentials against
|
||||||
// AWS - it only does logical validation on the fields.
|
// AWS - it only does logical validation on the fields.
|
||||||
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes)
|
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes, newCatalogAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,18 +191,41 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up new credential persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate assumed-role credentials when the role_arn changes so that
|
||||||
|
// an invalid ARN is caught before replacing the existing credential state.
|
||||||
|
if credential.GetCredentialType(newCredState.CredentialsConfig) == credential.DynamicAWS {
|
||||||
|
if err := newCredState.ValidateCreds(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newCatalogState, err := newAwsCatalogPersistedState(
|
newCatalogState, err := newAwsCatalogPersistedState(
|
||||||
append([]awsCatalogPersistedStateOption{
|
append([]awsCatalogPersistedStateOption{
|
||||||
withCredentials(newCredState),
|
withCredentials(newCredState),
|
||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
if st := dryRunValidation(ctx, newCatalogState); st != nil {
|
|
||||||
return nil, st.Err()
|
opts := []ec2Option{}
|
||||||
|
if newCatalogAttributes.DualStack {
|
||||||
|
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
|
||||||
|
}
|
||||||
|
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
|
||||||
|
}
|
||||||
|
// Run a DescribeInstances request with DryRun set to true to ensure
|
||||||
|
// we can interact with AWS resources as expected.
|
||||||
|
if _, err := checkHosts(ctx, newCatalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the existing credential state.
|
// Replace the existing credential state.
|
||||||
@@ -204,17 +262,29 @@ func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalo
|
|||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with AWS as expected.
|
opts := []ec2Option{}
|
||||||
if st := dryRunValidation(ctx, catalogState); st != nil {
|
if newCatalogAttributes.DualStack {
|
||||||
return nil, st.Err()
|
opts = append(opts, WithDualStack(newCatalogAttributes.DualStack))
|
||||||
|
}
|
||||||
|
input, err := buildDescribeInstancesInput(&SetAttributes{}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
|
||||||
|
}
|
||||||
|
// Run a DescribeInstances request with DryRun set to true to ensure
|
||||||
|
// we can interact with AWS resources as expected.
|
||||||
|
if _, err := checkHosts(ctx, catalogState, input, opts, newCatalogAttributes.TargetAccount); err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
persistedProto, err := catalogState.toProto()
|
persistedProto, err := catalogState.toProto()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus(err.Error())
|
return nil, errors.BadRequestStatusf("error converting persisted state to proto: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.OnUpdateCatalogResponse{
|
return &pb.OnUpdateCatalogResponse{
|
||||||
@@ -248,9 +318,10 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
|
|||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetSecrets(),
|
req.GetPersisted().GetSecrets(),
|
||||||
catalogAttributes.CredentialAttributes,
|
catalogAttributes.CredentialAttributes,
|
||||||
|
catalogAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = newAwsCatalogPersistedState(
|
_, err = newAwsCatalogPersistedState(
|
||||||
@@ -259,7 +330,7 @@ func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalo
|
|||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to delete static credentials
|
// try to delete static credentials
|
||||||
@@ -324,9 +395,10 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
|
|||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetSecrets(),
|
req.GetPersisted().GetSecrets(),
|
||||||
catalogAttributes.CredentialAttributes,
|
catalogAttributes.CredentialAttributes,
|
||||||
|
catalogAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
catalogState, err := newAwsCatalogPersistedState(
|
||||||
@@ -335,7 +407,7 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
|
|||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
set := req.GetSet()
|
set := req.GetSet()
|
||||||
@@ -351,14 +423,23 @@ func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
describeInstanceFilters, err := buildFilters(setAttrs)
|
opts := []ec2Option{}
|
||||||
|
if catalogAttributes.DualStack {
|
||||||
|
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
||||||
|
}
|
||||||
|
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error building set filters: %s", err)
|
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
|
||||||
|
}
|
||||||
|
// Run a DescribeInstances request with DryRun set to true to ensure
|
||||||
|
// we can interact with AWS resources as expected.
|
||||||
|
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if st := dryRunValidation(ctx, catalogState, describeInstanceFilters...); st != nil {
|
|
||||||
return nil, st.Err()
|
|
||||||
}
|
|
||||||
return &pb.OnCreateSetResponse{}, nil
|
return &pb.OnCreateSetResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,9 +463,10 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
|
|||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetSecrets(),
|
req.GetPersisted().GetSecrets(),
|
||||||
catalogAttributes.CredentialAttributes,
|
catalogAttributes.CredentialAttributes,
|
||||||
|
catalogAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
catalogState, err := newAwsCatalogPersistedState(
|
||||||
@@ -393,7 +475,7 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
|
|||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("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
|
// As with catalog, we don't need to really look at the old host
|
||||||
@@ -411,13 +493,21 @@ func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
describeInstanceFilters, err := buildFilters(setAttrs)
|
opts := []ec2Option{}
|
||||||
if err != nil {
|
if catalogAttributes.DualStack {
|
||||||
return nil, errors.BadRequestStatus("error building set filters: %s", err)
|
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
||||||
}
|
}
|
||||||
|
input, err := buildDescribeInstancesInput(setAttrs, true)
|
||||||
if st := dryRunValidation(ctx, catalogState, describeInstanceFilters...); st != nil {
|
if err != nil {
|
||||||
return nil, st.Err()
|
return nil, status.Errorf(codes.Unknown, errBuildDescribeInstancesInput, err)
|
||||||
|
}
|
||||||
|
// Run a DescribeInstances request with DryRun set to true to ensure
|
||||||
|
// we can interact with AWS resources as expected.
|
||||||
|
if _, err := checkHosts(ctx, catalogState, input, opts, catalogAttributes.TargetAccount); err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf(errDryRunFailed, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.OnUpdateSetResponse{}, nil
|
return &pb.OnUpdateSetResponse{}, nil
|
||||||
@@ -450,9 +540,10 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
credState, err := credential.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetSecrets(),
|
req.GetPersisted().GetSecrets(),
|
||||||
catalogAttributes.CredentialAttributes,
|
catalogAttributes.CredentialAttributes,
|
||||||
|
catalogAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogState, err := newAwsCatalogPersistedState(
|
catalogState, err := newAwsCatalogPersistedState(
|
||||||
@@ -461,7 +552,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
}, p.testCatalogStateOpts...)...,
|
}, p.testCatalogStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sets := req.GetSets()
|
sets := req.GetSets()
|
||||||
@@ -470,13 +561,6 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build all the queries in advance.
|
// Build all the queries in advance.
|
||||||
type hostSetQuery struct {
|
|
||||||
Id string
|
|
||||||
Input *ec2.DescribeInstancesInput
|
|
||||||
Output *ec2.DescribeInstancesOutput
|
|
||||||
OutputHosts []*pb.ListHostsResponseHost
|
|
||||||
}
|
|
||||||
|
|
||||||
queries := make([]hostSetQuery, len(sets))
|
queries := make([]hostSetQuery, len(sets))
|
||||||
for i, set := range sets {
|
for i, set := range sets {
|
||||||
// Validate Id since we use it in output
|
// Validate Id since we use it in output
|
||||||
@@ -485,7 +569,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
if set.GetAttributes() == nil {
|
if set.GetAttributes() == nil {
|
||||||
return nil, errors.BadRequestStatus("set %s attributes are required", set.GetId())
|
return nil, errors.BadRequestStatusf("set %s attributes are required", set.GetId())
|
||||||
}
|
}
|
||||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -494,7 +578,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
|
|
||||||
input, err := buildDescribeInstancesInput(setAttrs, false)
|
input, err := buildDescribeInstancesInput(setAttrs, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("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{
|
queries[i] = hostSetQuery{
|
||||||
Id: set.GetId(),
|
Id: set.GetId(),
|
||||||
@@ -502,28 +586,38 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ec2Client, err := catalogState.EC2Client(ctx)
|
opts := []ec2Option{}
|
||||||
if err != nil {
|
if catalogAttributes.DualStack {
|
||||||
return nil, errors.BadRequestStatus("error getting EC2 client: %s", err)
|
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run all queries now and assemble output.
|
// Run all queries now and assemble output.
|
||||||
var maxLen int
|
var maxLen int
|
||||||
for i, query := range queries {
|
for i, query := range queries {
|
||||||
output, err := ec2Client.DescribeInstances(ctx, query.Input)
|
output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error running DescribeInstances for host set id %q: %s", query.Id, err)
|
// Mapped AWS failures are already gRPC statuses from checkHosts.
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if catalogAttributes.TargetAccount != nil {
|
||||||
|
return nil, status.Errorf(codes.Unknown, "unable to list hosts for host set %q: %v", query.Id, err)
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Unknown, "error retrieving host results for host set id %q: %v", query.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queries[i].Output = output
|
queries[i].Output = output
|
||||||
|
if output == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Process the output here, we will normalize this into a single
|
// Process the output here, we will normalize this into a single
|
||||||
// set of hosts afterwards (possibly removing duplicates).
|
// set of hosts afterwards (possibly removing duplicates).
|
||||||
for _, reservation := range output.Reservations {
|
for _, reservation := range output.Reservations {
|
||||||
for _, instance := range reservation.Instances {
|
for _, instance := range reservation.Instances {
|
||||||
host, err := awsInstanceToHost(instance)
|
host, err := awsInstanceToHost(instance, catalogAttributes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error processing host results for host set id %q: %s", query.Id, err)
|
return nil, status.Errorf(codes.Unknown, "error processing host results for host set id %q: %v", query.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||||
@@ -572,11 +666,11 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
|
|||||||
splitFilter := strings.Split(filterAttr, "=")
|
splitFilter := strings.Split(filterAttr, "=")
|
||||||
switch {
|
switch {
|
||||||
case len(splitFilter) != 2:
|
case len(splitFilter) != 2:
|
||||||
return nil, fmt.Errorf("expected filter %q to contain a single equal sign", filterAttr)
|
return nil, errors.BadRequestStatusf("expected filter %q to contain a single equal sign", filterAttr)
|
||||||
case len(splitFilter[0]) == 0:
|
case len(splitFilter[0]) == 0:
|
||||||
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
|
return nil, errors.BadRequestStatusf("filter %q contains an empty filter key", filterAttr)
|
||||||
case len(splitFilter[1]) == 0:
|
case len(splitFilter[1]) == 0:
|
||||||
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
|
return nil, errors.BadRequestStatusf("filter %q contains an empty value", filterAttr)
|
||||||
}
|
}
|
||||||
|
|
||||||
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
filterKey, filterValue := splitFilter[0], splitFilter[1]
|
||||||
@@ -605,9 +699,13 @@ func buildFilters(attrs *SetAttributes) ([]types.Filter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.DescribeInstancesInput, error) {
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = &SetAttributes{}
|
||||||
|
}
|
||||||
|
|
||||||
filters, err := buildFilters(attrs)
|
filters, err := buildFilters(attrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error building filters: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ec2.DescribeInstancesInput{
|
return &ec2.DescribeInstancesInput{
|
||||||
@@ -619,10 +717,13 @@ func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.Descri
|
|||||||
// awsInstanceToHost processes data from an ec2.Instance and returns
|
// awsInstanceToHost processes data from an ec2.Instance and returns
|
||||||
// a ListHostsResponseHost with the ID and network addresses
|
// a ListHostsResponseHost with the ID and network addresses
|
||||||
// populated.
|
// populated.
|
||||||
func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, error) {
|
func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) {
|
||||||
if aws.ToString(instance.InstanceId) == "" {
|
if aws.ToString(instance.InstanceId) == "" {
|
||||||
return nil, fmt.Errorf("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)
|
result := new(pb.ListHostsResponseHost)
|
||||||
|
|
||||||
@@ -637,10 +738,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)
|
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.
|
// every interface.
|
||||||
for _, iface := range instance.NetworkInterfaces {
|
for _, iface := range instance.NetworkInterfaces {
|
||||||
// Populate default IP addresses/DNS name similar to how we do
|
// Populate default IP addresses/DNS name similar to how we do
|
||||||
@@ -669,26 +776,121 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
|
func checkHosts(ctx context.Context,
|
||||||
// credentials, the host listing functionality as well as the filters, if any
|
principalCatalogState *awsCatalogPersistedState,
|
||||||
// are passed in. This function can therefore be used for both host catalog and
|
input *ec2.DescribeInstancesInput,
|
||||||
// host set validation.
|
opts []ec2Option,
|
||||||
func dryRunValidation(ctx context.Context, state *awsCatalogPersistedState, filters ...types.Filter) *status.Status {
|
target *credential.CredentialAttributes,
|
||||||
if state == nil {
|
) (*ec2.DescribeInstancesOutput, error) {
|
||||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
if principalCatalogState == nil {
|
||||||
|
return nil, fmt.Errorf("unable to create EC2 client from catalog state")
|
||||||
}
|
}
|
||||||
|
|
||||||
ec2Client, err := state.EC2Client(ctx)
|
var ec2Client EC2API
|
||||||
|
var err error
|
||||||
|
if target == nil {
|
||||||
|
// use the principal account to execute the DescribeInstances command
|
||||||
|
ec2Client, err = principalCatalogState.EC2Client(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
if st, _ := errors.ParseAWSError("EC2 client setup", err); st.Code() != codes.Unknown {
|
||||||
|
return nil, st.Err()
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// use the target account to execute the DescribeInstances command
|
||||||
|
ec2Client, err = ec2ClientForTarget(ctx, principalCatalogState, target, opts)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("EC2 client setup failed for target account: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := ec2Client.DescribeInstances(ctx, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting EC2 client: %s", err))
|
if errors.IsDryRunSuccess(err) {
|
||||||
|
// DryRunOperation: request would have succeeded; not a failure.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if st, _ := errors.ParseAWSError("DescribeInstances", err); st.Code() != codes.Unknown {
|
||||||
|
return nil, st.Err()
|
||||||
|
}
|
||||||
|
if target != nil {
|
||||||
|
return nil, fmt.Errorf("EC2 DescribeInstances failed for target account: %w", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters})
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ec2ClientForTarget builds an EC2 client to query the target account.
|
||||||
|
// The hop is a continuation of the principal session: STS AssumeRole is called
|
||||||
|
// as the principal to get to the target, then EC2 uses the resulting temporary credentials.
|
||||||
|
// This is not a second worker-level credential chain on the target attributes.
|
||||||
|
func ec2ClientForTarget(
|
||||||
|
ctx context.Context,
|
||||||
|
principalCatalogState *awsCatalogPersistedState,
|
||||||
|
target *credential.CredentialAttributes,
|
||||||
|
opts []ec2Option) (EC2API, error) {
|
||||||
|
// Reuse the principal SDK config (HTTP client, retries, logger, and the
|
||||||
|
// principal identity).
|
||||||
|
principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("aws describe instances failed: %s", err))
|
if st, _ := errors.ParseAWSError("EC2 client setup for target account", err); st.Code() != codes.Unknown {
|
||||||
|
return nil, st.Err()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("principal AWS configuration could not be loaded: %w", err)
|
||||||
|
}
|
||||||
|
if principalCfg == nil {
|
||||||
|
return nil, fmt.Errorf("nil principal AWS configuration while configuring EC2 client for target account")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// STS must run as the principal so the target role's trust policy sees that
|
||||||
|
// identity, not the worker's. Session name, external ID, and tags are
|
||||||
|
// forwarded from attrs when set so the AssumeRole matches the target role's configuration.
|
||||||
|
stsClient := sts.NewFromConfig(*principalCfg)
|
||||||
|
provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) {
|
||||||
|
if target.RoleSessionName != "" {
|
||||||
|
o.RoleSessionName = target.RoleSessionName
|
||||||
|
}
|
||||||
|
if target.RoleExternalId != "" {
|
||||||
|
o.ExternalID = &target.RoleExternalId
|
||||||
|
}
|
||||||
|
for k, v := range target.RoleTags {
|
||||||
|
o.Tags = append(o.Tags, stsTypes.Tag{
|
||||||
|
Key: aws.String(k),
|
||||||
|
Value: aws.String(v),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Same worker SDK environment as the principal; only identity and region change.
|
||||||
|
targetCfg := principalCfg.Copy()
|
||||||
|
targetCfg.Credentials = aws.NewCredentialsCache(provider)
|
||||||
|
if target.Region != "" {
|
||||||
|
targetCfg.Region = target.Region
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := getOpts(opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("EC2 client options are invalid: %w", err)
|
||||||
|
}
|
||||||
|
// Dual-stack is the worker's path to AWS endpoints, not per-account. Apply
|
||||||
|
// it on the hopped client the same way EC2Client does for the principal.
|
||||||
|
var ec2Opts []func(*ec2.Options)
|
||||||
|
if parsed.withDualStack {
|
||||||
|
ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{
|
||||||
|
dualStack: parsed.withDualStack,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if principalCatalogState.testEC2APIFunc != nil {
|
||||||
|
return principalCatalogState.testEC2APIFunc(targetCfg)
|
||||||
|
}
|
||||||
|
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendDistinct will append the elements to the slice
|
// appendDistinct will append the elements to the slice
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
@@ -7,12 +7,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"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"
|
||||||
|
smithyEndpoints "github.com/aws/smithy-go/endpoints"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"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)
|
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 {
|
type awsCatalogPersistedState struct {
|
||||||
*credential.AwsCredentialPersistedState
|
*credential.AwsCredentialPersistedState
|
||||||
|
|
||||||
@@ -86,7 +73,11 @@ func (s *awsCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
|||||||
|
|
||||||
// EC2Client returns a configured EC2 client based on the session
|
// EC2Client returns a configured EC2 client based on the session
|
||||||
// information stored in the state.
|
// 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)
|
awsCfg, err := s.GenerateCredentialChain(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error getting AWS configuration when fetching EC2 client: %w", err)
|
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 {
|
if awsCfg == nil {
|
||||||
return nil, fmt.Errorf("nil aws configuration")
|
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 {
|
if s.testEC2APIFunc != nil {
|
||||||
return s.testEC2APIFunc(*awsCfg)
|
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,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package host
|
package host
|
||||||
@@ -22,11 +22,17 @@ const (
|
|||||||
type testMockEC2State struct {
|
type testMockEC2State struct {
|
||||||
DescribeInstancesCalled bool
|
DescribeInstancesCalled bool
|
||||||
DescribeInstancesInputParams *ec2.DescribeInstancesInput
|
DescribeInstancesInputParams *ec2.DescribeInstancesInput
|
||||||
|
DescribeInstancesCallCount int
|
||||||
|
DescribeInstancesInputs []*ec2.DescribeInstancesInput
|
||||||
|
ClientRegions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *testMockEC2State) Reset() {
|
func (s *testMockEC2State) Reset() {
|
||||||
s.DescribeInstancesCalled = false
|
s.DescribeInstancesCalled = false
|
||||||
s.DescribeInstancesInputParams = nil
|
s.DescribeInstancesInputParams = nil
|
||||||
|
s.DescribeInstancesCallCount = 0
|
||||||
|
s.DescribeInstancesInputs = nil
|
||||||
|
s.ClientRegions = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type testMockEC2 struct {
|
type testMockEC2 struct {
|
||||||
@@ -72,7 +78,10 @@ func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFu
|
|||||||
if cfg.Region != "" {
|
if cfg.Region != "" {
|
||||||
m.Region = cfg.Region
|
m.Region = cfg.Region
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state != nil {
|
||||||
|
state.ClientRegions = append(state.ClientRegions, m.Region)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -83,6 +92,8 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
|
|||||||
if m.State != nil {
|
if m.State != nil {
|
||||||
m.State.DescribeInstancesCalled = true
|
m.State.DescribeInstancesCalled = true
|
||||||
m.State.DescribeInstancesInputParams = input
|
m.State.DescribeInstancesInputParams = input
|
||||||
|
m.State.DescribeInstancesCallCount++
|
||||||
|
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.DescribeInstancesError != nil {
|
if m.DescribeInstancesError != nil {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -19,6 +19,9 @@ type StorageAttributes struct {
|
|||||||
|
|
||||||
// EndpointUrl is used for configuring how the aws client will resolve requests.
|
// EndpointUrl is used for configuring how the aws client will resolve requests.
|
||||||
EndpointUrl string
|
EndpointUrl string
|
||||||
|
|
||||||
|
// DualStack is used for configuring how the aws client will resolve requests.
|
||||||
|
DualStack bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
|
func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
|
||||||
@@ -37,6 +40,12 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
|
|||||||
}
|
}
|
||||||
delete(unknownFields, ConstAwsEndpointUrl)
|
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 {
|
for s := range unknownFields {
|
||||||
switch s {
|
switch s {
|
||||||
// Ignore knownFields from CredentialAttributes
|
// Ignore knownFields from CredentialAttributes
|
||||||
@@ -64,5 +73,6 @@ func getStorageAttributes(in *structpb.Struct) (*StorageAttributes, error) {
|
|||||||
return &StorageAttributes{
|
return &StorageAttributes{
|
||||||
CredentialAttributes: credAttributes,
|
CredentialAttributes: credAttributes,
|
||||||
EndpointUrl: endpointUrl,
|
EndpointUrl: endpointUrl,
|
||||||
|
DualStack: dualStack,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -41,6 +41,21 @@ func TestGetStorageAttributes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
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",
|
name: "good w/ endpoint",
|
||||||
in: &structpb.Struct{
|
in: &structpb.Struct{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -7,6 +7,9 @@ const (
|
|||||||
// ConstAwsEndpointUrl is the key for the endpoint url in the aws s3 client.
|
// ConstAwsEndpointUrl is the key for the endpoint url in the aws s3 client.
|
||||||
ConstAwsEndpointUrl = "endpoint_url"
|
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 is the recommened chunk size for sending data through a stream
|
||||||
defaultStreamChunkSize = 65536 // 64 KiB
|
defaultStreamChunkSize = 1 << 20 // 1 MiB
|
||||||
)
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -40,6 +40,10 @@ func setStatePermission(req any, permission *pb.Permission) *pb.StorageBucketCre
|
|||||||
state.State.Delete = permission
|
state.State.Delete = permission
|
||||||
case *pb.DeleteObjectsRequest:
|
case *pb.DeleteObjectsRequest:
|
||||||
state.State.Delete = permission
|
state.State.Delete = permission
|
||||||
|
case pb.ListObjectsRequest:
|
||||||
|
state.State.Read = permission
|
||||||
|
case *pb.ListObjectsRequest:
|
||||||
|
state.State.Read = permission
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
stderr "errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"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/s3"
|
||||||
@@ -24,14 +26,24 @@ import (
|
|||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure that we are implementing StoragePluginServiceServer
|
// Ensure that we are implementing StoragePluginServiceServer
|
||||||
var _ pb.StoragePluginServiceServer = (*StoragePlugin)(nil)
|
var _ pb.StoragePluginServiceServer = (*StoragePlugin)(nil)
|
||||||
|
|
||||||
|
// clientCache caches previously initialized S3API clients created with a RoleARN attribute.
|
||||||
|
type clientCache struct {
|
||||||
|
// cache is a map of cached s3 clients. The key of the map
|
||||||
|
// is the public id of the storage bucket
|
||||||
|
cache map[string]S3API
|
||||||
|
sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
// StoragePlugin implements the StoragePluginServiceServer interface for the
|
// StoragePlugin implements the StoragePluginServiceServer interface for the
|
||||||
// AWS storage service plugin.
|
// AWS storage service plugin.
|
||||||
type StoragePlugin struct {
|
type StoragePlugin struct {
|
||||||
@@ -42,6 +54,137 @@ type StoragePlugin struct {
|
|||||||
|
|
||||||
// testStorageStateOpts are passed in to the stored state to control test behavior
|
// testStorageStateOpts are passed in to the stored state to control test behavior
|
||||||
testStorageStateOpts []awsStoragePersistedStateOption
|
testStorageStateOpts []awsStoragePersistedStateOption
|
||||||
|
|
||||||
|
clients *clientCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new StoragePlugin
|
||||||
|
func New() *StoragePlugin {
|
||||||
|
c := &clientCache{
|
||||||
|
cache: make(map[string]S3API),
|
||||||
|
}
|
||||||
|
return &StoragePlugin{
|
||||||
|
clients: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClient returns an S3API client for the given storage bucket id.
|
||||||
|
func (p *StoragePlugin) getClient(ctx context.Context,
|
||||||
|
storageBucketId string,
|
||||||
|
storageState *awsStoragePersistedState,
|
||||||
|
opt ...s3Option) (S3API, error) {
|
||||||
|
if storageBucketId == "" {
|
||||||
|
// No storage bucket ID to key cache on, create new client and return
|
||||||
|
client, err := storageState.s3Client(ctx, opt...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error creating S3 client: %s", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := getOpts(opt...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing options in cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.withCacheRefresh {
|
||||||
|
// Forced cache refresh
|
||||||
|
p.clients.Lock()
|
||||||
|
defer p.clients.Unlock()
|
||||||
|
client, err := storageState.s3Client(ctx, opt...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error creating S3 client: %s", err)
|
||||||
|
}
|
||||||
|
p.clients.cache[storageBucketId] = client
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.clients.RLock()
|
||||||
|
client, ok := p.clients.cache[storageBucketId]
|
||||||
|
p.clients.RUnlock()
|
||||||
|
|
||||||
|
if !ok || client.Credentials().Expired() {
|
||||||
|
// We got a cache miss or the credentials has expired, time to refresh
|
||||||
|
p.clients.Lock()
|
||||||
|
defer p.clients.Unlock()
|
||||||
|
|
||||||
|
// Check cache again in case another caller updated it since we got the lock
|
||||||
|
client, ok = p.clients.cache[storageBucketId]
|
||||||
|
if ok && !client.Credentials().Expired() {
|
||||||
|
// Got a cache hit with valid credentials, return cached client
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new client and cache it
|
||||||
|
var err error
|
||||||
|
client, err = storageState.s3Client(ctx, opt...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error creating S3 client: %s", err)
|
||||||
|
}
|
||||||
|
p.clients.cache[storageBucketId] = client
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type s3Caller func(client S3API) (any, error)
|
||||||
|
|
||||||
|
// call gets the s3client and passes it to the s3caller function, if a permission error is returned
|
||||||
|
// it forces a cache refresh and tries again.
|
||||||
|
func (p *StoragePlugin) call(
|
||||||
|
ctx context.Context,
|
||||||
|
fn s3Caller,
|
||||||
|
storageBucketId string,
|
||||||
|
storageState *awsStoragePersistedState,
|
||||||
|
opts ...s3Option) (any, error) {
|
||||||
|
|
||||||
|
s3Client, err := p.getClient(ctx, storageBucketId, storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error getting S3 client: %s", err)
|
||||||
|
}
|
||||||
|
firstAttempt := true
|
||||||
|
for {
|
||||||
|
resp, err := fn(s3Client)
|
||||||
|
if err != nil {
|
||||||
|
st, _ := errors.ParseAWSError("", err)
|
||||||
|
if st.Code() == codes.PermissionDenied && firstAttempt {
|
||||||
|
// We got a permission error, this is the first time so refresh cache and try again
|
||||||
|
firstAttempt = false
|
||||||
|
opts = append(opts, WithCacheRefresh(true))
|
||||||
|
s3Client, err = p.getClient(ctx, storageBucketId, storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error refreshing S3 client: %s", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeStorageBucketData is called by boundary before calling OnCreate or OnUpdate
|
||||||
|
//
|
||||||
|
// aws storage supports a total of 8 attributes, 6 as part of credential.CredentialAttributes
|
||||||
|
// and 2 more from storage.StorageAttributes. of these, only endpoint_url is normalized.
|
||||||
|
func (sp *StoragePlugin) NormalizeStorageBucketData(ctx context.Context, req *pb.NormalizeStorageBucketDataRequest) (*pb.NormalizeStorageBucketDataResponse, error) {
|
||||||
|
sa, err := getStorageAttributes(req.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := req.GetAttributes().GetFields()
|
||||||
|
if _, ok := fields[ConstAwsEndpointUrl]; ok {
|
||||||
|
if sa.EndpointUrl, err = parseutil.NormalizeAddr(sa.EndpointUrl); err != nil {
|
||||||
|
return nil, status.New(codes.InvalidArgument, fmt.Sprintf("failed to normalize provided %s: %s", ConstAwsEndpointUrl, err.Error())).Err()
|
||||||
|
}
|
||||||
|
fields[ConstAwsEndpointUrl] = structpb.NewStringValue(sa.EndpointUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: fields,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnCreateStorageBucket is called when a storage bucket is created.
|
// OnCreateStorageBucket is called when a storage bucket is created.
|
||||||
@@ -64,7 +207,7 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,7 +219,7 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to rotate AWS static credentials
|
// Try to rotate AWS static credentials
|
||||||
@@ -92,7 +235,7 @@ func (p *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCre
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
// perform dry run to ensure we can interact with the bucket as expected.
|
||||||
@@ -147,15 +290,16 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd
|
|||||||
credState, err := cred.AwsCredentialPersistedStateFromProto(
|
credState, err := cred.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetData(),
|
req.GetPersisted().GetData(),
|
||||||
oldStorageAttributes.CredentialAttributes,
|
oldStorageAttributes.CredentialAttributes,
|
||||||
|
oldStorageAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("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
|
// 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
|
// user if they're not. Note this doesn't validate the credentials against
|
||||||
// AWS - it only does logical validation on the fields.
|
// AWS - it only does logical validation on the fields.
|
||||||
updatedCredentials, err := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes)
|
updatedCredentials, err := cred.GetCredentialsConfig(newBucket.GetSecrets(), newStorageAttributes.CredentialAttributes, newStorageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -172,7 +316,7 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up new credential persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up new credential persisted state: %s", err)
|
||||||
}
|
}
|
||||||
newStorageState, err := newAwsStoragePersistedState(
|
newStorageState, err := newAwsStoragePersistedState(
|
||||||
append([]awsStoragePersistedStateOption{
|
append([]awsStoragePersistedStateOption{
|
||||||
@@ -180,7 +324,7 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
if st := dryRunValidation(ctx, newStorageState, newStorageAttributes, newBucket); st != nil {
|
if st := dryRunValidation(ctx, newStorageState, newStorageAttributes, newBucket); st != nil {
|
||||||
return nil, st.Err()
|
return nil, st.Err()
|
||||||
@@ -220,7 +364,7 @@ func (p *StoragePlugin) OnUpdateStorageBucket(ctx context.Context, req *pb.OnUpd
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
// perform dry run to ensure we can interact with the bucket as expected.
|
||||||
@@ -264,9 +408,10 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel
|
|||||||
credState, err := cred.AwsCredentialPersistedStateFromProto(
|
credState, err := cred.AwsCredentialPersistedStateFromProto(
|
||||||
req.GetPersisted().GetData(),
|
req.GetPersisted().GetData(),
|
||||||
storageAttributes.CredentialAttributes,
|
storageAttributes.CredentialAttributes,
|
||||||
|
storageAttributes.DualStack,
|
||||||
p.testCredStateOpts...)
|
p.testCredStateOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = newAwsStoragePersistedState(
|
_, err = newAwsStoragePersistedState(
|
||||||
@@ -275,7 +420,7 @@ func (p *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDel
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error loading persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error loading persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to delete static credentials
|
// try to delete static credentials
|
||||||
@@ -318,7 +463,7 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -330,7 +475,7 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
storageState, err := newAwsStoragePersistedState(
|
||||||
@@ -339,28 +484,32 @@ func (p *StoragePlugin) HeadObject(ctx context.Context, req *pb.HeadObjectReques
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []s3Option{}
|
opts := []s3Option{}
|
||||||
if storageAttributes.EndpointUrl != "" {
|
if storageAttributes.EndpointUrl != "" {
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
||||||
}
|
}
|
||||||
s3Client, err := storageState.S3Client(ctx, opts...)
|
if storageAttributes.DualStack {
|
||||||
if err != nil {
|
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
||||||
resp, err := s3Client.HeadObject(ctx, &s3.HeadObjectInput{
|
headCall := func(s3Client S3API) (any, error) {
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
return s3Client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||||
Key: aws.String(objectKey),
|
Bucket: aws.String(bucket.GetBucketName()),
|
||||||
})
|
Key: aws.String(objectKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
headResp, err := p.call(ctx, headCall, bucket.GetId(), storageState, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, parseS3Error("head object", err, req).Err()
|
return nil, parseS3Error("head object", err, req).Err()
|
||||||
}
|
}
|
||||||
|
resp := headResp.(*s3.HeadObjectOutput)
|
||||||
|
|
||||||
return &pb.HeadObjectResponse{
|
return &pb.HeadObjectResponse{
|
||||||
ContentLength: resp.ContentLength,
|
ContentLength: aws.ToInt64(resp.ContentLength),
|
||||||
LastModified: timestamppb.New(*resp.LastModified),
|
LastModified: timestamppb.New(*resp.LastModified),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -386,7 +535,7 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -398,7 +547,7 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
storageState, err := newAwsStoragePersistedState(
|
||||||
@@ -407,7 +556,7 @@ func (p *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.Validat
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// perform dry run to ensure we can interact with the bucket as expected.
|
// perform dry run to ensure we can interact with the bucket as expected.
|
||||||
@@ -444,7 +593,7 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -456,7 +605,7 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
storageState, err := newAwsStoragePersistedState(
|
||||||
@@ -465,26 +614,29 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []s3Option{}
|
opts := []s3Option{}
|
||||||
if storageAttributes.EndpointUrl != "" {
|
if storageAttributes.EndpointUrl != "" {
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
||||||
}
|
}
|
||||||
s3Client, err := storageState.S3Client(stream.Context(), opts...)
|
if storageAttributes.DualStack {
|
||||||
if err != nil {
|
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
||||||
return errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
||||||
resp, err := s3Client.GetObject(stream.Context(), &s3.GetObjectInput{
|
getCall := func(s3Client S3API) (any, error) {
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
return s3Client.GetObject(stream.Context(), &s3.GetObjectInput{
|
||||||
Key: aws.String(objectKey),
|
Bucket: aws.String(bucket.GetBucketName()),
|
||||||
})
|
Key: aws.String(objectKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getResp, err := p.call(stream.Context(), getCall, bucket.GetId(), storageState, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return parseS3Error("get object", err, req).Err()
|
return parseS3Error("get object", err, req).Err()
|
||||||
}
|
}
|
||||||
|
resp := getResp.(*s3.GetObjectOutput)
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
@@ -496,13 +648,13 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu
|
|||||||
buffer := make([]byte, chunkSize)
|
buffer := make([]byte, chunkSize)
|
||||||
n, err := reader.Read(buffer)
|
n, err := reader.Read(buffer)
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return errors.UnknownStatus("error reading chunk from s3: %s", err)
|
return errors.UnknownStatusf("error reading chunk from s3: %s", err)
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
if err := stream.Send(&pb.GetObjectResponse{
|
if err := stream.Send(&pb.GetObjectResponse{
|
||||||
FileChunk: buffer[:n],
|
FileChunk: buffer[:n],
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return errors.UnknownStatus("error sending chunk to client: %s", err)
|
return errors.UnknownStatusf("error sending chunk to client: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@@ -513,6 +665,23 @@ func (p *StoragePlugin) GetObject(req *pb.GetObjectRequest, stream pb.StoragePlu
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// multipartThreshold is the file size, in bytes, above which PutObject
|
||||||
|
// uploads an object using a multipart upload instead of a single PutObject
|
||||||
|
// request. 8 MiB matches the AWS CLI and boto3 defaults.
|
||||||
|
multipartThreshold = 8 * 1024 * 1024 // 8 MiB
|
||||||
|
|
||||||
|
// defaultMultipartPartSize is the default size, in bytes, of each part
|
||||||
|
// uploaded during a multipart upload. It is kept equal to
|
||||||
|
// multipartThreshold so a multipart upload always has at least two parts
|
||||||
|
// (S3 requires every part except the last to be at least 5 MiB).
|
||||||
|
defaultMultipartPartSize = 8 * 1024 * 1024 // 8 MiB
|
||||||
|
|
||||||
|
// maxMultipartParts is the maximum number of parts S3 permits for a single
|
||||||
|
// multipart upload.
|
||||||
|
maxMultipartParts = 10000
|
||||||
|
)
|
||||||
|
|
||||||
// PutObject is called when putting objects into an s3 bucket.
|
// PutObject is called when putting objects into an s3 bucket.
|
||||||
func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) (*pb.PutObjectResponse, error) {
|
func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest) (*pb.PutObjectResponse, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
@@ -555,7 +724,7 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -567,7 +736,7 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest)
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
storageState, err := newAwsStoragePersistedState(
|
||||||
@@ -576,16 +745,15 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest)
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []s3Option{}
|
opts := []s3Option{}
|
||||||
if storageAttributes.EndpointUrl != "" {
|
if storageAttributes.EndpointUrl != "" {
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
||||||
}
|
}
|
||||||
s3Client, err := storageState.S3Client(ctx, opts...)
|
if storageAttributes.DualStack {
|
||||||
if err != nil {
|
opts = append(opts, WithDualStack(storageAttributes.DualStack))
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
@@ -598,33 +766,175 @@ func (p *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.UnknownStatus("failed to rewind file pointer")
|
return nil, errors.UnknownStatus("failed to rewind file pointer")
|
||||||
}
|
}
|
||||||
|
|
||||||
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
objectKey := path.Join(bucket.GetBucketPrefix(), req.GetKey())
|
||||||
resp, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
// decodedChecksum is the raw SHA-256 digest of the entire object. Boundary
|
||||||
Key: aws.String(objectKey),
|
// compares this byte-for-byte against the hash it computed locally before
|
||||||
Body: file,
|
// the upload, so it must always be the whole-object checksum regardless of
|
||||||
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
// whether a single PutObject or a multipart upload is used.
|
||||||
ChecksumSHA256: aws.String(checksum),
|
decodedChecksum, err := base64.StdEncoding.DecodeString(checksum)
|
||||||
})
|
if err != nil {
|
||||||
|
return nil, errors.UnknownStatus("failed to decode checksum value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// for larger files, use a multipart upload instead of a single PutObject
|
||||||
|
// request.
|
||||||
|
if info.Size() > multipartThreshold {
|
||||||
|
partSize := int64(defaultMultipartPartSize)
|
||||||
|
// S3 allows at most maxMultipartParts parts per upload, so grow the
|
||||||
|
// part size for very large objects to stay within that limit.
|
||||||
|
if sz := info.Size(); sz/partSize >= maxMultipartParts {
|
||||||
|
partSize = (sz + maxMultipartParts - 1) / maxMultipartParts
|
||||||
|
}
|
||||||
|
if err := p.putObjectMultipart(ctx, req, storageState, bucket.GetBucketName(), objectKey, file, partSize, opts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &pb.PutObjectResponse{
|
||||||
|
ChecksumSha_256: decodedChecksum,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
putCall := func(s3Client S3API) (any, error) {
|
||||||
|
return s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(bucket.GetBucketName()),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
Body: file,
|
||||||
|
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
||||||
|
ChecksumSHA256: aws.String(checksum),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
putResp, err := p.call(ctx, putCall, bucket.GetId(), storageState, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, parseS3Error("put object", err, req).Err()
|
return nil, parseS3Error("put object", err, req).Err()
|
||||||
}
|
}
|
||||||
|
resp := putResp.(*s3.PutObjectOutput)
|
||||||
|
|
||||||
if resp.ChecksumSHA256 == nil {
|
if resp.ChecksumSHA256 == nil {
|
||||||
return nil, errors.UnknownStatus("missing checksum response from aws")
|
return nil, errors.UnknownStatus("missing checksum response from aws")
|
||||||
}
|
}
|
||||||
if checksum != *resp.ChecksumSHA256 {
|
if checksum != *resp.ChecksumSHA256 {
|
||||||
return nil, checksumMistmatchStatus()
|
return nil, checksumMistmatchStatus()
|
||||||
}
|
}
|
||||||
decodedChecksum, err := base64.StdEncoding.DecodeString(*resp.ChecksumSHA256)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.UnknownStatus("failed to decode checksum value from aws")
|
|
||||||
}
|
|
||||||
return &pb.PutObjectResponse{
|
return &pb.PutObjectResponse{
|
||||||
ChecksumSha_256: decodedChecksum,
|
ChecksumSha_256: decodedChecksum,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// putObjectMultipart uploads file to the given bucket and key using an S3
|
||||||
|
// multipart upload. The file is split into parts of partSize bytes (the final
|
||||||
|
// part may be smaller) and each part is uploaded with its own SHA-256 checksum
|
||||||
|
// so S3 can verify part integrity on the wire.
|
||||||
|
//
|
||||||
|
// If any step fails the upload is aborted on a best-effort basis to avoid
|
||||||
|
// leaving orphaned parts behind, which would otherwise continue to accrue
|
||||||
|
// storage charges.
|
||||||
|
func (p *StoragePlugin) putObjectMultipart(
|
||||||
|
ctx context.Context,
|
||||||
|
req *pb.PutObjectRequest,
|
||||||
|
storageState *awsStoragePersistedState,
|
||||||
|
bucketName, objectKey string,
|
||||||
|
file *os.File,
|
||||||
|
partSize int64,
|
||||||
|
opts ...s3Option,
|
||||||
|
) error {
|
||||||
|
bucketId := req.GetBucket().GetId()
|
||||||
|
|
||||||
|
createCall := func(s3Client S3API) (any, error) {
|
||||||
|
return s3Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createResp, err := p.call(ctx, createCall, bucketId, storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return parseS3Error("create multipart upload", err, req).Err()
|
||||||
|
}
|
||||||
|
uploadId := createResp.(*s3.CreateMultipartUploadOutput).UploadId
|
||||||
|
|
||||||
|
// once the upload has been created, any subsequent failure must abort it so
|
||||||
|
// uploaded parts are not orphaned.
|
||||||
|
abort := func() {
|
||||||
|
abortCall := func(s3Client S3API) (any, error) {
|
||||||
|
return s3Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
UploadId: uploadId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// best effort: the failure that triggered the abort is more useful to
|
||||||
|
// the caller than an error from the abort itself.
|
||||||
|
_, _ = p.call(ctx, abortCall, bucketId, storageState, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedParts []types.CompletedPart
|
||||||
|
buf := make([]byte, partSize)
|
||||||
|
var partNumber int32 = 1
|
||||||
|
for {
|
||||||
|
n, readErr := io.ReadFull(file, buf)
|
||||||
|
if readErr == io.EOF {
|
||||||
|
// previous iteration consumed the final bytes of the file
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
||||||
|
abort()
|
||||||
|
return errors.UnknownStatusf("failed to read file: %s", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
partData := buf[:n]
|
||||||
|
sum := sha256.Sum256(partData)
|
||||||
|
partChecksum := base64.StdEncoding.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
pn := partNumber
|
||||||
|
uploadCall := func(s3Client S3API) (any, error) {
|
||||||
|
return s3Client.UploadPart(ctx, &s3.UploadPartInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
UploadId: uploadId,
|
||||||
|
PartNumber: aws.Int32(pn),
|
||||||
|
Body: bytes.NewReader(partData),
|
||||||
|
ContentLength: aws.Int64(int64(n)),
|
||||||
|
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
||||||
|
ChecksumSHA256: aws.String(partChecksum),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
uploadResp, err := p.call(ctx, uploadCall, bucketId, storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
abort()
|
||||||
|
return parseS3Error("upload part", err, req).Err()
|
||||||
|
}
|
||||||
|
part := uploadResp.(*s3.UploadPartOutput)
|
||||||
|
completedParts = append(completedParts, types.CompletedPart{
|
||||||
|
ETag: part.ETag,
|
||||||
|
ChecksumSHA256: part.ChecksumSHA256,
|
||||||
|
PartNumber: aws.Int32(pn),
|
||||||
|
})
|
||||||
|
|
||||||
|
partNumber++
|
||||||
|
if readErr == io.ErrUnexpectedEOF {
|
||||||
|
// the final, short part
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeCall := func(s3Client S3API) (any, error) {
|
||||||
|
return s3Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
UploadId: uploadId,
|
||||||
|
MultipartUpload: &types.CompletedMultipartUpload{
|
||||||
|
Parts: completedParts,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := p.call(ctx, completeCall, bucketId, storageState, opts...); err != nil {
|
||||||
|
abort()
|
||||||
|
return parseS3Error("complete multipart upload", err, req).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteObjects is used to delete one or many objects from an s3 bucket.
|
// DeleteObjects is used to delete one or many objects from an s3 bucket.
|
||||||
func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjectsRequest) (*pb.DeleteObjectsResponse, error) {
|
func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjectsRequest) (*pb.DeleteObjectsResponse, error) {
|
||||||
if req.GetKeyPrefix() == "" {
|
if req.GetKeyPrefix() == "" {
|
||||||
@@ -650,7 +960,7 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes)
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, storageAttributes.DualStack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -662,7 +972,7 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
}, p.testCredStateOpts...)...,
|
}, p.testCredStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
storageState, err := newAwsStoragePersistedState(
|
storageState, err := newAwsStoragePersistedState(
|
||||||
@@ -671,17 +981,13 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
}, p.testStorageStateOpts...)...,
|
}, p.testStorageStateOpts...)...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.BadRequestStatus("error setting up persisted state: %s", err)
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []s3Option{}
|
opts := []s3Option{}
|
||||||
if storageAttributes.EndpointUrl != "" {
|
if storageAttributes.EndpointUrl != "" {
|
||||||
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
||||||
}
|
}
|
||||||
client, err := storageState.S3Client(ctx, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.BadRequestStatus("error getting S3 client: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := path.Join(bucket.GetBucketPrefix(), req.GetKeyPrefix())
|
prefix := path.Join(bucket.GetBucketPrefix(), req.GetKeyPrefix())
|
||||||
if strings.HasSuffix(req.GetKeyPrefix(), "/") {
|
if strings.HasSuffix(req.GetKeyPrefix(), "/") {
|
||||||
@@ -691,10 +997,13 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !req.Recursive {
|
if !req.Recursive {
|
||||||
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
deleteCall := func(s3Client S3API) (any, error) {
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
return s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
Key: aws.String(prefix),
|
Bucket: aws.String(bucket.GetBucketName()),
|
||||||
})
|
Key: aws.String(prefix),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, err := p.call(ctx, deleteCall, bucket.GetId(), storageState, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, parseS3Error("delete object", err, req).Err()
|
return nil, parseS3Error("delete object", err, req).Err()
|
||||||
}
|
}
|
||||||
@@ -704,21 +1013,36 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxkeys = 1000
|
const maxkeys = 1000
|
||||||
|
|
||||||
objects := []types.ObjectIdentifier{}
|
objects := []types.ObjectIdentifier{}
|
||||||
|
client, err := p.getClient(ctx, bucket.GetId(), storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error getting S3 client: %s", err)
|
||||||
|
}
|
||||||
var conToken *string
|
var conToken *string
|
||||||
truncated := true
|
truncated, firstAttempt := true, true
|
||||||
for truncated {
|
for truncated {
|
||||||
res, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
res, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||||||
Bucket: aws.String(bucket.GetBucketName()),
|
Bucket: aws.String(bucket.GetBucketName()),
|
||||||
Prefix: aws.String(prefix),
|
Prefix: aws.String(prefix),
|
||||||
MaxKeys: maxkeys,
|
MaxKeys: aws.Int32(maxkeys),
|
||||||
ContinuationToken: conToken,
|
ContinuationToken: conToken,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
st, _ := errors.ParseAWSError("", err)
|
||||||
|
if st.Code() == codes.PermissionDenied && firstAttempt {
|
||||||
|
// This is first attempt and we get a permission error, refresh cache and try again
|
||||||
|
firstAttempt = false
|
||||||
|
opts = append(opts, WithCacheRefresh(true))
|
||||||
|
client, err = p.getClient(ctx, bucket.GetId(), storageState, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error refreshing S3 client: %s", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
return nil, parseS3Error("list objects", err, req).Err()
|
return nil, parseS3Error("list objects", err, req).Err()
|
||||||
}
|
}
|
||||||
truncated = res.IsTruncated
|
truncated = aws.ToBool(res.IsTruncated)
|
||||||
conToken = res.NextContinuationToken
|
conToken = res.NextContinuationToken
|
||||||
for _, o := range res.Contents {
|
for _, o := range res.Contents {
|
||||||
objects = append(objects, types.ObjectIdentifier{
|
objects = append(objects, types.ObjectIdentifier{
|
||||||
@@ -748,6 +1072,191 @@ func (p *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjects
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListObjects retrieves files and directories from an S3 bucket under a given prefix.
|
||||||
|
// If the "recursive" flag in the request is false (default), it returns only the top-level objects directly under the prefix.
|
||||||
|
// If the "recursive" is true, it returns all objects under the prefix recursively.
|
||||||
|
func (p *StoragePlugin) ListObjects(ctx context.Context, req *pb.ListObjectsRequest) (*pb.ListObjectsResponse, error) {
|
||||||
|
bucket := req.GetBucket()
|
||||||
|
if bucket == nil {
|
||||||
|
return nil, errors.BadRequestStatus("bucket is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bucket.BucketName == "" {
|
||||||
|
return nil, errors.BadRequestStatus("bucketName is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := bucket.GetAttributes()
|
||||||
|
if attrs == nil {
|
||||||
|
return nil, errors.BadRequestStatus("attributes is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
storageAttributes, err := getStorageAttributes(attrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
credConfig, err := cred.GetCredentialsConfig(bucket.GetSecrets(), storageAttributes.CredentialAttributes, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the creds in our persisted state.
|
||||||
|
credState, err := cred.NewAwsCredentialPersistedState(
|
||||||
|
append([]cred.AwsCredentialPersistedStateOption{
|
||||||
|
cred.WithCredentialsConfig(credConfig),
|
||||||
|
}, p.testCredStateOpts...)...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storageState, err := newAwsStoragePersistedState(
|
||||||
|
append([]awsStoragePersistedStateOption{
|
||||||
|
withCredentials(credState),
|
||||||
|
}, p.testStorageStateOpts...)...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error setting up persisted state: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var opts []s3Option
|
||||||
|
if storageAttributes.EndpointUrl != "" {
|
||||||
|
opts = append(opts, WithEndpoint(storageAttributes.EndpointUrl))
|
||||||
|
}
|
||||||
|
s3Client, err := storageState.s3Client(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.BadRequestStatusf("error getting S3 client: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := path.Join(bucket.GetBucketPrefix(), req.GetKeyPrefix())
|
||||||
|
if strings.HasSuffix(req.GetKeyPrefix(), "/") {
|
||||||
|
// path.Join ends by "cleaning" the path, including removing a trailing slash, if
|
||||||
|
// it exists. given that a slash is used to denote a folder, it is required here.
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
var objects []*pb.Object
|
||||||
|
|
||||||
|
if req.GetRecursive() {
|
||||||
|
objects, err = listObjectsRecursive(ctx, s3Client, bucket.GetBucketName(), prefix, req)
|
||||||
|
} else {
|
||||||
|
objects, err = listObjectsTopLevel(ctx, s3Client, bucket.GetBucketName(), prefix, req)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListObjectsResponse{
|
||||||
|
Objects: objects,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listObjectsTopLevel lists only the objects that exist directly under the specified prefix (This does not traverse subdirectories)
|
||||||
|
// It uses the S3 "Delimiter" option to group results so that subdirectories appear as CommonPrefixes.
|
||||||
|
func listObjectsTopLevel(ctx context.Context, s3Client S3API, bucketName, prefix string, req *pb.ListObjectsRequest) ([]*pb.Object, error) {
|
||||||
|
var objects []*pb.Object
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3Client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
Delimiter: aws.String("/"),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
switch {
|
||||||
|
case stderr.Is(err, context.Canceled):
|
||||||
|
return nil, status.Error(codes.Canceled, "request canceled")
|
||||||
|
case stderr.Is(err, context.DeadlineExceeded):
|
||||||
|
return nil, status.Error(codes.DeadlineExceeded, "request deadline exceeded")
|
||||||
|
default:
|
||||||
|
return nil, status.Error(codes.Internal, "unknown context error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, parseS3Error("failed to list objects", err, req).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range page.Contents {
|
||||||
|
if c.Key != nil {
|
||||||
|
objects = append(objects, &pb.Object{Key: *c.Key, IsDir: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cp := range page.CommonPrefixes {
|
||||||
|
if cp.Prefix != nil {
|
||||||
|
objects = append(objects, &pb.Object{Key: *cp.Prefix, IsDir: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listObjectsRecursive lists all objects under the specified prefix, traversing all subdirectories.
|
||||||
|
// It does not use the S3 "Delimiter" option, so every object key that matches the prefix is returned.
|
||||||
|
// This mode is useful for fully scanning all objects in a folder tree.
|
||||||
|
func listObjectsRecursive(ctx context.Context, s3Client S3API, bucketName, prefix string, req *pb.ListObjectsRequest) ([]*pb.Object, error) {
|
||||||
|
var objects []*pb.Object
|
||||||
|
folderSet := make(map[string]struct{})
|
||||||
|
paginator := s3.NewListObjectsV2Paginator(s3Client, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(bucketName),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
|
||||||
|
for paginator.HasMorePages() {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
switch {
|
||||||
|
case stderr.Is(err, context.Canceled):
|
||||||
|
return nil, status.Error(codes.Canceled, "request canceled")
|
||||||
|
case stderr.Is(err, context.DeadlineExceeded):
|
||||||
|
return nil, status.Error(codes.DeadlineExceeded, "request deadline exceeded")
|
||||||
|
default:
|
||||||
|
return nil, status.Error(codes.Internal, "unknown context error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page, err := paginator.NextPage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, parseS3Error("failed to list objects", err, req).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range page.Contents {
|
||||||
|
// We intentionally ignore "empty objects" that simulate folders.
|
||||||
|
// S3 is not a traditional file system and does not have real directories.
|
||||||
|
// Folders are an abstraction; they are represented by zero-byte objects
|
||||||
|
// whose keys end with a slash (e.g., "myfolder/"). These objects exist
|
||||||
|
// solely as placeholders to give the appearance of directories in S3 tools.
|
||||||
|
// Here, we only include actual objects with content (non-zero length).
|
||||||
|
if c.Key != nil && !strings.HasSuffix(*c.Key, "/") {
|
||||||
|
objects = append(objects, &pb.Object{Key: *c.Key, IsDir: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all parent folder paths from the object key.
|
||||||
|
// We walk up the directory hierarchy using path.Dir,
|
||||||
|
// adding each parent folder to folderSet until we reach
|
||||||
|
// the root ("/" or ".") or leave the current prefix subtree.
|
||||||
|
// This ensures all intermediate "folders" appear in the result even if
|
||||||
|
// no explicit zero-byte object exists for them.
|
||||||
|
dir := path.Dir(*c.Key)
|
||||||
|
for dir != "." && dir != "/" {
|
||||||
|
folder := dir + "/"
|
||||||
|
if !strings.HasPrefix(folder, prefix) {
|
||||||
|
break // left the subtree, stop climbing up
|
||||||
|
}
|
||||||
|
if folder != prefix {
|
||||||
|
folderSet[folder] = struct{}{}
|
||||||
|
}
|
||||||
|
dir = path.Dir(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add folders
|
||||||
|
for folder := range folderSet {
|
||||||
|
objects = append(objects, &pb.Object{
|
||||||
|
Key: folder,
|
||||||
|
IsDir: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
|
||||||
// dryRunValidation validates that the IAM role policy attached to the given secrets of the
|
// dryRunValidation validates that the IAM role policy attached to the given secrets of the
|
||||||
// storage bucket has the minimum required permissions needed for this plugin to function
|
// storage bucket has the minimum required permissions needed for this plugin to function
|
||||||
// as expected. This function will create an object in the s3 bucket to validate it has
|
// as expected. This function will create an object in the s3 bucket to validate it has
|
||||||
@@ -769,8 +1278,11 @@ func dryRunValidation(ctx context.Context, state *awsStoragePersistedState, attr
|
|||||||
if attrs.EndpointUrl != "" {
|
if attrs.EndpointUrl != "" {
|
||||||
opts = append(opts, WithEndpoint(attrs.EndpointUrl))
|
opts = append(opts, WithEndpoint(attrs.EndpointUrl))
|
||||||
}
|
}
|
||||||
|
if attrs.DualStack {
|
||||||
|
opts = append(opts, WithDualStack(attrs.DualStack))
|
||||||
|
}
|
||||||
|
|
||||||
client, err := state.S3Client(ctx, opts...)
|
client, err := state.s3Client(ctx, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting S3 client: %s", err))
|
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting S3 client: %s", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
internal "github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
internal "github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
|
||||||
|
"github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||||
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
"github.com/hashicorp/go-secure-stdlib/awsutil/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -36,6 +37,204 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestStoragePlugin_NormalizeStorageBucketData(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
req *plugin.NormalizeStorageBucketDataRequest
|
||||||
|
expected *plugin.NormalizeStorageBucketDataResponse
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing attributes",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{},
|
||||||
|
err: "attributes.region: missing required value \"region\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty attributes",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{},
|
||||||
|
},
|
||||||
|
err: "attributes.region: missing required value \"region\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required field",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("http://127.0.0.1:9000"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
err: "attributes.region: missing required value \"region\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing endpoint_url",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &plugin.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid endpoint_url returned unchanged",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("http://127.0.0.1:9000"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &plugin.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("http://127.0.0.1:9000"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ipv6 endpoint_url returned normalized",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:BEEF:0:0:0:1:0:0001]"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &plugin.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:beef::1:0:1]"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ipv6 endpoint_url with port returned normalized",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:BEEF:0:0:0:1:0:0001]:9000"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &plugin.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:beef::1:0:1]:9000"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid ipv6 endpoint_url returns normalization error",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:BEEF:0:0:0:1:0:0001"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
err: "failed to normalize provided endpoint_url: failed to parse address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid ipv6 endpoint_url returns normalization error 2",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("https://[2001:BEEF:0:0:0:1:0:0001]:"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
err: "failed to normalize provided endpoint_url: url has malformed host: missing port value after colon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid all fields returns normalized",
|
||||||
|
req: &plugin.NormalizeStorageBucketDataRequest{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("http://[2001:BEEF:0:0:0:1:0:0001]:9000/test/path"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere ❤"),
|
||||||
|
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(false),
|
||||||
|
credential.ConstRoleArn: structpb.NewStringValue("an arn"),
|
||||||
|
credential.ConstRoleExternalId: structpb.NewStringValue("id"),
|
||||||
|
credential.ConstRoleSessionName: structpb.NewStringValue("sesh"),
|
||||||
|
ConstAwsDualStack: structpb.NewBoolValue(false),
|
||||||
|
credential.ConstRoleTags: structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"foo": structpb.NewStringValue("bar"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: &plugin.NormalizeStorageBucketDataResponse{
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
ConstAwsEndpointUrl: structpb.NewStringValue("http://[2001:beef::1:0:1]:9000/test/path"),
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("somewhere ❤"),
|
||||||
|
credential.ConstDisableCredentialRotation: structpb.NewBoolValue(false),
|
||||||
|
credential.ConstRoleArn: structpb.NewStringValue("an arn"),
|
||||||
|
credential.ConstRoleExternalId: structpb.NewStringValue("id"),
|
||||||
|
credential.ConstRoleSessionName: structpb.NewStringValue("sesh"),
|
||||||
|
ConstAwsDualStack: structpb.NewBoolValue(false),
|
||||||
|
credential.ConstRoleTags: structpb.NewStructValue(&structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
"foo": structpb.NewStringValue("bar"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
require := require.New(t)
|
||||||
|
sp := new(StoragePlugin)
|
||||||
|
res, err := sp.NormalizeStorageBucketData(ctx, tt.req)
|
||||||
|
|
||||||
|
if tt.err != "" {
|
||||||
|
require.ErrorContains(err, tt.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if st, ok := status.FromError(err); ok {
|
||||||
|
require.Nil(err, st.Message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Nil(err)
|
||||||
|
for key, val := range tt.expected.GetAttributes().GetFields() {
|
||||||
|
v, ok := res.GetAttributes().GetFields()[key]
|
||||||
|
assert.True(ok, "missing expected value: %q", key)
|
||||||
|
if ok {
|
||||||
|
assert.Equal(val, v, "expected[%q]: %v, but got: %v", key, val, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) {
|
func TestStoragePlugin_OnCreateStorageBucket(t *testing.T) {
|
||||||
validRequest := func() *pb.OnCreateStorageBucketRequest {
|
validRequest := func() *pb.OnCreateStorageBucketRequest {
|
||||||
return &pb.OnCreateStorageBucketRequest{
|
return &pb.OnCreateStorageBucketRequest{
|
||||||
@@ -2241,7 +2440,7 @@ func TestStoragePlugin_HeadObject(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{
|
testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{
|
||||||
ContentLength: 1024,
|
ContentLength: aws.Int64(1024),
|
||||||
LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")),
|
LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -2272,7 +2471,7 @@ func TestStoragePlugin_HeadObject(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{
|
testMockS3WithHeadObjectOutput(&s3.HeadObjectOutput{
|
||||||
ContentLength: 1024,
|
ContentLength: aws.Int64(1024),
|
||||||
LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")),
|
LastModified: aws.Time(createTime(t, "2006-01-02T15:04:05Z")),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -3516,6 +3715,195 @@ func TestStoragePlugin_PutObject(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoragePlugin_PutObjectMultipart(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
|
||||||
|
// genFile writes size bytes of deterministic content to a temp file and
|
||||||
|
// returns its path along with the raw SHA-256 digest of the whole file.
|
||||||
|
genFile := func(t *testing.T, name string, size int) (string, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
data := make([]byte, size)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
fp := path.Join(td, name)
|
||||||
|
require.NoError(t, os.WriteFile(fp, data, 0o600))
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return fp, sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
validRequest := func(p string) *pb.PutObjectRequest {
|
||||||
|
return &pb.PutObjectRequest{
|
||||||
|
Bucket: &storagebuckets.StorageBucket{
|
||||||
|
BucketName: "external-obj-store",
|
||||||
|
BucketPrefix: "prefix",
|
||||||
|
Secrets: credential.MockStaticCredentialSecrets(),
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Key: "mock-object",
|
||||||
|
Path: p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("multipart success spanning multiple parts", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
// 20 MiB with 8 MiB parts -> 3 parts (8, 8, 4 MiB).
|
||||||
|
fp, wholeChecksum := genFile(t, "large-file", 20*1024*1024)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(state)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
|
||||||
|
// Boundary must receive the whole-object SHA-256, not the multipart
|
||||||
|
// composite checksum.
|
||||||
|
assert.True(bytes.Equal(wholeChecksum, resp.GetChecksumSha_256()))
|
||||||
|
|
||||||
|
// The single-shot PutObject path must not have been used.
|
||||||
|
assert.False(state.PutObjectCalled)
|
||||||
|
assert.True(state.CreateMultipartUploadCalled)
|
||||||
|
assert.Equal(3, state.UploadPartCallCount)
|
||||||
|
assert.True(state.CompleteMultipartUploadCalled)
|
||||||
|
assert.False(state.AbortMultipartUploadCalled)
|
||||||
|
|
||||||
|
// The reassembled part bodies must equal the original file content.
|
||||||
|
orig, err := os.ReadFile(fp)
|
||||||
|
require.NoError(err)
|
||||||
|
assert.True(bytes.Equal(orig, state.UploadPartBody))
|
||||||
|
|
||||||
|
// The object key must include the configured bucket prefix.
|
||||||
|
require.NotNil(state.CreateMultipartUploadInputParams)
|
||||||
|
assert.Equal("prefix/mock-object", aws.ToString(state.CreateMultipartUploadInputParams.Key))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file just above threshold uses two parts", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
// 8 MiB + 1 byte -> 2 parts (8 MiB, 1 byte).
|
||||||
|
fp, wholeChecksum := genFile(t, "just-over", 8*1024*1024+1)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(state)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.NoError(err)
|
||||||
|
assert.True(bytes.Equal(wholeChecksum, resp.GetChecksumSha_256()))
|
||||||
|
assert.Equal(2, state.UploadPartCallCount)
|
||||||
|
assert.True(state.CompleteMultipartUploadCalled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file at threshold uses single PutObject", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
// Exactly 8 MiB is not above the threshold, so the single-shot path is used.
|
||||||
|
fp, wholeChecksum := genFile(t, "at-threshold", 8*1024*1024)
|
||||||
|
checksum := base64.StdEncoding.EncodeToString(wholeChecksum)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(
|
||||||
|
state,
|
||||||
|
testMockS3WithPutObjectOutput(&s3.PutObjectOutput{
|
||||||
|
ChecksumSHA256: aws.String(checksum),
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.NoError(err)
|
||||||
|
assert.True(bytes.Equal(wholeChecksum, resp.GetChecksumSha_256()))
|
||||||
|
assert.True(state.PutObjectCalled)
|
||||||
|
assert.False(state.CreateMultipartUploadCalled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create multipart upload error", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
fp, _ := genFile(t, "create-err", 20*1024*1024)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(
|
||||||
|
state,
|
||||||
|
testMockS3WithCreateMultipartUploadError(TestAwsS3Error("AccessDenied", "CreateMultipartUpload", "not authorized")),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.Error(err)
|
||||||
|
assert.Nil(resp)
|
||||||
|
assert.Equal(codes.PermissionDenied, status.Code(err))
|
||||||
|
// Nothing was created, so nothing should be aborted.
|
||||||
|
assert.False(state.AbortMultipartUploadCalled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upload part error aborts upload", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
fp, _ := genFile(t, "upload-err", 20*1024*1024)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(
|
||||||
|
state,
|
||||||
|
testMockS3WithUploadPartErrorOnPart(2, TestAwsS3Error("AccessDenied", "UploadPart", "not authorized")),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.Error(err)
|
||||||
|
assert.Nil(resp)
|
||||||
|
assert.Equal(codes.PermissionDenied, status.Code(err))
|
||||||
|
assert.True(state.CreateMultipartUploadCalled)
|
||||||
|
assert.False(state.CompleteMultipartUploadCalled)
|
||||||
|
// The failed upload must be aborted to avoid orphaned parts.
|
||||||
|
assert.True(state.AbortMultipartUploadCalled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("complete multipart upload error aborts upload", func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
fp, _ := genFile(t, "complete-err", 20*1024*1024)
|
||||||
|
|
||||||
|
state := &testMockS3State{}
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: validSTSMock(),
|
||||||
|
testStorageStateOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(newTestMockS3(
|
||||||
|
state,
|
||||||
|
testMockS3WithCompleteMultipartUploadError(TestAwsS3Error("InternalError", "CompleteMultipartUpload", "boom")),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.PutObject(context.Background(), validRequest(fp))
|
||||||
|
require.Error(err)
|
||||||
|
assert.Nil(resp)
|
||||||
|
assert.Equal(3, state.UploadPartCallCount)
|
||||||
|
assert.True(state.AbortMultipartUploadCalled)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
||||||
validRequest := func() *pb.DeleteObjectsRequest {
|
validRequest := func() *pb.DeleteObjectsRequest {
|
||||||
return &pb.DeleteObjectsRequest{
|
return &pb.DeleteObjectsRequest{
|
||||||
@@ -3685,7 +4073,7 @@ func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
IsTruncated: false,
|
IsTruncated: aws.Bool(false),
|
||||||
Contents: []s3types.Object{
|
Contents: []s3types.Object{
|
||||||
{
|
{
|
||||||
Key: aws.String("abc/abc"),
|
Key: aws.String("abc/abc"),
|
||||||
@@ -3763,7 +4151,7 @@ func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
IsTruncated: false,
|
IsTruncated: aws.Bool(false),
|
||||||
Contents: []s3types.Object{
|
Contents: []s3types.Object{
|
||||||
s3types.Object{
|
s3types.Object{
|
||||||
Key: aws.String("abc/abc"),
|
Key: aws.String("abc/abc"),
|
||||||
@@ -3808,7 +4196,7 @@ func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
IsTruncated: false,
|
IsTruncated: aws.Bool(false),
|
||||||
Contents: []s3types.Object{
|
Contents: []s3types.Object{
|
||||||
{Key: aws.String("abc/abc1")},
|
{Key: aws.String("abc/abc1")},
|
||||||
{Key: aws.String("abc/abc2")},
|
{Key: aws.String("abc/abc2")},
|
||||||
@@ -3836,7 +4224,7 @@ func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
|||||||
newTestMockS3(
|
newTestMockS3(
|
||||||
nil,
|
nil,
|
||||||
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
IsTruncated: false,
|
IsTruncated: aws.Bool(false),
|
||||||
Contents: []s3types.Object{},
|
Contents: []s3types.Object{},
|
||||||
}),
|
}),
|
||||||
// no deleted response since it shouldn't be called
|
// no deleted response since it shouldn't be called
|
||||||
@@ -3917,3 +4305,477 @@ func TestStoragePlugin_DeleteObjects(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestS3ClientCache(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
assert, require := assert.New(t), require.New(t)
|
||||||
|
|
||||||
|
cred1, err := awsutil.NewCredentialsConfig(
|
||||||
|
awsutil.WithRegion("us-east-1"),
|
||||||
|
awsutil.WithAccessKey("cred1"),
|
||||||
|
awsutil.WithSecretKey("secret1"),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
cred2, err := awsutil.NewCredentialsConfig(
|
||||||
|
awsutil.WithRegion("us-east-1"),
|
||||||
|
awsutil.WithAccessKey("cred2"),
|
||||||
|
awsutil.WithSecretKey("secret2"),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
cred3, err := awsutil.NewCredentialsConfig(
|
||||||
|
awsutil.WithRegion("us-east-1"),
|
||||||
|
awsutil.WithAccessKey("cred3"),
|
||||||
|
awsutil.WithSecretKey("secret3"),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
p := New()
|
||||||
|
cred, err := credential.NewAwsCredentialPersistedState(
|
||||||
|
credential.WithCredentialsConfig(cred1),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
state, err := newAwsStoragePersistedState(withCredentials(cred))
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// Cache should be empty before first call
|
||||||
|
assert.Empty(p.clients.cache)
|
||||||
|
|
||||||
|
client, err := p.getClient(ctx, "id", state)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(client)
|
||||||
|
assert.Equal(cred1.AccessKey, client.Credentials().AccessKeyID)
|
||||||
|
assert.Equal(cred1.SecretKey, client.Credentials().SecretAccessKey)
|
||||||
|
|
||||||
|
// Update state with new credentials
|
||||||
|
cred, err = credential.NewAwsCredentialPersistedState(
|
||||||
|
credential.WithCredentialsConfig(cred2),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
state, err = newAwsStoragePersistedState(withCredentials(cred))
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// Client is cached and creds valid so it should still grab initial client
|
||||||
|
client, err = p.getClient(ctx, "id", state)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(client)
|
||||||
|
assert.Equal(cred1.AccessKey, client.Credentials().AccessKeyID)
|
||||||
|
assert.Equal(cred1.SecretKey, client.Credentials().SecretAccessKey)
|
||||||
|
|
||||||
|
// Force credentials to expire
|
||||||
|
c := client.(*s3Client)
|
||||||
|
c.creds.CanExpire = true
|
||||||
|
p.clients.Lock()
|
||||||
|
p.clients.cache["id"] = c
|
||||||
|
p.clients.Unlock()
|
||||||
|
|
||||||
|
// Getting client should now create new client on cred2 since the credentials have expired
|
||||||
|
client, err = p.getClient(ctx, "id", state)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(client)
|
||||||
|
assert.Equal(cred2.AccessKey, client.Credentials().AccessKeyID)
|
||||||
|
assert.Equal(cred2.SecretKey, client.Credentials().SecretAccessKey)
|
||||||
|
|
||||||
|
// Update state with new credentials again
|
||||||
|
cred, err = credential.NewAwsCredentialPersistedState(
|
||||||
|
credential.WithCredentialsConfig(cred3),
|
||||||
|
)
|
||||||
|
require.NoError(err)
|
||||||
|
state, err = newAwsStoragePersistedState(withCredentials(cred))
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// Getting client and force cache refresh should return cred3 based client
|
||||||
|
client, err = p.getClient(ctx, "id", state, WithCacheRefresh(true))
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(client)
|
||||||
|
assert.Equal(cred3.AccessKey, client.Credentials().AccessKeyID)
|
||||||
|
assert.Equal(cred3.SecretKey, client.Credentials().SecretAccessKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoragePlugin_ListObjects(t *testing.T) {
|
||||||
|
validRequest := func() *pb.ListObjectsRequest {
|
||||||
|
return &pb.ListObjectsRequest{
|
||||||
|
KeyPrefix: "foo/bar/",
|
||||||
|
Bucket: &storagebuckets.StorageBucket{
|
||||||
|
BucketName: "foo",
|
||||||
|
Secrets: credential.MockStaticCredentialSecrets(),
|
||||||
|
Attributes: &structpb.Struct{
|
||||||
|
Fields: map[string]*structpb.Value{
|
||||||
|
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
req *pb.ListObjectsRequest
|
||||||
|
credOpts []credential.AwsCredentialPersistedStateOption
|
||||||
|
storageOpts []awsStoragePersistedStateOption
|
||||||
|
ctx context.Context
|
||||||
|
expectedObjects []*pb.Object
|
||||||
|
expectedErrContains string
|
||||||
|
expectedErrCode codes.Code
|
||||||
|
expectedDetails *pb.StorageBucketCredentialState
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil bucket",
|
||||||
|
req: &pb.ListObjectsRequest{KeyPrefix: "foo/bar"},
|
||||||
|
expectedErrContains: "bucket is required",
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty bucket name",
|
||||||
|
req: &pb.ListObjectsRequest{
|
||||||
|
KeyPrefix: "foo/bar",
|
||||||
|
Bucket: &storagebuckets.StorageBucket{},
|
||||||
|
},
|
||||||
|
expectedErrContains: "bucketName is required",
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil attributes",
|
||||||
|
req: &pb.ListObjectsRequest{
|
||||||
|
KeyPrefix: "foo/bar",
|
||||||
|
Bucket: &storagebuckets.StorageBucket{
|
||||||
|
BucketName: "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "attributes is required",
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error reading attributes",
|
||||||
|
req: &pb.ListObjectsRequest{
|
||||||
|
KeyPrefix: "foo/bar",
|
||||||
|
Bucket: &storagebuckets.StorageBucket{
|
||||||
|
BucketName: "foo",
|
||||||
|
Attributes: new(structpb.Struct),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: "missing required value \"region\"",
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credential persisted state setup error",
|
||||||
|
req: validRequest(),
|
||||||
|
credOpts: []credential.AwsCredentialPersistedStateOption{
|
||||||
|
func(s *credential.AwsCredentialPersistedState) error {
|
||||||
|
return errors.New(testOptionErr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: fmt.Sprintf("error setting up persisted state: %s", testOptionErr),
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "storage persisted state setup error",
|
||||||
|
req: validRequest(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
func(s *awsStoragePersistedState) error {
|
||||||
|
return errors.New(testOptionErr)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedErrContains: fmt.Sprintf("error setting up persisted state: %s", testOptionErr),
|
||||||
|
expectedErrCode: codes.InvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "listObjectsV2 access denied error",
|
||||||
|
req: validRequest(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
testMockS3WithListObjectsV2Error(TestAwsS3Error("AccessDenied", "ListObjectsV2", "not authorized")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "aws service s3: invalid credentials: failed to list objects",
|
||||||
|
expectedErrCode: codes.PermissionDenied,
|
||||||
|
expectedDetails: &pb.StorageBucketCredentialState{
|
||||||
|
State: &pb.Permissions{
|
||||||
|
Read: &pb.Permission{
|
||||||
|
State: pb.StateType_STATE_TYPE_ERROR,
|
||||||
|
ErrorDetails: "not authorized",
|
||||||
|
CheckedAt: timestamppb.Now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "listObjectsV2 throttling error",
|
||||||
|
req: validRequest(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
testMockS3WithListObjectsV2Error(TestAwsS3Error("ThrottlingException", "ListObjectsV2", "throttling exception")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "aws service s3: throttling: failed to list objects",
|
||||||
|
expectedErrCode: codes.Unavailable,
|
||||||
|
expectedDetails: &pb.StorageBucketCredentialState{
|
||||||
|
State: &pb.Permissions{
|
||||||
|
Read: &pb.Permission{
|
||||||
|
State: pb.StateType_STATE_TYPE_UNKNOWN,
|
||||||
|
CheckedAt: timestamppb.Now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context canceled - non recursive",
|
||||||
|
req: validRequest(),
|
||||||
|
ctx: func() context.Context {
|
||||||
|
c, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
// Return an error that simulates context cancellation being detected at the AWS level
|
||||||
|
testMockS3WithListObjectsV2Error(context.Canceled),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "request canceled",
|
||||||
|
expectedErrCode: codes.Canceled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context deadline exceeded - non recursive",
|
||||||
|
req: validRequest(),
|
||||||
|
ctx: func() context.Context {
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
time.Sleep(2 * time.Nanosecond) // Ensure deadline is exceeded
|
||||||
|
cancel()
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
// Return an error that simulates deadline exceeded being detected at the AWS level
|
||||||
|
testMockS3WithListObjectsV2Error(context.DeadlineExceeded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "request deadline exceeded",
|
||||||
|
expectedErrCode: codes.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context canceled - recursive",
|
||||||
|
req: func() *pb.ListObjectsRequest {
|
||||||
|
r := validRequest()
|
||||||
|
r.Recursive = true
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
ctx: func() context.Context {
|
||||||
|
c, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
// Return an error that simulates context cancellation being detected at the AWS level
|
||||||
|
testMockS3WithListObjectsV2Error(context.Canceled),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "request canceled",
|
||||||
|
expectedErrCode: codes.Canceled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context deadline exceeded - recursive",
|
||||||
|
req: func() *pb.ListObjectsRequest {
|
||||||
|
r := validRequest()
|
||||||
|
r.Recursive = true
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
ctx: func() context.Context {
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||||
|
// Ensure the deadline is exceeded
|
||||||
|
time.Sleep(2 * time.Nanosecond)
|
||||||
|
cancel()
|
||||||
|
return c
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
// Return an error that simulates deadline exceeded being detected at the AWS level
|
||||||
|
testMockS3WithListObjectsV2Error(context.DeadlineExceeded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedErrContains: "request deadline exceeded",
|
||||||
|
expectedErrCode: codes.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success - single page top-level",
|
||||||
|
req: validRequest(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
|
CommonPrefixes: []s3types.CommonPrefix{
|
||||||
|
{Prefix: aws.String("foo/bar/key/")},
|
||||||
|
},
|
||||||
|
Contents: []s3types.Object{
|
||||||
|
{Key: aws.String("foo/bar/file1.bsr")},
|
||||||
|
{Key: aws.String("foo/bar/file2.bsr")},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedObjects: []*pb.Object{
|
||||||
|
{Key: "foo/bar/key/", IsDir: true},
|
||||||
|
{Key: "foo/bar/file1.bsr", IsDir: false},
|
||||||
|
{Key: "foo/bar/file2.bsr", IsDir: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success - recursive listing with nested dirs",
|
||||||
|
req: func() *pb.ListObjectsRequest {
|
||||||
|
r := validRequest()
|
||||||
|
r.Recursive = true
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{
|
||||||
|
Contents: []s3types.Object{
|
||||||
|
{Key: aws.String("foo/bar/file1.bsr")},
|
||||||
|
{Key: aws.String("foo/bar/nested/file2.bsr")},
|
||||||
|
{Key: aws.String("foo/bar/nested/deeper/file3.bsr")},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedObjects: []*pb.Object{
|
||||||
|
{Key: "foo/bar/file1.bsr", IsDir: false},
|
||||||
|
{Key: "foo/bar/nested/", IsDir: true},
|
||||||
|
{Key: "foo/bar/nested/file2.bsr", IsDir: false},
|
||||||
|
{Key: "foo/bar/nested/deeper/", IsDir: true},
|
||||||
|
{Key: "foo/bar/nested/deeper/file3.bsr", IsDir: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success - recursive multi-page",
|
||||||
|
req: func() *pb.ListObjectsRequest {
|
||||||
|
r := validRequest()
|
||||||
|
r.Recursive = true
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(
|
||||||
|
nil,
|
||||||
|
testMockS3WithListObjectsV2OutputFunc(func(input *s3.ListObjectsV2Input) *s3.ListObjectsV2Output {
|
||||||
|
if input.ContinuationToken == nil {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []s3types.Object{
|
||||||
|
{Key: aws.String("foo/bar/page1/file1.bsr")},
|
||||||
|
},
|
||||||
|
NextContinuationToken: aws.String("token123"),
|
||||||
|
IsTruncated: aws.Bool(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aws.ToString(input.ContinuationToken) == "token123" {
|
||||||
|
return &s3.ListObjectsV2Output{
|
||||||
|
Contents: []s3types.Object{
|
||||||
|
{Key: aws.String("foo/bar/page2/file2.bsr")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &s3.ListObjectsV2Output{}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedObjects: []*pb.Object{
|
||||||
|
{Key: "foo/bar/page1/", IsDir: true},
|
||||||
|
{Key: "foo/bar/page1/file1.bsr", IsDir: false},
|
||||||
|
{Key: "foo/bar/page2/", IsDir: true},
|
||||||
|
{Key: "foo/bar/page2/file2.bsr", IsDir: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "success - recursive empty",
|
||||||
|
req: func() *pb.ListObjectsRequest {
|
||||||
|
r := validRequest()
|
||||||
|
r.Recursive = true
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
credOpts: validSTSMock(),
|
||||||
|
storageOpts: []awsStoragePersistedStateOption{
|
||||||
|
withTestS3APIFunc(
|
||||||
|
newTestMockS3(nil, testMockS3WithListObjectsV2Output(&s3.ListObjectsV2Output{})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expectedObjects: []*pb.Object(nil),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
require, assert := require.New(t), assert.New(t)
|
||||||
|
p := &StoragePlugin{
|
||||||
|
testCredStateOpts: tc.credOpts,
|
||||||
|
testStorageStateOpts: tc.storageOpts,
|
||||||
|
}
|
||||||
|
ctx := tc.ctx
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
resp, err := p.ListObjects(ctx, tc.req)
|
||||||
|
|
||||||
|
if tc.expectedErrContains != "" {
|
||||||
|
require.Error(err)
|
||||||
|
assert.Contains(err.Error(), tc.expectedErrContains)
|
||||||
|
assert.Equal(tc.expectedErrCode, status.Code(err))
|
||||||
|
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
if tc.expectedDetails != nil {
|
||||||
|
var state *pb.StorageBucketCredentialState
|
||||||
|
for _, detail := range st.Details() {
|
||||||
|
if errDetail, ok := detail.(*pb.StorageBucketCredentialState); ok {
|
||||||
|
state = errDetail
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(state)
|
||||||
|
internal.CheckSimilarPermission(assert, tc.expectedDetails.State.Read, state.State.Read, false)
|
||||||
|
internal.CheckSimilarPermission(assert, tc.expectedDetails.State.Write, state.State.Write, false)
|
||||||
|
internal.CheckSimilarPermission(assert, tc.expectedDetails.State.Delete, state.State.Delete, false)
|
||||||
|
} else {
|
||||||
|
assert.Len(st.Details(), 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
require.ElementsMatch(resp.Objects, tc.expectedObjects)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"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"
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
smithyEndpoints "github.com/aws/smithy-go/endpoints"
|
smithyEndpoints "github.com/aws/smithy-go/endpoints"
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
"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)
|
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)
|
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)
|
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)
|
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)
|
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
|
||||||
|
Credentials() aws.Credentials
|
||||||
}
|
}
|
||||||
|
|
||||||
var customClient = &http.Client{
|
type s3Client struct {
|
||||||
Transport: &http.Transport{
|
*s3.Client
|
||||||
Proxy: http.ProxyFromEnvironment,
|
creds aws.Credentials
|
||||||
DialContext: (&net.Dialer{
|
}
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
KeepAlive: 30 * time.Second,
|
func (c *s3Client) Credentials() aws.Credentials {
|
||||||
DualStack: true,
|
return c.creds
|
||||||
}).DialContext,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type awsStoragePersistedState struct {
|
type awsStoragePersistedState struct {
|
||||||
@@ -91,9 +95,9 @@ func (s *awsStoragePersistedState) toProto() (*storagebuckets.StorageBucketPersi
|
|||||||
return &storagebuckets.StorageBucketPersisted{Data: data}, nil
|
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.
|
// 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...)
|
opts, err := getOpts(opt...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err)
|
return nil, fmt.Errorf("error parsing options when fetching S3 client: %w", err)
|
||||||
@@ -106,15 +110,28 @@ func (s *awsStoragePersistedState) S3Client(ctx context.Context, opt ...s3Option
|
|||||||
return nil, fmt.Errorf("nil aws configuration")
|
return nil, fmt.Errorf("nil aws configuration")
|
||||||
}
|
}
|
||||||
var s3Opts []func(*s3.Options)
|
var s3Opts []func(*s3.Options)
|
||||||
if opts.withEndpoint != "" {
|
if opts.withEndpoint != "" || opts.withDualStack {
|
||||||
s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{
|
s3Opts = append(s3Opts, s3.WithEndpointResolverV2(&endpointResolver{
|
||||||
endpoint: opts.withEndpoint,
|
endpoint: opts.withEndpoint,
|
||||||
|
dualStack: opts.withDualStack,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if s.testS3APIFunc != nil {
|
if s.testS3APIFunc != nil {
|
||||||
return s.testS3APIFunc(*awsCfg)
|
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
|
// getOpts iterates the inbound s3Options and returns a struct
|
||||||
@@ -136,13 +153,23 @@ type s3Option func(*s3Options) error
|
|||||||
|
|
||||||
// options = how options are represented
|
// options = how options are represented
|
||||||
type s3Options struct {
|
type s3Options struct {
|
||||||
withEndpoint string
|
withEndpoint string
|
||||||
|
withDualStack bool
|
||||||
|
withCacheRefresh bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultS3Options() s3Options {
|
func getDefaultS3Options() s3Options {
|
||||||
return 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
|
// WithEndpoint contains the endpoint to use
|
||||||
func WithEndpoint(with string) s3Option {
|
func WithEndpoint(with string) s3Option {
|
||||||
return func(o *s3Options) error {
|
return func(o *s3Options) error {
|
||||||
@@ -151,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 {
|
type endpointResolver struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
|
dualStack bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
|
func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (resolver smithyEndpoints.Endpoint, err error) {
|
||||||
var uri *url.URL
|
var uri *url.URL
|
||||||
|
params.UseDualStack = aws.Bool(e.dualStack)
|
||||||
resolver, err = s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
|
resolver, err = s3.NewDefaultEndpointResolverV2().ResolveEndpoint(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e.endpoint == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
uri, err = url.Parse(e.endpoint)
|
uri, err = url.Parse(e.endpoint)
|
||||||
@@ -170,7 +206,6 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if uri == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
resolver = smithyEndpoints.Endpoint{
|
resolver = smithyEndpoints.Endpoint{
|
||||||
@@ -178,3 +213,30 @@ func (e *endpointResolver) ResolveEndpoint(ctx context.Context, params s3.Endpoi
|
|||||||
}
|
}
|
||||||
return
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -64,7 +64,7 @@ func TestAwsStoragePersistedStateS3Client(t *testing.T) {
|
|||||||
)
|
)
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
|
|
||||||
clientRaw, err := state.S3Client(context.Background(), tc.s3Opts...)
|
clientRaw, err := state.s3Client(context.Background(), tc.s3Opts...)
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
require.NotNil(clientRaw)
|
require.NotNil(clientRaw)
|
||||||
client, ok := clientRaw.(*testMockS3)
|
client, ok := clientRaw.(*testMockS3)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package storage
|
package storage
|
||||||
@@ -6,6 +6,8 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -39,6 +41,23 @@ type testMockS3State struct {
|
|||||||
PutObjectInputParams *s3.PutObjectInput
|
PutObjectInputParams *s3.PutObjectInput
|
||||||
PutObjectBody []byte
|
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
|
HeadObjectCalled bool
|
||||||
HeadObjectInputParams *s3.HeadObjectInput
|
HeadObjectInputParams *s3.HeadObjectInput
|
||||||
|
|
||||||
@@ -57,6 +76,16 @@ func (s *testMockS3State) Reset() {
|
|||||||
s.PutObjectCalled = false
|
s.PutObjectCalled = false
|
||||||
s.PutObjectInputParams = nil
|
s.PutObjectInputParams = nil
|
||||||
s.PutObjectBody = 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.HeadObjectCalled = false
|
||||||
s.HeadObjectInputParams = nil
|
s.HeadObjectInputParams = nil
|
||||||
s.ListObjectsV2Called = false
|
s.ListObjectsV2Called = false
|
||||||
@@ -81,6 +110,19 @@ type testMockS3 struct {
|
|||||||
PutObjectOutput *s3.PutObjectOutput
|
PutObjectOutput *s3.PutObjectOutput
|
||||||
PutObjectErr error
|
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
|
// mocked responses for headObject
|
||||||
HeadObjectOutput *s3.HeadObjectOutput
|
HeadObjectOutput *s3.HeadObjectOutput
|
||||||
HeadObjectErr error
|
HeadObjectErr error
|
||||||
@@ -114,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 {
|
func testMockS3WithGetObjectOutput(o *s3.GetObjectOutput) testMockS3Option {
|
||||||
return func(m *testMockS3) error {
|
return func(m *testMockS3) error {
|
||||||
m.GetObjectOutput = o
|
m.GetObjectOutput = o
|
||||||
@@ -255,6 +354,95 @@ func (m *testMockS3) PutObject(ctx context.Context, params *s3.PutObjectInput, o
|
|||||||
return m.PutObjectOutput, nil
|
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) {
|
func (m *testMockS3) ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
||||||
if m.State != nil {
|
if m.State != nil {
|
||||||
m.State.ListObjectsV2Called = true
|
m.State.ListObjectsV2Called = true
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// Copyright IBM Corp. 2021, 2026
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package testing
|
||||||
|
|
||||||
|
// This file contains table-driven test infrastructure for AssumeRole and
|
||||||
|
// cross-role (two-hop) Dynamic Host Catalog scenarios. Each plugin operation
|
||||||
|
// has a corresponding case type and runner function. The orchestrator in
|
||||||
|
// e2e_host_test.go populates the case tables using the Terraform outputs.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
|
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
||||||
|
"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/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Attr builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// roleArnAttrs builds catalog attributes for a single-hop AssumeRole catalog.
|
||||||
|
func roleArnAttrs(region, roleArn string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: true,
|
||||||
|
credential.ConstRoleArn: roleArn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crossRoleAttrs builds catalog attributes for a two-hop cross-role catalog.
|
||||||
|
func crossRoleAttrs(region, principalRoleArn, targetRoleArn string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: true,
|
||||||
|
credential.ConstRoleArn: principalRoleArn,
|
||||||
|
credential.ConstTargetRoleArn: targetRoleArn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagFilterSetAttrs builds set attributes with a single tag-key filter.
|
||||||
|
func tagFilterSetAttrs(tags []string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireTagInstancesMatchProvisioned asserts that every instance ID in tagInstancesMap
|
||||||
|
// is present in provisionedIds (the raw []any slice from a Terraform output).
|
||||||
|
func requireTagInstancesMatchProvisioned(t *testing.T, provisionedIds []any, tagInstancesMap map[string][]string) {
|
||||||
|
t.Helper()
|
||||||
|
idSet := make(map[string]struct{}, len(provisionedIds))
|
||||||
|
for _, id := range provisionedIds {
|
||||||
|
idSet[id.(string)] = struct{}{}
|
||||||
|
}
|
||||||
|
for tag, ids := range tagInstancesMap {
|
||||||
|
for _, id := range ids {
|
||||||
|
require.Contains(t, idSet, id, "instance ID %s in tag bucket %q was not provisioned by Terraform", id, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildExpectedTagInstancesMap builds a map of tag key to instance IDs from a Terraform instance_tags output map.
|
||||||
|
func buildExpectedTagInstancesMap(rawTags map[string]any, expectedTags []string) map[string][]string {
|
||||||
|
tagMap := make(map[string][]string)
|
||||||
|
for instanceId, instanceTags := range rawTags {
|
||||||
|
for tagKey := range instanceTags.(map[string]any) {
|
||||||
|
for _, expectedTag := range expectedTags {
|
||||||
|
if tagKey == expectedTag {
|
||||||
|
tagMap[tagKey] = append(tagMap[tagKey], instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tagMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Table-driven runners
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type onCreateCatalogCase struct {
|
||||||
|
name string
|
||||||
|
catalogAttrs map[string]any
|
||||||
|
wantErr string // empty = expect success with empty persisted secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnCreateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onCreateCatalogCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
attrs, err := structpb.NewStruct(tc.catalogAttrs)
|
||||||
|
require.NoError(err)
|
||||||
|
resp, err := p.OnCreateCatalog(ctx, &pb.OnCreateCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: attrs},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(err)
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
require.Contains(st.Message(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
// Dynamic credentials are never stored — persisted secrets should be empty.
|
||||||
|
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type onUpdateCatalogCase struct {
|
||||||
|
name string
|
||||||
|
catalogAttrs map[string]any // used for both current and new when currentAttrs/newAttrs are nil
|
||||||
|
currentAttrs map[string]any // optional: override current catalog attrs
|
||||||
|
newAttrs map[string]any // optional: override new catalog attrs
|
||||||
|
wantErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnUpdateCatalogCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onUpdateCatalogCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
currentRaw := tc.catalogAttrs
|
||||||
|
if tc.currentAttrs != nil {
|
||||||
|
currentRaw = tc.currentAttrs
|
||||||
|
}
|
||||||
|
newRaw := tc.catalogAttrs
|
||||||
|
if tc.newAttrs != nil {
|
||||||
|
newRaw = tc.newAttrs
|
||||||
|
}
|
||||||
|
currentAttrs, err := structpb.NewStruct(currentRaw)
|
||||||
|
require.NoError(err)
|
||||||
|
newAttrs, err := structpb.NewStruct(newRaw)
|
||||||
|
require.NoError(err)
|
||||||
|
resp, err := p.OnUpdateCatalog(ctx, &pb.OnUpdateCatalogRequest{
|
||||||
|
CurrentCatalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: currentAttrs},
|
||||||
|
},
|
||||||
|
NewCatalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: newAttrs},
|
||||||
|
},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{},
|
||||||
|
})
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(err)
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
require.Contains(st.Message(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
// Dynamic credentials are never stored — persisted secrets should be empty.
|
||||||
|
require.Empty(resp.GetPersisted().GetSecrets().GetFields())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type onSetCase struct {
|
||||||
|
name string
|
||||||
|
catalogAttrs map[string]any
|
||||||
|
tags []string // nil = no filter (empty set attrs)
|
||||||
|
wantErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnCreateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
|
||||||
|
require.NoError(err)
|
||||||
|
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
|
||||||
|
if len(tc.tags) > 0 {
|
||||||
|
setRaw = tagFilterSetAttrs(tc.tags)
|
||||||
|
}
|
||||||
|
setAttrs, err := structpb.NewStruct(setRaw)
|
||||||
|
require.NoError(err)
|
||||||
|
resp, err := p.OnCreateSet(ctx, &pb.OnCreateSetRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
|
||||||
|
Set: &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{},
|
||||||
|
})
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(err)
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
require.Contains(st.Message(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnUpdateSetCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []onSetCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
|
||||||
|
require.NoError(err)
|
||||||
|
setRaw := map[string]any{host.ConstDescribeInstancesFilters: []any{}}
|
||||||
|
if len(tc.tags) > 0 {
|
||||||
|
setRaw = tagFilterSetAttrs(tc.tags)
|
||||||
|
}
|
||||||
|
setAttrs, err := structpb.NewStruct(setRaw)
|
||||||
|
require.NoError(err)
|
||||||
|
set := &hostsets.HostSet{Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}}
|
||||||
|
resp, err := p.OnUpdateSet(ctx, &pb.OnUpdateSetRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
|
||||||
|
CurrentSet: set,
|
||||||
|
NewSet: set,
|
||||||
|
Persisted: &pb.HostCatalogPersisted{},
|
||||||
|
})
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(err)
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
require.Contains(st.Message(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listHostsCase struct {
|
||||||
|
name string
|
||||||
|
catalogAttrs map[string]any
|
||||||
|
tags []string
|
||||||
|
expected map[string][]string // nil = don't assert instances (error cases)
|
||||||
|
wantErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListHostsCases(ctx context.Context, t *testing.T, p *host.HostPlugin, cases []listHostsCase) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require := require.New(t)
|
||||||
|
catalogAttrs, err := structpb.NewStruct(tc.catalogAttrs)
|
||||||
|
require.NoError(err)
|
||||||
|
sets := make([]*hostsets.HostSet, len(tc.tags))
|
||||||
|
for i, tag := range tc.tags {
|
||||||
|
setAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
sets[i] = &hostsets.HostSet{
|
||||||
|
Id: fmt.Sprintf("hostset-%d", i),
|
||||||
|
Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := p.ListHosts(ctx, &pb.ListHostsRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{Attrs: &hostcatalogs.HostCatalog_Attributes{Attributes: catalogAttrs}},
|
||||||
|
Sets: sets,
|
||||||
|
Persisted: &pb.HostCatalogPersisted{},
|
||||||
|
})
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(err)
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
require.True(ok)
|
||||||
|
require.Contains(st.Message(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(resp)
|
||||||
|
expectedInstances := make(map[string][]string)
|
||||||
|
for i, tag := range tc.tags {
|
||||||
|
for _, instanceId := range tc.expected[tag] {
|
||||||
|
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actualInstances := make(map[string][]string)
|
||||||
|
for _, h := range resp.GetHosts() {
|
||||||
|
actualInstances[h.ExternalId] = h.SetIds
|
||||||
|
}
|
||||||
|
require.Equal(expectedInstances, actualInstances)
|
||||||
|
t.Logf("success: %d hosts matched", len(actualInstances))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Copyright IBM Corp. 2021, 2026
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package testing
|
||||||
|
|
||||||
|
// This file contains helper functions for the static-credential (access key /
|
||||||
|
// secret access key) test paths exercised by TestHostPlugin in e2e_host_test.go.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||||
|
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
||||||
|
"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/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate)
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
reqAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: !rotate,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstAccessKeyId: accessKeyId,
|
||||||
|
credential.ConstSecretAccessKey: secretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
request := &pb.OnCreateCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: reqAttrs,
|
||||||
|
},
|
||||||
|
Secrets: reqSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, err := p.OnCreateCatalog(ctx, request)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(response)
|
||||||
|
persisted := response.GetPersisted()
|
||||||
|
require.NotNil(persisted)
|
||||||
|
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginOnUpdateCatalog(
|
||||||
|
ctx context.Context, t *testing.T, p *host.HostPlugin,
|
||||||
|
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
|
||||||
|
rotated, rotate bool,
|
||||||
|
) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
// Take a timestamp of the current time to get a point in time to
|
||||||
|
// reference, ensuring that we are updating credential rotation
|
||||||
|
// timestamps.
|
||||||
|
currentCredsLastRotatedTime := time.Now()
|
||||||
|
|
||||||
|
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: !rotated,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqNewAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: !rotate,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
var reqSecrets *structpb.Struct
|
||||||
|
if newAccessKeyId != "" && newSecretAccessKey != "" {
|
||||||
|
reqSecrets, err = structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstAccessKeyId: newAccessKeyId,
|
||||||
|
credential.ConstSecretAccessKey: newSecretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
}
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstAccessKeyId: currentAccessKeyId,
|
||||||
|
credential.ConstSecretAccessKey: currentSecretAccessKey,
|
||||||
|
credential.ConstCredsLastRotatedTime: func() string {
|
||||||
|
if rotated {
|
||||||
|
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (time.Time{}).Format(time.RFC3339Nano)
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(reqPersistedSecrets)
|
||||||
|
request := &pb.OnUpdateCatalogRequest{
|
||||||
|
CurrentCatalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: reqCurrentAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NewCatalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: reqNewAttrs,
|
||||||
|
},
|
||||||
|
Secrets: reqSecrets,
|
||||||
|
},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: reqPersistedSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, err := p.OnUpdateCatalog(ctx, request)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(response)
|
||||||
|
persisted := response.GetPersisted()
|
||||||
|
require.NotNil(persisted)
|
||||||
|
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated)
|
||||||
|
require := require.New(t)
|
||||||
|
|
||||||
|
reqAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: !rotated,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstAccessKeyId: accessKeyId,
|
||||||
|
credential.ConstSecretAccessKey: secretAccessKey,
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstAccessKeyId: accessKeyId,
|
||||||
|
credential.ConstSecretAccessKey: secretAccessKey,
|
||||||
|
credential.ConstCredsLastRotatedTime: func() string {
|
||||||
|
if rotated {
|
||||||
|
return time.Now().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (time.Time{}).Format(time.RFC3339Nano)
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
request := &pb.OnDeleteCatalogRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: reqAttrs,
|
||||||
|
},
|
||||||
|
Secrets: reqSecrets,
|
||||||
|
},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: reqPersistedSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, err := p.OnDeleteCatalog(ctx, request)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(response)
|
||||||
|
|
||||||
|
// We want to test the validity of the credentials post-deletion.
|
||||||
|
if rotated {
|
||||||
|
// The credentials should no longer be valid.
|
||||||
|
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
|
||||||
|
} else {
|
||||||
|
// The credentials should still be valid. Sleep 10s first just to
|
||||||
|
// be sure, since we're not rotating.
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
|
requireCredentialsValid(t, accessKeyId, secretAccessKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags)
|
||||||
|
require := require.New(t)
|
||||||
|
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
setAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
createRequest := &pb.OnCreateSetRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: catalogAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Set: &hostsets.HostSet{
|
||||||
|
Attrs: &hostsets.HostSet_Attributes{
|
||||||
|
Attributes: setAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: reqPersistedSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
createResponse, err := p.OnCreateSet(ctx, createRequest)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(createResponse)
|
||||||
|
|
||||||
|
// Do an update test in the same function, as it's pretty much the
|
||||||
|
// same function right now.
|
||||||
|
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
|
||||||
|
updateRequest := &pb.OnUpdateSetRequest{
|
||||||
|
Catalog: &hostcatalogs.HostCatalog{
|
||||||
|
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||||
|
Attributes: catalogAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentSet: &hostsets.HostSet{
|
||||||
|
Attrs: &hostsets.HostSet_Attributes{
|
||||||
|
Attributes: setAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NewSet: &hostsets.HostSet{
|
||||||
|
Attrs: &hostsets.HostSet_Attributes{
|
||||||
|
Attributes: setAttrs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: reqPersistedSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(updateResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags)
|
||||||
|
require := require.New(t)
|
||||||
|
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
credential.ConstRegion: region,
|
||||||
|
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
sets := make([]*hostsets.HostSet, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
setAttrs, err := structpb.NewStruct(map[string]any{
|
||||||
|
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
|
||||||
|
})
|
||||||
|
require.NoError(err)
|
||||||
|
sets[i] = &hostsets.HostSet{
|
||||||
|
Id: fmt.Sprintf("hostset-%d", i),
|
||||||
|
Attrs: &hostsets.HostSet_Attributes{
|
||||||
|
Attributes: setAttrs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: sets,
|
||||||
|
Persisted: &pb.HostCatalogPersisted{
|
||||||
|
Secrets: reqPersistedSecrets,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, err := p.ListHosts(ctx, request)
|
||||||
|
require.NoError(err)
|
||||||
|
require.NotNil(response)
|
||||||
|
|
||||||
|
// Validate the returned instances by ID. Assemble the instance
|
||||||
|
// details from the expected set.
|
||||||
|
expectedInstances := make(map[string][]string)
|
||||||
|
for i, tag := range tags {
|
||||||
|
for _, instanceId := range expected[tag] {
|
||||||
|
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the returned hosts by ID and create the same kind of map.
|
||||||
|
actualInstances := make(map[string][]string)
|
||||||
|
for _, host := range response.GetHosts() {
|
||||||
|
actualInstances[host.ExternalId] = host.SetIds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare
|
||||||
|
require.Equal(expectedInstances, actualInstances)
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
-296
@@ -1,29 +1,30 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|
||||||
|
// This file contains the TestHostPlugin orchestrator. It deploys the Terraform
|
||||||
|
// workspace, collects outputs, and calls the per-credential-type helper
|
||||||
|
// functions defined in e2e_host_static_test.go and e2e_host_assumerole_test.go.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
|
||||||
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
"github.com/hashicorp/boundary-plugin-aws/plugin/service/host"
|
||||||
"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/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envTargetAccessKeyId = "TARGET_AWS_ACCESS_KEY_ID"
|
||||||
|
envTargetSecretAccessKey = "TARGET_AWS_SECRET_ACCESS_KEY"
|
||||||
|
envTargetSessionToken = "TARGET_AWS_SESSION_TOKEN"
|
||||||
|
envTargetRegion = "TARGET_AWS_REGION"
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedEc2InstanceCount = 5
|
const expectedEc2InstanceCount = 5
|
||||||
|
|
||||||
var expectedTags = []string{"foo", "bar", "baz"}
|
|
||||||
|
|
||||||
func TestHostPlugin(t *testing.T) {
|
func TestHostPlugin(t *testing.T) {
|
||||||
region := os.Getenv("AWS_REGION")
|
region := os.Getenv("AWS_REGION")
|
||||||
if region == "" {
|
if region == "" {
|
||||||
@@ -37,7 +38,29 @@ func TestHostPlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require := require.New(t)
|
require := require.New(t)
|
||||||
tf, err := NewTestTerraformer("testdata/host")
|
|
||||||
|
// Collect cross-account credentials up front so they can be passed as
|
||||||
|
// Terraform variables. The cross-account section is skipped (not failed)
|
||||||
|
// when these are absent, but we need the values available before Deploy.
|
||||||
|
targetAccessKeyId := os.Getenv(envTargetAccessKeyId)
|
||||||
|
targetSecretAccessKey := os.Getenv(envTargetSecretAccessKey)
|
||||||
|
targetSessionToken := os.Getenv(envTargetSessionToken)
|
||||||
|
targetRegion := os.Getenv(envTargetRegion)
|
||||||
|
if targetRegion == "" {
|
||||||
|
targetRegion = region
|
||||||
|
}
|
||||||
|
|
||||||
|
tfVars := map[string]string{}
|
||||||
|
if targetAccessKeyId != "" && targetSecretAccessKey != "" {
|
||||||
|
tfVars["target_access_key_id"] = targetAccessKeyId
|
||||||
|
tfVars["target_secret_access_key"] = targetSecretAccessKey
|
||||||
|
tfVars["target_region"] = targetRegion
|
||||||
|
if targetSessionToken != "" {
|
||||||
|
tfVars["target_session_token"] = targetSessionToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tf, err := NewTestTerraformer("testdata/host", tfVars)
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
require.NotNil(tf)
|
require.NotNil(tf)
|
||||||
|
|
||||||
@@ -80,6 +103,14 @@ func TestHostPlugin(t *testing.T) {
|
|||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
require.Len(ec2InstanceTags, expectedEc2InstanceCount)
|
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
|
// 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
|
// 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
|
// the access key ID/secret access key will no longer be valid. We
|
||||||
@@ -131,25 +162,18 @@ func TestHostPlugin(t *testing.T) {
|
|||||||
// Reassign the keyid and secret first.
|
// Reassign the keyid and secret first.
|
||||||
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
|
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
|
||||||
// Process the collection of instances and index by expected tag names.
|
// Process the collection of instances and index by expected tag names.
|
||||||
expectedTagInstancesMap := make(map[string][]string)
|
expectedTagInstancesMap := buildExpectedTagInstancesMap(ec2InstanceTags, expectedTags)
|
||||||
for instanceId, instanceTags := range ec2InstanceTags {
|
|
||||||
for tagKey := range instanceTags.(map[string]any) {
|
requireTagInstancesMatchProvisioned(t, ec2InstanceIds, expectedTagInstancesMap)
|
||||||
for _, expectedTag := range expectedTags {
|
|
||||||
if tagKey == expectedTag {
|
|
||||||
expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := [][]string{
|
cases := [][]string{
|
||||||
{"foo"},
|
{expectedTags[0]},
|
||||||
{"bar"},
|
{expectedTags[1]},
|
||||||
{"baz"},
|
{expectedTags[2]},
|
||||||
{"foo", "bar"},
|
{expectedTags[0], expectedTags[1]},
|
||||||
{"foo", "baz"},
|
{expectedTags[0], expectedTags[2]},
|
||||||
{"bar", "baz"},
|
{expectedTags[1], expectedTags[2]},
|
||||||
{"foo", "bar", "baz"},
|
{expectedTags[0], expectedTags[1], expectedTags[2]},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -159,288 +183,165 @@ func TestHostPlugin(t *testing.T) {
|
|||||||
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
|
testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap)
|
||||||
// TODO: add OnDeleteSet if it needs to be implemented
|
// TODO: add OnDeleteSet if it needs to be implemented
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) {
|
// finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses
|
||||||
t.Helper()
|
testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret)
|
||||||
t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate)
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
// ********************
|
||||||
credential.ConstRegion: region,
|
// * AssumeRole - Happy Path & Error Cases
|
||||||
credential.ConstDisableCredentialRotation: !rotate,
|
// ********************
|
||||||
|
assumeRoleArn, err := tf.GetOutputString("assume_role_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
noTrustRoleArn, err := tf.GetOutputString("assume_role_no_trust_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
missingPermRoleArn, err := tf.GetOutputString("assume_role_no_ec2_permission_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
|
||||||
|
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
|
||||||
|
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
|
||||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn)},
|
||||||
credential.ConstAccessKeyId: accessKeyId,
|
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
|
||||||
credential.ConstSecretAccessKey: secretAccessKey,
|
{name: "assume role missing ec2 permission", currentAttrs: roleArnAttrs(region, assumeRoleArn), newAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnCreateSetCases(ctx, t, p, []onSetCase{
|
||||||
request := &pb.OnCreateCatalogRequest{
|
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
|
||||||
Catalog: &hostcatalogs.HostCatalog{
|
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
|
||||||
Attributes: reqAttrs,
|
|
||||||
},
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.OnCreateCatalog(ctx, request)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(response)
|
|
||||||
persisted := response.GetPersisted()
|
|
||||||
require.NotNil(persisted)
|
|
||||||
return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnUpdateCatalog(
|
|
||||||
ctx context.Context, t *testing.T, p *host.HostPlugin,
|
|
||||||
region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string,
|
|
||||||
rotated, rotate bool,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", rotated, rotate)
|
|
||||||
require := require.New(t)
|
|
||||||
|
|
||||||
// Take a timestamp of the current time to get a point in time to
|
|
||||||
// reference, ensuring that we are updating credential rotation
|
|
||||||
// timestamps.
|
|
||||||
currentCredsLastRotatedTime := time.Now()
|
|
||||||
|
|
||||||
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstRegion: region,
|
|
||||||
credential.ConstDisableCredentialRotation: !rotated,
|
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnUpdateSetCases(ctx, t, p, []onSetCase{
|
||||||
reqNewAttrs, err := structpb.NewStruct(map[string]any{
|
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0]},
|
||||||
credential.ConstRegion: region,
|
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), wantErr: "AccessDenied"},
|
||||||
credential.ConstDisableCredentialRotation: !rotate,
|
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), wantErr: "UnauthorizedOperation"},
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testListHostsCases(ctx, t, p, []listHostsCase{
|
||||||
var reqSecrets *structpb.Struct
|
{name: "assume role happy path", catalogAttrs: roleArnAttrs(region, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
|
||||||
if newAccessKeyId != "" && newSecretAccessKey != "" {
|
{name: "assume role no trust", catalogAttrs: roleArnAttrs(region, noTrustRoleArn), tags: cases[0], wantErr: "AccessDenied"},
|
||||||
reqSecrets, err = structpb.NewStruct(map[string]any{
|
{name: "assume role missing ec2 permission", catalogAttrs: roleArnAttrs(region, missingPermRoleArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
|
||||||
credential.ConstAccessKeyId: newAccessKeyId,
|
|
||||||
credential.ConstSecretAccessKey: newSecretAccessKey,
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
}
|
|
||||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstAccessKeyId: currentAccessKeyId,
|
|
||||||
credential.ConstSecretAccessKey: currentSecretAccessKey,
|
|
||||||
credential.ConstCredsLastRotatedTime: func() string {
|
|
||||||
if rotated {
|
|
||||||
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (time.Time{}).Format(time.RFC3339Nano)
|
|
||||||
}(),
|
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(reqPersistedSecrets)
|
|
||||||
request := &pb.OnUpdateCatalogRequest{
|
|
||||||
CurrentCatalog: &hostcatalogs.HostCatalog{
|
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
|
||||||
Attributes: reqCurrentAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NewCatalog: &hostcatalogs.HostCatalog{
|
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
|
||||||
Attributes: reqNewAttrs,
|
|
||||||
},
|
|
||||||
Secrets: reqSecrets,
|
|
||||||
},
|
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
|
||||||
Secrets: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.OnUpdateCatalog(ctx, request)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(response)
|
|
||||||
persisted := response.GetPersisted()
|
|
||||||
require.NotNil(persisted)
|
|
||||||
return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) {
|
// ********************
|
||||||
t.Helper()
|
// * Cross-Role AssumeRole - Happy Path & Error Cases
|
||||||
t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated)
|
// ********************
|
||||||
require := require.New(t)
|
// These tests exercise the two-hop role chain within a single AWS account.
|
||||||
|
// The principal role has no ec2:DescribeInstances; the target role
|
||||||
|
// (assume_role) does. The plugin must assume the principal first, then use
|
||||||
|
// those credentials to assume the target role before calling DescribeInstances.
|
||||||
|
// This validates the plugin's chained credential logic without requiring a
|
||||||
|
// second AWS account.
|
||||||
|
crossRolePrincipalArn, err := tf.GetOutputString("cross_role_principal_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
crossRolePrincipalNoTrustArn, err := tf.GetOutputString("cross_role_principal_no_trust_arn")
|
||||||
credential.ConstRegion: region,
|
require.NoError(err)
|
||||||
credential.ConstDisableCredentialRotation: !rotated,
|
|
||||||
|
crossRoleTargetNoTrustArn, err := tf.GetOutputString("cross_role_target_no_trust_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
crossRoleTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_role_target_no_ec2_permission_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
// assume_role_arn is reused as the cross-role target — it has ec2:DescribeInstances
|
||||||
|
// and its trust policy allows the cross-role principal to assume it.
|
||||||
|
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
|
||||||
|
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
|
||||||
|
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
|
||||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn)},
|
||||||
credential.ConstAccessKeyId: accessKeyId,
|
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
|
||||||
credential.ConstSecretAccessKey: secretAccessKey,
|
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnCreateSetCases(ctx, t, p, []onSetCase{
|
||||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
|
||||||
credential.ConstAccessKeyId: accessKeyId,
|
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
|
||||||
credential.ConstSecretAccessKey: secretAccessKey,
|
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
credential.ConstCredsLastRotatedTime: func() string {
|
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
if rotated {
|
|
||||||
return time.Now().Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (time.Time{}).Format(time.RFC3339Nano)
|
|
||||||
}(),
|
|
||||||
})
|
})
|
||||||
require.NoError(err)
|
testOnUpdateSetCases(ctx, t, p, []onSetCase{
|
||||||
request := &pb.OnDeleteCatalogRequest{
|
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0]},
|
||||||
Catalog: &hostcatalogs.HostCatalog{
|
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), wantErr: "AccessDenied"},
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
Attributes: reqAttrs,
|
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
},
|
})
|
||||||
Secrets: reqSecrets,
|
testListHostsCases(ctx, t, p, []listHostsCase{
|
||||||
},
|
{name: "cross-role happy path", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, assumeRoleArn), tags: cases[0], expected: expectedTagInstancesMap},
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
{name: "cross-role principal no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalNoTrustArn, assumeRoleArn), tags: cases[0], wantErr: "AccessDenied"},
|
||||||
Secrets: reqPersistedSecrets,
|
{name: "cross-role target no trust", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
|
||||||
},
|
{name: "cross-role target missing ec2 permission", catalogAttrs: crossRoleAttrs(region, crossRolePrincipalArn, crossRoleTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
|
||||||
}
|
})
|
||||||
response, err := p.OnDeleteCatalog(ctx, request)
|
// ********************
|
||||||
require.NoError(err)
|
// * Cross-Account AssumeRole - Happy Path & Error Cases
|
||||||
require.NotNil(response)
|
// ********************
|
||||||
|
// These tests require credentials for a second AWS account supplied via
|
||||||
// We want to test the validity of the credentials post-deletion.
|
// TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for
|
||||||
if rotated {
|
// temporary credentials) TARGET_AWS_SESSION_TOKEN. They are skipped
|
||||||
// The credentials should no longer be valid.
|
// (not failed) when the access key ID or secret access key are absent.
|
||||||
requireCredentialsInvalid(t, accessKeyId, secretAccessKey)
|
if targetAccessKeyId == "" || targetSecretAccessKey == "" {
|
||||||
|
t.Log("skipping cross-account tests: set TARGET_AWS_ACCESS_KEY_ID and TARGET_AWS_SECRET_ACCESS_KEY to enable")
|
||||||
} else {
|
} else {
|
||||||
// The credentials should still be valid. Sleep 10s first just to
|
crossAccountPrincipalArn, err := tf.GetOutputString("cross_account_principal_arn")
|
||||||
// be sure, since we're not rotating.
|
|
||||||
time.Sleep(time.Second * 10)
|
|
||||||
requireCredentialsValid(t, accessKeyId, secretAccessKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags)
|
|
||||||
require := require.New(t)
|
|
||||||
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstRegion: region,
|
|
||||||
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
setAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))},
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
createRequest := &pb.OnCreateSetRequest{
|
|
||||||
Catalog: &hostcatalogs.HostCatalog{
|
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
|
||||||
Attributes: catalogAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Set: &hostsets.HostSet{
|
|
||||||
Attrs: &hostsets.HostSet_Attributes{
|
|
||||||
Attributes: setAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
|
||||||
Secrets: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
createResponse, err := p.OnCreateSet(ctx, createRequest)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(createResponse)
|
|
||||||
|
|
||||||
// Do an update test in the same function, as it's pretty much the
|
|
||||||
// same function right now.
|
|
||||||
t.Logf("testing OnUpdateSet (region=%s, tags=%v)", region, tags)
|
|
||||||
updateRequest := &pb.OnUpdateSetRequest{
|
|
||||||
Catalog: &hostcatalogs.HostCatalog{
|
|
||||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
|
||||||
Attributes: catalogAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CurrentSet: &hostsets.HostSet{
|
|
||||||
Attrs: &hostsets.HostSet_Attributes{
|
|
||||||
Attributes: setAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NewSet: &hostsets.HostSet{
|
|
||||||
Attrs: &hostsets.HostSet_Attributes{
|
|
||||||
Attributes: setAttrs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
|
||||||
Secrets: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(updateResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) {
|
|
||||||
t.Helper()
|
|
||||||
t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags)
|
|
||||||
require := require.New(t)
|
|
||||||
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
credential.ConstRegion: region,
|
|
||||||
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
|
||||||
sets := make([]*hostsets.HostSet, len(tags))
|
|
||||||
for i, tag := range tags {
|
|
||||||
setAttrs, err := structpb.NewStruct(map[string]any{
|
|
||||||
host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)},
|
|
||||||
})
|
|
||||||
require.NoError(err)
|
require.NoError(err)
|
||||||
sets[i] = &hostsets.HostSet{
|
|
||||||
Id: fmt.Sprintf("hostset-%d", i),
|
|
||||||
Attrs: &hostsets.HostSet_Attributes{
|
|
||||||
Attributes: setAttrs,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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: sets,
|
|
||||||
Persisted: &pb.HostCatalogPersisted{
|
|
||||||
Secrets: reqPersistedSecrets,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, err := p.ListHosts(ctx, request)
|
|
||||||
require.NoError(err)
|
|
||||||
require.NotNil(response)
|
|
||||||
|
|
||||||
// Validate the returned instances by ID. Assemble the instance
|
crossAccountPrincipalNoTrustArn, err := tf.GetOutputString("cross_account_principal_no_trust_arn")
|
||||||
// details from the expected set.
|
require.NoError(err)
|
||||||
expectedInstances := make(map[string][]string)
|
|
||||||
for i, tag := range tags {
|
|
||||||
for _, instanceId := range expected[tag] {
|
|
||||||
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Take the returned hosts by ID and create the same kind of map.
|
crossAccountTargetArn, err := tf.GetOutputString("cross_account_target_arn")
|
||||||
actualInstances := make(map[string][]string)
|
require.NoError(err)
|
||||||
for _, host := range response.GetHosts() {
|
|
||||||
actualInstances[host.ExternalId] = host.SetIds
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare
|
crossAccountTargetNoTrustArn, err := tf.GetOutputString("cross_account_target_no_trust_arn")
|
||||||
require.Equal(expectedInstances, actualInstances)
|
require.NoError(err)
|
||||||
// Success
|
|
||||||
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances)
|
crossAccountTargetNoEc2PermissionArn, err := tf.GetOutputString("cross_account_target_no_ec2_permission_arn")
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
targetEc2InstanceIds, err := tf.GetOutputSlice("target_instance_ids")
|
||||||
|
require.NoError(err)
|
||||||
|
require.Len(targetEc2InstanceIds, expectedEc2InstanceCount)
|
||||||
|
|
||||||
|
targetEc2InstanceTags, err := tf.GetOutputMap("target_instance_tags")
|
||||||
|
require.NoError(err)
|
||||||
|
expectedTargetTagInstancesMap := buildExpectedTagInstancesMap(targetEc2InstanceTags, expectedTags)
|
||||||
|
|
||||||
|
requireTagInstancesMatchProvisioned(t, targetEc2InstanceIds, expectedTargetTagInstancesMap)
|
||||||
|
|
||||||
|
testOnCreateCatalogCases(ctx, t, p, []onCreateCatalogCase{
|
||||||
|
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
|
||||||
|
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
|
})
|
||||||
|
testOnUpdateCatalogCases(ctx, t, p, []onUpdateCatalogCase{
|
||||||
|
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn)},
|
||||||
|
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
|
})
|
||||||
|
testOnCreateSetCases(ctx, t, p, []onSetCase{
|
||||||
|
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
|
||||||
|
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
|
})
|
||||||
|
testOnUpdateSetCases(ctx, t, p, []onSetCase{
|
||||||
|
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0]},
|
||||||
|
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), wantErr: "UnauthorizedOperation"},
|
||||||
|
})
|
||||||
|
testListHostsCases(ctx, t, p, []listHostsCase{
|
||||||
|
{name: "cross-account happy path", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetArn), tags: cases[0], expected: expectedTargetTagInstancesMap},
|
||||||
|
{name: "cross-account principal no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalNoTrustArn, crossAccountTargetArn), tags: cases[0], wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target no trust", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoTrustArn), tags: cases[0], wantErr: "AccessDenied"},
|
||||||
|
{name: "cross-account target missing ec2 permission", catalogAttrs: crossRoleAttrs(targetRegion, crossAccountPrincipalArn, crossAccountTargetNoEc2PermissionArn), tags: cases[0], wantErr: "UnauthorizedOperation"},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+208
-1
@@ -1,10 +1,11 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -85,6 +86,12 @@ func TestStoragePlugin(t *testing.T) {
|
|||||||
iamSecretAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_secret_access_key_missing_delete_obj")
|
iamSecretAccessKeyMissingDeleteObject, err := tf.GetOutputString("iam_secret_access_key_missing_delete_obj")
|
||||||
require.NoError(err)
|
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
|
// 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
|
// 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
|
// the access key ID/secret access key will no longer be valid. We
|
||||||
@@ -138,6 +145,11 @@ func TestStoragePlugin(t *testing.T) {
|
|||||||
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
|
keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string)
|
||||||
testPluginObjectMethods(ctx, t, p, bucketName, region, keyid, secret)
|
testPluginObjectMethods(ctx, t, p, bucketName, region, keyid, secret)
|
||||||
|
|
||||||
|
// ********************
|
||||||
|
// * ListObjects
|
||||||
|
// ********************
|
||||||
|
testListObjects(ctx, t, p, objectCount, bucketName, region, keyid, secret)
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
// * Validate Permissions
|
// * Validate Permissions
|
||||||
// ********************
|
// ********************
|
||||||
@@ -514,3 +526,198 @@ func testPluginObjectMethods(ctx context.Context, t *testing.T, p *storage.Stora
|
|||||||
require.NoError(conn.Close())
|
require.NoError(conn.Close())
|
||||||
grpcServer.Stop()
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+22
-15
@@ -1,24 +1,20 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# 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" {
|
resource "random_id" "aws_iam_user_name" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
prefix = "${var.iam_user_prefix}-${count.index}"
|
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
|
||||||
byte_length = 4
|
byte_length = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_user" "user" {
|
resource "aws_iam_user" "user" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
name = random_id.aws_iam_user_name[count.index].dec
|
name = random_id.aws_iam_user_name[count.index].dec
|
||||||
force_destroy = true
|
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" {
|
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
|
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" {
|
resource "aws_iam_policy" "ec2_describeinstances" {
|
||||||
name = "BoundaryPluginHostAwsTestDescribeInstancesPolicy"
|
name = random_id.aws_ec2_policy_name.dec
|
||||||
|
|
||||||
policy = <<EOF
|
policy = <<EOF
|
||||||
{
|
{
|
||||||
@@ -51,9 +52,15 @@ resource "aws_iam_user_policy_attachment" "user_ec2_describeinstances_attachment
|
|||||||
policy_arn = aws_iam_policy.ec2_describeinstances.arn
|
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" {
|
resource "aws_iam_policy" "user_self_manage_policy" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
name = "BoundaryPluginHostAwsTestUserSelfManagePolicy-${count.index}"
|
name = random_id.aws_iam_policy_name[count.index].dec
|
||||||
|
|
||||||
policy = <<EOF
|
policy = <<EOF
|
||||||
{
|
{
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
# Copyright IBM Corp. 2021, 2026
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
resource "random_id" "assume_role_name" {
|
||||||
|
prefix = "BoundaryPluginAssumeRole"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "assume_role" {
|
||||||
|
name = random_id.assume_role_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
AWS = data.aws_caller_identity.current.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# Also trusted by the cross-role principal role so it can be used
|
||||||
|
# as the target in two-hop AssumeRole tests.
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
AWS = aws_iam_role.cross_role_principal.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "assume_role_ec2_describeinstances" {
|
||||||
|
role = aws_iam_role.assume_role.name
|
||||||
|
policy_arn = aws_iam_policy.ec2_describeinstances.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "assume_role_arn" {
|
||||||
|
value = aws_iam_role.assume_role.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: role exists with ec2:DescribeInstances but trust policy
|
||||||
|
# does not allow the caller to assume it.
|
||||||
|
resource "random_id" "assume_role_no_trust_name" {
|
||||||
|
prefix = "BoundaryPluginNoTrust"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "assume_role_no_trust" {
|
||||||
|
name = random_id.assume_role_no_trust_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Deny"
|
||||||
|
Principal = {
|
||||||
|
AWS = data.aws_caller_identity.current.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "assume_role_no_trust_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_trust.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: role exists and the caller can assume it, but has no
|
||||||
|
# ec2:DescribeInstances permission.
|
||||||
|
# Also trusted by cross_role_principal so this same role can be reused as the
|
||||||
|
# no-EC2-permission target in two-hop (cross-role) error case tests.
|
||||||
|
resource "random_id" "assume_role_no_ec2_permission_name" {
|
||||||
|
prefix = "BoundaryPluginNoEC2Perm"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "assume_role_no_ec2_permission" {
|
||||||
|
name = random_id.assume_role_no_ec2_permission_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
AWS = data.aws_caller_identity.current.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# Also trusted by the cross-role principal so it can serve as the
|
||||||
|
# no-EC2-permission target in two-hop error case tests.
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
AWS = aws_iam_role.cross_role_principal.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "assume_role_no_ec2_permission_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_ec2_permission.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cross-role (two-hop) principal role.
|
||||||
|
# The test runner can assume this role. It has permission to AssumeRole into
|
||||||
|
# aws_iam_role.assume_role (the target role) but has no ec2:DescribeInstances
|
||||||
|
# of its own. This simulates the principal side of a two-hop role chain: the
|
||||||
|
# plugin must assume the principal first, then use those credentials to assume
|
||||||
|
# the target role before calling DescribeInstances.
|
||||||
|
# Note: both roles are in the same AWS account. This tests the plugin's
|
||||||
|
# two-hop credential chain logic without requiring a second account.
|
||||||
|
resource "random_id" "cross_role_principal_name" {
|
||||||
|
prefix = "BoundaryPluginCRPrincipal"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "cross_role_principal" {
|
||||||
|
name = random_id.cross_role_principal_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = {
|
||||||
|
AWS = data.aws_caller_identity.current.arn
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Single policy granting cross_role_principal permission to assume all target
|
||||||
|
# roles it needs for both happy path and error case tests.
|
||||||
|
resource "aws_iam_policy" "cross_role_principal_assume_targets" {
|
||||||
|
name = "${random_id.cross_role_principal_name.dec}-assume-targets"
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Resource = [
|
||||||
|
aws_iam_role.assume_role.arn,
|
||||||
|
aws_iam_role.assume_role_no_ec2_permission.arn,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "cross_role_principal_assume_targets" {
|
||||||
|
role = aws_iam_role.cross_role_principal.name
|
||||||
|
policy_arn = aws_iam_policy.cross_role_principal_assume_targets.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cross_role_principal_arn" {
|
||||||
|
value = aws_iam_role.cross_role_principal.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: caller cannot assume the principal role (first hop denied).
|
||||||
|
# Reuses assume_role_no_trust — it already has a Deny trust policy for the
|
||||||
|
# caller, so it also serves as an unassumable principal in two-hop tests.
|
||||||
|
output "cross_role_principal_no_trust_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_trust.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: principal can be assumed, but the target role's trust policy
|
||||||
|
# does not allow the principal to assume it (second hop denied).
|
||||||
|
# Reuses assume_role_no_trust as the target — its Deny trust policy blocks
|
||||||
|
# all callers including cross_role_principal.
|
||||||
|
output "cross_role_target_no_trust_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_trust.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: both hops succeed but the target has no ec2:DescribeInstances.
|
||||||
|
# Reuses assume_role_no_ec2_permission — its trust policy allows
|
||||||
|
# cross_role_principal (see above), and it has no EC2 permission.
|
||||||
|
output "cross_role_target_no_ec2_permission_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_ec2_permission.arn
|
||||||
|
}
|
||||||
+322
@@ -0,0 +1,322 @@
|
|||||||
|
# Copyright IBM Corp. 2021, 2026
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
# Cross-account Dynamic Host Catalog test infrastructure.
|
||||||
|
#
|
||||||
|
# This file provisions the IAM resources needed to test two-hop AssumeRole
|
||||||
|
# across two separate AWS accounts:
|
||||||
|
#
|
||||||
|
# Account A (principal account) — the default provider
|
||||||
|
# * cross_account_principal: the plugin assumes this role first.
|
||||||
|
# It has sts:AssumeRole permission on the target role in Account B.
|
||||||
|
#
|
||||||
|
# Account B (target account) — the "target" provider alias
|
||||||
|
# * cross_account_target: holds ec2:DescribeInstances; its trust policy
|
||||||
|
# allows the Account A principal role to assume it. (happy path)
|
||||||
|
# * cross_account_target_no_ec2_permission: same trust policy but no
|
||||||
|
# ec2:DescribeInstances. (error case)
|
||||||
|
# * cross_account_target_no_trust: exists in Account B but its trust
|
||||||
|
# policy does not allow the Account A principal. (error case)
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# TARGET_AWS_ACCESS_KEY_ID, TARGET_AWS_SECRET_ACCESS_KEY, and (for temporary
|
||||||
|
# credentials) TARGET_AWS_SESSION_TOKEN must be set to credentials that have
|
||||||
|
# sufficient IAM permissions in Account B. The test runner skips this
|
||||||
|
# section when the access key ID or secret access key are absent.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider for Account B (target account)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
provider "aws" {
|
||||||
|
alias = "target"
|
||||||
|
region = var.target_region
|
||||||
|
access_key = var.target_access_key_id
|
||||||
|
secret_key = var.target_secret_access_key
|
||||||
|
token = var.target_session_token
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_caller_identity" "target" {
|
||||||
|
provider = aws.target
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Variables for Account B credentials and region
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
variable "target_access_key_id" {
|
||||||
|
description = "AWS access key ID for the target account (Account B)."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "target_secret_access_key" {
|
||||||
|
description = "AWS secret access key for the target account (Account B)."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "target_session_token" {
|
||||||
|
description = "AWS session token for the target account (Account B). Required when using temporary (STS) credentials."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "target_region" {
|
||||||
|
description = "AWS region for the target account (Account B). Defaults to the same region as Account A."
|
||||||
|
type = string
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account A: cross_account_principal role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "random_id" "cross_account_principal_name" {
|
||||||
|
prefix = "BoundaryPluginCAPrincipal"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "cross_account_principal" {
|
||||||
|
name = random_id.cross_account_principal_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = { AWS = data.aws_caller_identity.current.arn }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allow the principal role to assume the happy-path and no-EC2-permission
|
||||||
|
# target roles in Account B.
|
||||||
|
resource "aws_iam_policy" "cross_account_principal_assume_targets" {
|
||||||
|
name = "${random_id.cross_account_principal_name.dec}-assume-targets"
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Resource = [
|
||||||
|
aws_iam_role.cross_account_target.arn,
|
||||||
|
aws_iam_role.cross_account_target_no_ec2_permission.arn,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "cross_account_principal_assume_targets" {
|
||||||
|
role = aws_iam_role.cross_account_principal.name
|
||||||
|
policy_arn = aws_iam_policy.cross_account_principal_assume_targets.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cross_account_principal_arn" {
|
||||||
|
value = aws_iam_role.cross_account_principal.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error case: caller cannot assume the principal role (first hop denied).
|
||||||
|
# Reuses assume_role_no_trust from iam_assume_role.tf — its Deny trust policy
|
||||||
|
# also blocks the test runner from assuming it as a principal.
|
||||||
|
output "cross_account_principal_no_trust_arn" {
|
||||||
|
value = aws_iam_role.assume_role_no_trust.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account B: happy-path target role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "random_id" "cross_account_target_name" {
|
||||||
|
prefix = "BoundaryPluginCATarget"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "cross_account_target" {
|
||||||
|
provider = aws.target
|
||||||
|
name = random_id.cross_account_target_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "cross_account_target_ec2_describeinstances" {
|
||||||
|
provider = aws.target
|
||||||
|
name = "${random_id.cross_account_target_name.dec}-ec2"
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["ec2:DescribeInstances"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Resource = "*"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "cross_account_target_ec2_describeinstances" {
|
||||||
|
provider = aws.target
|
||||||
|
role = aws_iam_role.cross_account_target.name
|
||||||
|
policy_arn = aws_iam_policy.cross_account_target_ec2_describeinstances.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cross_account_target_arn" {
|
||||||
|
value = aws_iam_role.cross_account_target.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account B: error case — both hops succeed but target has no ec2:DescribeInstances
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "random_id" "cross_account_target_no_ec2_permission_name" {
|
||||||
|
prefix = "BoundaryPluginCATargetNoEC2"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "cross_account_target_no_ec2_permission" {
|
||||||
|
provider = aws.target
|
||||||
|
name = random_id.cross_account_target_no_ec2_permission_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Allow"
|
||||||
|
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cross_account_target_no_ec2_permission_arn" {
|
||||||
|
value = aws_iam_role.cross_account_target_no_ec2_permission.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account B: error case — principal cannot assume this target (second hop denied)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "random_id" "cross_account_target_no_trust_name" {
|
||||||
|
prefix = "BoundaryPluginCATargetNoTrust"
|
||||||
|
byte_length = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "cross_account_target_no_trust" {
|
||||||
|
provider = aws.target
|
||||||
|
name = random_id.cross_account_target_no_trust_name.dec
|
||||||
|
|
||||||
|
assume_role_policy = jsonencode({
|
||||||
|
Version = "2012-10-17"
|
||||||
|
Statement = [
|
||||||
|
{
|
||||||
|
Action = ["sts:AssumeRole", "sts:SetSourceIdentity"]
|
||||||
|
Effect = "Deny"
|
||||||
|
Principal = { AWS = aws_iam_role.cross_account_principal.arn }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"boundary-demo" = local.hashicorp_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cross_account_target_no_trust_arn" {
|
||||||
|
value = aws_iam_role.cross_account_target_no_trust.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Account B: EC2 instance for cross-account host discovery validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
data "aws_availability_zones" "target_azs" {
|
||||||
|
count = var.target_access_key_id != "" ? 1 : 0
|
||||||
|
provider = aws.target
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_ami" "target_ubuntu" {
|
||||||
|
count = var.target_access_key_id != "" ? 1 : 0
|
||||||
|
provider = aws.target
|
||||||
|
most_recent = true
|
||||||
|
|
||||||
|
filter {
|
||||||
|
name = "name"
|
||||||
|
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
filter {
|
||||||
|
name = "virtualization-type"
|
||||||
|
values = ["hvm"]
|
||||||
|
}
|
||||||
|
|
||||||
|
owners = ["099720109477"] # Canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_vpc" "target_vpc" {
|
||||||
|
count = var.target_access_key_id != "" ? 1 : 0
|
||||||
|
provider = aws.target
|
||||||
|
cidr_block = "10.1.0.0/16"
|
||||||
|
assign_generated_ipv6_cidr_block = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "target_subnet" {
|
||||||
|
count = var.target_access_key_id != "" ? 1 : 0
|
||||||
|
provider = aws.target
|
||||||
|
vpc_id = aws_vpc.target_vpc[0].id
|
||||||
|
cidr_block = aws_vpc.target_vpc[0].cidr_block
|
||||||
|
ipv6_cidr_block = cidrsubnet(aws_vpc.target_vpc[0].ipv6_cidr_block, 8, 0)
|
||||||
|
availability_zone = data.aws_availability_zones.target_azs[0].names[0]
|
||||||
|
map_public_ip_on_launch = true
|
||||||
|
assign_ipv6_address_on_creation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_instance" "target_instances" {
|
||||||
|
count = var.target_access_key_id != "" ? length(local.instance_tags) : 0
|
||||||
|
provider = aws.target
|
||||||
|
ami = data.aws_ami.target_ubuntu[0].id
|
||||||
|
instance_type = "t3.nano"
|
||||||
|
subnet_id = aws_subnet.target_subnet[0].id
|
||||||
|
|
||||||
|
tags = local.instance_tags[count.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
output "target_instance_ids" {
|
||||||
|
value = aws_instance.target_instances.*.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "target_instance_tags" {
|
||||||
|
value = {
|
||||||
|
for _, r in aws_instance.target_instances : r.id => r.tags
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
terraform {
|
terraform {
|
||||||
required_providers {
|
required_providers {
|
||||||
aws = {
|
aws = {
|
||||||
source = "hashicorp/aws"
|
source = "hashicorp/aws"
|
||||||
version = "~> 3.0"
|
version = "~> 6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+49
@@ -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"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+133
-30
@@ -1,28 +1,6 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# 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_availability_zones" "azs" {}
|
||||||
|
|
||||||
data "aws_ami" "ubuntu" {
|
data "aws_ami" "ubuntu" {
|
||||||
@@ -42,22 +20,117 @@ data "aws_ami" "ubuntu" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_vpc" "vpc" {
|
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" {
|
resource "aws_subnet" "subnet" {
|
||||||
vpc_id = aws_vpc.vpc.id
|
vpc_id = aws_vpc.vpc.id
|
||||||
cidr_block = aws_vpc.vpc.cidr_block
|
cidr_block = aws_vpc.vpc.cidr_block
|
||||||
availability_zone = data.aws_availability_zones.azs.names[0]
|
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" {
|
resource "aws_instance" "instances" {
|
||||||
count = length(var.instance_tags)
|
count = length(local.instance_tags)
|
||||||
ami = data.aws_ami.ubuntu.id
|
ami = data.aws_ami.ubuntu.id
|
||||||
instance_type = "t3.nano"
|
instance_type = "t3.nano"
|
||||||
subnet_id = aws_subnet.subnet.id
|
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" {
|
output "instance_ids" {
|
||||||
@@ -74,4 +147,34 @@ output "instance_tags" {
|
|||||||
value = {
|
value = {
|
||||||
for _, r in aws_instance.instances : r.id => r.tags
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
resource "random_id" "foo" {
|
resource "random_id" "foo" {
|
||||||
|
|||||||
Vendored
+23
-21
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
resource "aws_iam_role" "valid" {
|
resource "aws_iam_role" "valid" {
|
||||||
@@ -34,10 +34,18 @@ resource "aws_iam_policy" "valid" {
|
|||||||
"s3:GetObject",
|
"s3:GetObject",
|
||||||
"s3:DeleteObject",
|
"s3:DeleteObject",
|
||||||
"s3:GetObjectAttributes",
|
"s3:GetObjectAttributes",
|
||||||
"s3:ListBucket"
|
"s3:ListBucket",
|
||||||
|
"s3:DeleteObject"
|
||||||
],
|
],
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Resource": "${aws_s3_bucket.test.arn}*"
|
"Resource": "${aws_s3_bucket.test.arn}*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Action": [
|
||||||
|
"s3:ListBucket"
|
||||||
|
],
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Resource": "${aws_s3_bucket.test.arn}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ resource "aws_iam_role_policy_attachment" "missing_delete_obj" {
|
|||||||
# Static Credential Testing
|
# Static Credential Testing
|
||||||
resource "random_id" "user_name" {
|
resource "random_id" "user_name" {
|
||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
|
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
|
byte_length = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,10 +212,8 @@ resource "aws_iam_user" "test" {
|
|||||||
count = var.iam_user_count
|
count = var.iam_user_count
|
||||||
name = random_id.user_name[count.index].dec
|
name = random_id.user_name[count.index].dec
|
||||||
force_destroy = true
|
force_destroy = true
|
||||||
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
|
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 = {
|
tags = local.tags
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_access_key" "test" {
|
resource "aws_iam_access_key" "test" {
|
||||||
@@ -277,17 +283,15 @@ resource "aws_iam_user_policy_attachment" "missing_delete_obj" {
|
|||||||
# Edge case for missing GetObject permission
|
# Edge case for missing GetObject permission
|
||||||
|
|
||||||
resource "random_id" "missing_get_obj" {
|
resource "random_id" "missing_get_obj" {
|
||||||
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
|
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
|
byte_length = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_user" "missing_get_obj" {
|
resource "aws_iam_user" "missing_get_obj" {
|
||||||
name = random_id.missing_get_obj.dec
|
name = random_id.missing_get_obj.dec
|
||||||
force_destroy = true
|
force_destroy = true
|
||||||
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
|
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 = {
|
tags = local.tags
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_access_key" "missing_get_obj" {
|
resource "aws_iam_access_key" "missing_get_obj" {
|
||||||
@@ -302,17 +306,15 @@ resource "aws_iam_user_policy_attachment" "missing_get_obj" {
|
|||||||
# Edge case for missing PutObject permission
|
# Edge case for missing PutObject permission
|
||||||
|
|
||||||
resource "random_id" "missing_put_obj" {
|
resource "random_id" "missing_put_obj" {
|
||||||
prefix = "demo-${local.hashicorp_email}-boundary-iam-user"
|
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
|
byte_length = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_user" "missing_put_obj" {
|
resource "aws_iam_user" "missing_put_obj" {
|
||||||
name = random_id.missing_put_obj.dec
|
name = random_id.missing_put_obj.dec
|
||||||
force_destroy = true
|
force_destroy = true
|
||||||
permissions_boundary = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/DemoUser"
|
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 = {
|
tags = local.tags
|
||||||
"boundary-demo" = local.hashicorp_email
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_access_key" "missing_put_obj" {
|
resource "aws_iam_access_key" "missing_put_obj" {
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
terraform {
|
terraform {
|
||||||
required_providers {
|
required_providers {
|
||||||
aws = {
|
aws = {
|
||||||
source = "hashicorp/aws"
|
source = "hashicorp/aws"
|
||||||
version = "~> 3.0"
|
version = "~> 6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+11
-6
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
output "bucket_name" {
|
output "bucket_name" {
|
||||||
@@ -6,19 +6,19 @@ output "bucket_name" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
output "valid_policy_arn" {
|
output "valid_policy_arn" {
|
||||||
value = aws_iam_role.valid.arn
|
value = aws_iam_role.valid.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "missing_put_obj_policy_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_get_obj_policy_arn" {
|
output "missing_get_obj_policy_arn" {
|
||||||
value = aws_iam_role.missing_get_obj.arn
|
value = aws_iam_role.missing_get_obj.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "missing_delete_obj_policy_arn" {
|
output "missing_delete_obj_policy_arn" {
|
||||||
value = aws_iam_role.missing_delete_obj.arn
|
value = aws_iam_role.missing_delete_obj.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "iam_user_names" {
|
output "iam_user_names" {
|
||||||
@@ -67,4 +67,9 @@ output "iam_access_key_missing_delete_obj" {
|
|||||||
output "iam_secret_access_key_missing_delete_obj" {
|
output "iam_secret_access_key_missing_delete_obj" {
|
||||||
value = aws_iam_access_key.missing_delete_obj.secret
|
value = aws_iam_access_key.missing_delete_obj.secret
|
||||||
sensitive = true
|
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
|
||||||
|
}
|
||||||
Vendored
+11
-3
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
resource "aws_s3_bucket" "test" {
|
resource "aws_s3_bucket" "test" {
|
||||||
@@ -9,9 +9,17 @@ resource "aws_s3_bucket" "test" {
|
|||||||
|
|
||||||
resource "aws_s3_bucket_public_access_block" "secure_access" {
|
resource "aws_s3_bucket_public_access_block" "secure_access" {
|
||||||
bucket = aws_s3_bucket.test.id
|
bucket = aws_s3_bucket.test.id
|
||||||
|
|
||||||
block_public_acls = true
|
block_public_acls = true
|
||||||
block_public_policy = true
|
block_public_policy = true
|
||||||
ignore_public_acls = true
|
ignore_public_acls = true
|
||||||
restrict_public_buckets = 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
|
||||||
|
}
|
||||||
Vendored
+42
-2
@@ -1,4 +1,4 @@
|
|||||||
# Copyright (c) HashiCorp, Inc.
|
# Copyright IBM Corp. 2021, 2026
|
||||||
# SPDX-License-Identifier: MPL-2.0
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
data "aws_caller_identity" "current" {}
|
data "aws_caller_identity" "current" {}
|
||||||
@@ -16,7 +16,47 @@ locals {
|
|||||||
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
|
hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1]
|
||||||
tags = {
|
tags = {
|
||||||
User = split(":", data.aws_caller_identity.current.user_id)[1]
|
User = split(":", data.aws_caller_identity.current.user_id)[1]
|
||||||
Repo = "boundary-plugin-aws"
|
Repo = "boundary-plugin-aws"
|
||||||
Environment = "Demo"
|
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/" = ""
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+30
-8
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
@@ -39,6 +39,10 @@ type TestTerraformer struct {
|
|||||||
// The working directory for Terraform commands.
|
// The working directory for Terraform commands.
|
||||||
workDir string
|
workDir string
|
||||||
|
|
||||||
|
// vars holds optional key/value pairs passed as -var flags to every
|
||||||
|
// Terraform command that accepts them (apply, destroy, plan).
|
||||||
|
vars map[string]string
|
||||||
|
|
||||||
// The Terraform state. Use the Output method to look up particular
|
// The Terraform state. Use the Output method to look up particular
|
||||||
// outputs from the Terraform state.
|
// outputs from the Terraform state.
|
||||||
state *tfjson.State
|
state *tfjson.State
|
||||||
@@ -53,8 +57,9 @@ type TestTerraformer struct {
|
|||||||
|
|
||||||
// NewTestTerraformer initializes a Terraform directory. It just
|
// NewTestTerraformer initializes a Terraform directory. It just
|
||||||
// returns an initialized project, it does not perform any actions on
|
// returns an initialized project, it does not perform any actions on
|
||||||
// it.
|
// it. An optional vars map may be provided; each entry is passed as a
|
||||||
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
// -var flag to Terraform commands.
|
||||||
|
func NewTestTerraformer(workDir string, vars ...map[string]string) (*TestTerraformer, error) {
|
||||||
tfPath, err := exec.LookPath("terraform")
|
tfPath, err := exec.LookPath("terraform")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("LookPath error: %w", err)
|
return nil, fmt.Errorf("LookPath error: %w", err)
|
||||||
@@ -64,10 +69,24 @@ func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
|||||||
return nil, errors.New("workDir is empty")
|
return nil, errors.New("workDir is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &TestTerraformer{
|
tf := &TestTerraformer{
|
||||||
tfPath: tfPath,
|
tfPath: tfPath,
|
||||||
workDir: workDir,
|
workDir: workDir,
|
||||||
}, nil
|
}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
tf.vars = vars[0]
|
||||||
|
}
|
||||||
|
return tf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// varArgs converts the stored vars map into a slice of -var flags
|
||||||
|
// suitable for appending to a Terraform command's argument list.
|
||||||
|
func (t *TestTerraformer) varArgs() []string {
|
||||||
|
args := make([]string, 0, len(t.vars)*2)
|
||||||
|
for k, v := range t.vars {
|
||||||
|
args = append(args, "-var", fmt.Sprintf("%s=%s", k, v))
|
||||||
|
}
|
||||||
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy runs the following commands, in order: init, apply, plan,
|
// Deploy runs the following commands, in order: init, apply, plan,
|
||||||
@@ -214,7 +233,8 @@ func (t *TestTerraformer) init() error {
|
|||||||
|
|
||||||
// apply runs "terraform apply" in the project's working directory.
|
// apply runs "terraform apply" in the project's working directory.
|
||||||
func (t *TestTerraformer) apply() error {
|
func (t *TestTerraformer) apply() error {
|
||||||
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
|
args := append([]string{"apply", "-input=false", "-auto-approve"}, t.varArgs()...)
|
||||||
|
cmd := exec.Command(t.tfPath, args...)
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -226,7 +246,8 @@ func (t *TestTerraformer) apply() error {
|
|||||||
|
|
||||||
// destroy runs "terraform destroy" in the project's working directory.
|
// destroy runs "terraform destroy" in the project's working directory.
|
||||||
func (t *TestTerraformer) destroy() error {
|
func (t *TestTerraformer) destroy() error {
|
||||||
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
|
args := append([]string{"destroy", "-input=false", "-auto-approve"}, t.varArgs()...)
|
||||||
|
cmd := exec.Command(t.tfPath, args...)
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,7 +263,8 @@ func (t *TestTerraformer) destroy() error {
|
|||||||
// it's run to get the data for for importing the state via
|
// it's run to get the data for for importing the state via
|
||||||
// terraform-json. The apply method ignores the plan generated here.
|
// terraform-json. The apply method ignores the plan generated here.
|
||||||
func (t *TestTerraformer) plan() error {
|
func (t *TestTerraformer) plan() error {
|
||||||
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
|
args := append([]string{"plan", "-input=false", "-out=plan.tfplan"}, t.varArgs()...)
|
||||||
|
cmd := exec.Command(t.tfPath, args...)
|
||||||
cmd.Dir = t.workDir
|
cmd.Dir = t.workDir
|
||||||
stdoutStderr, err := cmd.CombinedOutput()
|
stdoutStderr, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) HashiCorp, Inc.
|
// Copyright IBM Corp. 2021, 2026
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
package testing
|
package testing
|
||||||
|
|||||||
Reference in new issue
Block a user