Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0e5ad5f7 | ||
|
|
a4a55ce70b | ||
|
|
de9ee3c408 | ||
|
|
d3a155bd3f | ||
|
|
baa2c7a2e7 | ||
|
|
9acd3acf59 | ||
|
|
cf0a591dcb | ||
|
|
7076cd0bad | ||
|
|
0d5cc5d444 | ||
|
|
89c8ee2d6d | ||
|
|
a75b9bd310 | ||
|
|
779d383a24 | ||
|
|
e4099a5f64 | ||
|
|
beaa93d3d8 | ||
|
|
ba53ae16b2 | ||
|
|
08fd6e5b01 | ||
|
|
6b917b170d | ||
|
|
636432f2fb | ||
|
|
be892acbd9 | ||
|
|
eec01f7d69 | ||
|
|
6588e10934 | ||
|
|
cd73a74e1e | ||
|
|
b09755d302 |
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
contact_links:
|
||||
- name: Ask a question
|
||||
url: https://discuss.hashicorp.com/c/boundary
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo, unless a later match takes precedence.
|
||||
|
||||
@hashicorp/boundary
|
||||
* @hashicorp/boundary
|
||||
@@ -1,90 +1,54 @@
|
||||
# Google Plugin for HashiCorp Boundary
|
||||
# GCP Plugin for HashiCorp Boundary
|
||||
|
||||
This repo contains the google plugin for [HashiCorp
|
||||
This repo contains the GCP plugin for [HashiCorp
|
||||
Boundary](https://www.boundaryproject.io/).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is a prototype. Not officially supported by HashiCorp.
|
||||
|
||||
## Credentials
|
||||
|
||||
This plugin uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc)
|
||||
to authenticate to Google and retrieve a list of instances and instance groups.
|
||||
### Service Account Credentials
|
||||
|
||||
As a result, it does not support rotation of the credentials since they are inferred
|
||||
from the environment.
|
||||
The plugin will authenticate using service account credential when the `secrets`
|
||||
object is set. By default, the plugin will attempt to rotate the credentials.
|
||||
The given credentials will be used to create a new credential, and then the given
|
||||
credential will be revoked. In this way, after rotation, only Boundary knows the
|
||||
client secret in use by this plugin.
|
||||
|
||||
Credential rotation can be turned off by setting the `disable_credential_rotation`
|
||||
attribute to true.
|
||||
|
||||
### Service Account Impersonation
|
||||
|
||||
The plugin will attempt to impersonate a service account when the `target_service_account_id`
|
||||
field is supplied through the `attributes` object. The base service account will be
|
||||
used to assume the identity and permissions of the target service account. A temporary
|
||||
credential will be generated for authentication. The base service account requires the
|
||||
[service account token creator](https://cloud.google.com/iam/docs/service-account-permissions#token-creator-role)
|
||||
role to assume the role of the target service account.
|
||||
|
||||
By default, the credentials of the base service account will be rotated if
|
||||
credential rotation is not disabled by setting the `disable_credential_rotation`
|
||||
attribute.
|
||||
|
||||
### Application Default Credentials
|
||||
|
||||
The plugin uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc)
|
||||
for authentication when no `secrets` object is set. The plugin will attempt to
|
||||
retrieve the credentials based on the environment.
|
||||
|
||||
## Dynamic Hosts
|
||||
|
||||
This plugin supports dynamically sourcing instances from Google Compute Engine.
|
||||
This plugin supports dynamically sourcing instances from GCP Google Compute Engine.
|
||||
|
||||
Host sets created with this plugin define filters or instance groups
|
||||
which select and group like instances within Google; these host sets can in turn be
|
||||
Host sets created with this plugin define filters
|
||||
which select and group like instances within GCP; these host sets can in turn be
|
||||
added to targets within Boundary as host sources.
|
||||
|
||||
At creation, update or deletion of a host catalog of this type, configuration of the
|
||||
plugin is performed via the attribute/secret values passed to the create, update, or
|
||||
delete calls actions. The values passed in to the plugin here are the attributes set
|
||||
on on a host catalog in Boundary.
|
||||
on a host catalog in Boundary.
|
||||
|
||||
### Google IAM permissions
|
||||
The plugin fetches hosts through the [Instances.List](https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter)
|
||||
call.
|
||||
|
||||
The plugin requires the following permissions:
|
||||
|
||||
- `compute.instances.get`
|
||||
- `compute.instances.list`
|
||||
- `compute.instanceGroups.get`
|
||||
- `compute.instanceGroups.list`
|
||||
|
||||
### Attributes
|
||||
|
||||
### Host Catalog
|
||||
|
||||
The following attributes are valid on a Google host catalog resource:
|
||||
|
||||
- `project` (string): required. Project ID of the instances you want to add to host catalog.
|
||||
- `zone` (string): required. Zone of the instances you want to add to host catalog.
|
||||
|
||||
Example:
|
||||
|
||||
```shell
|
||||
$ boundary host-catalogs create plugin -scope-id p_1234567890 -plugin-name google -attr zone=us-central1-a -attr project=$GOOGLE_PROJECT
|
||||
```
|
||||
|
||||
### Host Set
|
||||
|
||||
The following attributes are valid on a Google host set resource:
|
||||
|
||||
- `filter` (string): Google Cloud [filter expression](https://cloud.google.com/sdk/gcloud/reference/topic/filters)
|
||||
to filter instances.
|
||||
|
||||
- `instance_group` (string): Name of instance group to get a list of instances.
|
||||
|
||||
You can only set a `filter` or `instance_group` attribute, you cannot set both.
|
||||
|
||||
Example:
|
||||
|
||||
```shell
|
||||
$ boundary host-sets create plugin -host-catalog-id $HOST_CATALOG_ID -name "filter-example" -description "example using filters" -attr filter="status=RUNNING"
|
||||
|
||||
$ boundary host-sets create plugin -host-catalog-id $HOST_CATALOG_ID -name "group-example" -description "example using instance groups" -attr instance_group="instance-group-name"
|
||||
```
|
||||
|
||||
After generating the host set, create a target.
|
||||
|
||||
```shell
|
||||
$ boundary targets create tcp -name google -description "Google Cloud" -scope-id p_1234567890 -default-port 22
|
||||
$ boundary targets add-host-sources -id $TARGET_ID -host-source $HOST_SET_ID
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
You will need to build your own Boundary binary, as Boundary imports the plugins as a dependency.
|
||||
|
||||
As a reference, you can review [this fork](https://github.com/joatmon08/boundary) for changes.
|
||||
|
||||
To build the fork, go into the repository.
|
||||
|
||||
1. Run `make build`.
|
||||
1. Authenticate to Google Cloud using `gcloud auth application-default login`.
|
||||
1. Run Boundary with `./bin/boundary dev`.
|
||||
[Getting Started](plugin/service/host/README.md)
|
||||
@@ -1,41 +1,48 @@
|
||||
module github.com/hashicorp/boundary-plugin-google
|
||||
module github.com/hashicorp/boundary-plugin-gcp
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
cloud.google.com/go/iam v1.2.0
|
||||
cloud.google.com/go/resourcemanager v1.10.1
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/googleapis/gax-go/v2 v2.13.0
|
||||
github.com/hashicorp/boundary/sdk v0.0.47
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
|
||||
google.golang.org/api v0.188.0
|
||||
github.com/hashicorp/terraform-json v0.23.0
|
||||
golang.org/x/oauth2 v0.22.0
|
||||
google.golang.org/api v0.196.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.7.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
|
||||
cloud.google.com/go v0.115.1 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.5 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/zclconf/go-cty v1.15.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute v1.27.3
|
||||
cloud.google.com/go/compute v1.28.0
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/eventlogger v0.2.9 // indirect
|
||||
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
|
||||
@@ -48,12 +55,12 @@ require (
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
|
||||
google.golang.org/grpc v1.65.0
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.0
|
||||
google.golang.org/protobuf v1.34.2
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,14 +1,20 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
||||
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
||||
cloud.google.com/go/auth v0.7.0 h1:kf/x9B3WTbBUHkC+1VS8wwwli9TzhSt0vSTVBmMR8Ts=
|
||||
cloud.google.com/go/auth v0.7.0/go.mod h1:D+WqdrpcjmiCgWrXmLLxOVq1GACoE36chW6KXoEvuIw=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
|
||||
cloud.google.com/go/compute v1.27.3 h1:GFfoejA5qGeFYpKAfgoVzvgJ3D8upFKI/UR7MdiPdS0=
|
||||
cloud.google.com/go/compute v1.27.3/go.mod h1:5GuDo3l1k9CFhfIHK1sXqlqOW/iWX4/eBlO5FtxDhvQ=
|
||||
cloud.google.com/go v0.115.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
|
||||
cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||
cloud.google.com/go/compute v1.28.0 h1:OPtBxMcheSS+DWfci803qvPly3d4w7Eu5ztKBcFfzwk=
|
||||
cloud.google.com/go/compute v1.28.0/go.mod h1:DEqZBtYrDnD5PvjsKwb3onnhX+qjdCVM7eshj1XdjV4=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
cloud.google.com/go/iam v1.2.0 h1:kZKMKVNk/IsSSc/udOb83K0hL/Yh/Gcqpz+oAkoIFN8=
|
||||
cloud.google.com/go/iam v1.2.0/go.mod h1:zITGuWgsLZxd8OwAlX+eMFgZDXzBm7icj1PVTYG766Q=
|
||||
cloud.google.com/go/longrunning v0.6.0 h1:mM1ZmaNsQsnb+5n1DNPeL0KwQd9jQRqSqSDEkBZr+aI=
|
||||
cloud.google.com/go/longrunning v0.6.0/go.mod h1:uHzSZqW89h7/pasCWNYdUpwGz3PcVWhrWupreVPYLts=
|
||||
cloud.google.com/go/resourcemanager v1.10.1 h1:fO/QoSJ1lepmTM9dCbSXYWgTIhecmQkpY0mM1X9OGN0=
|
||||
cloud.google.com/go/resourcemanager v1.10.1/go.mod h1:A/ANV/Sv7y7fcjd4LSH7PJGTZcWRkO/69yN5UhYUmvE=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
@@ -16,6 +22,8 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y
|
||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
|
||||
@@ -68,15 +76,15 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
|
||||
github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A=
|
||||
github.com/hashicorp/boundary/sdk v0.0.47 h1:h5AXOASS2duHkCYEmNKnI9AR6YBZxD7VbFPV8BoE0z0=
|
||||
github.com/hashicorp/boundary/sdk v0.0.47/go.mod h1:9iOT7kDM6mYcSkKxNuZlv8rP7U5BG1kXoevjLLL8lNQ=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -119,8 +127,12 @@ github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3ly
|
||||
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/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
|
||||
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.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
|
||||
@@ -181,26 +193,30 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=
|
||||
github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -218,27 +234,29 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
|
||||
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/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/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -252,26 +270,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
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/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
|
||||
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
|
||||
google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
|
||||
google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
|
||||
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
|
||||
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@@ -6,26 +6,38 @@ package credential
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-google/internal/errors"
|
||||
"github.com/hashicorp/boundary-plugin-google/internal/values"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/errors"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
// CredentialAttributes contain attributes used for authenticating to Google Cloud
|
||||
// CredentialAttributes contain attributes used for authenticating to GCP
|
||||
// and accessing a list of instances
|
||||
type CredentialAttributes struct {
|
||||
Project string
|
||||
Zone string
|
||||
// ProjectId is the project id associated with the GCP credentials
|
||||
ProjectId string
|
||||
|
||||
// Zone is the zone associated with the GCP credentials
|
||||
Zone string
|
||||
|
||||
// DisableCredentialRotation disables the rotation of GCP service account key associated with the plugin
|
||||
DisableCredentialRotation bool
|
||||
|
||||
// ClientEmail is the email associated with the GCP cloud credentials
|
||||
ClientEmail string
|
||||
|
||||
// TargetServiceAccount is the unique identifier for the service account that will be impersonated
|
||||
TargetServiceAccountId string
|
||||
}
|
||||
|
||||
// GetCredentialAttributes checks attributes required by Google Cloud to access
|
||||
// GetCredentialAttributes checks attributes required by GCP to access
|
||||
// a list of instances and populate them into Boundary's host catalog
|
||||
func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
|
||||
badFields := make(map[string]string)
|
||||
|
||||
project, err := values.GetStringValue(in, ConstProject, true)
|
||||
projectId, err := values.GetStringValue(in, ConstProjectId, true)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstProject)] = err.Error()
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstProjectId)] = err.Error()
|
||||
}
|
||||
|
||||
zone, err := values.GetStringValue(in, ConstZone, true)
|
||||
@@ -33,12 +45,124 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstZone)] = err.Error()
|
||||
}
|
||||
|
||||
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
|
||||
}
|
||||
|
||||
clientEmail, err := values.GetStringValue(in, ConstClientEmail, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = err.Error()
|
||||
}
|
||||
|
||||
targetServiceAccountId, err := values.GetStringValue(in, ConstTargetServiceAccountID, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstTargetServiceAccountID)] = err.Error()
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Error in the attributes provider", badFields)
|
||||
}
|
||||
|
||||
return &CredentialAttributes{
|
||||
Project: project,
|
||||
Zone: zone,
|
||||
ProjectId: projectId,
|
||||
Zone: zone,
|
||||
DisableCredentialRotation: disableCredentialRotation,
|
||||
ClientEmail: clientEmail,
|
||||
TargetServiceAccountId: targetServiceAccountId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
|
||||
// Config used for configuring an GCP session. An status error is returned
|
||||
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
|
||||
// struct input.
|
||||
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*Config, error) {
|
||||
// initialize secrets if it is nil
|
||||
// secrets can be nil if the authentication type is Application Default Credential(ADC)
|
||||
if secrets == nil {
|
||||
secrets = &structpb.Struct{
|
||||
Fields: make(map[string]*structpb.Value),
|
||||
}
|
||||
}
|
||||
|
||||
// initialize attrs if it is nil
|
||||
// attributes can be nil if the authentication type is Application Default Credential(ADC)
|
||||
// where the credentials are inferred from the environment and no fields are provided
|
||||
// https://cloud.google.com/docs/authentication/application-default-credentials
|
||||
if attrs == nil {
|
||||
attrs = &CredentialAttributes{}
|
||||
}
|
||||
|
||||
unknownFields := values.StructFields(secrets)
|
||||
badFields := make(map[string]string)
|
||||
|
||||
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKeyId)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKeyId)
|
||||
|
||||
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
|
||||
if err != nil {
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = err.Error()
|
||||
}
|
||||
delete(unknownFields, ConstPrivateKey)
|
||||
|
||||
delete(unknownFields, ConstCredsLastRotatedTime)
|
||||
for s := range unknownFields {
|
||||
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
|
||||
}
|
||||
|
||||
switch {
|
||||
// service account impersonation authentication requires the target service account id and client_email
|
||||
case attrs.TargetServiceAccountId != "" && attrs.ClientEmail == "":
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
|
||||
// service account impersonation authentication requires the target service account id and private key
|
||||
case attrs.TargetServiceAccountId != "" && privateKey == "":
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
|
||||
// service account key credentials requires the private key and client email
|
||||
case privateKey != "" && attrs.ClientEmail == "":
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when private key is set"
|
||||
// service account key credentials requires the client_email
|
||||
case attrs.ClientEmail != "" && privateKey == "":
|
||||
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when client email is set"
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
|
||||
}
|
||||
|
||||
cfgOpts := []Option{}
|
||||
if privateKeyId != "" && privateKey != "" {
|
||||
cfgOpts = append(cfgOpts,
|
||||
WithPrivateKey(privateKey),
|
||||
WithPrivateKeyId(privateKeyId))
|
||||
}
|
||||
|
||||
if attrs.ProjectId != "" {
|
||||
cfgOpts = append(cfgOpts, WithProjectId(attrs.ProjectId))
|
||||
}
|
||||
if attrs.ClientEmail != "" {
|
||||
cfgOpts = append(cfgOpts, WithClientEmail(attrs.ClientEmail))
|
||||
}
|
||||
if attrs.Zone != "" {
|
||||
cfgOpts = append(cfgOpts, WithZone(attrs.Zone))
|
||||
}
|
||||
if privateKey != "" {
|
||||
cfgOpts = append(cfgOpts, WithPrivateKey(privateKey))
|
||||
}
|
||||
if privateKeyId != "" {
|
||||
cfgOpts = append(cfgOpts, WithPrivateKeyId(privateKeyId))
|
||||
}
|
||||
if attrs.TargetServiceAccountId != "" {
|
||||
cfgOpts = append(cfgOpts, WithTargetServiceAccountId(attrs.TargetServiceAccountId))
|
||||
}
|
||||
|
||||
credentialsConfig, err := NewConfig(cfgOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating credentials config: %w", err)
|
||||
}
|
||||
|
||||
return credentialsConfig, nil
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestGetCredentialAttributes(t *testing.T) {
|
||||
{
|
||||
name: "missing project",
|
||||
in: map[string]any{},
|
||||
expectedErrContains: "missing required value \"project\"",
|
||||
expectedErrContains: "missing required value \"project_id\"",
|
||||
},
|
||||
{
|
||||
name: "missing zone",
|
||||
@@ -32,12 +32,52 @@ func TestGetCredentialAttributes(t *testing.T) {
|
||||
{
|
||||
name: "valid project and zone",
|
||||
in: map[string]any{
|
||||
ConstProject: "test-project",
|
||||
ConstZone: "us-central-1",
|
||||
ConstProjectId: "test-project",
|
||||
ConstZone: "us-central-1",
|
||||
},
|
||||
expected: &CredentialAttributes{
|
||||
Project: "test-project",
|
||||
Zone: "us-central-1",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1",
|
||||
DisableCredentialRotation: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with disable_credential_rotation",
|
||||
in: map[string]any{
|
||||
ConstDisableCredentialRotation: true,
|
||||
ConstProjectId: "test-project",
|
||||
ConstZone: "us-central-1",
|
||||
},
|
||||
expected: &CredentialAttributes{
|
||||
DisableCredentialRotation: true,
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with client_email",
|
||||
in: map[string]any{
|
||||
ConstClientEmail: "test@test.com",
|
||||
ConstProjectId: "test-project",
|
||||
ConstZone: "us-central-1",
|
||||
},
|
||||
expected: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with target_service_account_id",
|
||||
in: map[string]any{
|
||||
ConstTargetServiceAccountID: "test-target-service-account",
|
||||
ConstProjectId: "test-project",
|
||||
ConstZone: "us-central-1",
|
||||
},
|
||||
expected: &CredentialAttributes{
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -59,8 +99,177 @@ func TestGetCredentialAttributes(t *testing.T) {
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.EqualValues(tc.expected.Project, actual.Project)
|
||||
require.EqualValues(tc.expected.ProjectId, actual.ProjectId)
|
||||
require.EqualValues(tc.expected.Zone, actual.Zone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCredentialsConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
secrets map[string]any
|
||||
attrs *CredentialAttributes
|
||||
expected *Config
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "nil secrets",
|
||||
attrs: &CredentialAttributes{
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
Zone: "us-central-1a",
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
attrs: nil,
|
||||
expected: &Config{
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown fields in secrets",
|
||||
secrets: map[string]any{
|
||||
"unknown_field": "value",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "secrets.unknown_field: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "valid ignore creds_last_rotated_time",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
ConstCredsLastRotatedTime: "2006-01-02T15:04:05+07:00",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
ClientEmail: "test@test.com",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing private key with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstClientEmail: "test@test.com",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with target service account id",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKey: "private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expectedErrContains: "must not be empty when target service account id is set",
|
||||
},
|
||||
{
|
||||
name: "missing client email with private key",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when private key is set",
|
||||
},
|
||||
{
|
||||
name: "missing private key with client email",
|
||||
secrets: map[string]any{},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expectedErrContains: "must not be empty when client email is set",
|
||||
},
|
||||
{
|
||||
name: "valid static credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid dynamic credentials",
|
||||
secrets: map[string]any{
|
||||
ConstPrivateKeyId: "test-private-key-id",
|
||||
ConstPrivateKey: "test-private-key",
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
ClientEmail: "test@test.com",
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project",
|
||||
Zone: "us-central-1a",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
TargetServiceAccountId: "test-target-service-account",
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
secrets, err := structpb.NewStruct(tc.secrets)
|
||||
require.NoError(err)
|
||||
|
||||
actual, err := GetCredentialsConfig(secrets, tc.attrs)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.EqualValues(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/impersonate"
|
||||
googleOption "google.golang.org/api/option"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// impersonateServiceAccount is a function variable that creates a TokenSource
|
||||
var impersonateServiceAccountFn = func(
|
||||
ctx context.Context,
|
||||
config impersonate.CredentialsConfig,
|
||||
opts ...googleOption.ClientOption,
|
||||
) (oauth2.TokenSource, error) {
|
||||
return impersonate.CredentialsTokenSource(ctx, config, opts...)
|
||||
}
|
||||
|
||||
// findDefaultCredentialsFn is a function variable that finds the default credentials
|
||||
var findDefaultCredentialsFn = func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
|
||||
return google.FindDefaultCredentials(ctx, scopes...)
|
||||
}
|
||||
|
||||
const (
|
||||
// serviceAccountTokenLifetime is the lifetime of the generated GCP authentication token.
|
||||
// The token is valid for 1 hour.
|
||||
serviceAccountTokenLifetime = time.Hour
|
||||
// defaultGCPScope is the default scope for GCP.
|
||||
// GCP requires the cloud-platform scope to access all GCP services.
|
||||
// GCP recommends setting the scope to https://www.googleapis.com/auth/cloud-platform
|
||||
// and then controlling the service account's access by granting it IAM roles.
|
||||
// https://cloud.google.com/compute/docs/access/service-accounts#scopes_best_practice
|
||||
defaultGCPScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||
)
|
||||
|
||||
// Config is the configuration for the GCP credential.
|
||||
type Config struct {
|
||||
ProjectId string
|
||||
PrivateKey string
|
||||
PrivateKeyId string
|
||||
Zone string
|
||||
ClientEmail string
|
||||
TargetServiceAccountId string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// credentials represents a simplified version of the GCP credentials file format.
|
||||
type credentials struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
Type string `json:"type"`
|
||||
ProjectId string `json:"project_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
PrivateKeyId string `json:"private_key_id"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new GCP credential configuration
|
||||
// based on the provided options.
|
||||
// If the options are invalid, it will return an error.
|
||||
func NewConfig(opt ...Option) (*Config, error) {
|
||||
opts, err := getOpts(opt...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Config{
|
||||
ProjectId: opts.WithProjectId,
|
||||
PrivateKey: opts.WithPrivateKey,
|
||||
PrivateKeyId: opts.WithPrivateKeyId,
|
||||
ClientEmail: opts.WithClientEmail,
|
||||
TargetServiceAccountId: opts.WithTargetServiceAccountId,
|
||||
Zone: opts.WithZone,
|
||||
Scopes: opts.WithScopes,
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// toCredentials converts the config to credentials.
|
||||
func (c *Config) toCredentials() *credentials {
|
||||
return &credentials{
|
||||
Type: "service_account",
|
||||
ProjectId: c.ProjectId,
|
||||
PrivateKey: c.PrivateKey,
|
||||
PrivateKeyId: c.PrivateKeyId,
|
||||
ClientEmail: c.ClientEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// clone returns a copy of the configuration.
|
||||
func (c *Config) clone() *Config {
|
||||
return &Config{
|
||||
ProjectId: c.ProjectId,
|
||||
Zone: c.Zone,
|
||||
PrivateKey: c.PrivateKey,
|
||||
PrivateKeyId: c.PrivateKeyId,
|
||||
ClientEmail: c.ClientEmail,
|
||||
TargetServiceAccountId: c.TargetServiceAccountId,
|
||||
Scopes: c.Scopes,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateCredentials generates GCP credentials based on the provided configuration.
|
||||
// It supports Service Account Key, Service Account Impersonation, and ADC.
|
||||
// If the credentials are already generated, it will return the cached credentials.
|
||||
func (c *Config) GenerateCredentials(ctx context.Context) (*google.Credentials, error) {
|
||||
var creds *google.Credentials
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case c.PrivateKey != "" && c.ClientEmail != "" && c.TargetServiceAccountId != "":
|
||||
creds, err = c.credentialsFromImpersonation(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(
|
||||
codes.Internal,
|
||||
"failed to generate credentials from service account impersonation: %v", err,
|
||||
)
|
||||
}
|
||||
case c.PrivateKey != "" && c.ClientEmail != "":
|
||||
creds, err = c.credentialsFromServiceAccountKey(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(
|
||||
codes.Internal,
|
||||
"failed to generate credentials from service account key: %v", err,
|
||||
)
|
||||
}
|
||||
default:
|
||||
creds, err = c.credentialsFromADC(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(
|
||||
codes.Internal,
|
||||
"failed to generate credentials from Application Default Credentials: %v", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// credentialsFromServiceAccountKey generates the credentials from the service account key.
|
||||
func (c *Config) credentialsFromServiceAccountKey(ctx context.Context) (*google.Credentials, error) {
|
||||
if c.PrivateKey == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "private_key is required")
|
||||
}
|
||||
if c.ClientEmail == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "client_email is required")
|
||||
}
|
||||
|
||||
credBytes, err := json.Marshal(c.toCredentials())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "error marshaling credentials")
|
||||
}
|
||||
creds, err := google.CredentialsFromJSON(ctx, credBytes, c.Scopes...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to parse credentials: %v", err)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// credentialsFromImpersonation generates the credentials from the impersonation.
|
||||
func (c *Config) credentialsFromImpersonation(ctx context.Context) (*google.Credentials, error) {
|
||||
if c.PrivateKey == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "private_key is required")
|
||||
}
|
||||
if c.ClientEmail == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "client_email is required")
|
||||
}
|
||||
if c.TargetServiceAccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "target_service_account_id is required")
|
||||
}
|
||||
|
||||
credBytes, err := json.Marshal(c.toCredentials())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "error marshaling credentials")
|
||||
}
|
||||
creds, err := impersonateServiceAccount(ctx, c.ProjectId, credBytes, c.TargetServiceAccountId, c.Scopes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error impersonating service account: %v", err)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// credentialsFromADC generates the credentials from the Application Default Credentials (ADC).
|
||||
func (c *Config) credentialsFromADC(ctx context.Context) (*google.Credentials, error) {
|
||||
creds, err := findDefaultCredentialsFn(ctx, c.Scopes...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to find default credentials: %v", err)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// IsRotatable returns a boolean indicating if the credentials are rotatable.
|
||||
func (c *Config) IsRotatable() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(c.TargetServiceAccountId) > 0 && len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
case len(c.PrivateKey) > 0 && len(c.ClientEmail) > 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// impersonateServiceAccount impersonates the service account.
|
||||
// This is used to impersonate the service account to generate an access token.
|
||||
func impersonateServiceAccount(
|
||||
ctx context.Context,
|
||||
projectId string,
|
||||
baseCredentialsJSON []byte,
|
||||
targetServiceAccountId string,
|
||||
scopes []string,
|
||||
) (*google.Credentials, error) {
|
||||
if baseCredentialsJSON == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "base_service_account is required")
|
||||
}
|
||||
if targetServiceAccountId == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "target_service_account_id is required")
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "scope is required")
|
||||
}
|
||||
|
||||
ts, err := impersonateServiceAccountFn(ctx, impersonate.CredentialsConfig{
|
||||
TargetPrincipal: targetServiceAccountId,
|
||||
Scopes: scopes,
|
||||
Lifetime: serviceAccountTokenLifetime,
|
||||
}, googleOption.WithCredentialsJSON(baseCredentialsJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create impersonated token source: %v", err)
|
||||
}
|
||||
|
||||
return &google.Credentials{
|
||||
ProjectID: projectId,
|
||||
TokenSource: ts,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/impersonate"
|
||||
googleOption "google.golang.org/api/option"
|
||||
)
|
||||
|
||||
func TestGenerateCredentials(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testImpersonateServiceFn := func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
|
||||
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
|
||||
}
|
||||
impersonateServiceAccountFn = testImpersonateServiceFn
|
||||
|
||||
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA1y+Vmxg1hK6j0ef6
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Service Account Key Authentication",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test_email@example.com",
|
||||
ProjectId: "test_project",
|
||||
PrivateKeyId: "test_key_id",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Service Account Impersonation",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test_email@example.com",
|
||||
ProjectId: "test_project",
|
||||
PrivateKeyId: "test_key_id",
|
||||
TargetServiceAccountId: "target_service_account@example.com",
|
||||
Scopes: []string{"test_scope"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Application Default Credentials",
|
||||
config: &Config{},
|
||||
wantErr: true, // Since ADC might not be available in test environment
|
||||
expectedErr: "failed to find default credentials",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
creds, err := tt.config.GenerateCredentials(ctx)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, tt.expectedErr)
|
||||
require.Nil(t, creds)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
require.Equal(t, tt.config.ProjectId, creds.ProjectID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsFromServiceAccountKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Define a fake but correctly formatted PEM-encoded private key.
|
||||
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
// Test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Successful Credential Generation",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Missing Private Key",
|
||||
config: &Config{
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "private_key is required",
|
||||
},
|
||||
{
|
||||
name: "Missing Client Email",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "client_email is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
creds, err := tt.config.credentialsFromServiceAccountKey(ctx)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, tt.expectedErr)
|
||||
require.Nil(t, creds)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsFromADC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
mockTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
|
||||
AccessToken: "mock_access_token",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mockFindDefaultCred func(ctx context.Context, scopes ...string) (*google.Credentials, error)
|
||||
wantErr bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Successful ADC Retrieval",
|
||||
mockFindDefaultCred: func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
|
||||
return &google.Credentials{
|
||||
ProjectID: "test-project-id",
|
||||
TokenSource: mockTokenSource,
|
||||
}, nil
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ADC Not Available",
|
||||
mockFindDefaultCred: func(ctx context.Context, scopes ...string) (*google.Credentials, error) {
|
||||
return nil, errors.New("ADC not available")
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "failed to find default credentials",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Override findDefaultCredentials with the mock implementation.
|
||||
findDefaultCredentialsFn = tt.mockFindDefaultCred
|
||||
|
||||
config := &Config{}
|
||||
|
||||
creds, err := config.credentialsFromADC(ctx)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, tt.expectedErr)
|
||||
require.Nil(t, creds)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
require.Equal(t, "test-project-id", creds.ProjectID, "Expected the project ID to be 'test-project-id'")
|
||||
require.Equal(t, mockTokenSource, creds.TokenSource, "Expected the mock token source")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialsFromImpersonation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Define a fake but correctly formatted PEM-encoded private key.
|
||||
const fakePrivateKey = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
testImpersonateServiceFn := func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
|
||||
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
|
||||
}
|
||||
impersonateServiceAccountFn = testImpersonateServiceFn
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
mockImpersonateServiceAccountFn func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error)
|
||||
wantErr bool
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "Successful Impersonation",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
Scopes: []string{"test-scope"},
|
||||
},
|
||||
mockImpersonateServiceAccountFn: func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
|
||||
return oauth2.StaticTokenSource(&oauth2.Token{}), nil
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Error during Impersonation",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
Scopes: []string{"test-scope"},
|
||||
},
|
||||
mockImpersonateServiceAccountFn: func(ctx context.Context, config impersonate.CredentialsConfig, opts ...googleOption.ClientOption) (oauth2.TokenSource, error) {
|
||||
return nil, errors.New("mock impersonation error")
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "failed to create impersonated token source",
|
||||
},
|
||||
{
|
||||
name: "Missing Private Key",
|
||||
config: &Config{
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "private_key is required",
|
||||
},
|
||||
{
|
||||
name: "Missing Client Email",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
},
|
||||
mockImpersonateServiceAccountFn: nil,
|
||||
wantErr: true,
|
||||
expectedErr: "client_email is required",
|
||||
},
|
||||
{
|
||||
name: "Missing Target Service Account ID",
|
||||
config: &Config{
|
||||
PrivateKey: fakePrivateKey,
|
||||
ClientEmail: "test-email@example.com",
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
},
|
||||
mockImpersonateServiceAccountFn: nil,
|
||||
wantErr: true,
|
||||
expectedErr: "target_service_account_id is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.mockImpersonateServiceAccountFn != nil {
|
||||
impersonateServiceAccountFn = tt.mockImpersonateServiceAccountFn
|
||||
}
|
||||
|
||||
creds, err := tt.config.credentialsFromImpersonation(ctx)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, tt.expectedErr)
|
||||
require.Nil(t, creds)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, creds)
|
||||
require.Equal(t, tt.config.ProjectId, creds.ProjectID)
|
||||
|
||||
token, err := creds.TokenSource.Token()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
originalConfig := &Config{
|
||||
ProjectId: "original-project-id",
|
||||
PrivateKey: "original-private-key",
|
||||
PrivateKeyId: "original-private-key-id",
|
||||
ClientEmail: "original-client-email@example.com",
|
||||
TargetServiceAccountId: "original-target-service-account-id",
|
||||
Scopes: []string{"scope1", "scope2"},
|
||||
}
|
||||
|
||||
clonedConfig := originalConfig.clone()
|
||||
|
||||
require.NotNil(t, clonedConfig)
|
||||
require.Equal(t, originalConfig.ProjectId, clonedConfig.ProjectId)
|
||||
require.Equal(t, originalConfig.PrivateKey, clonedConfig.PrivateKey)
|
||||
require.Equal(t, originalConfig.PrivateKeyId, clonedConfig.PrivateKeyId)
|
||||
require.Equal(t, originalConfig.ClientEmail, clonedConfig.ClientEmail)
|
||||
require.Equal(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
||||
require.Equal(t, originalConfig.Scopes, clonedConfig.Scopes)
|
||||
|
||||
// Ensure that modifying the cloned config does not affect the original config
|
||||
clonedConfig.ProjectId = "modified-project-id"
|
||||
clonedConfig.PrivateKey = "modified-private-key"
|
||||
clonedConfig.PrivateKeyId = "modified-private-key-id"
|
||||
clonedConfig.ClientEmail = "modified-client-email@example.com"
|
||||
clonedConfig.TargetServiceAccountId = "modified-target-service-account-id"
|
||||
clonedConfig.Scopes = []string{"modified-scope1", "modified-scope2"}
|
||||
|
||||
require.NotEqual(t, originalConfig.ProjectId, clonedConfig.ProjectId)
|
||||
require.NotEqual(t, originalConfig.PrivateKey, clonedConfig.PrivateKey)
|
||||
require.NotEqual(t, originalConfig.PrivateKeyId, clonedConfig.PrivateKeyId)
|
||||
require.NotEqual(t, originalConfig.ClientEmail, clonedConfig.ClientEmail)
|
||||
require.NotEqual(t, originalConfig.TargetServiceAccountId, clonedConfig.TargetServiceAccountId)
|
||||
require.NotEqual(t, originalConfig.Scopes, clonedConfig.Scopes)
|
||||
}
|
||||
|
||||
func TestIsRotatable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Nil Config",
|
||||
config: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Service Account Impersonation Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Static GCP Config",
|
||||
config: &Config{
|
||||
PrivateKey: "fake-private-key",
|
||||
ClientEmail: "test-email@example.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Default Config",
|
||||
config: &Config{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.config.IsRotatable()
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options []Option
|
||||
expected *Config
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid Options",
|
||||
options: []Option{
|
||||
WithProjectId("test-project-id"),
|
||||
WithPrivateKey("test-private-key"),
|
||||
WithPrivateKeyId("test-private-key-id"),
|
||||
WithClientEmail("test-email@example.com"),
|
||||
WithTargetServiceAccountId("target-service-account@example.com"),
|
||||
WithZone("test-zone"),
|
||||
WithScopes([]string{"scope1", "scope2"}),
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test-email@example.com",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
Zone: "test-zone",
|
||||
Scopes: []string{"scope1", "scope2"},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "No Options",
|
||||
options: []Option{},
|
||||
expected: &Config{
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid Option",
|
||||
options: []Option{
|
||||
func(opts *Options) error {
|
||||
return errors.New("invalid option")
|
||||
},
|
||||
},
|
||||
expected: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no scope set",
|
||||
options: []Option{
|
||||
WithProjectId("test-project-id"),
|
||||
WithPrivateKey("test-private-key"),
|
||||
WithPrivateKeyId("test-private-key-id"),
|
||||
WithClientEmail("test-email@example.com"),
|
||||
WithTargetServiceAccountId("target-service-account@example.com"),
|
||||
WithZone("test-zone"),
|
||||
},
|
||||
expected: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test-email@example.com",
|
||||
TargetServiceAccountId: "target-service-account@example.com",
|
||||
Zone: "test-zone",
|
||||
Scopes: []string{defaultGCPScope},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config, err := NewConfig(tt.options...)
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
require.Nil(t, config)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.Equal(t, tt.expected, config)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,27 @@
|
||||
package credential
|
||||
|
||||
const (
|
||||
// ConstProject defines the attribute name for a Google Cloud project
|
||||
ConstProject = "project"
|
||||
// ConstProjectId defines the attribute name for a GCP project
|
||||
ConstProjectId = "project_id"
|
||||
|
||||
// ConstZone defines the attribute name for a Google Cloud zone
|
||||
// ConstZone defines the attribute name for a GCP zone
|
||||
ConstZone = "zone"
|
||||
|
||||
// ConstDisableCredentialRotation is the key for the disable credential rotation in the GCP credentials.
|
||||
ConstDisableCredentialRotation = "disable_credential_rotation"
|
||||
|
||||
// ConstCredsLastRotatedTime is the key for the last rotated time in the GCP credentials.
|
||||
ConstCredsLastRotatedTime = "creds_last_rotated_time"
|
||||
|
||||
// ConstClientEmail is the email address associated with the service account
|
||||
ConstClientEmail = "client_email"
|
||||
|
||||
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
|
||||
ConstTargetServiceAccountID = "target_service_account_id"
|
||||
|
||||
// ConstPrivateKeyId is the private key id associated with the service account
|
||||
ConstPrivateKeyId = "private_key_id"
|
||||
|
||||
// ConstPrivateKey is the private key associated with the service account
|
||||
ConstPrivateKey = "private_key"
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// options = how options are represented
|
||||
type Options struct {
|
||||
WithCredentialsConfig *Config
|
||||
WithCredsLastRotatedTime time.Time
|
||||
WithClientEmail string
|
||||
WithProjectId string
|
||||
WithTargetServiceAccountId string
|
||||
WithZone string
|
||||
WithPrivateKeyId string
|
||||
WithPrivateKey string
|
||||
WithScopes []string
|
||||
}
|
||||
|
||||
// getOpts - iterate the inbound Options and return a struct
|
||||
func getOpts(opts ...Option) (*Options, error) {
|
||||
defaultOptions := getDefaultOptions()
|
||||
for _, opt := range opts {
|
||||
if err := opt(defaultOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return defaultOptions, nil
|
||||
}
|
||||
|
||||
// Option - how Options are passed as arguments
|
||||
type Option func(*Options) error
|
||||
|
||||
func getDefaultOptions() *Options {
|
||||
return &Options{
|
||||
WithCredentialsConfig: &Config{},
|
||||
WithScopes: []string{defaultGCPScope},
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredentialsConfig - set the credentials config
|
||||
func WithCredentialsConfig(c *Config) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithCredentialsConfig = c
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredsLastRotatedTime - The last time the service account key was
|
||||
// rotated
|
||||
func WithCredsLastRotatedTime(t time.Time) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithCredsLastRotatedTime = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClientEmail - The email address associated with the service account.
|
||||
// The email address used to uniquely identify the service account
|
||||
func WithClientEmail(email string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithClientEmail = email
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithProjectId - The project ID associated with the service account
|
||||
func WithProjectId(id string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithProjectId = id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithTargetServiceAccountId - The account that will be impersonated.
|
||||
// This account has permission to perform actions that the base service
|
||||
// account does not have.
|
||||
func WithTargetServiceAccountId(id string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithTargetServiceAccountId = id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithZone - The zone where the GCP resources are located
|
||||
func WithZone(zone string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithZone = zone
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithPrivateKeyId - The private key ID
|
||||
// of the GCP service account
|
||||
func WithPrivateKeyId(id string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithPrivateKeyId = id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithPrivateKey - set the GCP service account
|
||||
// private key which is used to authentication
|
||||
func WithPrivateKey(key string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithPrivateKey = key
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithScopes - set the GCP scope that defines the level
|
||||
// of access that the requested access token will have.
|
||||
// This option is required when authenticating with
|
||||
// Service Account Impersonation.
|
||||
func WithScopes(scopes []string) Option {
|
||||
return func(o *Options) error {
|
||||
o.WithScopes = scopes
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_getOpts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("WithCredentialsConfig", func(t *testing.T) {
|
||||
c := &Config{
|
||||
ProjectId: "test",
|
||||
ClientEmail: "test",
|
||||
}
|
||||
opts, err := getOpts(WithCredentialsConfig(c))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithCredentialsConfig = c
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithCredsLastRotatedTime", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithCredsLastRotatedTime)
|
||||
|
||||
tm := time.Now()
|
||||
opts, err = getOpts(WithCredsLastRotatedTime(tm))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithCredsLastRotatedTime = tm
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithClientEmail", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithClientEmail)
|
||||
|
||||
email := "test"
|
||||
opts, err = getOpts(WithClientEmail(email))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithClientEmail = email
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithProjectId", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithProjectId)
|
||||
|
||||
projectID := "test"
|
||||
opts, err = getOpts(WithProjectId(projectID))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithProjectId = projectID
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithTargetServiceAccountId", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithTargetServiceAccountId)
|
||||
|
||||
serviceAccountID := "test"
|
||||
opts, err = getOpts(WithTargetServiceAccountId(serviceAccountID))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithTargetServiceAccountId = serviceAccountID
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithZone", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithZone)
|
||||
|
||||
zone := "test"
|
||||
opts, err = getOpts(WithZone(zone))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithZone = zone
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithPrivateKey", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithPrivateKey)
|
||||
|
||||
privateKey := "test"
|
||||
opts, err = getOpts(WithPrivateKey(privateKey))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithPrivateKey = privateKey
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithPrivateKeyId", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, opts.WithPrivateKeyId)
|
||||
|
||||
privateKeyID := "test"
|
||||
opts, err = getOpts(WithPrivateKeyId(privateKeyID))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithPrivateKeyId = privateKeyID
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
t.Run("WithScopes", func(t *testing.T) {
|
||||
opts, err := getOpts()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{defaultGCPScope}, opts.WithScopes)
|
||||
|
||||
scopes := []string{"test"}
|
||||
opts, err = getOpts(WithScopes(scopes))
|
||||
require.NoError(t, err)
|
||||
testOpts := getDefaultOptions()
|
||||
testOpts.WithScopes = scopes
|
||||
require.Equal(t, opts, testOpts)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
admin "cloud.google.com/go/iam/admin/apiv1"
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
resourcemanager "cloud.google.com/go/resourcemanager/apiv3"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// ComputeInstancesListPermission is the IAM permission required
|
||||
// to list compute instances.
|
||||
ComputeInstancesListPermission = "compute.instances.list"
|
||||
// IAMServiceAccountKeysCreatePermission is the IAM permission
|
||||
// required to create a service account key.
|
||||
// This permission is required to rotate service account keys.
|
||||
IAMServiceAccountKeysCreatePermission = "iam.serviceAccountKeys.create"
|
||||
// IAMServiceAccountKeysDeletePermission is the IAM permission
|
||||
// required to delete a service account key.
|
||||
// This permission is required to rotate service account keys.
|
||||
IAMServiceAccountKeysDeletePermission = "iam.serviceAccountKeys.delete"
|
||||
// validateServiceAccountKeyTimeout is the timeout for validating
|
||||
// a service account key.
|
||||
validateServiceAccountKeyTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ServiceAccountPrivateKey represents a decoded PrivateKeyData
|
||||
// from a Service Account Key.
|
||||
// https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys#ServiceAccountKey
|
||||
type ServiceAccountPrivateKey struct {
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"project_id"`
|
||||
PrivateKeyID string `json:"private_key_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ClientEmail string `json:"client_email"`
|
||||
ClientID string `json:"client_id"`
|
||||
AuthURI string `json:"auth_uri"`
|
||||
TokenURI string `json:"token_uri"`
|
||||
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
|
||||
ClientX509CertURL string `json:"client_x509_cert_url"`
|
||||
}
|
||||
|
||||
// ValidateCredsCallback is called to validate the credentials after rotating the service account key.
|
||||
// The callback should return an error if the credentials are invalid.
|
||||
type ValidateCredsCallback func(*Config, ...option.ClientOption) error
|
||||
|
||||
// RotateServiceAccountKey takes the private key from this credentials config
|
||||
// and first creates a new private key and private key id, then deletes the
|
||||
// old private key.
|
||||
//
|
||||
// If deletion of the old private key is successful, the new private key and
|
||||
// private key id are written into the credentials config and nil is returned.
|
||||
// On any error, the old credentials are not overwritten.
|
||||
func (c *Config) RotateServiceAccountKey(
|
||||
ctx context.Context,
|
||||
permissions []string,
|
||||
validateCredsCallback ValidateCredsCallback,
|
||||
opts ...option.ClientOption,
|
||||
) error {
|
||||
if c.PrivateKey == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key is not set")
|
||||
}
|
||||
if c.PrivateKeyId == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key ID is not set")
|
||||
}
|
||||
if c.ClientEmail == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot rotate credentials when client email is not set")
|
||||
}
|
||||
|
||||
creds, err := c.GenerateCredentials(ctx)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
||||
}
|
||||
if creds.TokenSource == nil {
|
||||
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
||||
}
|
||||
|
||||
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
||||
clientOptions = append(clientOptions, opts...)
|
||||
|
||||
iamClient, err := admin.NewIamClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
|
||||
}
|
||||
|
||||
createServiceAccountKeyRes, err := iamClient.CreateServiceAccountKey(ctx, &adminpb.CreateServiceAccountKeyRequest{
|
||||
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s", c.ProjectId, c.ClientEmail),
|
||||
PrivateKeyType: adminpb.ServiceAccountPrivateKeyType_TYPE_GOOGLE_CREDENTIALS_FILE,
|
||||
KeyAlgorithm: adminpb.ServiceAccountKeyAlgorithm_KEY_ALG_RSA_2048,
|
||||
})
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error creating service account key: %v", err)
|
||||
}
|
||||
|
||||
var serviceAccountKey ServiceAccountPrivateKey
|
||||
err = json.Unmarshal(createServiceAccountKeyRes.PrivateKeyData, &serviceAccountKey)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error unmarshalling service account key: %v", err)
|
||||
}
|
||||
|
||||
// Clone the config, update the private key and private key ID with the new key.
|
||||
newConfig := c.clone()
|
||||
newConfig.PrivateKey = serviceAccountKey.PrivateKey
|
||||
newConfig.PrivateKeyId = serviceAccountKey.PrivateKeyID
|
||||
|
||||
newCreds, err := newConfig.GenerateCredentials(ctx)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
||||
}
|
||||
if newCreds.TokenSource == nil {
|
||||
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
||||
}
|
||||
clientOptions = []option.ClientOption{option.WithTokenSource(newCreds.TokenSource)}
|
||||
clientOptions = append(clientOptions, opts...)
|
||||
|
||||
// Validate the new service account key
|
||||
err = newConfig.ValidateServiceAccountKey(ctx, permissions, validateCredsCallback, clientOptions...)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.PermissionDenied, "error validating rotated service account key: %v", err)
|
||||
}
|
||||
|
||||
iamClient, err = admin.NewIamClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error creating IAM client with rotated service account key: %v", err)
|
||||
}
|
||||
|
||||
err = iamClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
|
||||
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
|
||||
})
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error deleting service account key: %v", err)
|
||||
}
|
||||
|
||||
c.PrivateKey = newConfig.PrivateKey
|
||||
c.PrivateKeyId = newConfig.PrivateKeyId
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateServiceAccountKey validates the service account key by checking the IAM permissions
|
||||
// and calling the validation callback.
|
||||
// The function will retry validation until the timeout is reached.
|
||||
func (c *Config) ValidateServiceAccountKey(
|
||||
ctx context.Context,
|
||||
permissions []string,
|
||||
validateCredsCallback ValidateCredsCallback,
|
||||
opts ...option.ClientOption) error {
|
||||
var validatePermissionsErr error
|
||||
var validationCallbackErr error
|
||||
|
||||
for start := time.Now(); time.Since(start) < validateServiceAccountKeyTimeout; time.Sleep(200 * time.Millisecond) {
|
||||
_, validatePermissionsErr = c.ValidateIamPermissions(ctx, permissions, opts...)
|
||||
|
||||
if validateCredsCallback != nil {
|
||||
validationCallbackErr = validateCredsCallback(c, opts...)
|
||||
}
|
||||
|
||||
if validatePermissionsErr == nil && validationCallbackErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(validatePermissionsErr, validationCallbackErr)
|
||||
}
|
||||
|
||||
func (c *Config) DeletePrivateKey(ctx context.Context, opts ...option.ClientOption) error {
|
||||
if c.PrivateKey == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set")
|
||||
}
|
||||
if c.PrivateKeyId == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set")
|
||||
}
|
||||
if c.ClientEmail == "" {
|
||||
return status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set")
|
||||
}
|
||||
|
||||
creds, err := c.GenerateCredentials(ctx)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
||||
}
|
||||
if creds.TokenSource == nil {
|
||||
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
||||
}
|
||||
|
||||
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
||||
clientOptions = append(clientOptions, opts...)
|
||||
|
||||
iamClient, err := admin.NewIamClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
|
||||
}
|
||||
|
||||
err = iamClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
|
||||
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
|
||||
})
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Unknown, "error deleting service account key: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateIamPermissions tests the IAM permissions for the credentials.
|
||||
// It returns the granted permissions if successful.
|
||||
func (c *Config) ValidateIamPermissions(ctx context.Context, permissions []string, opts ...option.ClientOption) ([]string, error) {
|
||||
if len(permissions) == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "permissions are required")
|
||||
}
|
||||
|
||||
rmClient, err := resourcemanager.NewProjectsClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create Resource Manager client: %v", err)
|
||||
}
|
||||
|
||||
resp, err := rmClient.TestIamPermissions(ctx, &iampb.TestIamPermissionsRequest{
|
||||
Resource: "projects/" + c.ProjectId,
|
||||
Permissions: permissions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to validate IAM permissions: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Permissions) == 0 {
|
||||
return nil, status.Error(codes.PermissionDenied, "no permissions granted")
|
||||
}
|
||||
|
||||
if len(resp.Permissions) != len(permissions) {
|
||||
missingPermissions := make([]string, 0, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
found := slices.Contains(resp.Permissions, permission)
|
||||
if !found {
|
||||
missingPermissions = append(missingPermissions, permission)
|
||||
}
|
||||
}
|
||||
return nil, status.Errorf(codes.PermissionDenied, "missing permissions: %v", missingPermissions)
|
||||
}
|
||||
|
||||
return resp.Permissions, nil
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
"github.com/stretchr/testify/require"
|
||||
googleOption "google.golang.org/api/option"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestRotateServiceAccountKey(t *testing.T) {
|
||||
testIAMAdminServer := &testIAMAdminServer{}
|
||||
testResourceServer := &testResourceServer{
|
||||
testIamPermissionsResponse: &iampb.TestIamPermissionsResponse{
|
||||
Permissions: []string{
|
||||
ComputeInstancesListPermission,
|
||||
IAMServiceAccountKeysCreatePermission,
|
||||
IAMServiceAccountKeysDeletePermission,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
setup func()
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "PrivateKey is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when private key is not set"),
|
||||
},
|
||||
{
|
||||
name: "PrivateKeyId is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when private key ID is not set"),
|
||||
},
|
||||
{
|
||||
name: "ClientEmail is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot rotate credentials when client email is not set"),
|
||||
},
|
||||
{
|
||||
name: "CreateServiceAccountKey returns error",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
},
|
||||
setup: func() {
|
||||
testIAMAdminServer.testCreateServiceAccountKeyError = errors.New("create key error")
|
||||
},
|
||||
expectedError: errors.New("create key error"),
|
||||
},
|
||||
{
|
||||
name: "DeleteServiceAccountKey returns error",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
},
|
||||
setup: func() {
|
||||
testIAMAdminServer.testCreateServiceAccountKeyError = nil
|
||||
testIAMAdminServer.testDeleteServiceAccountKeyError = errors.New("delete key error")
|
||||
},
|
||||
expectedError: errors.New("delete key error"),
|
||||
},
|
||||
{
|
||||
name: "Successful rotation",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
},
|
||||
setup: func() {
|
||||
testIAMAdminServer.testCreateServiceAccountKeyError = nil
|
||||
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gsrv := NewGRPCServer()
|
||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
if tt.setup != nil {
|
||||
tt.setup()
|
||||
}
|
||||
testOptions := []googleOption.ClientOption{
|
||||
googleOption.WithEndpoint(addr),
|
||||
googleOption.WithoutAuthentication(),
|
||||
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||
googleOption.WithTokenSource(nil),
|
||||
}
|
||||
initialPrivateKey := tt.config.PrivateKey
|
||||
initialPrivateKeyId := tt.config.PrivateKeyId
|
||||
initialClientEmail := tt.config.ClientEmail
|
||||
|
||||
permissions := []string{
|
||||
ComputeInstancesListPermission,
|
||||
IAMServiceAccountKeysCreatePermission,
|
||||
IAMServiceAccountKeysDeletePermission,
|
||||
}
|
||||
validateCredsCallbackCalled := false
|
||||
|
||||
validateCredsCallBack := func(c *Config, opts ...googleOption.ClientOption) error {
|
||||
validateCredsCallbackCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err = tt.config.RotateServiceAccountKey(ctx, permissions, validateCredsCallBack, testOptions...)
|
||||
if tt.expectedError != nil {
|
||||
require.ErrorContains(t, err, tt.expectedError.Error())
|
||||
require.Equal(t, initialClientEmail, tt.config.ClientEmail)
|
||||
require.Equal(t, initialPrivateKey, tt.config.PrivateKey)
|
||||
require.Equal(t, initialPrivateKeyId, tt.config.PrivateKeyId)
|
||||
return
|
||||
}
|
||||
|
||||
require.Equal(t, initialClientEmail, tt.config.ClientEmail)
|
||||
require.NotEmpty(t, tt.config.PrivateKey)
|
||||
require.NotEmpty(t, tt.config.PrivateKeyId)
|
||||
require.NotEqual(t, initialPrivateKey, tt.config.PrivateKey)
|
||||
require.NotEqual(t, initialPrivateKeyId, tt.config.PrivateKeyId)
|
||||
require.True(t, validateCredsCallbackCalled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIamPermissions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testResourceServer := &testResourceServer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
testIamResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
permissions []string
|
||||
wantErr bool
|
||||
expectedErr string
|
||||
expectedPermissions []string
|
||||
}{
|
||||
{
|
||||
name: "Successful IAM Permissions Test",
|
||||
config: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
testIamResponse: &iampb.TestIamPermissionsResponse{
|
||||
Permissions: []string{"permission1", "permission2"},
|
||||
},
|
||||
permissions: []string{"permission1", "permission2"},
|
||||
expectedPermissions: []string{"permission1", "permission2"},
|
||||
},
|
||||
{
|
||||
name: "Missing Permissions",
|
||||
config: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
},
|
||||
wantErr: true,
|
||||
expectedErr: "permissions are required",
|
||||
},
|
||||
{
|
||||
name: "Failed to Test IAM Permissions",
|
||||
config: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
testIamPermissionsError: errors.New("failed to test IAM permissions"),
|
||||
permissions: []string{"permission1", "permission2"},
|
||||
wantErr: true,
|
||||
expectedErr: "failed to test IAM permissions",
|
||||
},
|
||||
{
|
||||
name: "No Permissions Granted",
|
||||
config: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
testIamResponse: &iampb.TestIamPermissionsResponse{
|
||||
Permissions: []string{},
|
||||
},
|
||||
testIamPermissionsError: nil,
|
||||
permissions: []string{"permission1", "permission2"},
|
||||
wantErr: true,
|
||||
expectedErr: "no permissions granted",
|
||||
},
|
||||
{
|
||||
name: "Missing Permissions",
|
||||
config: &Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
testIamResponse: &iampb.TestIamPermissionsResponse{
|
||||
Permissions: []string{"permission1"},
|
||||
},
|
||||
testIamPermissionsError: nil,
|
||||
permissions: []string{"permission1", "permission2"},
|
||||
wantErr: true,
|
||||
expectedErr: "missing permissions: [permission2]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
|
||||
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
|
||||
|
||||
gsrv := NewGRPCServer()
|
||||
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
testOptions := []googleOption.ClientOption{
|
||||
googleOption.WithEndpoint(addr),
|
||||
googleOption.WithoutAuthentication(),
|
||||
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||
googleOption.WithTokenSource(nil),
|
||||
}
|
||||
permissions, err := tt.config.ValidateIamPermissions(ctx, tt.permissions, testOptions...)
|
||||
if tt.wantErr {
|
||||
require.ErrorContains(t, err, tt.expectedErr)
|
||||
require.Nil(t, permissions)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectedPermissions, permissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestDeletePrivateKey(t *testing.T) {
|
||||
testIAMAdminServer := &testIAMAdminServer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
setup func()
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "PrivateKey is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set"),
|
||||
},
|
||||
{
|
||||
name: "PrivateKeyId is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set"),
|
||||
},
|
||||
{
|
||||
name: "ClientEmail is empty",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "",
|
||||
},
|
||||
expectedError: status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set"),
|
||||
},
|
||||
{
|
||||
name: "GenerateCredentials returns error",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
TargetServiceAccountId: "test-service-account-id",
|
||||
},
|
||||
expectedError: status.Errorf(codes.Unauthenticated, "error generating credentials"),
|
||||
},
|
||||
{
|
||||
name: "DeleteServiceAccountKey returns error",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
},
|
||||
setup: func() {
|
||||
testIAMAdminServer.testDeleteServiceAccountKeyError = errors.New("delete key error")
|
||||
},
|
||||
expectedError: status.Errorf(codes.Unknown, "error deleting service account key"),
|
||||
},
|
||||
{
|
||||
name: "Successful deletion",
|
||||
config: &Config{
|
||||
PrivateKey: "some-private-key",
|
||||
PrivateKeyId: "some-private-key-id",
|
||||
ClientEmail: "client@example.com",
|
||||
},
|
||||
setup: func() {
|
||||
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gsrv := NewGRPCServer()
|
||||
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
|
||||
addr, err := gsrv.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
if tt.setup != nil {
|
||||
tt.setup()
|
||||
}
|
||||
testOptions := []googleOption.ClientOption{
|
||||
googleOption.WithEndpoint(addr),
|
||||
googleOption.WithoutAuthentication(),
|
||||
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
|
||||
googleOption.WithTokenSource(nil),
|
||||
}
|
||||
|
||||
err = tt.config.DeletePrivateKey(ctx, testOptions...)
|
||||
if tt.expectedError != nil {
|
||||
require.ErrorContains(t, err, tt.expectedError.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
type CredentialType int
|
||||
|
||||
// PersistedState is the persisted state for the GCP credential.
|
||||
type PersistedState struct {
|
||||
// CredentialsConfig is the credential configuration for the GCP credential.
|
||||
CredentialsConfig *Config
|
||||
// CredsLastRotatedTime is the last rotation of service account key for the GCP credential.
|
||||
CredsLastRotatedTime time.Time
|
||||
}
|
||||
|
||||
// NewPersistedState - create a new PersistedState
|
||||
func NewPersistedState(opt ...Option) (*PersistedState, error) {
|
||||
s := new(PersistedState)
|
||||
opts, err := getOpts(opt...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.CredentialsConfig = opts.WithCredentialsConfig
|
||||
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RotateCreds rotates the credentials for the GCP catalog and updates the last rotated time.
|
||||
func (s *PersistedState) RotateCreds(
|
||||
ctx context.Context,
|
||||
permissions []string,
|
||||
validateCredsCallback ValidateCredsCallback,
|
||||
opts ...option.ClientOption) error {
|
||||
if s.CredentialsConfig == nil {
|
||||
return status.Error(codes.InvalidArgument, "missing credentials config")
|
||||
}
|
||||
if !s.CredentialsConfig.IsRotatable() {
|
||||
return status.Error(codes.InvalidArgument, "cannot rotate non-rotatable credentials")
|
||||
}
|
||||
|
||||
err := s.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, validateCredsCallback, opts...)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "failed to rotate credentials: %v", err)
|
||||
}
|
||||
s.CredsLastRotatedTime = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceCreds replaces the private key in the state with a new key.
|
||||
// If the existing key was rotated at any point in time, it is
|
||||
// deleted first, otherwise it's left alone. This method returns a
|
||||
// status error with PluginError details.
|
||||
func (s *PersistedState) ReplaceCreds(ctx context.Context, newCreds *Config, opts ...option.ClientOption) error {
|
||||
if newCreds == nil {
|
||||
return status.Errorf(codes.InvalidArgument, "missing new credentials")
|
||||
}
|
||||
if s.CredentialsConfig == nil {
|
||||
return status.Errorf(codes.InvalidArgument, "missing existing credentials")
|
||||
}
|
||||
|
||||
// Delete old/existing credentials.
|
||||
// This is done with the same credentials to ensure that it has the proper permissions to do it.
|
||||
if !s.CredsLastRotatedTime.IsZero() && s.CredentialsConfig.IsRotatable() {
|
||||
if err := s.DeleteCreds(ctx, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set the new attributes and clear the rotated time.
|
||||
s.CredentialsConfig = newCreds
|
||||
s.CredsLastRotatedTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCreds deletes the credentials in the state. The access key
|
||||
// ID, secret access key, and rotation time fields are zeroed out in
|
||||
// the state just to ensure that they cannot be re-used after. This
|
||||
// method returns a status error with PluginError details.
|
||||
func (s *PersistedState) DeleteCreds(ctx context.Context, opts ...option.ClientOption) error {
|
||||
if s.CredentialsConfig == nil {
|
||||
return status.Errorf(codes.InvalidArgument, "missing credentials config")
|
||||
}
|
||||
if !s.CredentialsConfig.IsRotatable() {
|
||||
return status.Errorf(codes.InvalidArgument, "cannot delete credentials for non-rotatable credentials")
|
||||
}
|
||||
|
||||
err := s.CredentialsConfig.DeletePrivateKey(ctx, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.CredentialsConfig = nil
|
||||
s.CredsLastRotatedTime = time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToMap returns a map of the credentials stored in the persisted state.
|
||||
// ToMap will return a map for long-term credentials with following keys:
|
||||
// private_key_id, private_key & creds_last_rotated_time
|
||||
func (s *PersistedState) ToMap() map[string]any {
|
||||
if !s.CredentialsConfig.IsRotatable() {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
|
||||
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
|
||||
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
// PersistedStateFromProto parses values out of a protobuf struct input
|
||||
// and returns a PersistedState used for GCP authentication.
|
||||
func PersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, opts ...Option) (*PersistedState, error) {
|
||||
if secrets == nil {
|
||||
secrets = &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{},
|
||||
}
|
||||
}
|
||||
|
||||
if attrs == nil {
|
||||
return nil, fmt.Errorf("missing credential attributes")
|
||||
}
|
||||
|
||||
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("persisted state integrity error: %w", err)
|
||||
}
|
||||
|
||||
s, err := NewPersistedState(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfgOpts := []Option{}
|
||||
if privateKeyId != "" && privateKey != "" {
|
||||
cfgOpts = append(cfgOpts,
|
||||
WithPrivateKey(privateKey),
|
||||
WithPrivateKeyId(privateKeyId))
|
||||
}
|
||||
|
||||
if attrs.Zone != "" {
|
||||
cfgOpts = append(cfgOpts, WithZone(attrs.Zone))
|
||||
}
|
||||
if attrs.ProjectId != "" {
|
||||
cfgOpts = append(cfgOpts, WithProjectId(attrs.ProjectId))
|
||||
}
|
||||
if attrs.ClientEmail != "" {
|
||||
cfgOpts = append(cfgOpts, WithClientEmail(attrs.ClientEmail))
|
||||
}
|
||||
if attrs.TargetServiceAccountId != "" {
|
||||
cfgOpts = append(cfgOpts, WithTargetServiceAccountId(attrs.TargetServiceAccountId))
|
||||
}
|
||||
credentialsConfig, err := NewConfig(cfgOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.CredentialsConfig = credentialsConfig
|
||||
s.CredsLastRotatedTime = credsLastRotatedTime
|
||||
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestNewGCPCredentialPersistedState(t *testing.T) {
|
||||
staticTime := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts []Option
|
||||
expected *PersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "error",
|
||||
opts: []Option{
|
||||
func(s *Options) error {
|
||||
return errors.New("option error")
|
||||
},
|
||||
},
|
||||
expectedErr: "option error",
|
||||
},
|
||||
{
|
||||
name: "with credentials config",
|
||||
opts: []Option{
|
||||
WithCredentialsConfig(
|
||||
&Config{
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
},
|
||||
),
|
||||
},
|
||||
expected: &PersistedState{
|
||||
CredentialsConfig: &Config{
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rotation time",
|
||||
opts: []Option{
|
||||
WithCredsLastRotatedTime(staticTime),
|
||||
},
|
||||
expected: &PersistedState{
|
||||
CredsLastRotatedTime: staticTime,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := NewPersistedState(tc.opts...)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
|
||||
if tc.expected.CredentialsConfig != nil {
|
||||
require.NotNil(actual.CredentialsConfig)
|
||||
require.Equal(tc.expected.CredentialsConfig.ProjectId, actual.CredentialsConfig.ProjectId)
|
||||
require.Equal(tc.expected.CredentialsConfig.ClientEmail, actual.CredentialsConfig.ClientEmail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedStateFromProto(t *testing.T) {
|
||||
staticTime := time.Now()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
secrets *structpb.Struct
|
||||
attrs *CredentialAttributes
|
||||
opts []Option
|
||||
expected *PersistedState
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "missing credential attributes",
|
||||
expectedErr: "missing credential attributes",
|
||||
},
|
||||
{
|
||||
name: "missing credential attributes",
|
||||
secrets: &structpb.Struct{},
|
||||
attrs: nil,
|
||||
expectedErr: "missing credential attributes",
|
||||
},
|
||||
{
|
||||
name: "with static credentials",
|
||||
secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
ConstCredsLastRotatedTime: structpb.NewStringValue(staticTime.Format(time.RFC3339Nano)),
|
||||
},
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
Zone: "us-central1-a",
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
},
|
||||
expected: &PersistedState{
|
||||
CredentialsConfig: &Config{
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
PrivateKey: "test-private-key",
|
||||
Zone: "us-central1-a",
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
},
|
||||
CredsLastRotatedTime: func() time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with service account impersonation credentials",
|
||||
secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstPrivateKeyId: structpb.NewStringValue("test-private-key-id"),
|
||||
ConstPrivateKey: structpb.NewStringValue("test-private-key"),
|
||||
ConstCredsLastRotatedTime: structpb.NewStringValue((time.Time{}).Format(time.RFC3339Nano)),
|
||||
},
|
||||
},
|
||||
attrs: &CredentialAttributes{
|
||||
Zone: "us-central1-a",
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
TargetServiceAccountId: "test-service-account-id",
|
||||
},
|
||||
expected: &PersistedState{
|
||||
CredentialsConfig: &Config{
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
PrivateKey: "test-private-key",
|
||||
Zone: "us-central1-a",
|
||||
ProjectId: "test-project",
|
||||
ClientEmail: "test-email",
|
||||
TargetServiceAccountId: "test-service-account-id",
|
||||
},
|
||||
CredsLastRotatedTime: time.Time{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := PersistedStateFromProto(tc.secrets, tc.attrs, tc.opts...)
|
||||
if tc.expectedErr != "" {
|
||||
require.EqualError(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected.CredsLastRotatedTime, actual.CredsLastRotatedTime)
|
||||
if tc.expected.CredentialsConfig != nil {
|
||||
require.NotNil(actual.CredentialsConfig)
|
||||
require.Equal(tc.expected.CredentialsConfig.PrivateKeyId, actual.CredentialsConfig.PrivateKeyId)
|
||||
require.Equal(tc.expected.CredentialsConfig.PrivateKey, actual.CredentialsConfig.PrivateKey)
|
||||
require.Equal(tc.expected.CredentialsConfig.Zone, actual.CredentialsConfig.Zone)
|
||||
require.Equal(tc.expected.CredentialsConfig.ProjectId, actual.CredentialsConfig.ProjectId)
|
||||
require.Equal(tc.expected.CredentialsConfig.ClientEmail, actual.CredentialsConfig.ClientEmail)
|
||||
require.Equal(tc.expected.CredentialsConfig.TargetServiceAccountId, actual.CredentialsConfig.TargetServiceAccountId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
||||
"cloud.google.com/go/iam/apiv1/iampb"
|
||||
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
type testIAMAdminServer struct {
|
||||
adminpb.UnimplementedIAMServer
|
||||
testCreateServiceAccountKeyError error
|
||||
testDeleteServiceAccountKeyError error
|
||||
}
|
||||
|
||||
func NewTestIAMAdminServer(createServiceAccountKeyError error, deleteServiceAccountKeyError error) *testIAMAdminServer {
|
||||
return &testIAMAdminServer{
|
||||
testCreateServiceAccountKeyError: createServiceAccountKeyError,
|
||||
testDeleteServiceAccountKeyError: deleteServiceAccountKeyError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
|
||||
serviceAccountKey := ServiceAccountPrivateKey{
|
||||
PrivateKey: "updated-private-key",
|
||||
PrivateKeyID: "updated-private-key-id",
|
||||
}
|
||||
serviceAccountKeyData, err := json.Marshal(serviceAccountKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal service account key: %v", err)
|
||||
}
|
||||
resp := &adminpb.ServiceAccountKey{
|
||||
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
|
||||
PrivateKeyData: serviceAccountKeyData,
|
||||
}
|
||||
return resp, f.testCreateServiceAccountKeyError
|
||||
}
|
||||
|
||||
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
|
||||
}
|
||||
|
||||
type testResourceServer struct {
|
||||
resourcemanagerpb.UnimplementedProjectsServer
|
||||
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
|
||||
testIamPermissionsError error
|
||||
}
|
||||
|
||||
func NewTestResourceServer(testIamPermissionsResponse *iampb.TestIamPermissionsResponse, testIamPermissionsError error) *testResourceServer {
|
||||
return &testResourceServer{
|
||||
testIamPermissionsResponse: testIamPermissionsResponse,
|
||||
testIamPermissionsError: testIamPermissionsError,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
|
||||
return f.testIamPermissionsResponse, f.testIamPermissionsError
|
||||
}
|
||||
|
||||
type grpcServer struct {
|
||||
*grpc.Server
|
||||
}
|
||||
|
||||
func NewGRPCServer() *grpcServer {
|
||||
return &grpcServer{Server: grpc.NewServer()}
|
||||
}
|
||||
|
||||
func (s *grpcServer) Start() (string, error) {
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := s.Serve(l); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
return l.Addr().String(), nil
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cred "github.com/hashicorp/boundary-plugin-google/internal/credential"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestGetCatalogAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
expected *CatalogAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing project and zone",
|
||||
in: &structpb.Struct{
|
||||
Fields: make(map[string]*structpb.Value),
|
||||
},
|
||||
expectedErrContains: "attributes.project: missing required value \"project\", attributes.zone: missing required value \"zone\"",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"project": structpb.NewStringValue("test-12345"),
|
||||
"zone": structpb.NewStringValue("us-central-1a"),
|
||||
"foo": structpb.NewBoolValue(true),
|
||||
"bar": structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "valid project and zone",
|
||||
in: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"project": structpb.NewStringValue("test-12345"),
|
||||
"zone": structpb.NewStringValue("us-central-1a"),
|
||||
},
|
||||
},
|
||||
expected: &CatalogAttributes{&cred.CredentialAttributes{
|
||||
Project: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := getCatalogAttributes(tc.in)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
normalized map[string]any
|
||||
expected *SetAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
in: map[string]any{},
|
||||
expected: &SetAttributes{},
|
||||
},
|
||||
{
|
||||
name: "valid example filter",
|
||||
in: map[string]any{
|
||||
ConstListInstancesFilter: "tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag",
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
Filter: "tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid example instance group",
|
||||
in: map[string]any{
|
||||
ConstInstanceGroup: "test",
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
InstanceGroup: "test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: map[string]any{
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "invalid, cannot have both instance filter and group defined",
|
||||
in: map[string]any{
|
||||
ConstListInstancesFilter: "test",
|
||||
ConstInstanceGroup: "test",
|
||||
},
|
||||
expectedErrContains: "Error in the attributes provided, cannot define both filter and instance group",
|
||||
},
|
||||
}
|
||||
|
||||
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 := getSetAttributes(input)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-269
@@ -4,282 +4,26 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
errors "github.com/hashicorp/boundary-plugin-google/internal/errors"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/plugin/service/host"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// GooglePlugin implements the GooglePluginServiceServer interface for the
|
||||
// Google host service plugin.
|
||||
type GooglePlugin struct {
|
||||
pb.UnimplementedHostPluginServiceServer
|
||||
}
|
||||
|
||||
// Ensure that HostPlugin implements the following services:
|
||||
//
|
||||
// HostPluginServiceServer
|
||||
var (
|
||||
_ pb.HostPluginServiceServer = (*GooglePlugin)(nil)
|
||||
_ pb.HostPluginServiceServer = (*host.HostPlugin)(nil)
|
||||
)
|
||||
|
||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
||||
func (p *GooglePlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||
}
|
||||
|
||||
if _, err := getCatalogAttributes(attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pb.OnCreateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
}, nil
|
||||
// GCPPlugin contains a collection of all GCP plugin services.
|
||||
type GCPPlugin struct {
|
||||
// HostPlugin implements the HostPluginServiceServer interface for
|
||||
// supporting dynamically sourcing hosts from GCP Compute Engine.
|
||||
*host.HostPlugin
|
||||
}
|
||||
|
||||
// OnUpdateCatalog is called when a dynamic host catalog is updated.
|
||||
func (p *GooglePlugin) OnUpdateCatalog(_ context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
||||
currentCatalog := req.GetCurrentCatalog()
|
||||
if currentCatalog == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "current catalog is nil")
|
||||
func NewGCPPlugin() *GCPPlugin {
|
||||
return &GCPPlugin{
|
||||
HostPlugin: &host.HostPlugin{},
|
||||
}
|
||||
|
||||
secrets := req.GetNewCatalog().GetSecrets()
|
||||
if secrets == nil {
|
||||
// If new secrets weren't passed in, don't rotate what we have on
|
||||
// update.
|
||||
return &pb.OnUpdateCatalogResponse{}, nil
|
||||
}
|
||||
|
||||
return &pb.OnUpdateCatalogResponse{
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
|
||||
func (p *GooglePlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
if _, err := getCatalogAttributes(attrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &pb.OnDeleteCatalogResponse{}, nil
|
||||
}
|
||||
|
||||
// OnCreateSet is called when a dynamic host set is created.
|
||||
func (p *GooglePlugin) OnCreateSet(_ context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
||||
if err := validateSet(req.GetSet()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.OnCreateSetResponse{}, nil
|
||||
}
|
||||
|
||||
// OnUpdateSet is called when a dynamic host set is updated.
|
||||
func (p *GooglePlugin) OnUpdateSet(_ context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
|
||||
if err := validateSet(req.GetNewSet()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.OnUpdateSetResponse{}, nil
|
||||
}
|
||||
|
||||
// OnDeleteSet is called when a dynamic host set is deleted.
|
||||
func (p *GooglePlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
||||
return &pb.OnDeleteSetResponse{}, nil
|
||||
}
|
||||
|
||||
// ListHosts returns the list of instances based on filter or instance group name.
|
||||
func (p *GooglePlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sets := req.GetSets()
|
||||
if sets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "sets is nil")
|
||||
}
|
||||
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
InputInstances *computepb.ListInstancesRequest
|
||||
InputGroups *computepb.ListInstancesInstanceGroupsRequest
|
||||
Project string
|
||||
Zone string
|
||||
Output []*computepb.Instance
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
queries := make([]hostSetQuery, len(sets))
|
||||
for i, set := range sets {
|
||||
// Validate Id since we use it in output
|
||||
if set.GetId() == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing id")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if setAttrs.InstanceGroup != "" {
|
||||
queries[i] = hostSetQuery{
|
||||
Id: set.GetId(),
|
||||
InputGroups: buildListInstanceGroupsRequest(setAttrs, catalogAttributes),
|
||||
}
|
||||
} else {
|
||||
queries[i] = hostSetQuery{
|
||||
Id: set.GetId(),
|
||||
InputInstances: buildListInstancesRequest(setAttrs, catalogAttributes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
instancesClient, err := compute.NewInstancesRESTClient(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstancesRESTClient: %s", err)
|
||||
}
|
||||
|
||||
instanceGroupsClient, err := compute.NewInstanceGroupsRESTClient(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstanceGroupsRESTClient: %s", err)
|
||||
}
|
||||
|
||||
gclient := GoogleClient{
|
||||
InstancesClient: instancesClient,
|
||||
InstanceGroupClient: instanceGroupsClient,
|
||||
Context: ctx,
|
||||
}
|
||||
|
||||
// Run all queries now and assemble output.
|
||||
var maxLen int
|
||||
for i, query := range queries {
|
||||
var output []*computepb.Instance
|
||||
if query.InputGroups != nil {
|
||||
output, err = gclient.getInstancesForInstanceGroup(query.InputGroups)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error running getInstancesForInstanceGroup for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
} else {
|
||||
output, err = gclient.getInstances(query.InputInstances)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error running getInstances for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
|
||||
// Process the output here, we will normalize this into a single
|
||||
// set of hosts afterwards (possibly removing duplicates).
|
||||
for _, instance := range output {
|
||||
host, err := instanceToHost(instance)
|
||||
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||
maxLen++
|
||||
}
|
||||
}
|
||||
|
||||
// Now de-duplicate the hosts for the output. Maintain two sets:
|
||||
// * A slice of hosts that will be used in the output
|
||||
// * A map of hosts indexed by their external ID
|
||||
//
|
||||
// The map will is used in de-duplication to determine whether or
|
||||
// not we've seen the host before to simply add the query's set ID
|
||||
// to the list of set IDs that the host was seen in.
|
||||
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
||||
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
||||
for _, query := range queries {
|
||||
for _, host := range query.OutputHosts {
|
||||
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
||||
// Existing host, just add the set ID to the list of seen IDs
|
||||
// and continue
|
||||
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// This will be the first seen entry, so append the set ID to
|
||||
// this host, and add it.
|
||||
host.SetIds = append(host.SetIds, query.Id)
|
||||
hostResultSlice = append(hostResultSlice, host)
|
||||
hostResultMap[host.ExternalId] = host
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.ListHostsResponse{
|
||||
Hosts: hostResultSlice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateSet(s *hostsets.HostSet) error {
|
||||
if s == nil {
|
||||
return status.Error(codes.InvalidArgument, "set is nil")
|
||||
}
|
||||
var attrs SetAttributes
|
||||
attrMap := s.GetAttributes().AsMap()
|
||||
if err := mapstructure.Decode(attrMap, &attrs); err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
||||
}
|
||||
|
||||
badFields := make(map[string]string)
|
||||
_, filterSet := attrMap[ConstListInstancesFilter]
|
||||
_, instanceGroupSet := attrMap[ConstInstanceGroup]
|
||||
|
||||
if instanceGroupSet && filterSet {
|
||||
badFields["attributes"] = "must set instance group or filter, cannot set both"
|
||||
} else if instanceGroupSet && len(attrs.InstanceGroup) == 0 {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstInstanceGroup)] = "must not be empty."
|
||||
} else if filterSet && len(attrs.Filter) == 0 {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty."
|
||||
}
|
||||
|
||||
for f := range attrMap {
|
||||
if _, ok := allowedSetFields[f]; !ok {
|
||||
badFields[fmt.Sprintf("attributes.%s", f)] = "Unrecognized field."
|
||||
}
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return errors.InvalidArgumentError("Invalid arguments in the new set", badFields)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,701 +0,0 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
cred "github.com/hashicorp/boundary-plugin-google/internal/credential"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func wrapMap(t *testing.T, in map[string]interface{}) *structpb.Struct {
|
||||
t.Helper()
|
||||
out, err := structpb.NewStruct(in)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestListHosts(t *testing.T) {
|
||||
t.Skip("TODO: this needs a secrets file to run - maybe only manually?")
|
||||
ctx := context.Background()
|
||||
p := &GooglePlugin{}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, wd)
|
||||
project, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "project"))
|
||||
require.NoError(t, err)
|
||||
zone, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "zone"))
|
||||
require.NoError(t, err)
|
||||
|
||||
hostCatalogAttributes := &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
cred.ConstProject: project,
|
||||
cred.ConstZone: zone,
|
||||
}),
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.ListHostsRequest
|
||||
expected []*pb.ListHostsResponseHost
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil catalog",
|
||||
req: &pb.ListHostsRequest{},
|
||||
expectedErr: "catalog is nil",
|
||||
},
|
||||
{
|
||||
name: "project not defined",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
cred.ConstZone: zone,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.project: missing required value \"project\"",
|
||||
},
|
||||
{
|
||||
name: "get all three instances",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-all-instances",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-0",
|
||||
},
|
||||
{
|
||||
Name: "boundary-1",
|
||||
},
|
||||
{
|
||||
Name: "boundary-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get one instance by name",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-one-instance-by-name",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "name = boundary-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get instance group",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-instance-group",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstInstanceGroup: "boundary-servers",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-0",
|
||||
},
|
||||
{
|
||||
Name: "boundary-1",
|
||||
},
|
||||
{
|
||||
Name: "boundary-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get two specific instances with two host sets",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-one-instance-by-name-0",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "name = boundary-0",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "get-one-instance-by-name-2",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "name = boundary-2",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-0",
|
||||
},
|
||||
{
|
||||
Name: "boundary-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get one instance and an instance group in two host sets",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-one-instance-by-name",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "name = boundary-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "get-instance-group",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstInstanceGroup: "boundary-servers",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-0",
|
||||
},
|
||||
{
|
||||
Name: "boundary-1",
|
||||
},
|
||||
{
|
||||
Name: "boundary-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get an instance group and one instance in two host sets",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "get-instance-group",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstInstanceGroup: "boundary-servers",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "get-one-instance-by-name",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "name = boundary-2",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
Name: "boundary-0",
|
||||
},
|
||||
{
|
||||
Name: "boundary-1",
|
||||
},
|
||||
{
|
||||
Name: "boundary-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid filter",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "invalid-filter",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
ConstListInstancesFilter: "not-a-filter",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "Invalid list filter expression",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := p.ListHosts(ctx, tc.req)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(len(tc.expected), len(actual.GetHosts()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCatalog(t *testing.T) {
|
||||
t.Skip("TODO: this needs a secrets file to run - maybe only manually?")
|
||||
ctx := context.Background()
|
||||
p := &GooglePlugin{}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, wd)
|
||||
project, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "project"))
|
||||
require.NoError(t, err)
|
||||
zone, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "zone"))
|
||||
require.NoError(t, err)
|
||||
|
||||
hostCatalogAttributes := &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
cred.ConstProject: project,
|
||||
cred.ConstZone: zone,
|
||||
}),
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.OnCreateCatalogRequest
|
||||
expected *pb.HostCatalogPersisted
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil catalog",
|
||||
req: &pb.OnCreateCatalogRequest{},
|
||||
expectedErr: "catalog is nil",
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{},
|
||||
},
|
||||
expectedErr: "attributes are required",
|
||||
},
|
||||
{
|
||||
name: "error reading attributes",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: new(structpb.Struct),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.project: missing required value \"project\"",
|
||||
},
|
||||
{
|
||||
name: "do not persist secrets, use gcloud application default creds",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
},
|
||||
expected: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Nil(actual.GetPersisted().Secrets)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCatalog(t *testing.T) {
|
||||
t.Skip("TODO: this needs a secrets file to run - maybe only manually?")
|
||||
ctx := context.Background()
|
||||
p := &GooglePlugin{}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, wd)
|
||||
project, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "project"))
|
||||
require.NoError(t, err)
|
||||
zone, err := parseutil.ParsePath("file://" + filepath.Join(wd, "secrets", "zone"))
|
||||
require.NoError(t, err)
|
||||
|
||||
hostCatalogAttributes := &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: wrapMap(t, map[string]interface{}{
|
||||
cred.ConstProject: project,
|
||||
cred.ConstZone: zone,
|
||||
}),
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.OnCreateCatalogRequest
|
||||
expected *pb.HostCatalogPersisted
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil catalog",
|
||||
req: &pb.OnCreateCatalogRequest{},
|
||||
expectedErr: "catalog is nil",
|
||||
},
|
||||
{
|
||||
name: "nil attributes",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{},
|
||||
},
|
||||
expectedErr: "attributes are required",
|
||||
},
|
||||
{
|
||||
name: "error reading attributes",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: new(structpb.Struct),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.project: missing required value \"project\"",
|
||||
},
|
||||
{
|
||||
name: "do not persist secrets, use gcloud ADC",
|
||||
req: &pb.OnCreateCatalogRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: hostCatalogAttributes,
|
||||
},
|
||||
},
|
||||
expected: &pb.HostCatalogPersisted{
|
||||
Secrets: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := p.OnCreateCatalog(ctx, tc.req)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(actual.GetPersisted().Secrets, tc.expected.GetSecrets())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
p := &GooglePlugin{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.OnCreateSetRequest
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil set",
|
||||
req: &pb.OnCreateSetRequest{},
|
||||
expectedErr: "set is nil",
|
||||
},
|
||||
{
|
||||
name: "nil set",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstProject: structpb.NewStringValue("test-project"),
|
||||
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "set is nil",
|
||||
},
|
||||
{
|
||||
name: "only allow instance group or filter, not both",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Set: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"),
|
||||
ConstInstanceGroup: structpb.NewStringValue("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes: must set instance group or filter",
|
||||
},
|
||||
{
|
||||
name: "empty filter",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Set: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstListInstancesFilter: structpb.NewStringValue(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.filter: must not be empty",
|
||||
},
|
||||
{
|
||||
name: "empty instance group",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Set: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.instance_group: must not be empty",
|
||||
},
|
||||
{
|
||||
name: "good filter",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Set: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue("status=RUNNING"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good instance group",
|
||||
req: &pb.OnCreateSetRequest{
|
||||
Set: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, err := p.OnCreateSet(ctx, tc.req)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
p := &GooglePlugin{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *pb.OnUpdateSetRequest
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "nil set",
|
||||
req: &pb.OnUpdateSetRequest{},
|
||||
expectedErr: "set is nil",
|
||||
},
|
||||
{
|
||||
name: "nil set",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
cred.ConstProject: structpb.NewStringValue("test-project"),
|
||||
cred.ConstZone: structpb.NewStringValue("us-central1-a"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "set is nil",
|
||||
},
|
||||
{
|
||||
name: "only allow instance group or filter, not both",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstListInstancesFilter: structpb.NewStringValue("status=RUNNING"),
|
||||
ConstInstanceGroup: structpb.NewStringValue("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes: must set instance group or filter",
|
||||
},
|
||||
{
|
||||
name: "empty filter",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstListInstancesFilter: structpb.NewStringValue(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.filter: must not be empty",
|
||||
},
|
||||
{
|
||||
name: "empty instance group",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "attributes.instance_group: must not be empty",
|
||||
},
|
||||
{
|
||||
name: "good filter",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue("status=RUNNING"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "good instance group",
|
||||
req: &pb.OnUpdateSetRequest{
|
||||
NewSet: &hostsets.HostSet{
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstInstanceGroup: structpb.NewStringValue("test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
_, err := p.OnUpdateSet(ctx, tc.req)
|
||||
if tc.expectedErr != "" {
|
||||
require.Contains(err.Error(), tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
## Getting Started
|
||||
|
||||
Refer to [Attributes and Secrets](#attributes-and-secrets) for more detail on
|
||||
configuration options for host catalogs and sets.
|
||||
|
||||
To create a host catalog (using default scope created by `boundary dev`):
|
||||
|
||||
```sh
|
||||
boundary host-catalogs create plugin \
|
||||
-scope-id p_1234567890 \
|
||||
-name "Example Plugin-Based Host Catalog" \
|
||||
-description "Description for plugin-based host catalog" \
|
||||
-plugin-name gcp \
|
||||
-attr client_email=CLIENT_EMAIL \
|
||||
-attr project_id=PROJECT_ID \
|
||||
-attr zone=ZONE \
|
||||
-secret private_key_id='PRIVATE_KEY_ID' \
|
||||
-secret private_key='PRIVATE_KEY'
|
||||
```
|
||||
|
||||
To create a host set, filtering the host set based on status value
|
||||
of `RUNNING` and a label.
|
||||
|
||||
```sh
|
||||
boundary host-sets create plugin \
|
||||
-host-catalog-id HOST_CATALOG_ID \
|
||||
-name "Example Plugin-Based Host Set" \
|
||||
-description "Description for plugin-based host set" \
|
||||
-attr filters=status=RUNNING \
|
||||
-attr filters=labels.env:prod
|
||||
```
|
||||
|
||||
## Required IAM Permissions
|
||||
|
||||
The following IAM permissions, at the very least, are required to be attached to
|
||||
a configured service account for this provider:
|
||||
|
||||
### List Instances
|
||||
|
||||
To list instances, the credential requires these permissions:
|
||||
```
|
||||
[
|
||||
"compute.instances.list"
|
||||
]
|
||||
```
|
||||
|
||||
### Rotate Service Account Keys
|
||||
|
||||
When GCP service account keys are provided with credential rotation enabled, GCP
|
||||
service account keys will be rotated when requests are made to the plugin. These
|
||||
permissions are required to rotate the service account key:
|
||||
|
||||
```
|
||||
[
|
||||
"iam.serviceAccountKeys.create",
|
||||
"iam.serviceAccountKeys.delete",
|
||||
]
|
||||
```
|
||||
|
||||
### Service Account Impersonation
|
||||
|
||||
For the base service account to authenticate by service account impersonation.
|
||||
The base service account needs to have these roles:
|
||||
|
||||
```
|
||||
[
|
||||
"iam.serviceAccountTokenCreator"
|
||||
]
|
||||
```
|
||||
|
||||
## Attributes and Secrets
|
||||
|
||||
### Host Catalog
|
||||
|
||||
The following `attributes` are valid on a GCP host catalog resource:
|
||||
|
||||
|
||||
- `zone` (string, required): The zone to configure the host catalog for. All host sets
|
||||
in this catalog will be configured for this zone.
|
||||
- `project_id` (string, required): The project ID associated with the service account. All host sets
|
||||
in this catalog will be configured for this project.
|
||||
- `disable_credential_rotation` (bool): If `true`, credential rotation will not
|
||||
be performed. See the [Credentials](../../../README.md#credentials) readme for more information.
|
||||
- `client_email` (string): The email address associated with the service account. The email address
|
||||
used to uniquely identify the service account. It is required for authentication and authorization.
|
||||
- `target_service_account_id` (string): The unique identifier for the service account that will be
|
||||
impersonate. This is only required when authenticating with service account impersonation.
|
||||
|
||||
The following `secrets` are required on an GCP host catalog resource:
|
||||
|
||||
- `private_key_id` (string): The private key ID for the service account to use with this
|
||||
host catalog.
|
||||
- `private_key` (string): The private key for the service account to use
|
||||
with this host catalog.
|
||||
|
||||
See the [Credentials](../../../README.md#credentials) readme for more information.
|
||||
|
||||
### Host Set
|
||||
|
||||
The following attributes are valid on a GCP host Set resource:
|
||||
|
||||
- `filters` (array of strings): Host Set filters are used to narrow down the list of hosts
|
||||
returned by the plugin. The filter string is expected to be in the format "key operator value".
|
||||
The operator is expected to be one of =, !=, >, <, <=, >=, :, eq, ne.
|
||||
as per GCP API documentation:
|
||||
[instances.list reference](https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter).
|
||||
@@ -1,15 +1,16 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
package host
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
cred "github.com/hashicorp/boundary-plugin-google/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-google/internal/errors"
|
||||
"github.com/hashicorp/boundary-plugin-google/internal/values"
|
||||
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/errors"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
@@ -34,10 +35,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||
for s := range unknownFields {
|
||||
switch s {
|
||||
// Ignore knownFields from CredentialAttributes
|
||||
case cred.ConstProject:
|
||||
case cred.ConstProjectId:
|
||||
continue
|
||||
case cred.ConstZone:
|
||||
continue
|
||||
case cred.ConstDisableCredentialRotation:
|
||||
continue
|
||||
case cred.ConstClientEmail:
|
||||
continue
|
||||
case cred.ConstTargetServiceAccountID:
|
||||
continue
|
||||
default:
|
||||
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
|
||||
}
|
||||
@@ -54,8 +61,7 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
|
||||
|
||||
// SetAttributes defines attributes fro the host set
|
||||
type SetAttributes struct {
|
||||
Filter string `mapstructure:"filter"`
|
||||
InstanceGroup string `mapstructure:"instance_group"`
|
||||
Filters []string `mapstructure:"filters"`
|
||||
}
|
||||
|
||||
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
@@ -64,14 +70,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
badFields := make(map[string]string)
|
||||
unknownFields := values.StructFields(in)
|
||||
|
||||
if _, filter := unknownFields[ConstListInstancesFilter]; filter {
|
||||
if _, ig := unknownFields[ConstInstanceGroup]; ig {
|
||||
return nil, errors.InvalidArgumentError("Error in the attributes provided, cannot define both filter and instance group", nil)
|
||||
}
|
||||
}
|
||||
|
||||
delete(unknownFields, ConstListInstancesFilter)
|
||||
delete(unknownFields, ConstInstanceGroup)
|
||||
|
||||
for a := range unknownFields {
|
||||
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
|
||||
@@ -87,14 +86,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
if filtersRaw, ok := inMap[ConstListInstancesFilter]; ok {
|
||||
switch filterVal := filtersRaw.(type) {
|
||||
case string:
|
||||
inMap[ConstListInstancesFilter] = string(filterVal)
|
||||
}
|
||||
}
|
||||
|
||||
if instanceGroupRaw, ok := inMap[ConstInstanceGroup]; ok {
|
||||
switch instanceGroupValue := instanceGroupRaw.(type) {
|
||||
case string:
|
||||
inMap[ConstInstanceGroup] = string(instanceGroupValue)
|
||||
inMap[ConstListInstancesFilter] = []string{filterVal}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,29 +97,21 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
|
||||
return &setAttrs, nil
|
||||
}
|
||||
|
||||
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest {
|
||||
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) (*computepb.ListInstancesRequest, error) {
|
||||
request := &computepb.ListInstancesRequest{
|
||||
Project: catalog.Project,
|
||||
Project: catalog.ProjectId,
|
||||
Zone: catalog.Zone,
|
||||
}
|
||||
|
||||
if len(attributes.Filter) > 1 {
|
||||
request.Filter = &attributes.Filter
|
||||
filters, err := buildFilters(attributes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building filters: %w", err)
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesInstanceGroupsRequest {
|
||||
request := &computepb.ListInstancesInstanceGroupsRequest{
|
||||
InstanceGroup: attributes.InstanceGroup,
|
||||
Project: catalog.Project,
|
||||
Zone: catalog.Zone,
|
||||
}
|
||||
|
||||
if len(attributes.Filter) > 1 {
|
||||
request.Filter = &attributes.Filter
|
||||
}
|
||||
|
||||
return request
|
||||
if len(filters) > 1 {
|
||||
filters := strings.Join(filters, " AND ")
|
||||
request.Filter = &filters
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestGetCatalogAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *structpb.Struct
|
||||
expected *CatalogAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing project_id and zone",
|
||||
in: &structpb.Struct{
|
||||
Fields: make(map[string]*structpb.Value),
|
||||
},
|
||||
expectedErrContains: "attributes.project_id: missing required value \"project_id\", attributes.zone: missing required value \"zone\"",
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"project_id": structpb.NewStringValue("test-12345"),
|
||||
"zone": structpb.NewStringValue("us-central-1a"),
|
||||
"foo": structpb.NewBoolValue(true),
|
||||
"bar": structpb.NewBoolValue(true),
|
||||
},
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
{
|
||||
name: "valid project and zone",
|
||||
in: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"project_id": structpb.NewStringValue("test-12345"),
|
||||
"zone": structpb.NewStringValue("us-central-1a"),
|
||||
},
|
||||
},
|
||||
expected: &CatalogAttributes{&cred.CredentialAttributes{
|
||||
ProjectId: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := getCatalogAttributes(tc.in)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSetAttributes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
normalized map[string]any
|
||||
expected *SetAttributes
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
in: map[string]any{},
|
||||
expected: &SetAttributes{},
|
||||
},
|
||||
{
|
||||
name: "valid example filter",
|
||||
in: map[string]any{
|
||||
ConstListInstancesFilter: "tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag",
|
||||
},
|
||||
expected: &SetAttributes{
|
||||
Filters: []string{"tags.items=my-tag AND -tags.items=my-other-tag) OR tags.items=alternative-tag"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown fields",
|
||||
in: map[string]any{
|
||||
"foo": true,
|
||||
"bar": true,
|
||||
},
|
||||
expectedErrContains: "attributes.bar: unrecognized field, attributes.foo: unrecognized field",
|
||||
},
|
||||
}
|
||||
|
||||
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 := getSetAttributes(input)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
require.Equal(status.Code(err), codes.InvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListInstancesRequest(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
attributes *SetAttributes
|
||||
catalog *CatalogAttributes
|
||||
expected *computepb.ListInstancesRequest
|
||||
expectedErrContains string
|
||||
}{
|
||||
{
|
||||
name: "valid request with single filter",
|
||||
attributes: &SetAttributes{
|
||||
Filters: []string{"tags.items=my-tag"},
|
||||
},
|
||||
catalog: &CatalogAttributes{
|
||||
CredentialAttributes: &cred.CredentialAttributes{
|
||||
ProjectId: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
},
|
||||
expected: &computepb.ListInstancesRequest{
|
||||
Project: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
Filter: stringPtr("tags.items=my-tag AND status = running"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid request with multiple filters",
|
||||
attributes: &SetAttributes{
|
||||
Filters: []string{"tags.items=my-tag", "tags.items=my-other-tag"},
|
||||
},
|
||||
catalog: &CatalogAttributes{
|
||||
CredentialAttributes: &cred.CredentialAttributes{
|
||||
ProjectId: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
},
|
||||
expected: &computepb.ListInstancesRequest{
|
||||
Project: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
Filter: stringPtr("tags.items=my-tag AND tags.items=my-other-tag AND status = running"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error building filters",
|
||||
attributes: &SetAttributes{
|
||||
Filters: []string{"invalid-filter"},
|
||||
},
|
||||
catalog: &CatalogAttributes{
|
||||
CredentialAttributes: &cred.CredentialAttributes{
|
||||
ProjectId: "test-12345",
|
||||
Zone: "us-central-1a",
|
||||
},
|
||||
},
|
||||
expectedErrContains: "error building filters",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
actual, err := buildListInstancesRequest(tc.attributes, tc.catalog)
|
||||
if tc.expectedErrContains != "" {
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), tc.expectedErrContains)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Equal(tc.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/api/iterator"
|
||||
@@ -16,66 +15,25 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
NumberMaxResults = uint32(100)
|
||||
)
|
||||
|
||||
// GoogleClient contains clients and attributes for authenticating to Google Cloud
|
||||
// and finding instances
|
||||
type GoogleClient struct {
|
||||
InstancesClient *compute.InstancesClient
|
||||
InstanceGroupClient *compute.InstanceGroupsClient
|
||||
Context context.Context
|
||||
Project string
|
||||
Zone string
|
||||
}
|
||||
|
||||
func (c *GoogleClient) getInstances(request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) {
|
||||
func getInstances(ctx context.Context, instancesClient InstancesAPI, request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) {
|
||||
hosts := []*computepb.Instance{}
|
||||
it := c.InstancesClient.List(c.Context, request)
|
||||
it := instancesClient.List(ctx, request)
|
||||
for {
|
||||
resp, err := it.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error listing instances: %s", err)
|
||||
return nil, status.Errorf(codes.Unknown, "error listing instances: %s", err)
|
||||
}
|
||||
hosts = append(hosts, resp)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (c *GoogleClient) getInstancesForInstanceGroup(request *computepb.ListInstancesInstanceGroupsRequest) ([]*computepb.Instance, error) {
|
||||
hosts := []*computepb.Instance{}
|
||||
instances := c.InstanceGroupClient.ListInstances(c.Context, request)
|
||||
|
||||
for {
|
||||
resp, err := instances.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error listing instances for instance group %s: %s", request.InstanceGroup, err)
|
||||
}
|
||||
|
||||
instance, err := c.InstancesClient.Get(c.Context, &computepb.GetInstanceRequest{
|
||||
Instance: path.Base(resp.GetInstance()),
|
||||
Project: request.Project,
|
||||
Zone: request.Zone,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting instance %s for instance group %s: %s", resp.GetInstance(), request.InstanceGroup, err)
|
||||
}
|
||||
|
||||
hosts = append(hosts, instance)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, error) {
|
||||
if instance.GetSelfLink() == "" {
|
||||
return nil, errors.New("response integrity error: missing instance self-link")
|
||||
if instance.GetId() == 0 {
|
||||
return nil, errors.New("response integrity error: missing instance id")
|
||||
}
|
||||
|
||||
if instance.GetName() == "" {
|
||||
@@ -84,22 +42,33 @@ func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, er
|
||||
|
||||
result := new(pb.ListHostsResponseHost)
|
||||
|
||||
result.ExternalId = instance.GetSelfLink()
|
||||
result.ExternalId = strconv.FormatUint(instance.GetId(), 10)
|
||||
result.ExternalName = instance.GetName()
|
||||
|
||||
// Internal DNS name is the hostname of the instance.
|
||||
// https://cloud.google.com/compute/docs/networking/using-internal-dns
|
||||
dnsName := instance.GetHostname()
|
||||
result.DnsNames = appendDistinct(result.DnsNames, &dnsName)
|
||||
|
||||
// Now go through all of the interfaces and log the IP address of
|
||||
// every interface.
|
||||
for _, iface := range instance.GetNetworkInterfaces() {
|
||||
// Populate default IP addresses/DNS name similar to how we do
|
||||
// Populate default IP addresses name similar to how we do
|
||||
// for the entire instance.
|
||||
result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP)
|
||||
|
||||
for _, external := range iface.AccessConfigs {
|
||||
result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP)
|
||||
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
|
||||
}
|
||||
|
||||
// Add the IPv6 addresses.
|
||||
result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address)
|
||||
if iface.Ipv6AccessConfigs != nil {
|
||||
for _, external := range iface.Ipv6AccessConfigs {
|
||||
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done
|
||||
@@ -1,18 +1,22 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstanceToHost(t *testing.T) {
|
||||
exampleId := "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/instances/test-instance"
|
||||
exampleId := uint64(123456789)
|
||||
examplePrivateIp := "10.0.0.1"
|
||||
examplePrivateIp2 := "10.0.0.2"
|
||||
examplePublicIp := "1.1.1.1"
|
||||
@@ -27,24 +31,24 @@ func TestInstanceToHost(t *testing.T) {
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "missing instance self-link",
|
||||
name: "missing instance id",
|
||||
instance: &computepb.Instance{
|
||||
Name: &exampleName,
|
||||
},
|
||||
expectedErr: "response integrity error: missing instance self-link",
|
||||
expectedErr: "response integrity error: missing instance id",
|
||||
},
|
||||
{
|
||||
name: "missing instance name",
|
||||
instance: &computepb.Instance{
|
||||
SelfLink: &exampleId,
|
||||
Id: &exampleId,
|
||||
},
|
||||
expectedErr: "response integrity error: missing instance name",
|
||||
},
|
||||
{
|
||||
name: "good, single private IP with public IP address",
|
||||
instance: &computepb.Instance{
|
||||
SelfLink: &exampleId,
|
||||
Name: &exampleName,
|
||||
Id: &exampleId,
|
||||
Name: &exampleName,
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
NetworkIP: &examplePrivateIp,
|
||||
@@ -57,7 +61,7 @@ func TestInstanceToHost(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: &pb.ListHostsResponseHost{
|
||||
ExternalId: exampleId,
|
||||
ExternalId: strconv.FormatUint(exampleId, 10),
|
||||
ExternalName: exampleName,
|
||||
IpAddresses: []string{examplePrivateIp, examplePublicIp},
|
||||
},
|
||||
@@ -65,8 +69,8 @@ func TestInstanceToHost(t *testing.T) {
|
||||
{
|
||||
name: "good, single private IP address",
|
||||
instance: &computepb.Instance{
|
||||
SelfLink: &exampleId,
|
||||
Name: &exampleName,
|
||||
Id: &exampleId,
|
||||
Name: &exampleName,
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
NetworkIP: &examplePrivateIp,
|
||||
@@ -75,7 +79,7 @@ func TestInstanceToHost(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: &pb.ListHostsResponseHost{
|
||||
ExternalId: exampleId,
|
||||
ExternalId: strconv.FormatUint(exampleId, 10),
|
||||
ExternalName: exampleName,
|
||||
IpAddresses: []string{examplePrivateIp},
|
||||
},
|
||||
@@ -83,8 +87,8 @@ func TestInstanceToHost(t *testing.T) {
|
||||
{
|
||||
name: "good, multiple interfaces",
|
||||
instance: &computepb.Instance{
|
||||
SelfLink: &exampleId,
|
||||
Name: &exampleName,
|
||||
Id: &exampleId,
|
||||
Name: &exampleName,
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
NetworkIP: &examplePrivateIp,
|
||||
@@ -105,7 +109,7 @@ func TestInstanceToHost(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: &pb.ListHostsResponseHost{
|
||||
ExternalId: exampleId,
|
||||
ExternalId: strconv.FormatUint(exampleId, 10),
|
||||
ExternalName: exampleName,
|
||||
IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2},
|
||||
},
|
||||
@@ -113,8 +117,8 @@ func TestInstanceToHost(t *testing.T) {
|
||||
{
|
||||
name: "good, single private IP address with IPv6",
|
||||
instance: &computepb.Instance{
|
||||
SelfLink: &exampleId,
|
||||
Name: &exampleName,
|
||||
Id: &exampleId,
|
||||
Name: &exampleName,
|
||||
NetworkInterfaces: []*computepb.NetworkInterface{
|
||||
{
|
||||
NetworkIP: &examplePrivateIp,
|
||||
@@ -124,7 +128,7 @@ func TestInstanceToHost(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: &pb.ListHostsResponseHost{
|
||||
ExternalId: exampleId,
|
||||
ExternalId: strconv.FormatUint(exampleId, 10),
|
||||
ExternalName: exampleName,
|
||||
IpAddresses: []string{examplePrivateIp, exampleIPv6},
|
||||
},
|
||||
@@ -146,3 +150,88 @@ func TestInstanceToHost(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstances(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
exampleId := uint64(123456789)
|
||||
exampleName := "test-instance"
|
||||
exampleInstance := &computepb.Instance{
|
||||
Id: &exampleId,
|
||||
Name: &exampleName,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
instancesClient InstancesAPI
|
||||
request *computepb.ListInstancesRequest
|
||||
expected []*computepb.Instance
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "successful retrieval",
|
||||
instancesClient: func() InstancesAPI {
|
||||
fn := newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
|
||||
Items: []*computepb.Instance{exampleInstance},
|
||||
}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)
|
||||
client, err := fn(&credential.Config{})
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}(),
|
||||
request: &computepb.ListInstancesRequest{},
|
||||
expected: []*computepb.Instance{exampleInstance},
|
||||
},
|
||||
{
|
||||
name: "error during listing",
|
||||
instancesClient: func() InstancesAPI {
|
||||
fn := newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
|
||||
testMockInstancesWithListInstancesError(errors.New("internal error")),
|
||||
)
|
||||
client, err := fn(&credential.Config{})
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}(),
|
||||
request: &computepb.ListInstancesRequest{},
|
||||
expectedErr: "error listing instances",
|
||||
},
|
||||
{
|
||||
name: "no instances",
|
||||
instancesClient: func() InstancesAPI {
|
||||
fn := newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)
|
||||
client, err := fn(&credential.Config{})
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}(),
|
||||
request: &computepb.ListInstancesRequest{},
|
||||
expected: []*computepb.Instance{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
actual, err := getInstances(ctx, tc.instancesClient, tc.request)
|
||||
if tc.expectedErr != "" {
|
||||
require.ErrorContains(err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(err)
|
||||
require.Len(actual, len(tc.expected))
|
||||
if len(actual) > 0 {
|
||||
require.Equal(tc.expected[0].Name, actual[0].Name)
|
||||
require.Equal(tc.expected[0].Id, actual[0].Id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package plugin
|
||||
package host
|
||||
|
||||
const (
|
||||
// ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances
|
||||
ConstListInstancesFilter = "filter"
|
||||
|
||||
// ConstInstanceGroup refers to the name of a Google Cloud instance group
|
||||
ConstInstanceGroup = "instance_group"
|
||||
ConstListInstancesFilter = "filters"
|
||||
)
|
||||
|
||||
var allowedSetFields = map[string]struct{}{
|
||||
ConstListInstancesFilter: {},
|
||||
ConstInstanceGroup: {},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
// Package host provides a plugin for Boundary that retrieves host information from (GCP) Google Cloud Platform.
|
||||
//
|
||||
// A host catalog is a resource that contains hosts and host sets.
|
||||
//
|
||||
// A host is a resource that represents a computing element with a network address reachable from Boundary.
|
||||
// A host belongs to a host catalog. Hosts can only be associated with host sets from the same host catalog as the host.
|
||||
//
|
||||
// A host set is a resource that represents a collection of hosts which are considered equivalent for the purposes of access control.
|
||||
//
|
||||
// The plugin supports authentication using GCP service account keys, impersonation of service accounts
|
||||
// and Application Default Credentials.
|
||||
//
|
||||
// Host plugin supports credential rotation for GCP service account keys and impersonation of service accounts (only for base account)
|
||||
// when the `disable_credential_rotation` attribute is set to false.
|
||||
//
|
||||
// The plugin implements Boundary's HostPluginService interface:
|
||||
// https://github.com/hashicorp/boundary-enterprise/blob/cb9ce07d6907cbda6ce82e82627de372f18ba7e1/internal/proto/plugin/v1/host_plugin_service.proto#L15.
|
||||
//
|
||||
// # Filters
|
||||
// Host Set filters are used to narrow down the list of hosts returned by the plugin.
|
||||
// The filter string is expected to be in the format "key operator value".
|
||||
// The operator is expected to be one of =, !=, >, <, <=, >=, :, eq, ne.
|
||||
// as per GCP API documentation:
|
||||
// https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter
|
||||
//
|
||||
// If there is no explicit instance status filter, the default status will be set to
|
||||
// `status = "running"`. This ensures that we filter on running instances only,
|
||||
// saving time when processing results.
|
||||
//
|
||||
// Host Set filters are represented as a list of strings. When multiple filters are provided,
|
||||
// the plugin will apply a logical `AND` operation to the filters.
|
||||
@@ -0,0 +1,727 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
errors "github.com/hashicorp/boundary-plugin-gcp/internal/errors"
|
||||
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"google.golang.org/api/iterator"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
// HostPlugin implements the HostPluginServiceServer interface for the
|
||||
// GCP host service plugin.
|
||||
type HostPlugin struct {
|
||||
pb.UnimplementedHostPluginServiceServer
|
||||
// testGCPClientOpts are passed in to the GCP client to control test behavior
|
||||
testGCPClientOpts []option.ClientOption
|
||||
// testCatalogStateOpts are passed in to the stored state to control test behavior
|
||||
testCatalogStateOpts []gcpCatalogPersistedStateOption
|
||||
}
|
||||
|
||||
var (
|
||||
_ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
||||
)
|
||||
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
InputInstances *computepb.ListInstancesRequest
|
||||
Output []*computepb.Instance
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
const (
|
||||
// defaultStatusFilter is the default filter to apply to instances
|
||||
// if no status filter is provided.
|
||||
// This is used to filter on running instances only.
|
||||
defaultStatusFilter = "status = running"
|
||||
)
|
||||
|
||||
// OnCreateCatalog is called when a dynamic host catalog is created.
|
||||
func (p *HostPlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "attributes are required")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credConfig, err := credential.GetCredentialsConfig(catalog.GetSecrets(), catalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.NewPersistedState([]credential.Option{
|
||||
credential.WithCredentialsConfig(credConfig),
|
||||
}...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error creating new persisted state: %s", err)
|
||||
}
|
||||
|
||||
permissions := []string{
|
||||
credential.ComputeInstancesListPermission,
|
||||
credential.IAMServiceAccountKeysCreatePermission,
|
||||
credential.IAMServiceAccountKeysDeletePermission,
|
||||
}
|
||||
|
||||
if credState.CredentialsConfig.IsRotatable() && !catalogAttributes.DisableCredentialRotation {
|
||||
state, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||
}
|
||||
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
|
||||
|
||||
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
catalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||
}
|
||||
|
||||
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
persistedProto, err := catalogState.toProto()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
|
||||
}
|
||||
|
||||
return &pb.OnCreateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnUpdateCatalog is called when a dynamic host catalog is updated.
|
||||
func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
|
||||
currentCatalog := req.GetCurrentCatalog()
|
||||
if currentCatalog == nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "current catalog is required")
|
||||
}
|
||||
newCatalog := req.GetNewCatalog()
|
||||
if newCatalog == nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "new catalog is required")
|
||||
}
|
||||
|
||||
oldAttrs := currentCatalog.GetAttributes()
|
||||
if oldAttrs == nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "old catalog attributes are required")
|
||||
}
|
||||
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting old catalog attributes: %s", err)
|
||||
}
|
||||
newAttrs := newCatalog.GetAttributes()
|
||||
if newAttrs == nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "new catalog attributes are required")
|
||||
}
|
||||
newCatalogAttributes, err := getCatalogAttributes(newAttrs)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting new catalog attributes: %s", err)
|
||||
}
|
||||
|
||||
credState, err := credential.PersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
oldCatalogAttributes.CredentialAttributes,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
||||
}
|
||||
|
||||
// We will be updating credentials when one of two changes occur:
|
||||
// 1. The new catalog has a service account key, in which case we need to delete the old service account key credentials
|
||||
// 2. The target service account ID has changed. This indicates a change of credential type to use service account impersonation
|
||||
if newCatalog.GetSecrets() != nil || newCatalogAttributes.TargetServiceAccountId != oldCatalogAttributes.TargetServiceAccountId {
|
||||
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newCredState, err := credential.NewPersistedState([]credential.Option{
|
||||
credential.WithCredentialsConfig(updatedCredentials),
|
||||
}...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, fmt.Sprintf("error creating new persisted state: %s", err))
|
||||
}
|
||||
newCatalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(newCredState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||
}
|
||||
if st := dryRunValidation(ctx, newCatalogState, nil, p.testGCPClientOpts...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
// Replace the existing credential state.
|
||||
// This checks the timestamp on the last rotation time as well
|
||||
// and deletes the credentials if we are managing them
|
||||
// (ie: if we've rotated them before).
|
||||
if err := credState.ReplaceCreds(ctx, updatedCredentials, p.testGCPClientOpts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// This is a validate check to make sure that we aren't trying to
|
||||
// rotate credentials that are not rotatable. For example,
|
||||
// Application Default Credentials (ADC) are not rotatable.
|
||||
if !credState.CredentialsConfig.IsRotatable() && !newCatalogAttributes.DisableCredentialRotation {
|
||||
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
|
||||
}
|
||||
|
||||
if credState.CredentialsConfig.IsRotatable() {
|
||||
// This is a validate check to make sure that we aren't disabling
|
||||
// rotation for credentials currently being managed by rotation.
|
||||
// This is not allowed.
|
||||
if newCatalogAttributes.DisableCredentialRotation && newCatalog.GetSecrets() == nil {
|
||||
if !credState.CredsLastRotatedTime.IsZero() {
|
||||
return nil, status.Error(codes.InvalidArgument, "cannot disable credential rotation for credentials currently being rotated")
|
||||
}
|
||||
}
|
||||
|
||||
permissions := []string{
|
||||
credential.ComputeInstancesListPermission,
|
||||
credential.IAMServiceAccountKeysCreatePermission,
|
||||
credential.IAMServiceAccountKeysDeletePermission,
|
||||
}
|
||||
|
||||
// If we're enabling rotation now but didn't before, or have
|
||||
// freshly replaced credentials, we can rotate here.
|
||||
if !newCatalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
|
||||
state, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||
}
|
||||
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
|
||||
|
||||
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
catalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
|
||||
}
|
||||
|
||||
// perform dry run to ensure we can interact with GCP as expected.
|
||||
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
persistedProto, err := catalogState.toProto()
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
|
||||
}
|
||||
|
||||
return &pb.OnUpdateCatalogResponse{
|
||||
Persisted: persistedProto,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
|
||||
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.PersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
catalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
||||
}
|
||||
|
||||
_, err = newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
// try to delete static credentials for dynamic and static credentials
|
||||
if credState.CredentialsConfig.IsRotatable() {
|
||||
if !credState.CredsLastRotatedTime.IsZero() {
|
||||
// Delete old/existing credentials. This is done with the same
|
||||
// credentials to ensure that it has the proper permissions to do
|
||||
// it.
|
||||
if err := credState.DeleteCreds(ctx, p.testGCPClientOpts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.OnDeleteCatalogResponse{}, nil
|
||||
}
|
||||
|
||||
// NormalizeCatalogData is called to normalize the catalog data.
|
||||
func (p *HostPlugin) NormalizeCatalogData(ctx context.Context, req *pb.NormalizeCatalogDataRequest) (*pb.NormalizeCatalogDataResponse, error) {
|
||||
// No-op, Catalog fields do not require any normalization
|
||||
return &pb.NormalizeCatalogDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
|
||||
// NormalizeSetData currently ensures that "filters" is an array value, even
|
||||
// though it's accepted as a string value for CLI UX reasons
|
||||
func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
|
||||
if req.Attributes == nil {
|
||||
return &pb.NormalizeSetDataResponse{}, nil
|
||||
}
|
||||
|
||||
val := req.Attributes.Fields["filters"]
|
||||
if val == nil {
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
}
|
||||
|
||||
switch val.GetKind().(type) {
|
||||
case *structpb.Value_ListValue:
|
||||
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
|
||||
case *structpb.Value_StringValue:
|
||||
stringVal := val.Kind.(*structpb.Value_StringValue)
|
||||
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
|
||||
retAttrs.Fields["filters"] = structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue(stringVal.StringValue),
|
||||
},
|
||||
})
|
||||
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
|
||||
default:
|
||||
return &pb.NormalizeSetDataResponse{}, status.Error(codes.InvalidArgument, "attribute.filters must be a string or list")
|
||||
}
|
||||
}
|
||||
|
||||
// OnCreateSet is called when a dynamic host set is created.
|
||||
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is required")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog attributes are required")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.PersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
catalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
set := req.GetSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set is required")
|
||||
}
|
||||
if err := validateSet(req.GetSet()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set attributes are required")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listInstanceFilters, err := buildFilters(setAttrs)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building filters: %s", err)
|
||||
}
|
||||
|
||||
// perform dry run to ensure we can interact with GCP as expected.
|
||||
if st := dryRunValidation(ctx, catalogState, listInstanceFilters, p.testGCPClientOpts...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
return &pb.OnCreateSetResponse{}, nil
|
||||
}
|
||||
|
||||
// OnUpdateSet is called when a dynamic host set is updated.
|
||||
func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is required")
|
||||
}
|
||||
|
||||
attrs := catalog.GetAttributes()
|
||||
if attrs == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog attributes are required")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(attrs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.PersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
catalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
set := req.GetNewSet()
|
||||
if set == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set is required")
|
||||
}
|
||||
if err := validateSet(set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "set attributes are required")
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listInstanceFilters, err := buildFilters(setAttrs)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building filters: %s", err)
|
||||
}
|
||||
|
||||
// perform dry run to ensure we can interact with GCP as expected.
|
||||
if st := dryRunValidation(ctx, catalogState, listInstanceFilters, p.testGCPClientOpts...); st != nil {
|
||||
return nil, st.Err()
|
||||
}
|
||||
|
||||
return &pb.OnUpdateSetResponse{}, nil
|
||||
}
|
||||
|
||||
// OnDeleteSet is called when a dynamic host set is deleted.
|
||||
func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
|
||||
return &pb.OnDeleteSetResponse{}, nil
|
||||
}
|
||||
|
||||
// ListHosts returns the list of instances based on instance filter.
|
||||
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
|
||||
catalog := req.GetCatalog()
|
||||
if catalog == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
|
||||
}
|
||||
|
||||
catalogAttrsRaw := catalog.GetAttributes()
|
||||
if catalogAttrsRaw == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
|
||||
}
|
||||
|
||||
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credState, err := credential.PersistedStateFromProto(
|
||||
req.GetPersisted().GetSecrets(),
|
||||
catalogAttributes.CredentialAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
|
||||
}
|
||||
|
||||
catalogState, err := newGCPCatalogPersistedState(
|
||||
append([]gcpCatalogPersistedStateOption{
|
||||
withCredentials(credState),
|
||||
}, p.testCatalogStateOpts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
|
||||
}
|
||||
|
||||
sets := req.GetSets()
|
||||
if sets == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "sets is nil")
|
||||
}
|
||||
|
||||
queries := make([]hostSetQuery, len(sets))
|
||||
for i, set := range sets {
|
||||
// Validate Id since we use it in output
|
||||
if set.GetId() == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "set missing id")
|
||||
}
|
||||
|
||||
if set.GetAttributes() == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("set %s missing attributes", set.GetId()))
|
||||
}
|
||||
setAttrs, err := getSetAttributes(set.GetAttributes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := buildListInstancesRequest(setAttrs, catalogAttributes)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error building list instances request: %s", err)
|
||||
}
|
||||
|
||||
queries[i] = hostSetQuery{
|
||||
Id: set.GetId(),
|
||||
InputInstances: input,
|
||||
}
|
||||
}
|
||||
|
||||
instancesClient, err := catalogState.InstancesClient(ctx, p.testGCPClientOpts...)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error getting instances client: %s", err)
|
||||
}
|
||||
|
||||
// Run all queries now and assemble output.
|
||||
var maxLen int
|
||||
for i, query := range queries {
|
||||
output, err := getInstances(ctx, instancesClient, query.InputInstances)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error running list instances for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
|
||||
// Process the output here, we will normalize this into a single
|
||||
// set of hosts afterwards (possibly removing duplicates).
|
||||
for _, instance := range output {
|
||||
host, err := instanceToHost(instance)
|
||||
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
|
||||
maxLen++
|
||||
}
|
||||
}
|
||||
|
||||
// Now de-duplicate the hosts for the output. Maintain two sets:
|
||||
// * A slice of hosts that will be used in the output
|
||||
// * A map of hosts indexed by their external ID
|
||||
//
|
||||
// The map will is used in de-duplication to determine whether or
|
||||
// not we've seen the host before to simply add the query's set ID
|
||||
// to the list of set IDs that the host was seen in.
|
||||
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
|
||||
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
|
||||
for _, query := range queries {
|
||||
for _, host := range query.OutputHosts {
|
||||
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
|
||||
// Existing host, just add the set ID to the list of seen IDs
|
||||
// and continue
|
||||
existingHost.SetIds = append(existingHost.SetIds, query.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
// This will be the first seen entry, so append the set ID to
|
||||
// this host, and add it.
|
||||
host.SetIds = append(host.SetIds, query.Id)
|
||||
hostResultSlice = append(hostResultSlice, host)
|
||||
hostResultMap[host.ExternalId] = host
|
||||
}
|
||||
}
|
||||
|
||||
return &pb.ListHostsResponse{
|
||||
Hosts: hostResultSlice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateSet(s *hostsets.HostSet) error {
|
||||
if s == nil {
|
||||
return status.Error(codes.InvalidArgument, "set is nil")
|
||||
}
|
||||
var attrs SetAttributes
|
||||
attrMap := s.GetAttributes().AsMap()
|
||||
if err := mapstructure.Decode(attrMap, &attrs); err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
|
||||
}
|
||||
|
||||
badFields := make(map[string]string)
|
||||
_, filterSet := attrMap[ConstListInstancesFilter]
|
||||
|
||||
if filterSet && len(attrs.Filters) == 0 {
|
||||
badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty"
|
||||
}
|
||||
|
||||
for f := range attrMap {
|
||||
if _, ok := allowedSetFields[f]; !ok {
|
||||
badFields[fmt.Sprintf("attributes.%s", f)] = "unrecognized field"
|
||||
}
|
||||
}
|
||||
|
||||
if len(badFields) > 0 {
|
||||
return errors.InvalidArgumentError("Invalid arguments in the new set", badFields)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dryRunValidation performs an GCP List Instances call to verify the state's
|
||||
// credentials. If the call fails, the error is returned.
|
||||
func dryRunValidation(
|
||||
ctx context.Context,
|
||||
state *gcpCatalogPersistedState,
|
||||
listFilters []string,
|
||||
clientOptions ...option.ClientOption,
|
||||
) *status.Status {
|
||||
if state == nil {
|
||||
return status.New(codes.InvalidArgument, "persisted state is required")
|
||||
}
|
||||
|
||||
instancesClient, err := state.InstancesClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return status.New(codes.InvalidArgument, fmt.Sprintf("error getting instances client: %s", err))
|
||||
}
|
||||
|
||||
var filters string
|
||||
if len(listFilters) > 0 {
|
||||
filters = strings.Join(listFilters, " AND ")
|
||||
}
|
||||
|
||||
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
|
||||
Project: state.CredentialsConfig.ProjectId,
|
||||
Zone: state.CredentialsConfig.Zone,
|
||||
Filter: &filters,
|
||||
})
|
||||
|
||||
_, err = it.Next()
|
||||
if err != nil && err != iterator.Done {
|
||||
return status.New(codes.FailedPrecondition, fmt.Sprintf("gcp list instances failed: %s", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildFilters constructs a list of filters to be used in a ListInstancesRequest.
|
||||
// If no filters are provided, a default filter is added to filter on running instances.
|
||||
func buildFilters(attrs *SetAttributes) ([]string, error) {
|
||||
var filters []string
|
||||
var foundStateFilter bool
|
||||
// If filters are provided, validate them
|
||||
if len(attrs.Filters) != 0 {
|
||||
for _, filterAttr := range attrs.Filters {
|
||||
key, _, value, valid := extractFilterValue(filterAttr)
|
||||
switch {
|
||||
case !valid:
|
||||
return nil, fmt.Errorf("invalid filter %q. Ensure the operator is one of =, !=, >, <, <=, >=, :, eq, ne", filterAttr)
|
||||
case len(strings.TrimSpace(key)) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty filter key", filterAttr)
|
||||
case len(strings.TrimSpace(value)) == 0:
|
||||
return nil, fmt.Errorf("filter %q contains an empty value", filterAttr)
|
||||
}
|
||||
|
||||
if key == "status" {
|
||||
foundStateFilter = true
|
||||
}
|
||||
|
||||
filters = append(filters, filterAttr)
|
||||
}
|
||||
}
|
||||
|
||||
if !foundStateFilter {
|
||||
// if there is no explicit instance state filter, add the
|
||||
// status = "running" to the filter set. This
|
||||
// ensures that we filter on running instances only at the API
|
||||
// side, saving time when processing results.
|
||||
filters = append(filters, defaultStatusFilter)
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// extractFilterValue extracts the key and value from a filter string.
|
||||
// The filter string is expected to be in the format "key operator value".
|
||||
// The key and value are returned as strings, along with a boolean indicating
|
||||
// whether the extraction was successful.
|
||||
// The operator is expected to be one of =, !=, >, <, <=, >=, :, eq, ne.
|
||||
// as per GCP API documentation:
|
||||
// https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter
|
||||
func extractFilterValue(s string) (string, string, string, bool) {
|
||||
re := regexp.MustCompile(`(?i)([^=><!:\s]+)\s*(=|!=|>|<|<=|>=|:|eq|ne)\s*([^,]+)`)
|
||||
|
||||
matches := re.FindStringSubmatch(s)
|
||||
|
||||
// Check if we have the expected number of matches
|
||||
if len(matches) == 4 {
|
||||
key := matches[1]
|
||||
operator := matches[2]
|
||||
value := matches[3]
|
||||
return key, operator, value, true
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/googleapis/gax-go/v2"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
"google.golang.org/api/iterator"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
type InstancesAPI interface {
|
||||
List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator
|
||||
}
|
||||
|
||||
type gcpCatalogPersistedState struct {
|
||||
// CredentialsConfig is the configuration for the GCP credentials
|
||||
*credential.PersistedState
|
||||
// testInstancesAPIFunc provides a way to provide a factory for a mock Instances client
|
||||
testInstancesAPIFunc instancesAPIFunc
|
||||
}
|
||||
|
||||
type instancesAPIFunc func(...*credential.Config) (InstancesAPI, error)
|
||||
|
||||
type gcpCatalogPersistedStateOption func(s *gcpCatalogPersistedState) error
|
||||
|
||||
func withCredentials(x *credential.PersistedState) gcpCatalogPersistedStateOption {
|
||||
return func(s *gcpCatalogPersistedState) error {
|
||||
if s.PersistedState != nil {
|
||||
return errors.New("gcp credentials already set")
|
||||
}
|
||||
|
||||
s.PersistedState = x
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func withTestInstancesAPIFunc(f instancesAPIFunc) gcpCatalogPersistedStateOption {
|
||||
return func(s *gcpCatalogPersistedState) error {
|
||||
if s.testInstancesAPIFunc != nil {
|
||||
return errors.New("test API function already set")
|
||||
}
|
||||
|
||||
s.testInstancesAPIFunc = f
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newGCPCatalogPersistedState(opts ...gcpCatalogPersistedStateOption) (*gcpCatalogPersistedState, error) {
|
||||
s := new(gcpCatalogPersistedState)
|
||||
for _, opt := range opts {
|
||||
if err := opt(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ToProto converts the state to HostCatalogPersisted proto message.
|
||||
func (s *gcpCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
|
||||
data, err := structpb.NewStruct(s.ToMap())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
|
||||
}
|
||||
return &pb.HostCatalogPersisted{Secrets: data}, nil
|
||||
}
|
||||
|
||||
// InstancesClient returns a configured Instances client based on the session
|
||||
// information stored in the state.
|
||||
func (s *gcpCatalogPersistedState) InstancesClient(
|
||||
ctx context.Context,
|
||||
opts ...option.ClientOption,
|
||||
) (InstancesAPI, error) {
|
||||
if s.testInstancesAPIFunc != nil {
|
||||
return s.testInstancesAPIFunc(s.CredentialsConfig)
|
||||
}
|
||||
creds, err := s.CredentialsConfig.GenerateCredentials(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generation GCP credentials: %w", err)
|
||||
}
|
||||
if creds == nil {
|
||||
return nil, fmt.Errorf("nil gcp credentials")
|
||||
}
|
||||
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
||||
clientOptions = append(clientOptions, opts...)
|
||||
|
||||
client, err := compute.NewInstancesRESTClient(ctx, clientOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating instances client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// validateCredsCallbackFn returns a ValidateCredsCallback function that
|
||||
// validates the credentials by listing instances in the project and zone.
|
||||
func (s *gcpCatalogPersistedState) ValidateCredsCallbackFn(
|
||||
ctx context.Context,
|
||||
opts ...option.ClientOption,
|
||||
) credential.ValidateCredsCallback {
|
||||
return func(config *credential.Config, opts ...option.ClientOption) error {
|
||||
s.CredentialsConfig = config
|
||||
instancesClient, err := s.InstancesClient(ctx, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating instances client: %w", err)
|
||||
}
|
||||
|
||||
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
|
||||
Project: s.CredentialsConfig.ProjectId,
|
||||
Zone: s.CredentialsConfig.Zone,
|
||||
})
|
||||
|
||||
_, err = it.Next()
|
||||
if err != nil && err != iterator.Done {
|
||||
return fmt.Errorf("error listing instances: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
computepb "cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestWithCredentials(t *testing.T) {
|
||||
cred := &credential.PersistedState{}
|
||||
opt := withCredentials(cred)
|
||||
|
||||
state := &gcpCatalogPersistedState{}
|
||||
err := opt(state)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cred, state.PersistedState)
|
||||
|
||||
// Test setting credentials again should return an error
|
||||
err = opt(state)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "gcp credentials already set", err.Error())
|
||||
}
|
||||
|
||||
func TestNewGCPCatalogPersistedState(t *testing.T) {
|
||||
cred := &credential.PersistedState{}
|
||||
opt := withCredentials(cred)
|
||||
|
||||
state, err := newGCPCatalogPersistedState(opt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cred, state.PersistedState)
|
||||
}
|
||||
|
||||
func TestToProto(t *testing.T) {
|
||||
lastRotatedTime := time.Now()
|
||||
state := &gcpCatalogPersistedState{PersistedState: &credential.PersistedState{
|
||||
CredentialsConfig: &credential.Config{
|
||||
PrivateKey: "test-private-key",
|
||||
PrivateKeyId: "test-private-key-id",
|
||||
ClientEmail: "test@test.com",
|
||||
},
|
||||
CredsLastRotatedTime: lastRotatedTime,
|
||||
},
|
||||
}
|
||||
|
||||
proto, err := state.toProto()
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedData, err := structpb.NewStruct(map[string]any{
|
||||
"private_key": "test-private-key",
|
||||
"private_key_id": "test-private-key-id",
|
||||
"creds_last_rotated_time": lastRotatedTime.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedData, proto.Secrets)
|
||||
}
|
||||
|
||||
func TestInstancesClient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cred := &credential.PersistedState{
|
||||
CredentialsConfig: &credential.Config{},
|
||||
}
|
||||
state := &gcpCatalogPersistedState{PersistedState: cred}
|
||||
|
||||
t.Run("GenerateCredentials error", func(t *testing.T) {
|
||||
_, err := state.InstancesClient(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error generation GCP credentials")
|
||||
})
|
||||
|
||||
t.Run("NewInstancesRESTClient error", func(t *testing.T) {
|
||||
state.CredentialsConfig = &credential.Config{
|
||||
ProjectId: "test-project-id",
|
||||
PrivateKey: "test-private-key",
|
||||
ClientEmail: "test@example.com",
|
||||
}
|
||||
_, err := state.InstancesClient(ctx, option.WithoutAuthentication())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error creating instances client")
|
||||
})
|
||||
}
|
||||
func TestValidateCredsCallbackFn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cred := &credential.PersistedState{
|
||||
CredentialsConfig: &credential.Config{
|
||||
ProjectId: "test-project-id",
|
||||
Zone: "test-zone",
|
||||
},
|
||||
}
|
||||
state := &gcpCatalogPersistedState{PersistedState: cred}
|
||||
|
||||
t.Run("List instances error", func(t *testing.T) {
|
||||
state.testInstancesAPIFunc = newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(nil),
|
||||
testMockInstancesWithListInstancesError(errors.New("error listing instances")),
|
||||
)
|
||||
|
||||
callback := state.ValidateCredsCallbackFn(ctx)
|
||||
err := callback(&credential.Config{
|
||||
ProjectId: "test-project-id",
|
||||
Zone: "test-zone",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "error listing instances")
|
||||
})
|
||||
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
state.testInstancesAPIFunc = newTestMockInstances(ctx,
|
||||
nil,
|
||||
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
|
||||
testMockInstancesWithListInstancesError(nil),
|
||||
)
|
||||
|
||||
callback := state.ValidateCredsCallbackFn(ctx)
|
||||
err := callback(&credential.Config{
|
||||
ProjectId: "test-project-id",
|
||||
Zone: "test-zone",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
compute "cloud.google.com/go/compute/apiv1"
|
||||
"cloud.google.com/go/compute/apiv1/computepb"
|
||||
"github.com/googleapis/gax-go/v2"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type testHTTPServer struct {
|
||||
server *httptest.Server
|
||||
listInstancesResponse *computepb.InstanceList
|
||||
listInstancesError error
|
||||
}
|
||||
|
||||
func (s *testHTTPServer) start() *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var resp any
|
||||
if strings.Contains(r.URL.Path, "/compute/v1/projects") {
|
||||
if s.listInstancesError != nil {
|
||||
http.Error(w, "error listing instances: "+s.listInstancesError.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp = s.listInstancesResponse
|
||||
}
|
||||
if resp == nil {
|
||||
http.Error(w, "unknown path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to marshal request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, err = w.Write(b)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to write response: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}))
|
||||
s.server = ts
|
||||
return ts
|
||||
}
|
||||
|
||||
func pointer[T any](input T) *T {
|
||||
ret := input
|
||||
return &ret
|
||||
}
|
||||
|
||||
type testMockInstancesState struct {
|
||||
ListInstancesCalled bool
|
||||
ListInstancesInputParams *computepb.ListInstancesRequest
|
||||
}
|
||||
|
||||
func (s *testMockInstancesState) Reset() {
|
||||
s.ListInstancesCalled = false
|
||||
s.ListInstancesInputParams = nil
|
||||
}
|
||||
|
||||
type testMockInstances struct {
|
||||
InstancesAPI
|
||||
httpServer *testHTTPServer
|
||||
State *testMockInstancesState
|
||||
client *compute.InstancesClient
|
||||
ListInstancesOutput *computepb.InstanceList
|
||||
ListInstancesError error
|
||||
}
|
||||
|
||||
type testMockInstancesOption func(m *testMockInstances) error
|
||||
|
||||
func testMockInstancesWithListInstancesOutput(o *computepb.InstanceList) testMockInstancesOption {
|
||||
return func(m *testMockInstances) error {
|
||||
m.ListInstancesOutput = o
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testMockInstancesWithListInstancesError(e error) testMockInstancesOption {
|
||||
return func(m *testMockInstances) error {
|
||||
m.ListInstancesError = e
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMockInstances(ctx context.Context, state *testMockInstancesState, opts ...testMockInstancesOption) instancesAPIFunc {
|
||||
return func(cfgs ...*credential.Config) (InstancesAPI, error) {
|
||||
m := &testMockInstances{
|
||||
State: state,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
if err := opt(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
m.httpServer = &testHTTPServer{
|
||||
listInstancesResponse: m.ListInstancesOutput,
|
||||
listInstancesError: m.ListInstancesError,
|
||||
}
|
||||
m.httpServer.start()
|
||||
endpoint := m.httpServer.server.URL
|
||||
|
||||
if m.client == nil {
|
||||
client, err := compute.NewInstancesRESTClient(ctx,
|
||||
option.WithoutAuthentication(),
|
||||
option.WithEndpoint(endpoint),
|
||||
option.WithTokenSource(nil),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.client = client
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMockInstances) List(ctx context.Context, input *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator {
|
||||
if m.State != nil {
|
||||
m.State.ListInstancesCalled = true
|
||||
m.State.ListInstancesInputParams = input
|
||||
}
|
||||
|
||||
return m.client.List(ctx, input)
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package testing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/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"
|
||||
)
|
||||
|
||||
const (
|
||||
expectedInstanceCount = 3
|
||||
expectedTagsCount = 3
|
||||
expectedLabelsCount = 3
|
||||
)
|
||||
|
||||
// All the tests in this test depend on each other
|
||||
// to run in order. This is because the tests are
|
||||
// testing the same plugin instance and the same
|
||||
// persisted state. The tests are designed to run
|
||||
// in order to test the plugin's ability to rotate
|
||||
// credentials and manage the state of the plugin
|
||||
// instance.
|
||||
// To run the tests you need the following
|
||||
// environment variables set:
|
||||
// GOOGLE_APPLICATION_CREDENTIALS: This should be
|
||||
// the path to the service account key. This can also
|
||||
// be automatically set after authenticating with gcloud
|
||||
// using `gcloud auth application-default login`.
|
||||
//
|
||||
// TF_VAR_project_id: The project_id of the project.
|
||||
//
|
||||
// TF_VAR_region: The region the resources should be
|
||||
// deployed in.
|
||||
//
|
||||
// TF_VAR_zone: The zone the resources should be
|
||||
// deployed in.
|
||||
func TestHostPlugin(t *testing.T) {
|
||||
creds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
if creds == "" {
|
||||
t.Skip("set GOOGLE_APPLICATION_CREDENTIALS to use this test")
|
||||
}
|
||||
projectId := os.Getenv("TF_VAR_project_id")
|
||||
if projectId == "" {
|
||||
t.Skip("set TF_VAR_project_id to use this test")
|
||||
}
|
||||
region := os.Getenv("TF_VAR_region")
|
||||
if region == "" {
|
||||
t.Skip("set TF_VAR_region to use this test")
|
||||
}
|
||||
zone := os.Getenv("TF_VAR_zone")
|
||||
if zone == "" {
|
||||
t.Skip("set TF_VAR_zone to use this test")
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
tf, err := NewTestTerraformer("testdata/host")
|
||||
require.NoError(err)
|
||||
require.NotNil(tf)
|
||||
|
||||
t.Log("===== deploying test Terraform workspace =====")
|
||||
err = tf.Deploy()
|
||||
require.NoError(err)
|
||||
|
||||
defer func() {
|
||||
t.Log("===== destroying test Terraform workspace =====")
|
||||
if err := tf.Destroy(); err != nil {
|
||||
t.Logf("WARNING: could not run Terraform destroy: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
clientEmail, err := tf.GetOutput("client_email")
|
||||
require.NoError(err)
|
||||
require.NotNil(clientEmail)
|
||||
|
||||
instanceIds, err := tf.GetOutputSlice("instance_ids")
|
||||
require.NoError(err)
|
||||
require.Len(instanceIds, expectedInstanceCount)
|
||||
|
||||
instanceTags, err := tf.GetOutputMap("instance_tags")
|
||||
require.NoError(err)
|
||||
require.Len(instanceTags, expectedInstanceCount)
|
||||
|
||||
instanceLabels, err := tf.GetOutputMap("instance_labels")
|
||||
require.NoError(err)
|
||||
require.Len(instanceLabels, expectedLabelsCount)
|
||||
|
||||
privateKeys, err := tf.GetOutputSlice("private_keys")
|
||||
require.NoError(err)
|
||||
require.Len(privateKeys, expectedServiceAccountKeys)
|
||||
|
||||
privateKeyIds, err := tf.GetOutputSlice("private_key_ids")
|
||||
require.NoError(err)
|
||||
require.Len(privateKeyIds, expectedServiceAccountKeys)
|
||||
|
||||
p := new(host.HostPlugin)
|
||||
ctx := context.Background()
|
||||
|
||||
var keyId, secret string
|
||||
|
||||
t.Run("OnCreateCatalog", func(t *testing.T) {
|
||||
t.Run("Test non-rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnCreateCatalog(
|
||||
ctx,
|
||||
t,
|
||||
p,
|
||||
zone,
|
||||
projectId,
|
||||
clientEmail.(string),
|
||||
privateKeyIds[0].(string),
|
||||
privateKeys[0].(string),
|
||||
false,
|
||||
)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Test rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnCreateCatalog(
|
||||
ctx,
|
||||
t,
|
||||
p,
|
||||
zone,
|
||||
projectId,
|
||||
clientEmail.(string),
|
||||
keyId,
|
||||
secret,
|
||||
true,
|
||||
)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("OnUpdateCatalog", func(t *testing.T) {
|
||||
t.Run("Test no-op non-rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, false)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Switch to rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, true)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Test no-op with rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", true, true)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Switch credentials to next service account key. Don't rotate", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[1].(string), privateKeys[1].(string), true, false)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Switch credentials to next service account key. Add rotation", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[2].(string), privateKeys[2].(string), false, true)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Switch to next service account key, with rotation disabled", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[3].(string), privateKeys[3].(string), true, false)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
|
||||
t.Run("Switch to another service account key and keep rotation off", func(t *testing.T) {
|
||||
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[4].(string), privateKeys[4].(string), false, false)
|
||||
require.NotNil(keyId)
|
||||
require.NotNil(secret)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("OnDeleteCatalog", func(t *testing.T) {
|
||||
t.Run("Test non-rotated", func(t *testing.T) {
|
||||
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, false)
|
||||
})
|
||||
|
||||
t.Run("Test as if we had rotated these credentials.", func(t *testing.T) {
|
||||
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, true)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("HostSet", func(t *testing.T) {
|
||||
keyId, secret := privateKeyIds[5].(string), privateKeys[5].(string)
|
||||
expectedLabelInstancesMap := make(map[string][]string)
|
||||
for instanceId, instanceLabel := range instanceLabels {
|
||||
for tagKey := range instanceLabel.(map[string]any) {
|
||||
for _, expectedTag := range instanceLabels {
|
||||
if tagKey == expectedTag {
|
||||
expectedLabelInstancesMap[tagKey] = append(expectedLabelInstancesMap[tagKey], instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cases := map[string][]string{
|
||||
"label-1": {"foo"},
|
||||
"label-2": {"bar"},
|
||||
"label-3": {"baz"},
|
||||
"label-4": {"foo", "bar"},
|
||||
"label-5": {"foo", "baz"},
|
||||
"label-6": {"bar", "baz"},
|
||||
"label-7": {"foo", "bar", "baz"},
|
||||
}
|
||||
|
||||
for key, tc := range cases {
|
||||
t.Run(fmt.Sprintf("Label=%s", key), func(t *testing.T) {
|
||||
labels := make([]string, 0, len(tc))
|
||||
for _, label := range tc {
|
||||
labels = append(labels, fmt.Sprintf("labels.%s=%s", key, label))
|
||||
}
|
||||
testPluginOnCreateUpdateSet(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels)
|
||||
testPluginListHosts(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels, expectedLabelInstancesMap)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testPluginOnCreateCatalog(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
p *host.HostPlugin,
|
||||
zone string,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string,
|
||||
rotate bool) (string, string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnCreateCatalog (zone=%s, rotate=%t)", zone, rotate)
|
||||
require := require.New(t)
|
||||
|
||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstProjectId: projectId,
|
||||
credential.ConstZone: zone,
|
||||
credential.ConstDisableCredentialRotation: !rotate,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: privateKeyId,
|
||||
credential.ConstPrivateKey: privateKey,
|
||||
})
|
||||
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(), projectId, clientEmail, privateKeyId, privateKey, rotate)
|
||||
}
|
||||
|
||||
func testPluginOnUpdateCatalog(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
p *host.HostPlugin,
|
||||
zone string,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
currentPrivateKeyId string,
|
||||
currentPrivateKey string,
|
||||
newPrivateKeyId string,
|
||||
newPrivateKey string,
|
||||
rotated bool,
|
||||
rotate bool,
|
||||
) (string, string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnUpdateCatalog (zone=%s, newcreds=%t, rotated=%t, rotate=%t)", zone, newPrivateKeyId != "" && newPrivateKey != "", 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.ConstProjectId: projectId,
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstZone: zone,
|
||||
credential.ConstDisableCredentialRotation: !rotated,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqNewAttrs, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstProjectId: projectId,
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstZone: zone,
|
||||
credential.ConstDisableCredentialRotation: !rotate,
|
||||
})
|
||||
require.NoError(err)
|
||||
var reqSecrets *structpb.Struct
|
||||
if newPrivateKeyId != "" && newPrivateKey != "" {
|
||||
reqSecrets, err = structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: newPrivateKeyId,
|
||||
credential.ConstPrivateKey: newPrivateKey,
|
||||
})
|
||||
require.NoError(err)
|
||||
}
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: currentPrivateKeyId,
|
||||
credential.ConstPrivateKey: currentPrivateKey,
|
||||
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,
|
||||
projectId,
|
||||
clientEmail,
|
||||
persisted.GetSecrets(),
|
||||
currentCredsLastRotatedTime,
|
||||
currentPrivateKeyId,
|
||||
currentPrivateKey,
|
||||
newPrivateKeyId,
|
||||
newPrivateKey,
|
||||
rotated,
|
||||
rotate,
|
||||
)
|
||||
}
|
||||
|
||||
func testPluginOnDeleteCatalog(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
p *host.HostPlugin,
|
||||
zone string,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string,
|
||||
rotated bool) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnDeleteCatalog (zone=%s, rotated=%t)", zone, rotated)
|
||||
require := require.New(t)
|
||||
|
||||
reqAttrs, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstProjectId: projectId,
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstZone: zone,
|
||||
credential.ConstDisableCredentialRotation: !rotated,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: privateKeyId,
|
||||
credential.ConstPrivateKey: privateKey,
|
||||
})
|
||||
require.NoError(err)
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: privateKeyId,
|
||||
credential.ConstPrivateKey: privateKey,
|
||||
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, projectId, clientEmail, privateKeyId, privateKey)
|
||||
} 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, projectId, clientEmail, privateKeyId, privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
func testPluginOnCreateUpdateSet(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
p *host.HostPlugin,
|
||||
zone string,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string,
|
||||
labels []string) {
|
||||
t.Helper()
|
||||
t.Logf("testing OnCreateSet (zone=%s, labels=%v)", zone, labels)
|
||||
require := require.New(t)
|
||||
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstProjectId: projectId,
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstZone: zone,
|
||||
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.ConstListInstancesFilter: []any{strings.Join(labels, " AND ")},
|
||||
})
|
||||
require.NoError(err)
|
||||
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstPrivateKeyId: privateKeyId,
|
||||
credential.ConstPrivateKey: privateKey,
|
||||
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 (zone=%s, labels=%v)", zone, labels)
|
||||
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,
|
||||
zone string,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string,
|
||||
labels []string,
|
||||
expected map[string][]string) {
|
||||
t.Helper()
|
||||
t.Logf("testing ListHosts (zone=%s, labels=%v)", zone, labels)
|
||||
require := require.New(t)
|
||||
catalogAttrs, err := structpb.NewStruct(map[string]any{
|
||||
credential.ConstProjectId: projectId,
|
||||
credential.ConstClientEmail: clientEmail,
|
||||
credential.ConstZone: zone,
|
||||
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
|
||||
})
|
||||
require.NoError(err)
|
||||
sets := make([]*hostsets.HostSet, len(labels))
|
||||
for i, label := range labels {
|
||||
setAttrs, err := structpb.NewStruct(map[string]any{
|
||||
host.ConstListInstancesFilter: []any{label},
|
||||
})
|
||||
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.ConstPrivateKeyId: privateKeyId,
|
||||
credential.ConstPrivateKey: privateKey,
|
||||
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, label := range labels {
|
||||
for _, instanceId := range expected[label] {
|
||||
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))", zone, labels, len(actualInstances), actualInstances)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
data "google_compute_zones" "available" {}
|
||||
|
||||
resource "google_compute_instance" "default" {
|
||||
count = var.num_instances
|
||||
name = "boundary-${count.index}"
|
||||
machine_type = "n2-standard-2"
|
||||
zone = data.google_compute_zones.available.names[0]
|
||||
|
||||
labels = var.labels
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "ubuntu-os-cloud/ubuntu-2204-lts"
|
||||
labels = var.labels
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
|
||||
access_config {
|
||||
// Ephemeral public IP
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
|
||||
}
|
||||
|
||||
dynamic "service_account" {
|
||||
for_each = var.service_account_email != null ? [var.service_account_email] : []
|
||||
content {
|
||||
email = service_account.value
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_compute_instance_group" "webservers" {
|
||||
name = "boundary-servers"
|
||||
description = "Boundary server group"
|
||||
|
||||
instances = google_compute_instance.default.*.id
|
||||
|
||||
zone = data.google_compute_zones.available.names[0]
|
||||
}
|
||||
|
||||
# RSA key of size 4096 bits
|
||||
resource "tls_private_key" "ssh" {
|
||||
algorithm = "RSA"
|
||||
rsa_bits = 4096
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "rules" {
|
||||
name = "allow-ssh"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["22"]
|
||||
}
|
||||
source_ranges = [var.client_cidr_block]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
resource "local_file" "ssh_key" {
|
||||
content = tls_private_key.ssh.private_key_openssh
|
||||
filename = "${path.module}/secrets/id_rsa.pem"
|
||||
file_permission = "0400"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
variable "labels" {
|
||||
type = map(string)
|
||||
default = {
|
||||
repository = "boundary-plugin-google"
|
||||
purpose = "testing"
|
||||
}
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-central1"
|
||||
}
|
||||
|
||||
variable "num_instances" {
|
||||
type = number
|
||||
default = 3
|
||||
}
|
||||
|
||||
variable "service_account_email" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "client_cidr_block" {
|
||||
type = string
|
||||
}
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
resource "random_id" "service_account_client_email" {
|
||||
prefix = "test-${var.user_prefix}"
|
||||
byte_length = 2
|
||||
}
|
||||
|
||||
resource "google_service_account" "test_service_account" {
|
||||
project = var.project_id
|
||||
account_id = random_id.service_account_client_email.dec
|
||||
display_name = "test-${var.user_prefix}"
|
||||
}
|
||||
|
||||
resource "google_project_iam_binding" "test_service_account_user" {
|
||||
project = var.project_id
|
||||
count = length(var.rolesList)
|
||||
role = var.rolesList[count.index]
|
||||
members = [
|
||||
"serviceAccount:${google_service_account.test_service_account.email}"
|
||||
]
|
||||
}
|
||||
|
||||
resource "google_service_account_key" "test_service_account_key" {
|
||||
service_account_id = google_service_account.test_service_account.name
|
||||
public_key_type = "TYPE_X509_PEM_FILE"
|
||||
private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE"
|
||||
key_algorithm = "KEY_ALG_RSA_2048"
|
||||
}
|
||||
|
||||
resource "google_service_account_key" "test_service_account_keys" {
|
||||
count = var.num_service_account_keys
|
||||
service_account_id = google_service_account.test_service_account.name
|
||||
public_key_type = "TYPE_X509_PEM_FILE"
|
||||
private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE"
|
||||
key_algorithm = "KEY_ALG_RSA_2048"
|
||||
}
|
||||
|
||||
resource "google_compute_instance" "instances" {
|
||||
project = var.project_id
|
||||
count = var.num_instances
|
||||
name = "boundary-${count.index}"
|
||||
machine_type = "n2-standard-2"
|
||||
zone = var.zone
|
||||
labels = var.labels
|
||||
tags = ["test-instance-${count.index}"]
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "ubuntu-os-cloud/ubuntu-2204-lts"
|
||||
labels = var.labels
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
|
||||
access_config {
|
||||
// Ephemeral public IP
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
|
||||
}
|
||||
|
||||
service_account {
|
||||
email = google_service_account.test_service_account.email
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
}
|
||||
|
||||
# RSA key of size 4096 bits
|
||||
resource "tls_private_key" "ssh" {
|
||||
algorithm = "RSA"
|
||||
rsa_bits = 4096
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
output "client_email" {
|
||||
value = google_service_account.test_service_account.email
|
||||
}
|
||||
|
||||
output "private_keys" {
|
||||
value = [for key in google_service_account_key.test_service_account_keys : jsondecode(base64decode(key.private_key)).private_key]
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "private_key_ids" {
|
||||
value = [for key in google_service_account_key.test_service_account_keys : chomp(reverse(split("/", key.id))[0])]
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "instance_ids" {
|
||||
value = google_compute_instance.instances.*.id
|
||||
}
|
||||
|
||||
output "instance_tags" {
|
||||
value = {
|
||||
for instance in google_compute_instance.instances : instance.id => instance.tags
|
||||
}
|
||||
}
|
||||
|
||||
output "instance_labels" {
|
||||
value = {
|
||||
for instance in google_compute_instance.instances : instance.id => instance.labels
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,5 @@ terraform {
|
||||
|
||||
provider "google" {
|
||||
region = var.region
|
||||
project = var.project_id
|
||||
}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) HashiCorp, Inc.
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
data "google_client_openid_userinfo" "current" {}
|
||||
|
||||
variable "labels" {
|
||||
type = map(string)
|
||||
default = {
|
||||
repository = "boundary-plugin-gcp"
|
||||
purpose = "testing"
|
||||
}
|
||||
}
|
||||
|
||||
variable "project_id" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "zone" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "num_instances" {
|
||||
type = number
|
||||
default = 3
|
||||
}
|
||||
|
||||
variable "service_account_email" {
|
||||
type = string
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "rolesList" {
|
||||
type = list(string)
|
||||
description = "List of roles to assign to the service account"
|
||||
default = ["roles/compute.viewer","roles/iam.serviceAccountKeyAdmin"]
|
||||
}
|
||||
|
||||
variable "user_prefix" {
|
||||
type = string
|
||||
default = "boundary-gcp-plugin"
|
||||
}
|
||||
|
||||
variable "num_service_account_keys" {
|
||||
type = number
|
||||
default = 6
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package testing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
|
||||
tfjson "github.com/hashicorp/terraform-json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/option"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
// expectedServiceAccountKeys is the number of service account keys
|
||||
// expected to be created in the Terraform project.
|
||||
expectedServiceAccountKeys = 6
|
||||
)
|
||||
|
||||
// TestTerraformer is an object representing the lifecycle of a
|
||||
// particular Terraform project.
|
||||
//
|
||||
// Note that Terraform must be installed in the system PATH (not to
|
||||
// be confused with the working directory below). This object does
|
||||
// not manage anything with regards to installation or version
|
||||
// management.
|
||||
type TestTerraformer struct {
|
||||
// The full path to the Terraform binary, set during initialization
|
||||
// to the binary found in the system path.
|
||||
tfPath string
|
||||
|
||||
// The working directory for Terraform commands.
|
||||
workDir string
|
||||
|
||||
// The Terraform state. Use the Output method to look up particular
|
||||
// outputs from the Terraform state.
|
||||
state *tfjson.State
|
||||
|
||||
// Keeps track of whether or not we have run the init command
|
||||
// during this run of Terraform. This only needs to be done once.
|
||||
// Do not use initErr directly; it stores the init error if one was
|
||||
// encountered on init.
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
}
|
||||
|
||||
// NewTestTerraformer initializes a Terraform directory. It just
|
||||
// returns an initialized project, it does not perform any actions on
|
||||
// it.
|
||||
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
|
||||
tfPath, err := exec.LookPath("terraform")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LookPath error: %w", err)
|
||||
}
|
||||
|
||||
if workDir == "" {
|
||||
return nil, errors.New("workDir is empty")
|
||||
}
|
||||
|
||||
return &TestTerraformer{
|
||||
tfPath: tfPath,
|
||||
workDir: workDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deploy runs the following commands, in order: init, apply, plan,
|
||||
// json. The output of the JSON command is then imported to the
|
||||
// internal state.
|
||||
func (t *TestTerraformer) Deploy() error {
|
||||
if err := t.init(); err != nil {
|
||||
return fmt.Errorf("error running Terraform init: %w", err)
|
||||
}
|
||||
|
||||
if err := t.apply(); err != nil {
|
||||
return fmt.Errorf("error running Terraform apply: %w", err)
|
||||
}
|
||||
|
||||
if err := t.plan(); err != nil {
|
||||
return fmt.Errorf("error running Terraform plan: %w", err)
|
||||
}
|
||||
|
||||
output, err := t.showJson()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting Terraform plan JSON data: %w", err)
|
||||
}
|
||||
|
||||
newState := new(tfjson.Plan)
|
||||
if err := json.Unmarshal(output, newState); err != nil {
|
||||
return fmt.Errorf("error reading new Terraform plan JSON data: %w", err)
|
||||
}
|
||||
|
||||
t.state = newState.PriorState
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy runs the following commands, in order: init, destroy. The
|
||||
// state is also cleared.
|
||||
func (t *TestTerraformer) Destroy() error {
|
||||
if err := t.init(); err != nil {
|
||||
return fmt.Errorf("error running Terraform init: %w", err)
|
||||
}
|
||||
|
||||
if err := t.destroy(); err != nil {
|
||||
return fmt.Errorf("error running Terraform destroy: %w", err)
|
||||
}
|
||||
|
||||
t.state = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOutput returns the value of the output located at key.
|
||||
//
|
||||
// Outputs for the root module are supported only. It's an error to
|
||||
// run this when no state exists.
|
||||
func (t *TestTerraformer) GetOutput(key string) (any, error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("key must not be empty")
|
||||
}
|
||||
|
||||
if t.state == nil {
|
||||
return nil, errors.New("state is nil")
|
||||
}
|
||||
|
||||
if t.state.Values == nil {
|
||||
return nil, errors.New("state integrity error: no state values")
|
||||
}
|
||||
|
||||
if t.state.Values.Outputs == nil {
|
||||
return nil, errors.New("state integrity error: outputs is nil")
|
||||
}
|
||||
|
||||
output, ok := t.state.Values.Outputs[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("output with key %q not found", key)
|
||||
}
|
||||
|
||||
if output == nil {
|
||||
return nil, fmt.Errorf("state integrity error: output at key %q is nil", key)
|
||||
}
|
||||
|
||||
return output.Value, nil
|
||||
}
|
||||
|
||||
// GetOutputString wraps GetOutput and returns the value as a string. It's an
|
||||
// error if the return value is actually not a string.
|
||||
func (t *TestTerraformer) GetOutputString(key string) (string, error) {
|
||||
value, err := t.GetOutput(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("expected output value at key %q to be string, got %T", key, value)
|
||||
}
|
||||
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// GetOutputMap wraps GetOutput and returns the value as a map. It's an
|
||||
// error if the return value is actually not a map.
|
||||
func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) {
|
||||
value, err := t.GetOutput(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetOutputSlice wraps GetOutput and returns the value as a slice. It's an
|
||||
// error if the return value is actually not a slice.
|
||||
func (t *TestTerraformer) GetOutputSlice(key string) ([]any, error) {
|
||||
value, err := t.GetOutput(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// init runs "terraform init" in the project's working directory.
|
||||
//
|
||||
// This is only ever done once.
|
||||
func (t *TestTerraformer) init() error {
|
||||
t.initOnce.Do(func() {
|
||||
cmd := exec.Command(t.tfPath, "init")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.initErr = fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
return t.initErr
|
||||
}
|
||||
|
||||
// apply runs "terraform apply" in the project's working directory.
|
||||
func (t *TestTerraformer) apply() error {
|
||||
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// destroy runs "terraform destroy" in the project's working directory.
|
||||
func (t *TestTerraformer) destroy() error {
|
||||
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// plan runs "terraform plan" in the project's working directory.
|
||||
//
|
||||
// Note that plan is not run as part of the apply workflow; rather,
|
||||
// it's run to get the data for for importing the state via
|
||||
// terraform-json. The apply method ignores the plan generated here.
|
||||
func (t *TestTerraformer) plan() error {
|
||||
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
|
||||
cmd.Dir = t.workDir
|
||||
stdoutStderr, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s\n%s", stdoutStderr, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// showJson runs "terraform show -json". The output is returned along
|
||||
// with any error.
|
||||
//
|
||||
// Output is only the results of stdout and should be suitable for
|
||||
// parsing.
|
||||
//
|
||||
// This method expects the plan.tfplan file to be generated in the
|
||||
// working directory already. This can be done with the plan method.
|
||||
func (t *TestTerraformer) showJson() ([]byte, error) {
|
||||
cmd := exec.Command(t.tfPath, "show", "-json", "plan.tfplan")
|
||||
cmd.Dir = t.workDir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s\n%s", exitErr.Stderr, exitErr)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func requireCredentialsInvalid(
|
||||
t *testing.T,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
c, err := credential.NewConfig(
|
||||
credential.WithProjectId(projectId),
|
||||
credential.WithClientEmail(clientEmail),
|
||||
credential.WithPrivateKeyId(privateKeyId),
|
||||
credential.WithPrivateKey(privateKey))
|
||||
require.NoError(err)
|
||||
|
||||
creds, err := c.GenerateCredentials(context.Background())
|
||||
require.NoError(err)
|
||||
|
||||
// We need to wait for invalidation as while gcp waits for
|
||||
// credential creation, deletion of the old credentials returns
|
||||
// immediately.
|
||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
defer cancel()
|
||||
waitErr:
|
||||
for {
|
||||
_, err = c.ValidateIamPermissions(timeoutCtx, []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
// pass
|
||||
|
||||
case <-timeoutCtx.Done():
|
||||
break waitErr
|
||||
}
|
||||
}
|
||||
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), "failed to validate IAM permissions")
|
||||
}
|
||||
|
||||
func requireCredentialsValid(
|
||||
t *testing.T,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
c, err := credential.NewConfig(
|
||||
credential.WithProjectId(projectId),
|
||||
credential.WithClientEmail(clientEmail),
|
||||
credential.WithPrivateKeyId(privateKeyId),
|
||||
credential.WithPrivateKey(privateKey))
|
||||
require.NoError(err)
|
||||
|
||||
creds, err := c.GenerateCredentials(context.Background())
|
||||
require.NoError(err)
|
||||
|
||||
_, err = c.ValidateIamPermissions(context.Background(), []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource))
|
||||
require.NoError(err)
|
||||
}
|
||||
|
||||
func validatePersistedSecrets(
|
||||
t *testing.T,
|
||||
persistedSecrets *structpb.Struct,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
privateKeyId string,
|
||||
privateKey string,
|
||||
rotate bool) (string, string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
require.NotNil(persistedSecrets)
|
||||
persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedPrivateKeyId)
|
||||
if rotate {
|
||||
require.NotEqual(privateKeyId, persistedPrivateKeyId)
|
||||
} else {
|
||||
require.Equal(privateKeyId, persistedPrivateKeyId)
|
||||
}
|
||||
|
||||
persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedPrivateKey)
|
||||
if rotate {
|
||||
require.NotEqual(privateKey, persistedPrivateKey)
|
||||
} else {
|
||||
require.Equal(privateKey, persistedPrivateKey)
|
||||
}
|
||||
|
||||
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
|
||||
require.NoError(err)
|
||||
if rotate {
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey)
|
||||
} else {
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
}
|
||||
|
||||
return persistedPrivateKeyId, persistedPrivateKey
|
||||
}
|
||||
|
||||
func validateUpdateSecrets(
|
||||
t *testing.T,
|
||||
projectId string,
|
||||
clientEmail string,
|
||||
persistedSecrets *structpb.Struct,
|
||||
currentCredsLastRotatedTime time.Time,
|
||||
currentPrivateKeyId string,
|
||||
currentPrivateKey string,
|
||||
newPrivateKeyId string,
|
||||
newPrivateKey string,
|
||||
rotated bool,
|
||||
rotate bool) (string, string) {
|
||||
t.Helper()
|
||||
require := require.New(t)
|
||||
|
||||
// Complex checks based on the scenarios.
|
||||
persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedPrivateKeyId)
|
||||
persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true)
|
||||
require.NoError(err)
|
||||
require.NotZero(persistedPrivateKey)
|
||||
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
|
||||
require.NoError(err)
|
||||
|
||||
// Our test scenarios are complex due the multi-dimensional nature
|
||||
// of criteria, so we lay them out in a switch below.
|
||||
switch {
|
||||
case newPrivateKeyId != "" && rotated && rotate:
|
||||
// The new private key ID was provided, we had previously rotated
|
||||
// the credentials before, and the new credential set is to be
|
||||
// rotated as well. In this case, the old credentials should have
|
||||
// been deleted, and the new credentials should have been rotated,
|
||||
// hence, should not match the new credentials initially
|
||||
// provided. Rotation time should be non-zero and updated.
|
||||
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.NotEqual(persistedPrivateKeyId, newPrivateKeyId)
|
||||
require.NotEqual(persistedPrivateKey, newPrivateKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||
|
||||
case newPrivateKeyId != "" && rotated && !rotate:
|
||||
// The new private key ID was provided, we had previously rotated
|
||||
// the credentials before, and the new credential is *not*
|
||||
// rotated. In this case, the old credentials should have
|
||||
// been deleted, But the new credentials should have not been
|
||||
// rotated, and hence should be the same. Rotation time should be
|
||||
// zero.
|
||||
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.Equal(persistedPrivateKeyId, newPrivateKeyId)
|
||||
require.Equal(persistedPrivateKey, newPrivateKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newPrivateKeyId != "" && !rotated && rotate:
|
||||
// The new private key was provided, we *have not* previously
|
||||
// rotated the credentials, and the new credential set is to be
|
||||
// rotated. In this case, the old credentials should have been
|
||||
// left alone, and the new credentials should have been rotated,
|
||||
// hence, should not match the new credentials initially
|
||||
// provided. Rotation time should be non-zero, but updated.
|
||||
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.NotEqual(persistedPrivateKeyId, newPrivateKeyId)
|
||||
require.NotEqual(persistedPrivateKey, newPrivateKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
|
||||
|
||||
case newPrivateKeyId != "" && !rotated && !rotate:
|
||||
// The new private key was provided, but we have not rotated
|
||||
// the credentials previously and we still don't plan on rotating
|
||||
// them. In this case, the old credentials should still be valid,
|
||||
// and the persisted ones should match the new ones provided.
|
||||
// Rotation time should be zero.
|
||||
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.Equal(persistedPrivateKeyId, newPrivateKeyId)
|
||||
require.Equal(persistedPrivateKey, newPrivateKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newPrivateKeyId == "" && rotated && rotate:
|
||||
// No new credentials have been provided, but we have previously
|
||||
// rotated and are still rotating credentials. This is a no-op.
|
||||
// Existing credentials should still be valid and match the ones
|
||||
// persisted to state. Rotation time should be identical since
|
||||
// no new rotation occurred.
|
||||
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.Equal(persistedPrivateKeyId, currentPrivateKeyId)
|
||||
require.Equal(persistedPrivateKey, currentPrivateKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime))
|
||||
|
||||
case newPrivateKeyId == "" && rotated && !rotate:
|
||||
// No new credentials have been provided, and we have previously
|
||||
// rotated the credentials. This is actually an error, but we
|
||||
// don't test it here; it's covered in unit testing (see
|
||||
// plugin_test.go).
|
||||
require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper")
|
||||
|
||||
case newPrivateKeyId == "" && !rotated && rotate:
|
||||
// No new credentials have been provided, and while we did not
|
||||
// rotate before, we want to switch to rotation. In this case,
|
||||
// the existing persisted credentials should have been rotated,
|
||||
// with a new non-zero timestamp.
|
||||
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.NotEqual(persistedPrivateKeyId, currentPrivateKeyId)
|
||||
require.NotEqual(persistedPrivateKey, currentPrivateKey)
|
||||
require.NotZero(persistedCredsLastRotatedTime)
|
||||
|
||||
case newPrivateKeyId == "" && !rotated && !rotate:
|
||||
// No new credentials have been provided and we have not, nor do
|
||||
// not, plan on rotating the credentials. This is a no-op.
|
||||
// Existing credentials should still be valid and match the ones
|
||||
// persisted to state. Rotation time should remain at zero.
|
||||
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
|
||||
require.Equal(persistedPrivateKeyId, currentPrivateKeyId)
|
||||
require.Equal(persistedPrivateKey, currentPrivateKey)
|
||||
require.Zero(persistedCredsLastRotatedTime)
|
||||
|
||||
default:
|
||||
// Scenario was reached that was not covered by this function.
|
||||
require.FailNow("unknown test scenario")
|
||||
}
|
||||
|
||||
return persistedPrivateKeyId, persistedPrivateKey
|
||||
}
|
||||
Reference in new issue
Block a user