Compare commits

...
Author SHA1 Message Date
Louis Ruch 6aa6343f3f Update CODEOWNERS 2024-11-18 16:24:10 -08:00
hashicorp-copywrite[bot] cf0a591dcb [COMPLIANCE] Add Copyright and License Headers (#19)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2024-11-13 13:59:59 -05:00
Elim Tsiagbey 7076cd0bad fix: filter building (#17)
- update filter building for `dryRunValidation` to append with `AND`
- Add test coverage for filter building and dryRunValidation
2024-11-06 12:43:06 -05:00
Elim Tsiagbey 0d5cc5d444 feat: add miss license header (#18) 2024-11-06 12:42:28 -05:00
Elim Tsiagbey 89c8ee2d6d feat: Set default GCP Scope (#16)
* feat: Set default GCP Scope

Update the GCP credential configuration to include a default scope for GCP access. The default scope is set to `https://www.googleapis.com/auth/cloud-platform` as recommended by [GCP's Best Practice](https://cloud.google.com/compute/docs/access/service-accounts#scopes_best_practice). This allows controlling the service account's access by granting it IAM roles.
2024-10-30 13:58:45 -04:00
Elim Tsiagbey a75b9bd310 feat: Docs (#15)
* feat: Docs

- Add docs for GCP Dynamic Host Plugin
- Remove references of instances group since we do not currently support this
2024-10-29 12:52:56 -04:00
Elim Tsiagbey 779d383a24 feat: OnUpdateSet (#13) & ListHosts (#14)
* feat: OnUpdateSet

Add support for OnUpdateSet with validation. OnUpdateSet will validate he attributes passed to it and run a dryRunValidation with the new set

* feat: ListHosts (#14)

- Update list hosts to only support listing instances
- Update host client to support Instances API
- Add reading IPV6 Address by `Ipv6AccessConfigs`
- Add internal DNS which is the hostname
2024-10-28 13:10:44 -04:00
Elim Tsiagbey e4099a5f64 feat: onCreateSet
Add support for onCreateSet with validation.
- If a filter is not provided or a status is not set, a default filter of `status = running` is used.
- update `filters` field to be a slice instead of a string
- add dryRunValidation call to validate fields
2024-10-28 13:09:23 -04:00
Elim Tsiagbey beaa93d3d8 feat: NormalizeSetData (#11)
* feat: NormalizeSetData & NormalizeCatalogData

- Add support for always normalizing host set filters as an array. This implementation follows exactly what we do for AWS as well for consistency
- No-op for NormalizeCatalogData
2024-10-28 11:02:12 -04:00
Elim Tsiagbey ba53ae16b2 feat: OnDeleteCatalog (#10)
* feat: OnDeleteCatalog

Delete managed creds (creds we rotated previously) when a host catalog is deleted
2024-10-28 11:00:14 -04:00
Elim Tsiagbey 08fd6e5b01 feat: OnUpdate Host Catalog (#9)
* feat: OnUpdate Host Catalog

Add support for onUpdate host catalog
2024-10-28 10:48:26 -04:00
Elim Tsiagbey 6b917b170d feat: OnCreateCatalog (#8)
* feat: OnCreateCatalog

- Update onCreateCatalog to support credential rotation. Service authentication type creds will be rotated since we will have the credentials for the base service account without sufficient permissions.
- `onCreateCatalog` will also do a dry run validation to validate the credentials.
- The secrets and last rotated time will be returned
2024-10-21 10:47:06 -04:00
Elim Tsiagbey 636432f2fb feat: Rotate Service Account Key (#7)
* feat: Rotate Service Account Key

- Add support for rotating service account key
- Add a check to validate the permissions for the newly created service account key
- Remove `GetClient` for GCP config since HTTPClient is not an accepted form of authentication for all clients. Switching to using Google Credentials instead for authentication
2024-10-18 11:11:10 -04:00
Elim Tsiagbey be892acbd9 feat: GCP authentication (#5)
* feat: GCP authentication

Add authentication support for:
- authentication with service account key
- authentication with impersonating service account key
- authentication with application default credentials which are read from the environment

A HTTP client with a token set will be returned and used for authentication with all clients. for example
```
gcpClient, err := gcpConfig.GetClient(ctx)
if err != nil {
	log.Fatalf("Failed to get client: %v", err)
}

client, err := computeCloud.NewInstancesRESTClient(ctx, option.WithHTTPClient(gcpClient))
if err != nil {
	log.Fatalf("Failed to create client: %v", err)
}
```
2024-09-22 23:21:58 -04:00
Elim Tsiagbey eec01f7d69 feat: add attributes (#6)
add support for google host attributes:
- project_id (Required): The project ID associated with the service account
- zone (Required): The deployment area within a region
disable_credential_rotation (Optional): Disables credential rotation by the plugin
- client_email (Optional): 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. If it is not set, authentication will fail.
- target_service_account_id (Optional): The unique identifier for the service account that will be impersonated.
- disable_credential_rotation (Optional): Disable the rotation of google service account key associated with the plugin
2024-09-17 10:50:10 -04:00
Elim Tsiagbey 6588e10934 feat: package structure (#2)
Update structure to match other Boundary plugins which groups packages by service
2024-09-16 14:28:54 -04:00
Elim Tsiagbey cd73a74e1e feat: rename repository (#4)
Rename repository from `boundary-plugin-google` to `boundary-plugin-gcp`
2024-09-16 12:20:08 -04:00
Elim Tsiagbey b09755d302 Merge pull request #1 from hashicorp/initial-review
Initial commit of Google Cloud dynamic host catalog plugin
2024-09-12 16:24:14 -04:00
33 changed files with 6417 additions and 1396 deletions

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
contact_links: contact_links:
- name: Ask a question - name: Ask a question
url: https://discuss.hashicorp.com/c/boundary url: https://discuss.hashicorp.com/c/boundary
+1 -1
View File
@@ -1,4 +1,4 @@
# These owners will be the default owners for everything in # These owners will be the default owners for everything in
# the repo, unless a later match takes precedence. # the repo, unless a later match takes precedence.
@hashicorp/boundary * @hashicorp/boundary
+37 -73
View File
@@ -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/). Boundary](https://www.boundaryproject.io/).
> [!IMPORTANT]
> This is a prototype. Not officially supported by HashiCorp.
## Credentials ## Credentials
This plugin uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc) ### Service Account Credentials
to authenticate to Google and retrieve a list of instances and instance groups.
As a result, it does not support rotation of the credentials since they are inferred The plugin will authenticate using service account credential when the `secrets`
from the environment. 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 ## 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 Host sets created with this plugin define filters
which select and group like instances within Google; these host sets can in turn be which select and group like instances within GCP; these host sets can in turn be
added to targets within Boundary as host sources. added to targets within Boundary as host sources.
At creation, update or deletion of a host catalog of this type, configuration of the 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 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 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: [Getting Started](plugin/service/host/README.md)
- `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`.
+29 -26
View File
@@ -1,41 +1,44 @@
module github.com/hashicorp/boundary-plugin-google module github.com/hashicorp/boundary-plugin-gcp
go 1.21 go 1.21
require ( 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/boundary/sdk v0.0.47
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 golang.org/x/oauth2 v0.22.0
google.golang.org/api v0.188.0 google.golang.org/api v0.196.0
) )
require ( require (
cloud.google.com/go/auth v0.7.0 // indirect cloud.google.com/go v0.115.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // 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/compute/metadata v0.5.0 // indirect
cloud.google.com/go/longrunning v0.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.3 // 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
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect golang.org/x/sync v0.8.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // 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 ( 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/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/errwrap v1.1.0 // indirect
github.com/hashicorp/eventlogger v0.2.9 // indirect github.com/hashicorp/eventlogger v0.2.9 // indirect
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
@@ -48,12 +51,12 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.25.0 // indirect golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.22.0 // indirect golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.17.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.65.0 google.golang.org/grpc v1.66.0
google.golang.org/protobuf v1.34.2 google.golang.org/protobuf v1.34.2
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+56 -46
View File
@@ -1,14 +1,20 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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.1 h1:Jo0SM9cQnSkYfp44+v+NQXHpcHqlnRJk2qxh6yvxxxQ=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= cloud.google.com/go v0.115.1/go.mod h1:DuujITeaufu3gL68/lOFIirVNJwQeyf5UXyi+Wbgknc=
cloud.google.com/go/auth v0.7.0 h1:kf/x9B3WTbBUHkC+1VS8wwwli9TzhSt0vSTVBmMR8Ts= cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
cloud.google.com/go/auth v0.7.0/go.mod h1:D+WqdrpcjmiCgWrXmLLxOVq1GACoE36chW6KXoEvuIw= cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI= cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
cloud.google.com/go/compute v1.27.3 h1:GFfoejA5qGeFYpKAfgoVzvgJ3D8upFKI/UR7MdiPdS0= cloud.google.com/go/compute v1.28.0 h1:OPtBxMcheSS+DWfci803qvPly3d4w7Eu5ztKBcFfzwk=
cloud.google.com/go/compute v1.27.3/go.mod h1:5GuDo3l1k9CFhfIHK1sXqlqOW/iWX4/eBlO5FtxDhvQ= 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 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= 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/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 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
@@ -68,15 +74,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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= 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 h1:h5AXOASS2duHkCYEmNKnI9AR6YBZxD7VbFPV8BoE0z0=
github.com/hashicorp/boundary/sdk v0.0.47/go.mod h1:9iOT7kDM6mYcSkKxNuZlv8rP7U5BG1kXoevjLLL8lNQ= 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= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -183,24 +189,26 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= 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/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= 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.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 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 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/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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -218,27 +226,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-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-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.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.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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.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.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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-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-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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 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-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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -252,26 +262,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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag= 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.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 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-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-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-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-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= 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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= 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.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= 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-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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+131 -10
View File
@@ -6,26 +6,38 @@ package credential
import ( import (
"fmt" "fmt"
"github.com/hashicorp/boundary-plugin-google/internal/errors" "github.com/hashicorp/boundary-plugin-gcp/internal/errors"
"github.com/hashicorp/boundary-plugin-google/internal/values" "github.com/hashicorp/boundary-plugin-gcp/internal/values"
"google.golang.org/protobuf/types/known/structpb" "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 // and accessing a list of instances
type CredentialAttributes struct { type CredentialAttributes struct {
Project string // ProjectId is the project id associated with the GCP credentials
Zone string 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 // a list of instances and populate them into Boundary's host catalog
func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) { func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
badFields := make(map[string]string) badFields := make(map[string]string)
project, err := values.GetStringValue(in, ConstProject, true) projectId, err := values.GetStringValue(in, ConstProjectId, true)
if err != nil { 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) zone, err := values.GetStringValue(in, ConstZone, true)
@@ -33,12 +45,121 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
badFields[fmt.Sprintf("attributes.%s", ConstZone)] = err.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 { if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Error in the attributes provider", badFields) return nil, errors.InvalidArgumentError("Error in the attributes provider", badFields)
} }
return &CredentialAttributes{ return &CredentialAttributes{
Project: project, ProjectId: projectId,
Zone: zone, Zone: zone,
DisableCredentialRotation: disableCredentialRotation,
ClientEmail: clientEmail,
TargetServiceAccountId: targetServiceAccountId,
}, nil }, 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 {
// dynamic credentials requires the target service account id, cl and private key
case attrs.TargetServiceAccountId != "" && (privateKey == "" || attrs.ClientEmail == ""):
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
// static 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"
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
}
+215 -6
View File
@@ -22,7 +22,7 @@ func TestGetCredentialAttributes(t *testing.T) {
{ {
name: "missing project", name: "missing project",
in: map[string]any{}, in: map[string]any{},
expectedErrContains: "missing required value \"project\"", expectedErrContains: "missing required value \"project_id\"",
}, },
{ {
name: "missing zone", name: "missing zone",
@@ -32,12 +32,52 @@ func TestGetCredentialAttributes(t *testing.T) {
{ {
name: "valid project and zone", name: "valid project and zone",
in: map[string]any{ in: map[string]any{
ConstProject: "test-project", ConstProjectId: "test-project",
ConstZone: "us-central-1", ConstZone: "us-central-1",
}, },
expected: &CredentialAttributes{ expected: &CredentialAttributes{
Project: "test-project", ProjectId: "test-project",
Zone: "us-central-1", 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.NoError(err)
require.EqualValues(tc.expected.Project, actual.Project) require.EqualValues(tc.expected.ProjectId, actual.ProjectId)
require.EqualValues(tc.expected.Zone, actual.Zone) 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)
})
}
}
+246
View File
@@ -0,0 +1,246 @@
// 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,
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
}
+470
View File
@@ -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)
})
}
}
+21 -3
View File
@@ -4,9 +4,27 @@
package credential package credential
const ( const (
// ConstProject defines the attribute name for a Google Cloud project // ConstProjectId defines the attribute name for a GCP project
ConstProject = "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" 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"
) )
+123
View File
@@ -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
}
}
+123
View File
@@ -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)
})
}
+208
View File
@@ -0,0 +1,208 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"fmt"
"slices"
"strings"
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 = "compute.instances.list"
IAMServiceAccountKeysCreatePermission = "iam.serviceAccountKeys.create"
IAMServiceAccountKeysDeletePermission = "iam.serviceAccountKeys.delete"
CreateServiceAccountKeyResourceName = "projects/-/serviceAccounts"
)
// 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, 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("%s/%s", CreateServiceAccountKeyResourceName, 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)
}
privateKeyId, err := getPrivateKeyIdFromName(createServiceAccountKeyRes.Name)
if err != nil {
return status.Errorf(codes.Internal, "error parsing private key ID: %v", err)
}
// Clone the config, update the private key and private key ID with the new key.
newConfig := c.clone()
newConfig.PrivateKey = string(createServiceAccountKeyRes.PrivateKeyData)
newConfig.PrivateKeyId = 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 that the new credentials have the necessary permissions.
_, err = newConfig.ValidateIamPermissions(ctx, permissions, clientOptions...)
if err != nil {
return status.Errorf(codes.PermissionDenied, "error testing IAM permissions with 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("%s/%s/keys/%s", CreateServiceAccountKeyResourceName, 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
}
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/-/serviceAccounts/%s/keys/%s", 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")
}
creds, err := c.GenerateCredentials(ctx)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
}
if creds.TokenSource == nil {
return nil, status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
}
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
clientOptions = append(clientOptions, opts...)
rmClient, err := resourcemanager.NewProjectsClient(ctx, clientOptions...)
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
}
// getPrivateKeyIdFromName extracts the private key ID from the name of a service account key.
func getPrivateKeyIdFromName(input string) (string, error) {
lastSlashIndex := strings.LastIndex(input, "/")
if lastSlashIndex != -1 {
return input[lastSlashIndex+1:], nil
}
return "", fmt.Errorf("could not find private key ID in %s", input)
}
+363
View File
@@ -0,0 +1,363 @@
// 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,
}
err = tt.config.RotateServiceAccountKey(ctx, permissions, 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)
})
}
}
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 Generate Credentials",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
},
permissions: []string{"permission1", "permission2"},
wantErr: true,
expectedErr: "failed to generate credentials",
},
{
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)
})
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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
}
// 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
}
+186
View File
@@ -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)
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"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) {
resp := &adminpb.ServiceAccountKey{
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
PrivateKeyData: []byte("updated-private-key"),
}
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
}
-145
View File
@@ -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
View File
@@ -4,282 +4,26 @@
package plugin package plugin
import ( import (
"context" "github.com/hashicorp/boundary-plugin-gcp/plugin/service/host"
"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"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin" 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 // Ensure that HostPlugin implements the following services:
// Google host service plugin. //
type GooglePlugin struct { // HostPluginServiceServer
pb.UnimplementedHostPluginServiceServer
}
var ( var (
_ pb.HostPluginServiceServer = (*GooglePlugin)(nil) _ pb.HostPluginServiceServer = (*host.HostPlugin)(nil)
) )
// OnCreateCatalog is called when a dynamic host catalog is created. // GCPPlugin contains a collection of all GCP plugin services.
func (p *GooglePlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) { type GCPPlugin struct {
catalog := req.GetCatalog() // HostPlugin implements the HostPluginServiceServer interface for
if catalog == nil { // supporting dynamically sourcing hosts from GCP Compute Engine.
return nil, status.Error(codes.InvalidArgument, "catalog is nil") *host.HostPlugin
}
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
} }
// OnUpdateCatalog is called when a dynamic host catalog is updated. func NewGCPPlugin() *GCPPlugin {
func (p *GooglePlugin) OnUpdateCatalog(_ context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) { return &GCPPlugin{
currentCatalog := req.GetCurrentCatalog() HostPlugin: &host.HostPlugin{},
if currentCatalog == nil {
return nil, status.Error(codes.FailedPrecondition, "current catalog is nil")
} }
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
} }
-701
View File
@@ -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)
})
}
}
+106
View File
@@ -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. // Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
package plugin package host
import ( import (
"fmt" "fmt"
"strings"
computepb "cloud.google.com/go/compute/apiv1/computepb" computepb "cloud.google.com/go/compute/apiv1/computepb"
cred "github.com/hashicorp/boundary-plugin-google/internal/credential" cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-google/internal/errors" "github.com/hashicorp/boundary-plugin-gcp/internal/errors"
"github.com/hashicorp/boundary-plugin-google/internal/values" "github.com/hashicorp/boundary-plugin-gcp/internal/values"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
@@ -34,10 +35,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
for s := range unknownFields { for s := range unknownFields {
switch s { switch s {
// Ignore knownFields from CredentialAttributes // Ignore knownFields from CredentialAttributes
case cred.ConstProject: case cred.ConstProjectId:
continue continue
case cred.ConstZone: case cred.ConstZone:
continue continue
case cred.ConstDisableCredentialRotation:
continue
case cred.ConstClientEmail:
continue
case cred.ConstTargetServiceAccountID:
continue
default: default:
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field" 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 // SetAttributes defines attributes fro the host set
type SetAttributes struct { type SetAttributes struct {
Filter string `mapstructure:"filter"` Filters []string `mapstructure:"filters"`
InstanceGroup string `mapstructure:"instance_group"`
} }
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
@@ -64,14 +70,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
badFields := make(map[string]string) badFields := make(map[string]string)
unknownFields := values.StructFields(in) 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, ConstListInstancesFilter)
delete(unknownFields, ConstInstanceGroup)
for a := range unknownFields { for a := range unknownFields {
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field" 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 { if filtersRaw, ok := inMap[ConstListInstancesFilter]; ok {
switch filterVal := filtersRaw.(type) { switch filterVal := filtersRaw.(type) {
case string: case string:
inMap[ConstListInstancesFilter] = string(filterVal) inMap[ConstListInstancesFilter] = []string{filterVal}
}
}
if instanceGroupRaw, ok := inMap[ConstInstanceGroup]; ok {
switch instanceGroupValue := instanceGroupRaw.(type) {
case string:
inMap[ConstInstanceGroup] = string(instanceGroupValue)
} }
} }
@@ -105,29 +97,21 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
return &setAttrs, nil return &setAttrs, nil
} }
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest { func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) (*computepb.ListInstancesRequest, error) {
request := &computepb.ListInstancesRequest{ request := &computepb.ListInstancesRequest{
Project: catalog.Project, Project: catalog.ProjectId,
Zone: catalog.Zone, Zone: catalog.Zone,
} }
if len(attributes.Filter) > 1 { filters, err := buildFilters(attributes)
request.Filter = &attributes.Filter if err != nil {
return nil, fmt.Errorf("error building filters: %w", err)
} }
return request if len(filters) > 1 {
} filters := strings.Join(filters, " AND ")
request.Filter = &filters
func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesInstanceGroupsRequest { }
request := &computepb.ListInstancesInstanceGroupsRequest{
InstanceGroup: attributes.InstanceGroup, return request, nil
Project: catalog.Project,
Zone: catalog.Zone,
}
if len(attributes.Filter) > 1 {
request.Filter = &attributes.Filter
}
return request
} }
+208
View File
@@ -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. // Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
package plugin package host
import ( import (
"context" "context"
"errors" "errors"
"path" "strconv"
compute "cloud.google.com/go/compute/apiv1"
computepb "cloud.google.com/go/compute/apiv1/computepb" computepb "cloud.google.com/go/compute/apiv1/computepb"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin" pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/api/iterator" "google.golang.org/api/iterator"
@@ -16,66 +15,25 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
const ( func getInstances(ctx context.Context, instancesClient InstancesAPI, request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) {
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) {
hosts := []*computepb.Instance{} hosts := []*computepb.Instance{}
it := c.InstancesClient.List(c.Context, request) it := instancesClient.List(ctx, request)
for { for {
resp, err := it.Next() resp, err := it.Next()
if err == iterator.Done { if err == iterator.Done {
break break
} }
if err != nil { 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) hosts = append(hosts, resp)
} }
return hosts, nil 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) { func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, error) {
if instance.GetSelfLink() == "" { if instance.GetId() == 0 {
return nil, errors.New("response integrity error: missing instance self-link") return nil, errors.New("response integrity error: missing instance id")
} }
if instance.GetName() == "" { if instance.GetName() == "" {
@@ -84,22 +42,33 @@ func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, er
result := new(pb.ListHostsResponseHost) result := new(pb.ListHostsResponseHost)
result.ExternalId = instance.GetSelfLink() result.ExternalId = strconv.FormatUint(instance.GetId(), 10)
result.ExternalName = instance.GetName() 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 // Now go through all of the interfaces and log the IP address of
// every interface. // every interface.
for _, iface := range instance.GetNetworkInterfaces() { 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. // for the entire instance.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP) result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP)
for _, external := range iface.AccessConfigs { for _, external := range iface.AccessConfigs {
result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP) result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP)
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
} }
// Add the IPv6 addresses. // Add the IPv6 addresses.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address) result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address)
if iface.Ipv6AccessConfigs != nil {
for _, external := range iface.Ipv6AccessConfigs {
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
}
}
} }
// Done // Done
@@ -1,18 +1,22 @@
// Copyright (c) HashiCorp, Inc. // Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
package plugin package host
import ( import (
"context"
"errors"
"strconv"
"testing" "testing"
"cloud.google.com/go/compute/apiv1/computepb" "cloud.google.com/go/compute/apiv1/computepb"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin" pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestInstanceToHost(t *testing.T) { 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" examplePrivateIp := "10.0.0.1"
examplePrivateIp2 := "10.0.0.2" examplePrivateIp2 := "10.0.0.2"
examplePublicIp := "1.1.1.1" examplePublicIp := "1.1.1.1"
@@ -27,24 +31,24 @@ func TestInstanceToHost(t *testing.T) {
expectedErr string expectedErr string
}{ }{
{ {
name: "missing instance self-link", name: "missing instance id",
instance: &computepb.Instance{ instance: &computepb.Instance{
Name: &exampleName, Name: &exampleName,
}, },
expectedErr: "response integrity error: missing instance self-link", expectedErr: "response integrity error: missing instance id",
}, },
{ {
name: "missing instance name", name: "missing instance name",
instance: &computepb.Instance{ instance: &computepb.Instance{
SelfLink: &exampleId, Id: &exampleId,
}, },
expectedErr: "response integrity error: missing instance name", expectedErr: "response integrity error: missing instance name",
}, },
{ {
name: "good, single private IP with public IP address", name: "good, single private IP with public IP address",
instance: &computepb.Instance{ instance: &computepb.Instance{
SelfLink: &exampleId, Id: &exampleId,
Name: &exampleName, Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{ NetworkInterfaces: []*computepb.NetworkInterface{
{ {
NetworkIP: &examplePrivateIp, NetworkIP: &examplePrivateIp,
@@ -57,7 +61,7 @@ func TestInstanceToHost(t *testing.T) {
}, },
}, },
expected: &pb.ListHostsResponseHost{ expected: &pb.ListHostsResponseHost{
ExternalId: exampleId, ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName, ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp}, IpAddresses: []string{examplePrivateIp, examplePublicIp},
}, },
@@ -65,8 +69,8 @@ func TestInstanceToHost(t *testing.T) {
{ {
name: "good, single private IP address", name: "good, single private IP address",
instance: &computepb.Instance{ instance: &computepb.Instance{
SelfLink: &exampleId, Id: &exampleId,
Name: &exampleName, Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{ NetworkInterfaces: []*computepb.NetworkInterface{
{ {
NetworkIP: &examplePrivateIp, NetworkIP: &examplePrivateIp,
@@ -75,7 +79,7 @@ func TestInstanceToHost(t *testing.T) {
}, },
}, },
expected: &pb.ListHostsResponseHost{ expected: &pb.ListHostsResponseHost{
ExternalId: exampleId, ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName, ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp}, IpAddresses: []string{examplePrivateIp},
}, },
@@ -83,8 +87,8 @@ func TestInstanceToHost(t *testing.T) {
{ {
name: "good, multiple interfaces", name: "good, multiple interfaces",
instance: &computepb.Instance{ instance: &computepb.Instance{
SelfLink: &exampleId, Id: &exampleId,
Name: &exampleName, Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{ NetworkInterfaces: []*computepb.NetworkInterface{
{ {
NetworkIP: &examplePrivateIp, NetworkIP: &examplePrivateIp,
@@ -105,7 +109,7 @@ func TestInstanceToHost(t *testing.T) {
}, },
}, },
expected: &pb.ListHostsResponseHost{ expected: &pb.ListHostsResponseHost{
ExternalId: exampleId, ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName, ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2}, IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2},
}, },
@@ -113,8 +117,8 @@ func TestInstanceToHost(t *testing.T) {
{ {
name: "good, single private IP address with IPv6", name: "good, single private IP address with IPv6",
instance: &computepb.Instance{ instance: &computepb.Instance{
SelfLink: &exampleId, Id: &exampleId,
Name: &exampleName, Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{ NetworkInterfaces: []*computepb.NetworkInterface{
{ {
NetworkIP: &examplePrivateIp, NetworkIP: &examplePrivateIp,
@@ -124,7 +128,7 @@ func TestInstanceToHost(t *testing.T) {
}, },
}, },
expected: &pb.ListHostsResponseHost{ expected: &pb.ListHostsResponseHost{
ExternalId: exampleId, ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName, ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, exampleIPv6}, 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. // Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
package plugin package host
const ( const (
// ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances // ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances
ConstListInstancesFilter = "filter" ConstListInstancesFilter = "filters"
// ConstInstanceGroup refers to the name of a Google Cloud instance group
ConstInstanceGroup = "instance_group"
) )
var allowedSetFields = map[string]struct{}{ var allowedSetFields = map[string]struct{}{
ConstListInstancesFilter: {}, ConstListInstancesFilter: {},
ConstInstanceGroup: {},
} }
+36
View File
@@ -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.
+701
View File
@@ -0,0 +1,701 @@
// 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 {
if err := credState.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, 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)
}
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes)
if err != nil {
return nil, err
}
if newCatalog.GetSecrets() != nil {
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
}
}
if credState.CredentialsConfig.IsRotatable() {
if !updatedCredentials.IsRotatable() && !newCatalogAttributes.DisableCredentialRotation {
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
}
// 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() {
if err := credState.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, 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
+101
View File
@@ -0,0 +1,101 @@
// 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/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
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package host
import (
"context"
"testing"
"time"
"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")
})
}
+138
View File
@@ -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)
}
+1 -1
View File
@@ -4,7 +4,7 @@
variable "labels" { variable "labels" {
type = map(string) type = map(string)
default = { default = {
repository = "boundary-plugin-google" repository = "boundary-plugin-gcp"
purpose = "testing" purpose = "testing"
} }
} }