Compare commits

..
Author SHA1 Message Date
irenarindos be9a989aaa chore: bump crypto 2026-10-01 15:21:49 -04:00
dependabot[bot] 81811ae0fa chore(deps): bump google.golang.org/grpc from 1.79.3 to 1.83.2 (#42)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.83.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 15:38:13 -04:00
dependabot[bot] c1b4dbef9d chore(deps): bump golang.org/x/net from 0.48.0 to 0.55.0 (#39)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.55.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 15:30:40 -04:00
hashicorp-copywrite[bot] 722757e5c2 [COMPLIANCE] Add/Update Copyright Headers (#38)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-05-11 22:40:22 -07:00
dependabot[bot] 21340fdf79 chore(deps): bump go.opentelemetry.io/otel from 1.39.0 to 1.41.0 (#37)
Bumps [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) from 1.39.0 to 1.41.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.41.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.41.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 22:39:58 -07:00
dependabot[bot] fbdec31a30 chore(deps): bump google.golang.org/grpc from 1.66.0 to 1.79.3 (#35)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.66.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.66.0...v1.79.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 21:11:53 -07:00
adityawalvekar 6acda262d2 Fix: handle failure of key deletion and rollback (#36)
* Fix: handle failure of key deletion and rollback

* Address review comments

* Fix error messages

* Review comments - Add info in error messages

* Change error code as per review comment
2026-04-14 12:09:17 +05:30
dependabot[bot] 5677905e16 chore(deps): bump golang.org/x/crypto from 0.36.0 to 0.45.0 (#33)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.36.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.45.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-25 22:07:59 -08:00
oss-core-libraries-dashboard[bot]andhashicorp-copywrite[bot] 92a5d6424a [COMPLIANCE] Update Copyright and License Headers (#34)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-01-16 15:54:58 -08:00
compliance-pr-automation-bot[bot]andcompliance-pr-automation[bot] 4e5cd298d6 [Compliance] - PR Template Changes Required (#32)
Co-authored-by: compliance-pr-automation[bot] <1425255+compliance-pr-automation-bot[bot]@users.noreply.github.com>
2025-09-10 14:01:57 -04:00
dependabot[bot] b5332e277b chore(deps): bump golang.org/x/oauth2 from 0.22.0 to 0.27.0 (#31)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.22.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.22.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-18 14:51:53 -04:00
dependabot[bot] 317e51b73c chore(deps): bump golang.org/x/net from 0.36.0 to 0.38.0 (#30)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.36.0 to 0.38.0.
- [Commits](https://github.com/golang/net/compare/v0.36.0...v0.38.0)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-18 09:37:00 -07:00
Elim Tsiagbey bcb928e4f9 fix: remove filter validation when no filters are provided (#28)
the check currently prevents Boundary from unsetting filter
2025-01-09 11:52:13 -05:00
Elim Tsiagbey 877d285eef feat: update retry logic for validation call (#26)
* feat: update retry logic for validation call

- update validation for service account validation call to use `select` instead of `sleep`
- add `validateServiceAccountKeyTimeout` to config. This makes it easier to control the timeout from tests so tests do not have to run for long with the default timeout
2025-01-07 23:23:46 -05:00
Irena Rindos e17cbdb140 chore: update deps (#27) 2025-01-07 12:37:31 -05:00
David Kanney 295eb7a018 fix: add default filter when there are no filters (#25) 2024-12-18 15:27:52 -05:00
Elim Tsiagbey c04cb367ab feat: enhance service account key rotation with validation callback (#24)
* feat: enhance service account key rotation with validation callback

- add validation callback will enable the host service to validate the newly created key before the deleting the old key

- wrapped both the call to validate permissions and validate callback in a retry because sometimes it takes some time for the newly created key to be active on GCP services. This throws an error and prevents host catalog creation
2024-12-17 14:22:46 -05:00
Elim Tsiagbey 5b6541491e feat: Validate cred rotation during creation (#23)
Throw an error when creating a host catalog with credentials that are not rotatable but credential rotation has been enabled
2024-12-17 11:23:23 -05:00
Elim Tsiagbey d3a155bd3f test: add e2e tests (#21)
* test: add e2e tests

Spin up GCP resources for testing dynamic host catalogs. The resources are tested with tests that focus on credential changes and rotation for host catalog and host set creation and updates using labels.

- The tests all depend on each other so they need to run in order. - The test can also only be executed locally for now.
- The resources are cleaned up after the test as part of the test.
2024-11-25 11:17:01 -05:00
Elim Tsiagbey baa2c7a2e7 fix: Cred rotation (#20)
* fix: Cred rotation

- fix bug where the rotated service account key was not read. The response needs to be unmarshalled to pull out the values from th fields
- fix bug where the time stamp was not getting updated after rotating credentials
- fix bug where updates without setting a new secret fails
2024-11-20 10:22:21 -05:00
Louis Ruch 9acd3acf59 Update CODEOWNERS (#22) 2024-11-18 16:36:43 -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
48 changed files with 8268 additions and 1618 deletions

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Copyright IBM Corp. 2024, 2026
# SPDX-License-Identifier: MPL-2.0
contact_links:
- name: Ask a question
url: https://discuss.hashicorp.com/c/boundary
+11
View File
@@ -0,0 +1,11 @@
## PCI review checklist
<!-- heimdall_github_prtemplate:grc-pci_dss-2024-01-05 -->
- [ ] I have documented a clear reason for, and description of, the change I am making.
- [ ] If applicable, I've documented a plan to revert these changes if they require more than reverting the pull request.
- [ ] If applicable, I've documented the impact of any changes to security controls.
Examples of changes to security controls include using new access control methods, adding or removing logging pipelines, etc.
+1 -1
View File
@@ -1,4 +1,4 @@
# These owners will be the default owners for everything in
# the repo, unless a later match takes precedence.
@hashicorp/boundary
* @hashicorp/boundary
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2021 HashiCorp, Inc.
Copyright IBM Corp. 2024, 2026
Mozilla Public License Version 2.0
==================================
+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/).
> [!IMPORTANT]
> This is a prototype. Not officially supported by HashiCorp.
## Credentials
This plugin uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc)
to authenticate to Google and retrieve a list of instances and instance groups.
### Service Account Credentials
As a result, it does not support rotation of the credentials since they are inferred
from the environment.
The plugin will authenticate using service account credential when the `secrets`
object is set. By default, the plugin will attempt to rotate the credentials.
The given credentials will be used to create a new credential, and then the given
credential will be revoked. In this way, after rotation, only Boundary knows the
client secret in use by this plugin.
Credential rotation can be turned off by setting the `disable_credential_rotation`
attribute to true.
### Service Account Impersonation
The plugin will attempt to impersonate a service account when the `target_service_account_id`
field is supplied through the `attributes` object. The base service account will be
used to assume the identity and permissions of the target service account. A temporary
credential will be generated for authentication. The base service account requires the
[service account token creator](https://cloud.google.com/iam/docs/service-account-permissions#token-creator-role)
role to assume the role of the target service account.
By default, the credentials of the base service account will be rotated if
credential rotation is not disabled by setting the `disable_credential_rotation`
attribute.
### Application Default Credentials
The plugin uses [Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc)
for authentication when no `secrets` object is set. The plugin will attempt to
retrieve the credentials based on the environment.
## Dynamic Hosts
This plugin supports dynamically sourcing instances from Google Compute Engine.
This plugin supports dynamically sourcing instances from GCP Google Compute Engine.
Host sets created with this plugin define filters or instance groups
which select and group like instances within Google; these host sets can in turn be
Host sets created with this plugin define filters
which select and group like instances within GCP; these host sets can in turn be
added to targets within Boundary as host sources.
At creation, update or deletion of a host catalog of this type, configuration of the
plugin is performed via the attribute/secret values passed to the create, update, or
delete calls actions. The values passed in to the plugin here are the attributes set
on on a host catalog in Boundary.
on a host catalog in Boundary.
### Google IAM permissions
The plugin fetches hosts through the [Instances.List](https://cloud.google.com/compute/docs/reference/rest/v1/instances/list#filter)
call.
The plugin requires the following permissions:
- `compute.instances.get`
- `compute.instances.list`
- `compute.instanceGroups.get`
- `compute.instanceGroups.list`
### Attributes
### Host Catalog
The following attributes are valid on a Google host catalog resource:
- `project` (string): required. Project ID of the instances you want to add to host catalog.
- `zone` (string): required. Zone of the instances you want to add to host catalog.
Example:
```shell
$ boundary host-catalogs create plugin -scope-id p_1234567890 -plugin-name google -attr zone=us-central1-a -attr project=$GOOGLE_PROJECT
```
### Host Set
The following attributes are valid on a Google host set resource:
- `filter` (string): Google Cloud [filter expression](https://cloud.google.com/sdk/gcloud/reference/topic/filters)
to filter instances.
- `instance_group` (string): Name of instance group to get a list of instances.
You can only set a `filter` or `instance_group` attribute, you cannot set both.
Example:
```shell
$ boundary host-sets create plugin -host-catalog-id $HOST_CATALOG_ID -name "filter-example" -description "example using filters" -attr filter="status=RUNNING"
$ boundary host-sets create plugin -host-catalog-id $HOST_CATALOG_ID -name "group-example" -description "example using instance groups" -attr instance_group="instance-group-name"
```
After generating the host set, create a target.
```shell
$ boundary targets create tcp -name google -description "Google Cloud" -scope-id p_1234567890 -default-port 22
$ boundary targets add-host-sources -id $TARGET_ID -host-source $HOST_SET_ID
```
## Setup
You will need to build your own Boundary binary, as Boundary imports the plugins as a dependency.
As a reference, you can review [this fork](https://github.com/joatmon08/boundary) for changes.
To build the fork, go into the repository.
1. Run `make build`.
1. Authenticate to Google Cloud using `gcloud auth application-default login`.
1. Run Boundary with `./bin/boundary dev`.
[Getting Started](plugin/service/host/README.md)
+40 -33
View File
@@ -1,41 +1,48 @@
module github.com/hashicorp/boundary-plugin-google
module github.com/hashicorp/boundary-plugin-gcp
go 1.21
go 1.26.0
require (
cloud.google.com/go/iam v1.5.3
cloud.google.com/go/resourcemanager v1.10.7
github.com/google/go-cmp v0.7.0
github.com/googleapis/gax-go/v2 v2.17.0
github.com/hashicorp/boundary/sdk v0.0.47
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8
google.golang.org/api v0.188.0
github.com/hashicorp/terraform-json v0.23.0
golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.264.0
)
require (
cloud.google.com/go/auth v0.7.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/longrunning v0.8.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.5 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/zclconf/go-cty v1.15.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
require (
cloud.google.com/go/compute v1.27.3
cloud.google.com/go/compute v1.54.0
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/eventlogger v0.2.9 // indirect
github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 // indirect
@@ -47,13 +54,13 @@ require (
github.com/mitchellh/pointerstructure v1.2.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.56.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+97 -135
View File
@@ -1,82 +1,69 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.7.0 h1:kf/x9B3WTbBUHkC+1VS8wwwli9TzhSt0vSTVBmMR8Ts=
cloud.google.com/go/auth v0.7.0/go.mod h1:D+WqdrpcjmiCgWrXmLLxOVq1GACoE36chW6KXoEvuIw=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/compute v1.27.3 h1:GFfoejA5qGeFYpKAfgoVzvgJ3D8upFKI/UR7MdiPdS0=
cloud.google.com/go/compute v1.27.3/go.mod h1:5GuDo3l1k9CFhfIHK1sXqlqOW/iWX4/eBlO5FtxDhvQ=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute v1.54.0 h1:4CKmnpO+40z44bKG5bdcKxQ7ocNpRtOc9SCLLUzze1w=
cloud.google.com/go/compute v1.54.0/go.mod h1:RfBj0L1x/pIM84BrzNX2V21oEv16EKRPBiTcBRRH1Ww=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/resourcemanager v1.10.7 h1:oPZKIdjyVTuag+D4HF7HO0mnSqcqgjcuA18xblwA0V0=
cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs=
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/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/hashicorp/boundary/sdk v0.0.47 h1:h5AXOASS2duHkCYEmNKnI9AR6YBZxD7VbFPV8BoE0z0=
github.com/hashicorp/boundary/sdk v0.0.47/go.mod h1:9iOT7kDM6mYcSkKxNuZlv8rP7U5BG1kXoevjLLL8lNQ=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -119,8 +106,12 @@ github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3ly
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
@@ -131,8 +122,8 @@ github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W
github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -156,14 +147,15 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
@@ -171,79 +163,67 @@ github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=
github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/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/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
@@ -252,46 +232,28 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw=
google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d h1:/hmn0Ku5kWij/kjGsrcJeC1T/MrJi2iNWwgAqrihFwc=
google.golang.org/genproto v0.0.0-20240711142825-46eb208f015d/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d h1:JU0iKnSg02Gmb5ZdV8nYsKEKsP6o/FGVWTrw4i1DA9A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
mvdan.cc/gofumpt v0.1.1/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48=
+135 -11
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -6,26 +6,38 @@ package credential
import (
"fmt"
"github.com/hashicorp/boundary-plugin-google/internal/errors"
"github.com/hashicorp/boundary-plugin-google/internal/values"
"github.com/hashicorp/boundary-plugin-gcp/internal/errors"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
"google.golang.org/protobuf/types/known/structpb"
)
// CredentialAttributes contain attributes used for authenticating to Google Cloud
// CredentialAttributes contain attributes used for authenticating to GCP
// and accessing a list of instances
type CredentialAttributes struct {
Project string
Zone string
// ProjectId is the project id associated with the GCP credentials
ProjectId string
// Zone is the zone associated with the GCP credentials
Zone string
// DisableCredentialRotation disables the rotation of GCP service account key associated with the plugin
DisableCredentialRotation bool
// ClientEmail is the email associated with the GCP cloud credentials
ClientEmail string
// TargetServiceAccount is the unique identifier for the service account that will be impersonated
TargetServiceAccountId string
}
// GetCredentialAttributes checks attributes required by Google Cloud to access
// GetCredentialAttributes checks attributes required by GCP to access
// a list of instances and populate them into Boundary's host catalog
func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error) {
badFields := make(map[string]string)
project, err := values.GetStringValue(in, ConstProject, true)
projectId, err := values.GetStringValue(in, ConstProjectId, true)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstProject)] = err.Error()
badFields[fmt.Sprintf("attributes.%s", ConstProjectId)] = err.Error()
}
zone, err := values.GetStringValue(in, ConstZone, true)
@@ -33,12 +45,124 @@ func GetCredentialAttributes(in *structpb.Struct) (*CredentialAttributes, error)
badFields[fmt.Sprintf("attributes.%s", ConstZone)] = err.Error()
}
disableCredentialRotation, err := values.GetBoolValue(in, ConstDisableCredentialRotation, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstDisableCredentialRotation)] = err.Error()
}
clientEmail, err := values.GetStringValue(in, ConstClientEmail, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = err.Error()
}
targetServiceAccountId, err := values.GetStringValue(in, ConstTargetServiceAccountID, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstTargetServiceAccountID)] = err.Error()
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Error in the attributes provider", badFields)
}
return &CredentialAttributes{
Project: project,
Zone: zone,
ProjectId: projectId,
Zone: zone,
DisableCredentialRotation: disableCredentialRotation,
ClientEmail: clientEmail,
TargetServiceAccountId: targetServiceAccountId,
}, nil
}
// GetCredentialsConfig parses values out of a protobuf struct secrets and returns a
// Config used for configuring an GCP session. An status error is returned
// with an InvalidArgument code if any unrecognized fields are found in the protobuf
// struct input.
func GetCredentialsConfig(secrets *structpb.Struct, attrs *CredentialAttributes) (*Config, error) {
// initialize secrets if it is nil
// secrets can be nil if the authentication type is Application Default Credential(ADC)
if secrets == nil {
secrets = &structpb.Struct{
Fields: make(map[string]*structpb.Value),
}
}
// initialize attrs if it is nil
// attributes can be nil if the authentication type is Application Default Credential(ADC)
// where the credentials are inferred from the environment and no fields are provided
// https://cloud.google.com/docs/authentication/application-default-credentials
if attrs == nil {
attrs = &CredentialAttributes{}
}
unknownFields := values.StructFields(secrets)
badFields := make(map[string]string)
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKeyId)] = err.Error()
}
delete(unknownFields, ConstPrivateKeyId)
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = err.Error()
}
delete(unknownFields, ConstPrivateKey)
delete(unknownFields, ConstCredsLastRotatedTime)
for s := range unknownFields {
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
}
switch {
// service account impersonation authentication requires the target service account id and client_email
case attrs.TargetServiceAccountId != "" && attrs.ClientEmail == "":
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when target service account id is set"
// service account impersonation authentication requires the target service account id and private key
case attrs.TargetServiceAccountId != "" && privateKey == "":
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when target service account id is set"
// service account key credentials requires the private key and client email
case privateKey != "" && attrs.ClientEmail == "":
badFields[fmt.Sprintf("attributes.%s", ConstClientEmail)] = "must not be empty when private key is set"
// service account key credentials requires the client_email
case attrs.ClientEmail != "" && privateKey == "":
badFields[fmt.Sprintf("secrets.%s", ConstPrivateKey)] = "must not be empty when client email is set"
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("Error in the secrets provided", badFields)
}
cfgOpts := []Option{}
if privateKeyId != "" && privateKey != "" {
cfgOpts = append(cfgOpts,
WithPrivateKey(privateKey),
WithPrivateKeyId(privateKeyId))
}
if attrs.ProjectId != "" {
cfgOpts = append(cfgOpts, WithProjectId(attrs.ProjectId))
}
if attrs.ClientEmail != "" {
cfgOpts = append(cfgOpts, WithClientEmail(attrs.ClientEmail))
}
if attrs.Zone != "" {
cfgOpts = append(cfgOpts, WithZone(attrs.Zone))
}
if privateKey != "" {
cfgOpts = append(cfgOpts, WithPrivateKey(privateKey))
}
if privateKeyId != "" {
cfgOpts = append(cfgOpts, WithPrivateKeyId(privateKeyId))
}
if attrs.TargetServiceAccountId != "" {
cfgOpts = append(cfgOpts, WithTargetServiceAccountId(attrs.TargetServiceAccountId))
}
credentialsConfig, err := NewConfig(cfgOpts...)
if err != nil {
return nil, fmt.Errorf("error creating credentials config: %w", err)
}
return credentialsConfig, nil
}
+221 -7
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
@@ -22,7 +22,7 @@ func TestGetCredentialAttributes(t *testing.T) {
{
name: "missing project",
in: map[string]any{},
expectedErrContains: "missing required value \"project\"",
expectedErrContains: "missing required value \"project_id\"",
},
{
name: "missing zone",
@@ -32,12 +32,52 @@ func TestGetCredentialAttributes(t *testing.T) {
{
name: "valid project and zone",
in: map[string]any{
ConstProject: "test-project",
ConstZone: "us-central-1",
ConstProjectId: "test-project",
ConstZone: "us-central-1",
},
expected: &CredentialAttributes{
Project: "test-project",
Zone: "us-central-1",
ProjectId: "test-project",
Zone: "us-central-1",
DisableCredentialRotation: false,
},
},
{
name: "with disable_credential_rotation",
in: map[string]any{
ConstDisableCredentialRotation: true,
ConstProjectId: "test-project",
ConstZone: "us-central-1",
},
expected: &CredentialAttributes{
DisableCredentialRotation: true,
ProjectId: "test-project",
Zone: "us-central-1",
},
},
{
name: "with client_email",
in: map[string]any{
ConstClientEmail: "test@test.com",
ConstProjectId: "test-project",
ConstZone: "us-central-1",
},
expected: &CredentialAttributes{
ClientEmail: "test@test.com",
ProjectId: "test-project",
Zone: "us-central-1",
},
},
{
name: "with target_service_account_id",
in: map[string]any{
ConstTargetServiceAccountID: "test-target-service-account",
ConstProjectId: "test-project",
ConstZone: "us-central-1",
},
expected: &CredentialAttributes{
TargetServiceAccountId: "test-target-service-account",
ProjectId: "test-project",
Zone: "us-central-1",
},
},
}
@@ -59,8 +99,182 @@ func TestGetCredentialAttributes(t *testing.T) {
}
require.NoError(err)
require.EqualValues(tc.expected.Project, actual.Project)
require.EqualValues(tc.expected.ProjectId, actual.ProjectId)
require.EqualValues(tc.expected.Zone, actual.Zone)
})
}
}
func TestGetCredentialsConfig(t *testing.T) {
cases := []struct {
name string
secrets map[string]any
attrs *CredentialAttributes
expected *Config
expectedErrContains string
}{
{
name: "nil secrets",
attrs: &CredentialAttributes{
Zone: "us-central-1a",
},
expected: &Config{
Zone: "us-central-1a",
Scopes: []string{defaultGCPScope},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
},
{
name: "nil attributes",
attrs: nil,
expected: &Config{
Scopes: []string{defaultGCPScope},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
},
{
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},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
},
{
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},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
},
{
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},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
},
}
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)
})
}
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright IBM Corp. 2024, 2026
// 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"
// defaultValidateServiceAccountKeyTimeout is the timeout for validating
// a service account key.
defaultValidateServiceAccountKeyTimeout = 5 * time.Second
)
// 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
validateServiceAccountKeyTimeout time.Duration
}
// 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,
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
}
return c, nil
}
// toCredentials converts the config to credentials.
func (c *Config) toCredentials() *credentials {
return &credentials{
Type: "service_account",
ProjectId: c.ProjectId,
PrivateKey: c.PrivateKey,
PrivateKeyId: c.PrivateKeyId,
ClientEmail: c.ClientEmail,
}
}
// clone returns a copy of the configuration.
func (c *Config) clone() *Config {
return &Config{
ProjectId: c.ProjectId,
Zone: c.Zone,
PrivateKey: c.PrivateKey,
PrivateKeyId: c.PrivateKeyId,
ClientEmail: c.ClientEmail,
TargetServiceAccountId: c.TargetServiceAccountId,
Scopes: c.Scopes,
validateServiceAccountKeyTimeout: c.validateServiceAccountKeyTimeout,
}
}
// 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
}
+473
View File
@@ -0,0 +1,473 @@
// Copyright IBM Corp. 2024, 2026
// 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"},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
expectError: false,
},
{
name: "No Options",
options: []Option{},
expected: &Config{
Scopes: []string{defaultGCPScope},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
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},
validateServiceAccountKeyTimeout: defaultValidateServiceAccountKeyTimeout,
},
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)
})
}
}
+22 -4
View File
@@ -1,12 +1,30 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
const (
// ConstProject defines the attribute name for a Google Cloud project
ConstProject = "project"
// ConstProjectId defines the attribute name for a GCP project
ConstProjectId = "project_id"
// ConstZone defines the attribute name for a Google Cloud zone
// ConstZone defines the attribute name for a GCP zone
ConstZone = "zone"
// ConstDisableCredentialRotation is the key for the disable credential rotation in the GCP credentials.
ConstDisableCredentialRotation = "disable_credential_rotation"
// ConstCredsLastRotatedTime is the key for the last rotated time in the GCP credentials.
ConstCredsLastRotatedTime = "creds_last_rotated_time"
// ConstClientEmail is the email address associated with the service account
ConstClientEmail = "client_email"
// ConstTargetServiceAccountID is the unique identifier for the service account that will be impersonated.
ConstTargetServiceAccountID = "target_service_account_id"
// ConstPrivateKeyId is the private key id associated with the service account
ConstPrivateKeyId = "private_key_id"
// ConstPrivateKey is the private key associated with the service account
ConstPrivateKey = "private_key"
)
+123
View File
@@ -0,0 +1,123 @@
// Copyright IBM Corp. 2024, 2026
// 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 IBM Corp. 2024, 2026
// 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)
})
}
+281
View File
@@ -0,0 +1,281 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"time"
admin "cloud.google.com/go/iam/admin/apiv1"
"cloud.google.com/go/iam/admin/apiv1/adminpb"
"cloud.google.com/go/iam/apiv1/iampb"
resourcemanager "cloud.google.com/go/resourcemanager/apiv3"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
// ComputeInstancesListPermission is the IAM permission required
// to list compute instances.
ComputeInstancesListPermission = "compute.instances.list"
// IAMServiceAccountKeysCreatePermission is the IAM permission
// required to create a service account key.
// This permission is required to rotate service account keys.
IAMServiceAccountKeysCreatePermission = "iam.serviceAccountKeys.create"
// IAMServiceAccountKeysDeletePermission is the IAM permission
// required to delete a service account key.
// This permission is required to rotate service account keys.
IAMServiceAccountKeysDeletePermission = "iam.serviceAccountKeys.delete"
)
// ServiceAccountPrivateKey represents a decoded PrivateKeyData
// from a Service Account Key.
// https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys#ServiceAccountKey
type ServiceAccountPrivateKey struct {
Type string `json:"type"`
ProjectID string `json:"project_id"`
PrivateKeyID string `json:"private_key_id"`
PrivateKey string `json:"private_key"`
ClientEmail string `json:"client_email"`
ClientID string `json:"client_id"`
AuthURI string `json:"auth_uri"`
TokenURI string `json:"token_uri"`
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
ClientX509CertURL string `json:"client_x509_cert_url"`
}
// ValidateCredsCallback is called to validate the credentials after rotating the service account key.
// The callback should return an error if the credentials are invalid.
type ValidateCredsCallback func(*Config, ...option.ClientOption) error
// RotateServiceAccountKey takes the private key from this credentials config
// and first creates a new private key and private key id, then deletes the
// old private key.
//
// If deletion of the old private key is successful, the new private key and
// private key id are written into the credentials config and nil is returned.
// If validation fails or deleting the old private key fails, the function
// attempts to delete the newly created key as a rollback operation.
// On any error, the old credentials are not overwritten.
func (c *Config) RotateServiceAccountKey(
ctx context.Context,
permissions []string,
validateCredsCallback ValidateCredsCallback,
opts ...option.ClientOption,
) error {
if c.PrivateKey == "" {
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key is not set")
}
if c.PrivateKeyId == "" {
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key ID is not set")
}
if c.ClientEmail == "" {
return status.Error(codes.InvalidArgument, "cannot rotate credentials when client email is not set")
}
creds, err := c.GenerateCredentials(ctx)
if err != nil {
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
}
if creds.TokenSource == nil {
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
}
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
clientOptions = append(clientOptions, opts...)
currentIAMClient, err := admin.NewIamClient(ctx, clientOptions...)
if err != nil {
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
}
createServiceAccountKeyRes, err := currentIAMClient.CreateServiceAccountKey(ctx, &adminpb.CreateServiceAccountKeyRequest{
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s", c.ProjectId, c.ClientEmail),
PrivateKeyType: adminpb.ServiceAccountPrivateKeyType_TYPE_GOOGLE_CREDENTIALS_FILE,
KeyAlgorithm: adminpb.ServiceAccountKeyAlgorithm_KEY_ALG_RSA_2048,
})
if err != nil {
return status.Errorf(codes.Internal, "error creating service account key: %v", err)
}
var serviceAccountKey ServiceAccountPrivateKey
err = json.Unmarshal(createServiceAccountKeyRes.PrivateKeyData, &serviceAccountKey)
if err != nil {
return status.Errorf(codes.Internal, "error unmarshalling service account key: %v", err)
}
// Clone the config, update the private key and private key ID with the new key.
newConfig := c.clone()
newConfig.PrivateKey = serviceAccountKey.PrivateKey
newConfig.PrivateKeyId = serviceAccountKey.PrivateKeyID
newCreds, err := newConfig.GenerateCredentials(ctx)
if err != nil {
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
}
if newCreds.TokenSource == nil {
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
}
clientOptions = []option.ClientOption{option.WithTokenSource(newCreds.TokenSource)}
clientOptions = append(clientOptions, opts...)
// Validate the new service account key
err = newConfig.ValidateServiceAccountKey(ctx, permissions, validateCredsCallback, clientOptions...)
if err != nil {
// Roll back the new key if validation fails.
rollbackErr := currentIAMClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", newConfig.ProjectId, newConfig.ClientEmail, newConfig.PrivateKeyId),
})
if rollbackErr != nil {
return status.Errorf(codes.Internal, "error validating rotated service account key: %v; error rolling back newly created service account key: %v; service account may have multiple active keys", err, rollbackErr)
}
return status.Errorf(codes.PermissionDenied, "error validating rotated service account key: %v; successfully rolled back newly created service account key", err)
}
newIAMClient, 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 = newIAMClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
})
// If deletion of the old key fails, attempt to delete the new key to avoid leaving a dangling key, then return an error.
if err != nil {
// delete the new key using old client since the new client may not have permissions that the old client has.
rollbackErr := currentIAMClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", newConfig.ProjectId, newConfig.ClientEmail, newConfig.PrivateKeyId),
})
// If rollback also fails, return an error indicating both the failure to delete the old key and the failure to roll back the new key.
if rollbackErr != nil {
return status.Errorf(codes.Internal, "error deleting service account key: %v; error rolling back newly created service account key: %v; service account may have multiple active keys", err, rollbackErr)
}
return status.Errorf(codes.Internal, "error deleting service account key: %v; successfully rolled back newly created service account key", err)
}
c.PrivateKey = newConfig.PrivateKey
c.PrivateKeyId = newConfig.PrivateKeyId
return nil
}
// ValidateServiceAccountKey validates the service account key by checking the IAM permissions
// and calling the validation callback.
// The function will retry validation until the timeout is reached.
func (c *Config) ValidateServiceAccountKey(
ctx context.Context,
permissions []string,
validateCredsCallback ValidateCredsCallback,
opts ...option.ClientOption) error {
var validatePermissionsErr error
var validationCallbackErr error
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
ctx, cancel := context.WithTimeout(ctx, c.validateServiceAccountKeyTimeout)
defer cancel()
for {
select {
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("failed to validate service account key before timeout: %w", errors.Join(validatePermissionsErr, validationCallbackErr))
}
return ctx.Err()
case <-ticker.C:
_, validatePermissionsErr = c.ValidateIamPermissions(ctx, permissions, opts...)
if validateCredsCallback != nil {
validationCallbackErr = validateCredsCallback(c, opts...)
}
if validatePermissionsErr == nil && validationCallbackErr == nil {
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/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
})
if err != nil {
return status.Errorf(codes.Unknown, "error deleting service account key: %v", err)
}
return nil
}
// ValidateIamPermissions tests the IAM permissions for the credentials.
// It returns the granted permissions if successful.
func (c *Config) ValidateIamPermissions(ctx context.Context, permissions []string, opts ...option.ClientOption) ([]string, error) {
if len(permissions) == 0 {
return nil, status.Error(codes.InvalidArgument, "permissions are required")
}
rmClient, err := resourcemanager.NewProjectsClient(ctx, opts...)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create Resource Manager client: %v", err)
}
resp, err := rmClient.TestIamPermissions(ctx, &iampb.TestIamPermissionsRequest{
Resource: "projects/" + c.ProjectId,
Permissions: permissions,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to validate IAM permissions: %v", err)
}
if len(resp.Permissions) == 0 {
return nil, status.Error(codes.PermissionDenied, "no permissions granted")
}
if len(resp.Permissions) != len(permissions) {
missingPermissions := make([]string, 0, len(permissions))
for _, permission := range permissions {
found := slices.Contains(resp.Permissions, permission)
if !found {
missingPermissions = append(missingPermissions, permission)
}
}
return nil, status.Errorf(codes.PermissionDenied, "missing permissions: %v", missingPermissions)
}
return resp.Permissions, nil
}
+523
View File
@@ -0,0 +1,523 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"errors"
"testing"
"time"
"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,
},
},
}
testValidateTimeout := 1 * time.Second
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")
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = nil
testResourceServer.testIamPermissionsError = nil
},
expectedError: errors.New("create key error"),
},
{
name: "ValidateServiceAccountKey fails and rollback succeeds",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
setup: func() {
testIAMAdminServer.testCreateServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{nil}
testResourceServer.testIamPermissionsError = errors.New("iam permissions error")
},
expectedError: errors.New("successfully rolled back newly created service account key"),
},
{
name: "ValidateServiceAccountKey fails and rollback fails",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
setup: func() {
testIAMAdminServer.testCreateServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{errors.New("rollback key error")}
testResourceServer.testIamPermissionsError = errors.New("iam permissions error")
},
expectedError: errors.New("error rolling back newly created service account key"),
},
{
name: "DeleteServiceAccountKey old key fails and rollback fails",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
setup: func() {
testIAMAdminServer.testCreateServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{
errors.New("delete key error"),
errors.New("rollback key error"),
}
testResourceServer.testIamPermissionsError = nil
},
expectedError: errors.New("error rolling back newly created service account key"),
},
{
name: "DeleteServiceAccountKey old key fails and rollback succeeds",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
setup: func() {
testIAMAdminServer.testCreateServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = []error{
errors.New("delete key error"),
nil,
}
testResourceServer.testIamPermissionsError = nil
},
expectedError: errors.New("successfully rolled back newly created service account key"),
},
{
name: "Successful rotation",
config: &Config{
PrivateKey: "some-private-key",
PrivateKeyId: "some-private-key-id",
ClientEmail: "client@example.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
setup: func() {
testIAMAdminServer.testCreateServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyError = nil
testIAMAdminServer.testDeleteServiceAccountKeyErrors = nil
testResourceServer.testIamPermissionsError = nil
},
expectedError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gsrv := NewGRPCServer()
adminpb.RegisterIAMServer(gsrv.Server, testIAMAdminServer)
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
addr, err := gsrv.Start()
require.NoError(t, err)
ctx := context.Background()
if tt.setup != nil {
tt.setup()
}
testOptions := []googleOption.ClientOption{
googleOption.WithEndpoint(addr),
googleOption.WithoutAuthentication(),
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
googleOption.WithTokenSource(nil),
}
initialPrivateKey := tt.config.PrivateKey
initialPrivateKeyId := tt.config.PrivateKeyId
initialClientEmail := tt.config.ClientEmail
permissions := []string{
ComputeInstancesListPermission,
IAMServiceAccountKeysCreatePermission,
IAMServiceAccountKeysDeletePermission,
}
validateCredsCallbackCalled := false
validateCredsCallBack := func(c *Config, opts ...googleOption.ClientOption) error {
validateCredsCallbackCalled = true
return nil
}
err = tt.config.RotateServiceAccountKey(ctx, permissions, validateCredsCallBack, testOptions...)
if tt.expectedError != nil {
require.ErrorContains(t, err, tt.expectedError.Error())
require.Equal(t, initialClientEmail, tt.config.ClientEmail)
require.Equal(t, initialPrivateKey, tt.config.PrivateKey)
require.Equal(t, initialPrivateKeyId, tt.config.PrivateKeyId)
return
}
require.Equal(t, initialClientEmail, tt.config.ClientEmail)
require.NotEmpty(t, tt.config.PrivateKey)
require.NotEmpty(t, tt.config.PrivateKeyId)
require.NotEqual(t, initialPrivateKey, tt.config.PrivateKey)
require.NotEqual(t, initialPrivateKeyId, tt.config.PrivateKeyId)
require.True(t, validateCredsCallbackCalled)
})
}
}
func TestValidateIamPermissions(t *testing.T) {
ctx := context.Background()
testResourceServer := &testResourceServer{}
tests := []struct {
name string
config *Config
testIamResponse *iampb.TestIamPermissionsResponse
testIamPermissionsError error
permissions []string
wantErr bool
expectedErr string
expectedPermissions []string
}{
{
name: "Successful IAM Permissions Test",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
},
testIamResponse: &iampb.TestIamPermissionsResponse{
Permissions: []string{"permission1", "permission2"},
},
permissions: []string{"permission1", "permission2"},
expectedPermissions: []string{"permission1", "permission2"},
},
{
name: "Missing Permissions",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
},
wantErr: true,
expectedErr: "permissions are required",
},
{
name: "Failed to Test IAM Permissions",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
},
testIamPermissionsError: errors.New("failed to test IAM permissions"),
permissions: []string{"permission1", "permission2"},
wantErr: true,
expectedErr: "failed to test IAM permissions",
},
{
name: "No Permissions Granted",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
},
testIamResponse: &iampb.TestIamPermissionsResponse{
Permissions: []string{},
},
testIamPermissionsError: nil,
permissions: []string{"permission1", "permission2"},
wantErr: true,
expectedErr: "no permissions granted",
},
{
name: "Missing Permissions",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
},
testIamResponse: &iampb.TestIamPermissionsResponse{
Permissions: []string{"permission1"},
},
testIamPermissionsError: nil,
permissions: []string{"permission1", "permission2"},
wantErr: true,
expectedErr: "missing permissions: [permission2]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testResourceServer.testIamPermissionsResponse = tt.testIamResponse
testResourceServer.testIamPermissionsError = tt.testIamPermissionsError
gsrv := NewGRPCServer()
resourcemanagerpb.RegisterProjectsServer(gsrv.Server, testResourceServer)
addr, err := gsrv.Start()
require.NoError(t, err)
testOptions := []googleOption.ClientOption{
googleOption.WithEndpoint(addr),
googleOption.WithoutAuthentication(),
googleOption.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
googleOption.WithTokenSource(nil),
}
permissions, err := tt.config.ValidateIamPermissions(ctx, tt.permissions, testOptions...)
if tt.wantErr {
require.ErrorContains(t, err, tt.expectedErr)
require.Nil(t, permissions)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectedPermissions, permissions)
})
}
}
func TestValidateServiceAccountKey(t *testing.T) {
ctx := context.Background()
testResourceServer := &testResourceServer{}
testValidateTimeout := 1 * time.Second
tests := []struct {
name string
config *Config
permissions []string
validateCredsCallback ValidateCredsCallback
testIamPermissionsError error
expectedError error
}{
{
name: "Successful validation",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
permissions: []string{
ComputeInstancesListPermission,
IAMServiceAccountKeysCreatePermission,
IAMServiceAccountKeysDeletePermission,
},
validateCredsCallback: func(c *Config, opts ...googleOption.ClientOption) error {
return nil
},
expectedError: nil,
},
{
name: "Validation callback returns error",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
permissions: []string{
ComputeInstancesListPermission,
IAMServiceAccountKeysCreatePermission,
IAMServiceAccountKeysDeletePermission,
},
validateCredsCallback: func(c *Config, opts ...googleOption.ClientOption) error {
return errors.New("validation callback error")
},
expectedError: errors.New("validation callback error"),
},
{
name: "IAM permissions validation returns error",
config: &Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@test.com",
validateServiceAccountKeyTimeout: testValidateTimeout,
},
permissions: []string{
ComputeInstancesListPermission,
IAMServiceAccountKeysCreatePermission,
IAMServiceAccountKeysDeletePermission,
},
testIamPermissionsError: errors.New("IAM permissions error"),
expectedError: errors.New("failed to validate IAM permissions"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testResourceServer.testIamPermissionsResponse = &iampb.TestIamPermissionsResponse{
Permissions: tt.permissions,
}
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),
}
err = tt.config.ValidateServiceAccountKey(ctx, tt.permissions, tt.validateCredsCallback, testOptions...)
if tt.expectedError != nil {
require.ErrorContains(t, err, tt.expectedError.Error())
return
}
require.NoError(t, err)
})
}
}
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)
})
}
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"fmt"
"time"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
type CredentialType int
// PersistedState is the persisted state for the GCP credential.
type PersistedState struct {
// CredentialsConfig is the credential configuration for the GCP credential.
CredentialsConfig *Config
// CredsLastRotatedTime is the last rotation of service account key for the GCP credential.
CredsLastRotatedTime time.Time
}
// NewPersistedState - create a new PersistedState
func NewPersistedState(opt ...Option) (*PersistedState, error) {
s := new(PersistedState)
opts, err := getOpts(opt...)
if err != nil {
return nil, err
}
s.CredentialsConfig = opts.WithCredentialsConfig
s.CredsLastRotatedTime = opts.WithCredsLastRotatedTime
return s, nil
}
// RotateCreds rotates the credentials for the GCP catalog and updates the last rotated time.
func (s *PersistedState) RotateCreds(
ctx context.Context,
permissions []string,
validateCredsCallback ValidateCredsCallback,
opts ...option.ClientOption) error {
if s.CredentialsConfig == nil {
return status.Error(codes.InvalidArgument, "missing credentials config")
}
if !s.CredentialsConfig.IsRotatable() {
return status.Error(codes.InvalidArgument, "cannot rotate non-rotatable credentials")
}
err := s.CredentialsConfig.RotateServiceAccountKey(ctx, permissions, validateCredsCallback, opts...)
if err != nil {
return status.Errorf(codes.Internal, "failed to rotate credentials: %v", err)
}
s.CredsLastRotatedTime = time.Now()
return nil
}
// ReplaceCreds replaces the private key in the state with a new key.
// If the existing key was rotated at any point in time, it is
// deleted first, otherwise it's left alone. This method returns a
// status error with PluginError details.
func (s *PersistedState) ReplaceCreds(ctx context.Context, newCreds *Config, opts ...option.ClientOption) error {
if newCreds == nil {
return status.Errorf(codes.InvalidArgument, "missing new credentials")
}
if s.CredentialsConfig == nil {
return status.Errorf(codes.InvalidArgument, "missing existing credentials")
}
// Delete old/existing credentials.
// This is done with the same credentials to ensure that it has the proper permissions to do it.
if !s.CredsLastRotatedTime.IsZero() && s.CredentialsConfig.IsRotatable() {
if err := s.DeleteCreds(ctx, opts...); err != nil {
return err
}
}
// Set the new attributes and clear the rotated time.
s.CredentialsConfig = newCreds
s.CredsLastRotatedTime = time.Time{}
return nil
}
// DeleteCreds deletes the credentials in the state. The access key
// ID, secret access key, and rotation time fields are zeroed out in
// the state just to ensure that they cannot be re-used after. This
// method returns a status error with PluginError details.
func (s *PersistedState) DeleteCreds(ctx context.Context, opts ...option.ClientOption) error {
if s.CredentialsConfig == nil {
return status.Errorf(codes.InvalidArgument, "missing credentials config")
}
if !s.CredentialsConfig.IsRotatable() {
return status.Errorf(codes.InvalidArgument, "cannot delete credentials for non-rotatable credentials")
}
err := s.CredentialsConfig.DeletePrivateKey(ctx, opts...)
if err != nil {
return err
}
s.CredentialsConfig = nil
s.CredsLastRotatedTime = time.Time{}
return nil
}
// ToMap returns a map of the credentials stored in the persisted state.
// ToMap will return a map for long-term credentials with following keys:
// private_key_id, private_key & creds_last_rotated_time
func (s *PersistedState) ToMap() map[string]any {
if !s.CredentialsConfig.IsRotatable() {
return map[string]any{}
}
return map[string]any{
ConstPrivateKey: s.CredentialsConfig.PrivateKey,
ConstPrivateKeyId: s.CredentialsConfig.PrivateKeyId,
ConstCredsLastRotatedTime: s.CredsLastRotatedTime.Format(time.RFC3339Nano),
}
}
// PersistedStateFromProto parses values out of a protobuf struct input
// and returns a PersistedState used for GCP authentication.
func PersistedStateFromProto(secrets *structpb.Struct, attrs *CredentialAttributes, opts ...Option) (*PersistedState, error) {
if secrets == nil {
secrets = &structpb.Struct{
Fields: map[string]*structpb.Value{},
}
}
if attrs == nil {
return nil, fmt.Errorf("missing credential attributes")
}
privateKeyId, err := values.GetStringValue(secrets, ConstPrivateKeyId, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
privateKey, err := values.GetStringValue(secrets, ConstPrivateKey, false)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
credsLastRotatedTime, err := values.GetTimeValue(secrets, ConstCredsLastRotatedTime)
if err != nil {
return nil, fmt.Errorf("persisted state integrity error: %w", err)
}
s, err := NewPersistedState(opts...)
if err != nil {
return nil, err
}
cfgOpts := []Option{}
if privateKeyId != "" && privateKey != "" {
cfgOpts = append(cfgOpts,
WithPrivateKey(privateKey),
WithPrivateKeyId(privateKeyId))
}
if attrs.Zone != "" {
cfgOpts = append(cfgOpts, WithZone(attrs.Zone))
}
if attrs.ProjectId != "" {
cfgOpts = append(cfgOpts, WithProjectId(attrs.ProjectId))
}
if attrs.ClientEmail != "" {
cfgOpts = append(cfgOpts, WithClientEmail(attrs.ClientEmail))
}
if attrs.TargetServiceAccountId != "" {
cfgOpts = append(cfgOpts, WithTargetServiceAccountId(attrs.TargetServiceAccountId))
}
credentialsConfig, err := NewConfig(cfgOpts...)
if err != nil {
return nil, err
}
s.CredentialsConfig = credentialsConfig
s.CredsLastRotatedTime = credsLastRotatedTime
return s, nil
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright IBM Corp. 2024, 2026
// 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)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package credential
import (
"context"
"encoding/json"
"fmt"
"net"
"cloud.google.com/go/iam/admin/apiv1/adminpb"
"cloud.google.com/go/iam/apiv1/iampb"
"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
type testIAMAdminServer struct {
adminpb.UnimplementedIAMServer
testCreateServiceAccountKeyError error
testDeleteServiceAccountKeyError error
testDeleteServiceAccountKeyErrors []error
}
func NewTestIAMAdminServer(createServiceAccountKeyError error, deleteServiceAccountKeyError error) *testIAMAdminServer {
return &testIAMAdminServer{
testCreateServiceAccountKeyError: createServiceAccountKeyError,
testDeleteServiceAccountKeyError: deleteServiceAccountKeyError,
}
}
func (f *testIAMAdminServer) CreateServiceAccountKey(ctx context.Context, req *adminpb.CreateServiceAccountKeyRequest) (*adminpb.ServiceAccountKey, error) {
serviceAccountKey := ServiceAccountPrivateKey{
PrivateKey: "updated-private-key",
PrivateKeyID: "updated-private-key-id",
}
serviceAccountKeyData, err := json.Marshal(serviceAccountKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal service account key: %v", err)
}
resp := &adminpb.ServiceAccountKey{
Name: "projects/-/serviceAccounts/1234/keys/updated-private-key-id",
PrivateKeyData: serviceAccountKeyData,
}
return resp, f.testCreateServiceAccountKeyError
}
func (f *testIAMAdminServer) DeleteServiceAccountKey(ctx context.Context, req *adminpb.DeleteServiceAccountKeyRequest) (*emptypb.Empty, error) {
// Use queued delete results so tests can separately control deleting the old key and deleting the new key during rollback.
if len(f.testDeleteServiceAccountKeyErrors) > 0 {
err := f.testDeleteServiceAccountKeyErrors[0]
f.testDeleteServiceAccountKeyErrors = f.testDeleteServiceAccountKeyErrors[1:]
return &emptypb.Empty{}, err
}
return &emptypb.Empty{}, f.testDeleteServiceAccountKeyError
}
type testResourceServer struct {
resourcemanagerpb.UnimplementedProjectsServer
testIamPermissionsResponse *iampb.TestIamPermissionsResponse
testIamPermissionsError error
}
func NewTestResourceServer(testIamPermissionsResponse *iampb.TestIamPermissionsResponse, testIamPermissionsError error) *testResourceServer {
return &testResourceServer{
testIamPermissionsResponse: testIamPermissionsResponse,
testIamPermissionsError: testIamPermissionsError,
}
}
func (f *testResourceServer) TestIamPermissions(context.Context, *iampb.TestIamPermissionsRequest) (*iampb.TestIamPermissionsResponse, error) {
return f.testIamPermissionsResponse, f.testIamPermissionsError
}
type grpcServer struct {
*grpc.Server
}
func NewGRPCServer() *grpcServer {
return &grpcServer{Server: grpc.NewServer()}
}
func (s *grpcServer) Start() (string, error) {
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return "", fmt.Errorf("failed to listen: %v", err)
}
go func() {
if err := s.Serve(l); err != nil {
panic(err)
}
}()
return l.Addr().String(), nil
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package errors
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package values
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package values
-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)
})
}
}
-17
View File
@@ -1,17 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package plugin
const (
// ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances
ConstListInstancesFilter = "filter"
// ConstInstanceGroup refers to the name of a Google Cloud instance group
ConstInstanceGroup = "instance_group"
)
var allowedSetFields = map[string]struct{}{
ConstListInstancesFilter: {},
ConstInstanceGroup: {},
}
+14 -270
View File
@@ -1,285 +1,29 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
import (
"context"
"fmt"
compute "cloud.google.com/go/compute/apiv1"
computepb "cloud.google.com/go/compute/apiv1/computepb"
errors "github.com/hashicorp/boundary-plugin-google/internal/errors"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
"github.com/hashicorp/boundary-plugin-gcp/plugin/service/host"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// GooglePlugin implements the GooglePluginServiceServer interface for the
// Google host service plugin.
type GooglePlugin struct {
pb.UnimplementedHostPluginServiceServer
}
// Ensure that HostPlugin implements the following services:
//
// HostPluginServiceServer
var (
_ pb.HostPluginServiceServer = (*GooglePlugin)(nil)
_ pb.HostPluginServiceServer = (*host.HostPlugin)(nil)
)
// OnCreateCatalog is called when a dynamic host catalog is created.
func (p *GooglePlugin) OnCreateCatalog(_ context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "attributes are required")
}
if _, err := getCatalogAttributes(attrs); err != nil {
return nil, err
}
return &pb.OnCreateCatalogResponse{
Persisted: &pb.HostCatalogPersisted{
Secrets: nil,
},
}, nil
// GCPPlugin contains a collection of all GCP plugin services.
type GCPPlugin struct {
// HostPlugin implements the HostPluginServiceServer interface for
// supporting dynamically sourcing hosts from GCP Compute Engine.
*host.HostPlugin
}
// OnUpdateCatalog is called when a dynamic host catalog is updated.
func (p *GooglePlugin) OnUpdateCatalog(_ context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
currentCatalog := req.GetCurrentCatalog()
if currentCatalog == nil {
return nil, status.Error(codes.FailedPrecondition, "current catalog is nil")
func NewGCPPlugin() *GCPPlugin {
return &GCPPlugin{
HostPlugin: &host.HostPlugin{},
}
secrets := req.GetNewCatalog().GetSecrets()
if secrets == nil {
// If new secrets weren't passed in, don't rotate what we have on
// update.
return &pb.OnUpdateCatalogResponse{}, nil
}
return &pb.OnUpdateCatalogResponse{
Persisted: &pb.HostCatalogPersisted{
Secrets: nil,
},
}, nil
}
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
func (p *GooglePlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
}
if _, err := getCatalogAttributes(attrs); err != nil {
return nil, err
}
return &pb.OnDeleteCatalogResponse{}, nil
}
// OnCreateSet is called when a dynamic host set is created.
func (p *GooglePlugin) OnCreateSet(_ context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
if err := validateSet(req.GetSet()); err != nil {
return nil, err
}
return &pb.OnCreateSetResponse{}, nil
}
// OnUpdateSet is called when a dynamic host set is updated.
func (p *GooglePlugin) OnUpdateSet(_ context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
if err := validateSet(req.GetNewSet()); err != nil {
return nil, err
}
return &pb.OnUpdateSetResponse{}, nil
}
// OnDeleteSet is called when a dynamic host set is deleted.
func (p *GooglePlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
return &pb.OnDeleteSetResponse{}, nil
}
// ListHosts returns the list of instances based on filter or instance group name.
func (p *GooglePlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
if err != nil {
return nil, err
}
sets := req.GetSets()
if sets == nil {
return nil, status.Error(codes.InvalidArgument, "sets is nil")
}
type hostSetQuery struct {
Id string
InputInstances *computepb.ListInstancesRequest
InputGroups *computepb.ListInstancesInstanceGroupsRequest
Project string
Zone string
Output []*computepb.Instance
OutputHosts []*pb.ListHostsResponseHost
}
queries := make([]hostSetQuery, len(sets))
for i, set := range sets {
// Validate Id since we use it in output
if set.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "set missing id")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set missing attributes")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
if setAttrs.InstanceGroup != "" {
queries[i] = hostSetQuery{
Id: set.GetId(),
InputGroups: buildListInstanceGroupsRequest(setAttrs, catalogAttributes),
}
} else {
queries[i] = hostSetQuery{
Id: set.GetId(),
InputInstances: buildListInstancesRequest(setAttrs, catalogAttributes),
}
}
}
instancesClient, err := compute.NewInstancesRESTClient(ctx)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstancesRESTClient: %s", err)
}
instanceGroupsClient, err := compute.NewInstanceGroupsRESTClient(ctx)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error creating NewInstanceGroupsRESTClient: %s", err)
}
gclient := GoogleClient{
InstancesClient: instancesClient,
InstanceGroupClient: instanceGroupsClient,
Context: ctx,
}
// Run all queries now and assemble output.
var maxLen int
for i, query := range queries {
var output []*computepb.Instance
if query.InputGroups != nil {
output, err = gclient.getInstancesForInstanceGroup(query.InputGroups)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error running getInstancesForInstanceGroup for host set id %q: %s", query.Id, err)
}
} else {
output, err = gclient.getInstances(query.InputInstances)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error running getInstances for host set id %q: %s", query.Id, err)
}
}
queries[i].Output = output
// Process the output here, we will normalize this into a single
// set of hosts afterwards (possibly removing duplicates).
for _, instance := range output {
host, err := instanceToHost(instance)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
}
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
maxLen++
}
}
// Now de-duplicate the hosts for the output. Maintain two sets:
// * A slice of hosts that will be used in the output
// * A map of hosts indexed by their external ID
//
// The map will is used in de-duplication to determine whether or
// not we've seen the host before to simply add the query's set ID
// to the list of set IDs that the host was seen in.
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
for _, query := range queries {
for _, host := range query.OutputHosts {
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
// Existing host, just add the set ID to the list of seen IDs
// and continue
existingHost.SetIds = append(existingHost.SetIds, query.Id)
continue
}
// This will be the first seen entry, so append the set ID to
// this host, and add it.
host.SetIds = append(host.SetIds, query.Id)
hostResultSlice = append(hostResultSlice, host)
hostResultMap[host.ExternalId] = host
}
}
return &pb.ListHostsResponse{
Hosts: hostResultSlice,
}, nil
}
func validateSet(s *hostsets.HostSet) error {
if s == nil {
return status.Error(codes.InvalidArgument, "set is nil")
}
var attrs SetAttributes
attrMap := s.GetAttributes().AsMap()
if err := mapstructure.Decode(attrMap, &attrs); err != nil {
return status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
}
badFields := make(map[string]string)
_, filterSet := attrMap[ConstListInstancesFilter]
_, instanceGroupSet := attrMap[ConstInstanceGroup]
if instanceGroupSet && filterSet {
badFields["attributes"] = "must set instance group or filter, cannot set both"
} else if instanceGroupSet && len(attrs.InstanceGroup) == 0 {
badFields[fmt.Sprintf("attributes.%s", ConstInstanceGroup)] = "must not be empty."
} else if filterSet && len(attrs.Filter) == 0 {
badFields[fmt.Sprintf("attributes.%s", ConstListInstancesFilter)] = "must not be empty."
}
for f := range attrMap {
if _, ok := allowedSetFields[f]; !ok {
badFields[fmt.Sprintf("attributes.%s", f)] = "Unrecognized field."
}
}
if len(badFields) > 0 {
return errors.InvalidArgumentError("Invalid arguments in the new set", badFields)
}
return nil
}
-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 IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
package host
import (
"fmt"
"strings"
computepb "cloud.google.com/go/compute/apiv1/computepb"
cred "github.com/hashicorp/boundary-plugin-google/internal/credential"
"github.com/hashicorp/boundary-plugin-google/internal/errors"
"github.com/hashicorp/boundary-plugin-google/internal/values"
cred "github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-gcp/internal/errors"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
"github.com/mitchellh/mapstructure"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -34,10 +35,16 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
for s := range unknownFields {
switch s {
// Ignore knownFields from CredentialAttributes
case cred.ConstProject:
case cred.ConstProjectId:
continue
case cred.ConstZone:
continue
case cred.ConstDisableCredentialRotation:
continue
case cred.ConstClientEmail:
continue
case cred.ConstTargetServiceAccountID:
continue
default:
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
}
@@ -54,8 +61,7 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) {
// SetAttributes defines attributes fro the host set
type SetAttributes struct {
Filter string `mapstructure:"filter"`
InstanceGroup string `mapstructure:"instance_group"`
Filters []string `mapstructure:"filters"`
}
func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
@@ -64,14 +70,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
badFields := make(map[string]string)
unknownFields := values.StructFields(in)
if _, filter := unknownFields[ConstListInstancesFilter]; filter {
if _, ig := unknownFields[ConstInstanceGroup]; ig {
return nil, errors.InvalidArgumentError("Error in the attributes provided, cannot define both filter and instance group", nil)
}
}
delete(unknownFields, ConstListInstancesFilter)
delete(unknownFields, ConstInstanceGroup)
for a := range unknownFields {
badFields[fmt.Sprintf("attributes.%s", a)] = "unrecognized field"
@@ -87,14 +86,7 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
if filtersRaw, ok := inMap[ConstListInstancesFilter]; ok {
switch filterVal := filtersRaw.(type) {
case string:
inMap[ConstListInstancesFilter] = string(filterVal)
}
}
if instanceGroupRaw, ok := inMap[ConstInstanceGroup]; ok {
switch instanceGroupValue := instanceGroupRaw.(type) {
case string:
inMap[ConstInstanceGroup] = string(instanceGroupValue)
inMap[ConstListInstancesFilter] = []string{filterVal}
}
}
@@ -105,29 +97,21 @@ func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) {
return &setAttrs, nil
}
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest {
func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) (*computepb.ListInstancesRequest, error) {
request := &computepb.ListInstancesRequest{
Project: catalog.Project,
Project: catalog.ProjectId,
Zone: catalog.Zone,
}
if len(attributes.Filter) > 1 {
request.Filter = &attributes.Filter
filters, err := buildFilters(attributes)
if err != nil {
return nil, fmt.Errorf("error building filters: %w", err)
}
return request
}
func buildListInstanceGroupsRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesInstanceGroupsRequest {
request := &computepb.ListInstancesInstanceGroupsRequest{
InstanceGroup: attributes.InstanceGroup,
Project: catalog.Project,
Zone: catalog.Zone,
}
if len(attributes.Filter) > 1 {
request.Filter = &attributes.Filter
}
return request
if len(filters) > 0 {
filters := strings.Join(filters, " AND ")
request.Filter = &filters
}
return request, nil
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright IBM Corp. 2024, 2026
// 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",
},
{
name: "valid request with no filters",
attributes: &SetAttributes{
Filters: []string{},
},
catalog: &CatalogAttributes{
CredentialAttributes: &cred.CredentialAttributes{
ProjectId: "test-12345",
Zone: "us-central-1a",
},
},
expected: &computepb.ListInstancesRequest{
Project: "test-12345",
Zone: "us-central-1a",
Filter: stringPtr("status = running"),
},
},
}
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 IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
package host
import (
"context"
"errors"
"path"
"strconv"
compute "cloud.google.com/go/compute/apiv1"
computepb "cloud.google.com/go/compute/apiv1/computepb"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/api/iterator"
@@ -16,66 +15,25 @@ import (
"google.golang.org/grpc/status"
)
const (
NumberMaxResults = uint32(100)
)
// GoogleClient contains clients and attributes for authenticating to Google Cloud
// and finding instances
type GoogleClient struct {
InstancesClient *compute.InstancesClient
InstanceGroupClient *compute.InstanceGroupsClient
Context context.Context
Project string
Zone string
}
func (c *GoogleClient) getInstances(request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) {
func getInstances(ctx context.Context, instancesClient InstancesAPI, request *computepb.ListInstancesRequest) ([]*computepb.Instance, error) {
hosts := []*computepb.Instance{}
it := c.InstancesClient.List(c.Context, request)
it := instancesClient.List(ctx, request)
for {
resp, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error listing instances: %s", err)
return nil, status.Errorf(codes.Unknown, "error listing instances: %s", err)
}
hosts = append(hosts, resp)
}
return hosts, nil
}
func (c *GoogleClient) getInstancesForInstanceGroup(request *computepb.ListInstancesInstanceGroupsRequest) ([]*computepb.Instance, error) {
hosts := []*computepb.Instance{}
instances := c.InstanceGroupClient.ListInstances(c.Context, request)
for {
resp, err := instances.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error listing instances for instance group %s: %s", request.InstanceGroup, err)
}
instance, err := c.InstancesClient.Get(c.Context, &computepb.GetInstanceRequest{
Instance: path.Base(resp.GetInstance()),
Project: request.Project,
Zone: request.Zone,
})
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting instance %s for instance group %s: %s", resp.GetInstance(), request.InstanceGroup, err)
}
hosts = append(hosts, instance)
}
return hosts, nil
}
func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, error) {
if instance.GetSelfLink() == "" {
return nil, errors.New("response integrity error: missing instance self-link")
if instance.GetId() == 0 {
return nil, errors.New("response integrity error: missing instance id")
}
if instance.GetName() == "" {
@@ -84,22 +42,33 @@ func instanceToHost(instance *computepb.Instance) (*pb.ListHostsResponseHost, er
result := new(pb.ListHostsResponseHost)
result.ExternalId = instance.GetSelfLink()
result.ExternalId = strconv.FormatUint(instance.GetId(), 10)
result.ExternalName = instance.GetName()
// Internal DNS name is the hostname of the instance.
// https://cloud.google.com/compute/docs/networking/using-internal-dns
dnsName := instance.GetHostname()
result.DnsNames = appendDistinct(result.DnsNames, &dnsName)
// Now go through all of the interfaces and log the IP address of
// every interface.
for _, iface := range instance.GetNetworkInterfaces() {
// Populate default IP addresses/DNS name similar to how we do
// Populate default IP addresses name similar to how we do
// for the entire instance.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP)
for _, external := range iface.AccessConfigs {
result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP)
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
}
// Add the IPv6 addresses.
result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address)
if iface.Ipv6AccessConfigs != nil {
for _, external := range iface.Ipv6AccessConfigs {
result.IpAddresses = appendDistinct(result.IpAddresses, external.ExternalIpv6)
}
}
}
// Done
@@ -1,18 +1,22 @@
// Copyright (c) HashiCorp, Inc.
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package plugin
package host
import (
"context"
"errors"
"strconv"
"testing"
"cloud.google.com/go/compute/apiv1/computepb"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
)
func TestInstanceToHost(t *testing.T) {
exampleId := "https://www.googleapis.com/compute/v1/projects/test-project/zones/us-central1-a/instances/test-instance"
exampleId := uint64(123456789)
examplePrivateIp := "10.0.0.1"
examplePrivateIp2 := "10.0.0.2"
examplePublicIp := "1.1.1.1"
@@ -27,24 +31,24 @@ func TestInstanceToHost(t *testing.T) {
expectedErr string
}{
{
name: "missing instance self-link",
name: "missing instance id",
instance: &computepb.Instance{
Name: &exampleName,
},
expectedErr: "response integrity error: missing instance self-link",
expectedErr: "response integrity error: missing instance id",
},
{
name: "missing instance name",
instance: &computepb.Instance{
SelfLink: &exampleId,
Id: &exampleId,
},
expectedErr: "response integrity error: missing instance name",
},
{
name: "good, single private IP with public IP address",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
Id: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
@@ -57,7 +61,7 @@ func TestInstanceToHost(t *testing.T) {
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp},
},
@@ -65,8 +69,8 @@ func TestInstanceToHost(t *testing.T) {
{
name: "good, single private IP address",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
Id: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
@@ -75,7 +79,7 @@ func TestInstanceToHost(t *testing.T) {
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp},
},
@@ -83,8 +87,8 @@ func TestInstanceToHost(t *testing.T) {
{
name: "good, multiple interfaces",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
Id: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
@@ -105,7 +109,7 @@ func TestInstanceToHost(t *testing.T) {
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2},
},
@@ -113,8 +117,8 @@ func TestInstanceToHost(t *testing.T) {
{
name: "good, single private IP address with IPv6",
instance: &computepb.Instance{
SelfLink: &exampleId,
Name: &exampleName,
Id: &exampleId,
Name: &exampleName,
NetworkInterfaces: []*computepb.NetworkInterface{
{
NetworkIP: &examplePrivateIp,
@@ -124,7 +128,7 @@ func TestInstanceToHost(t *testing.T) {
},
},
expected: &pb.ListHostsResponseHost{
ExternalId: exampleId,
ExternalId: strconv.FormatUint(exampleId, 10),
ExternalName: exampleName,
IpAddresses: []string{examplePrivateIp, exampleIPv6},
},
@@ -146,3 +150,88 @@ func TestInstanceToHost(t *testing.T) {
})
}
}
func TestGetInstances(t *testing.T) {
ctx := context.Background()
exampleId := uint64(123456789)
exampleName := "test-instance"
exampleInstance := &computepb.Instance{
Id: &exampleId,
Name: &exampleName,
}
cases := []struct {
name string
instancesClient InstancesAPI
request *computepb.ListInstancesRequest
expected []*computepb.Instance
expectedErr string
}{
{
name: "successful retrieval",
instancesClient: func() InstancesAPI {
fn := newTestMockInstances(ctx,
nil,
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{
Items: []*computepb.Instance{exampleInstance},
}),
testMockInstancesWithListInstancesError(nil),
)
client, err := fn(&credential.Config{})
require.NoError(t, err)
return client
}(),
request: &computepb.ListInstancesRequest{},
expected: []*computepb.Instance{exampleInstance},
},
{
name: "error during listing",
instancesClient: func() InstancesAPI {
fn := newTestMockInstances(ctx,
nil,
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
testMockInstancesWithListInstancesError(errors.New("internal error")),
)
client, err := fn(&credential.Config{})
require.NoError(t, err)
return client
}(),
request: &computepb.ListInstancesRequest{},
expectedErr: "error listing instances",
},
{
name: "no instances",
instancesClient: func() InstancesAPI {
fn := newTestMockInstances(ctx,
nil,
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
testMockInstancesWithListInstancesError(nil),
)
client, err := fn(&credential.Config{})
require.NoError(t, err)
return client
}(),
request: &computepb.ListInstancesRequest{},
expected: []*computepb.Instance{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require := require.New(t)
actual, err := getInstances(ctx, tc.instancesClient, tc.request)
if tc.expectedErr != "" {
require.ErrorContains(err, tc.expectedErr)
return
}
require.NoError(err)
require.Len(actual, len(tc.expected))
if len(actual) > 0 {
require.Equal(tc.expected[0].Name, actual[0].Name)
require.Equal(tc.expected[0].Id, actual[0].Id)
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package host
const (
// ConstListInstancesFilter refers to a Google Cloud SDK filter to search for instances
ConstListInstancesFilter = "filters"
)
var allowedSetFields = map[string]struct{}{
ConstListInstancesFilter: {},
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright IBM Corp. 2024, 2026
// 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.
+726
View File
@@ -0,0 +1,726 @@
// Copyright IBM Corp. 2024, 2026
// 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 {
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
}
if credState.CredentialsConfig.IsRotatable() && !catalogAttributes.DisableCredentialRotation {
state, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
}
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
return nil, err
}
}
catalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
}
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
if err != nil {
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
}
return &pb.OnCreateCatalogResponse{
Persisted: persistedProto,
}, nil
}
// OnUpdateCatalog is called when a dynamic host catalog is updated.
func (p *HostPlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
currentCatalog := req.GetCurrentCatalog()
if currentCatalog == nil {
return nil, status.Errorf(codes.InvalidArgument, "current catalog is required")
}
newCatalog := req.GetNewCatalog()
if newCatalog == nil {
return nil, status.Errorf(codes.InvalidArgument, "new catalog is required")
}
oldAttrs := currentCatalog.GetAttributes()
if oldAttrs == nil {
return nil, status.Errorf(codes.InvalidArgument, "old catalog attributes are required")
}
oldCatalogAttributes, err := getCatalogAttributes(oldAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting old catalog attributes: %s", err)
}
newAttrs := newCatalog.GetAttributes()
if newAttrs == nil {
return nil, status.Errorf(codes.InvalidArgument, "new catalog attributes are required")
}
newCatalogAttributes, err := getCatalogAttributes(newAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting new catalog attributes: %s", err)
}
credState, err := credential.PersistedStateFromProto(
req.GetPersisted().GetSecrets(),
oldCatalogAttributes.CredentialAttributes,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
}
// We will be updating credentials when one of two changes occur:
// 1. The new catalog has a service account key, in which case we need to delete the old service account key credentials
// 2. The target service account ID has changed. This indicates a change of credential type to use service account impersonation
if newCatalog.GetSecrets() != nil || newCatalogAttributes.TargetServiceAccountId != oldCatalogAttributes.TargetServiceAccountId {
updatedCredentials, err := credential.GetCredentialsConfig(newCatalog.GetSecrets(), newCatalogAttributes.CredentialAttributes)
if err != nil {
return nil, err
}
newCredState, err := credential.NewPersistedState([]credential.Option{
credential.WithCredentialsConfig(updatedCredentials),
}...,
)
if err != nil {
return nil, status.Error(codes.Internal, fmt.Sprintf("error creating new persisted state: %s", err))
}
newCatalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(newCredState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
}
if st := dryRunValidation(ctx, newCatalogState, nil, p.testGCPClientOpts...); st != nil {
return nil, st.Err()
}
// Replace the existing credential state.
// This checks the timestamp on the last rotation time as well
// and deletes the credentials if we are managing them
// (ie: if we've rotated them before).
if err := credState.ReplaceCreds(ctx, updatedCredentials, p.testGCPClientOpts...); err != nil {
return nil, err
}
}
// This is a validate check to make sure that we aren't trying to
// rotate credentials that are not rotatable. For example,
// Application Default Credentials (ADC) are not rotatable.
if !credState.CredentialsConfig.IsRotatable() && !newCatalogAttributes.DisableCredentialRotation {
return nil, status.Error(codes.InvalidArgument, "cannot rotate credentials for non-rotatable credentials")
}
if credState.CredentialsConfig.IsRotatable() {
// This is a validate check to make sure that we aren't disabling
// rotation for credentials currently being managed by rotation.
// This is not allowed.
if newCatalogAttributes.DisableCredentialRotation && newCatalog.GetSecrets() == nil {
if !credState.CredsLastRotatedTime.IsZero() {
return nil, status.Error(codes.InvalidArgument, "cannot disable credential rotation for credentials currently being rotated")
}
}
permissions := []string{
credential.ComputeInstancesListPermission,
credential.IAMServiceAccountKeysCreatePermission,
credential.IAMServiceAccountKeysDeletePermission,
}
// If we're enabling rotation now but didn't before, or have
// freshly replaced credentials, we can rotate here.
if !newCatalogAttributes.DisableCredentialRotation && credState.CredsLastRotatedTime.IsZero() {
state, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
}
validateCredsCallback := state.ValidateCredsCallbackFn(ctx, p.testGCPClientOpts...)
if err := credState.RotateCreds(ctx, permissions, validateCredsCallback, p.testGCPClientOpts...); err != nil {
return nil, err
}
}
}
catalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "error setting up persisted state: %s", err)
}
// perform dry run to ensure we can interact with GCP as expected.
if st := dryRunValidation(ctx, catalogState, nil, p.testGCPClientOpts...); st != nil {
return nil, st.Err()
}
persistedProto, err := catalogState.toProto()
if err != nil {
return nil, status.Errorf(codes.Internal, "error converting state to proto: %s", err)
}
return &pb.OnUpdateCatalogResponse{
Persisted: persistedProto,
}, nil
}
// OnDeleteCatalog is called when a dynamic host catalog is deleted.
func (p *HostPlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog is nil")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "new catalog missing attributes")
}
catalogAttributes, err := getCatalogAttributes(attrs)
if err != nil {
return nil, err
}
credState, err := credential.PersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
}
_, err = newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
}
// try to delete static credentials for dynamic and static credentials
if credState.CredentialsConfig.IsRotatable() {
if !credState.CredsLastRotatedTime.IsZero() {
// Delete old/existing credentials. This is done with the same
// credentials to ensure that it has the proper permissions to do
// it.
if err := credState.DeleteCreds(ctx, p.testGCPClientOpts...); err != nil {
return nil, err
}
}
}
return &pb.OnDeleteCatalogResponse{}, nil
}
// NormalizeCatalogData is called to normalize the catalog data.
func (p *HostPlugin) NormalizeCatalogData(ctx context.Context, req *pb.NormalizeCatalogDataRequest) (*pb.NormalizeCatalogDataResponse, error) {
// No-op, Catalog fields do not require any normalization
return &pb.NormalizeCatalogDataResponse{Attributes: req.Attributes}, nil
}
// NormalizeSetData currently ensures that "filters" is an array value, even
// though it's accepted as a string value for CLI UX reasons
func (p *HostPlugin) NormalizeSetData(ctx context.Context, req *pb.NormalizeSetDataRequest) (*pb.NormalizeSetDataResponse, error) {
if req.Attributes == nil {
return &pb.NormalizeSetDataResponse{}, nil
}
val := req.Attributes.Fields["filters"]
if val == nil {
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
}
switch val.GetKind().(type) {
case *structpb.Value_ListValue:
return &pb.NormalizeSetDataResponse{Attributes: req.Attributes}, nil
case *structpb.Value_StringValue:
stringVal := val.Kind.(*structpb.Value_StringValue)
retAttrs := proto.Clone(req.Attributes).(*structpb.Struct)
retAttrs.Fields["filters"] = structpb.NewListValue(
&structpb.ListValue{
Values: []*structpb.Value{
structpb.NewStringValue(stringVal.StringValue),
},
})
return &pb.NormalizeSetDataResponse{Attributes: retAttrs}, nil
default:
return &pb.NormalizeSetDataResponse{}, status.Error(codes.InvalidArgument, "attribute.filters must be a string or list")
}
}
// OnCreateSet is called when a dynamic host set is created.
func (p *HostPlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
if err != nil {
return nil, err
}
credState, err := credential.PersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
}
catalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
}
set := req.GetSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "set is required")
}
if err := validateSet(req.GetSet()); err != nil {
return nil, err
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
listInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building filters: %s", err)
}
// perform dry run to ensure we can interact with GCP as expected.
if st := dryRunValidation(ctx, catalogState, listInstanceFilters, p.testGCPClientOpts...); st != nil {
return nil, st.Err()
}
return &pb.OnCreateSetResponse{}, nil
}
// OnUpdateSet is called when a dynamic host set is updated.
func (p *HostPlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is required")
}
attrs := catalog.GetAttributes()
if attrs == nil {
return nil, status.Error(codes.InvalidArgument, "catalog attributes are required")
}
catalogAttributes, err := getCatalogAttributes(attrs)
if err != nil {
return nil, err
}
credState, err := credential.PersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
}
catalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
}
set := req.GetNewSet()
if set == nil {
return nil, status.Error(codes.InvalidArgument, "set is required")
}
if err := validateSet(set); err != nil {
return nil, err
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, "set attributes are required")
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
listInstanceFilters, err := buildFilters(setAttrs)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building filters: %s", err)
}
// perform dry run to ensure we can interact with GCP as expected.
if st := dryRunValidation(ctx, catalogState, listInstanceFilters, p.testGCPClientOpts...); st != nil {
return nil, st.Err()
}
return &pb.OnUpdateSetResponse{}, nil
}
// OnDeleteSet is called when a dynamic host set is deleted.
func (p *HostPlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
return &pb.OnDeleteSetResponse{}, nil
}
// ListHosts returns the list of instances based on instance filter.
func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, status.Error(codes.InvalidArgument, "catalog is nil")
}
catalogAttrsRaw := catalog.GetAttributes()
if catalogAttrsRaw == nil {
return nil, status.Error(codes.InvalidArgument, "catalog missing attributes")
}
catalogAttributes, err := getCatalogAttributes(catalogAttrsRaw)
if err != nil {
return nil, err
}
credState, err := credential.PersistedStateFromProto(
req.GetPersisted().GetSecrets(),
catalogAttributes.CredentialAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting persisted state from proto: %s", err)
}
catalogState, err := newGCPCatalogPersistedState(
append([]gcpCatalogPersistedStateOption{
withCredentials(credState),
}, p.testCatalogStateOpts...)...,
)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error loading persisted state: %s", err)
}
sets := req.GetSets()
if sets == nil {
return nil, status.Error(codes.InvalidArgument, "sets is nil")
}
queries := make([]hostSetQuery, len(sets))
for i, set := range sets {
// Validate Id since we use it in output
if set.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "set missing id")
}
if set.GetAttributes() == nil {
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("set %s missing attributes", set.GetId()))
}
setAttrs, err := getSetAttributes(set.GetAttributes())
if err != nil {
return nil, err
}
input, err := buildListInstancesRequest(setAttrs, catalogAttributes)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error building list instances request: %s", err)
}
queries[i] = hostSetQuery{
Id: set.GetId(),
InputInstances: input,
}
}
instancesClient, err := catalogState.InstancesClient(ctx, p.testGCPClientOpts...)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error getting instances client: %s", err)
}
// Run all queries now and assemble output.
var maxLen int
for i, query := range queries {
output, err := getInstances(ctx, instancesClient, query.InputInstances)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error running list instances for host set id %q: %s", query.Id, err)
}
queries[i].Output = output
// Process the output here, we will normalize this into a single
// set of hosts afterwards (possibly removing duplicates).
for _, instance := range output {
host, err := instanceToHost(instance)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "error processing host results for host set id %q: %s", query.Id, err)
}
queries[i].OutputHosts = append(queries[i].OutputHosts, host)
maxLen++
}
}
// Now de-duplicate the hosts for the output. Maintain two sets:
// * A slice of hosts that will be used in the output
// * A map of hosts indexed by their external ID
//
// The map will is used in de-duplication to determine whether or
// not we've seen the host before to simply add the query's set ID
// to the list of set IDs that the host was seen in.
hostResultSlice := make([]*pb.ListHostsResponseHost, 0, maxLen)
hostResultMap := make(map[string]*pb.ListHostsResponseHost)
for _, query := range queries {
for _, host := range query.OutputHosts {
if existingHost, ok := hostResultMap[host.ExternalId]; ok {
// Existing host, just add the set ID to the list of seen IDs
// and continue
existingHost.SetIds = append(existingHost.SetIds, query.Id)
continue
}
// This will be the first seen entry, so append the set ID to
// this host, and add it.
host.SetIds = append(host.SetIds, query.Id)
hostResultSlice = append(hostResultSlice, host)
hostResultMap[host.ExternalId] = host
}
}
return &pb.ListHostsResponse{
Hosts: hostResultSlice,
}, nil
}
func validateSet(s *hostsets.HostSet) error {
if s == nil {
return status.Error(codes.InvalidArgument, "set is nil")
}
var attrs SetAttributes
attrMap := s.GetAttributes().AsMap()
if err := mapstructure.Decode(attrMap, &attrs); err != nil {
return status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err)
}
badFields := make(map[string]string)
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
+129
View File
@@ -0,0 +1,129 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package host
import (
"context"
"errors"
"fmt"
compute "cloud.google.com/go/compute/apiv1"
computepb "cloud.google.com/go/compute/apiv1/computepb"
"github.com/googleapis/gax-go/v2"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
"google.golang.org/protobuf/types/known/structpb"
)
type InstancesAPI interface {
List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) *compute.InstanceIterator
}
type gcpCatalogPersistedState struct {
// CredentialsConfig is the configuration for the GCP credentials
*credential.PersistedState
// testInstancesAPIFunc provides a way to provide a factory for a mock Instances client
testInstancesAPIFunc instancesAPIFunc
}
type instancesAPIFunc func(...*credential.Config) (InstancesAPI, error)
type gcpCatalogPersistedStateOption func(s *gcpCatalogPersistedState) error
func withCredentials(x *credential.PersistedState) gcpCatalogPersistedStateOption {
return func(s *gcpCatalogPersistedState) error {
if s.PersistedState != nil {
return errors.New("gcp credentials already set")
}
s.PersistedState = x
return nil
}
}
func withTestInstancesAPIFunc(f instancesAPIFunc) gcpCatalogPersistedStateOption {
return func(s *gcpCatalogPersistedState) error {
if s.testInstancesAPIFunc != nil {
return errors.New("test API function already set")
}
s.testInstancesAPIFunc = f
return nil
}
}
func newGCPCatalogPersistedState(opts ...gcpCatalogPersistedStateOption) (*gcpCatalogPersistedState, error) {
s := new(gcpCatalogPersistedState)
for _, opt := range opts {
if err := opt(s); err != nil {
return nil, err
}
}
return s, nil
}
// ToProto converts the state to HostCatalogPersisted proto message.
func (s *gcpCatalogPersistedState) toProto() (*pb.HostCatalogPersisted, error) {
data, err := structpb.NewStruct(s.ToMap())
if err != nil {
return nil, fmt.Errorf("error converting state to structpb.Struct: %w", err)
}
return &pb.HostCatalogPersisted{Secrets: data}, nil
}
// InstancesClient returns a configured Instances client based on the session
// information stored in the state.
func (s *gcpCatalogPersistedState) InstancesClient(
ctx context.Context,
opts ...option.ClientOption,
) (InstancesAPI, error) {
if s.testInstancesAPIFunc != nil {
return s.testInstancesAPIFunc(s.CredentialsConfig)
}
creds, err := s.CredentialsConfig.GenerateCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("error generation GCP credentials: %w", err)
}
if creds == nil {
return nil, fmt.Errorf("nil gcp credentials")
}
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
clientOptions = append(clientOptions, opts...)
client, err := compute.NewInstancesRESTClient(ctx, clientOptions...)
if err != nil {
return nil, fmt.Errorf("error creating instances client: %w", err)
}
return client, nil
}
// validateCredsCallbackFn returns a ValidateCredsCallback function that
// validates the credentials by listing instances in the project and zone.
func (s *gcpCatalogPersistedState) ValidateCredsCallbackFn(
ctx context.Context,
opts ...option.ClientOption,
) credential.ValidateCredsCallback {
return func(config *credential.Config, opts ...option.ClientOption) error {
s.CredentialsConfig = config
instancesClient, err := s.InstancesClient(ctx, opts...)
if err != nil {
return fmt.Errorf("error creating instances client: %w", err)
}
it := instancesClient.List(ctx, &computepb.ListInstancesRequest{
Project: s.CredentialsConfig.ProjectId,
Zone: s.CredentialsConfig.Zone,
})
_, err = it.Next()
if err != nil && err != iterator.Done {
return fmt.Errorf("error listing instances: %w", err)
}
return nil
}
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package host
import (
"context"
"errors"
"testing"
"time"
computepb "cloud.google.com/go/compute/apiv1/computepb"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/option"
"google.golang.org/protobuf/types/known/structpb"
)
func TestWithCredentials(t *testing.T) {
cred := &credential.PersistedState{}
opt := withCredentials(cred)
state := &gcpCatalogPersistedState{}
err := opt(state)
require.NoError(t, err)
assert.Equal(t, cred, state.PersistedState)
// Test setting credentials again should return an error
err = opt(state)
assert.Error(t, err)
assert.Equal(t, "gcp credentials already set", err.Error())
}
func TestNewGCPCatalogPersistedState(t *testing.T) {
cred := &credential.PersistedState{}
opt := withCredentials(cred)
state, err := newGCPCatalogPersistedState(opt)
require.NoError(t, err)
assert.Equal(t, cred, state.PersistedState)
}
func TestToProto(t *testing.T) {
lastRotatedTime := time.Now()
state := &gcpCatalogPersistedState{PersistedState: &credential.PersistedState{
CredentialsConfig: &credential.Config{
PrivateKey: "test-private-key",
PrivateKeyId: "test-private-key-id",
ClientEmail: "test@test.com",
},
CredsLastRotatedTime: lastRotatedTime,
},
}
proto, err := state.toProto()
require.NoError(t, err)
expectedData, err := structpb.NewStruct(map[string]any{
"private_key": "test-private-key",
"private_key_id": "test-private-key-id",
"creds_last_rotated_time": lastRotatedTime.Format(time.RFC3339Nano),
})
require.NoError(t, err)
assert.Equal(t, expectedData, proto.Secrets)
}
func TestInstancesClient(t *testing.T) {
ctx := context.Background()
cred := &credential.PersistedState{
CredentialsConfig: &credential.Config{},
}
state := &gcpCatalogPersistedState{PersistedState: cred}
t.Run("GenerateCredentials error", func(t *testing.T) {
_, err := state.InstancesClient(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "error generation GCP credentials")
})
t.Run("NewInstancesRESTClient error", func(t *testing.T) {
state.CredentialsConfig = &credential.Config{
ProjectId: "test-project-id",
PrivateKey: "test-private-key",
ClientEmail: "test@example.com",
}
_, err := state.InstancesClient(ctx, option.WithoutAuthentication())
assert.Error(t, err)
assert.Contains(t, err.Error(), "error creating instances client")
})
}
func TestValidateCredsCallbackFn(t *testing.T) {
ctx := context.Background()
cred := &credential.PersistedState{
CredentialsConfig: &credential.Config{
ProjectId: "test-project-id",
Zone: "test-zone",
},
}
state := &gcpCatalogPersistedState{PersistedState: cred}
t.Run("List instances error", func(t *testing.T) {
state.testInstancesAPIFunc = newTestMockInstances(ctx,
nil,
testMockInstancesWithListInstancesOutput(nil),
testMockInstancesWithListInstancesError(errors.New("error listing instances")),
)
callback := state.ValidateCredsCallbackFn(ctx)
err := callback(&credential.Config{
ProjectId: "test-project-id",
Zone: "test-zone",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "error listing instances")
})
t.Run("Success", func(t *testing.T) {
state.testInstancesAPIFunc = newTestMockInstances(ctx,
nil,
testMockInstancesWithListInstancesOutput(&computepb.InstanceList{}),
testMockInstancesWithListInstancesError(nil),
)
callback := state.ValidateCredsCallbackFn(ctx)
err := callback(&credential.Config{
ProjectId: "test-project-id",
Zone: "test-zone",
})
assert.NoError(t, err)
})
}
+138
View File
@@ -0,0 +1,138 @@
// Copyright IBM Corp. 2024, 2026
// 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)
}
+589
View File
@@ -0,0 +1,589 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-gcp/plugin/service/host"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
const (
expectedInstanceCount = 3
expectedTagsCount = 3
expectedLabelsCount = 3
)
// All the tests in this test depend on each other
// to run in order. This is because the tests are
// testing the same plugin instance and the same
// persisted state. The tests are designed to run
// in order to test the plugin's ability to rotate
// credentials and manage the state of the plugin
// instance.
// To run the tests you need the following
// environment variables set:
// GOOGLE_APPLICATION_CREDENTIALS: This should be
// the path to the service account key. This can also
// be automatically set after authenticating with gcloud
// using `gcloud auth application-default login`.
//
// TF_VAR_project_id: The project_id of the project.
//
// TF_VAR_region: The region the resources should be
// deployed in.
//
// TF_VAR_zone: The zone the resources should be
// deployed in.
func TestHostPlugin(t *testing.T) {
creds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
if creds == "" {
t.Skip("set GOOGLE_APPLICATION_CREDENTIALS to use this test")
}
projectId := os.Getenv("TF_VAR_project_id")
if projectId == "" {
t.Skip("set TF_VAR_project_id to use this test")
}
region := os.Getenv("TF_VAR_region")
if region == "" {
t.Skip("set TF_VAR_region to use this test")
}
zone := os.Getenv("TF_VAR_zone")
if zone == "" {
t.Skip("set TF_VAR_zone to use this test")
}
require := require.New(t)
tf, err := NewTestTerraformer("testdata/host")
require.NoError(err)
require.NotNil(tf)
t.Log("===== deploying test Terraform workspace =====")
err = tf.Deploy()
require.NoError(err)
defer func() {
t.Log("===== destroying test Terraform workspace =====")
if err := tf.Destroy(); err != nil {
t.Logf("WARNING: could not run Terraform destroy: %s", err)
}
}()
clientEmail, err := tf.GetOutput("client_email")
require.NoError(err)
require.NotNil(clientEmail)
instanceIds, err := tf.GetOutputSlice("instance_ids")
require.NoError(err)
require.Len(instanceIds, expectedInstanceCount)
instanceTags, err := tf.GetOutputMap("instance_tags")
require.NoError(err)
require.Len(instanceTags, expectedInstanceCount)
instanceLabels, err := tf.GetOutputMap("instance_labels")
require.NoError(err)
require.Len(instanceLabels, expectedLabelsCount)
privateKeys, err := tf.GetOutputSlice("private_keys")
require.NoError(err)
require.Len(privateKeys, expectedServiceAccountKeys)
privateKeyIds, err := tf.GetOutputSlice("private_key_ids")
require.NoError(err)
require.Len(privateKeyIds, expectedServiceAccountKeys)
p := new(host.HostPlugin)
ctx := context.Background()
var keyId, secret string
t.Run("OnCreateCatalog", func(t *testing.T) {
t.Run("Test non-rotation", func(t *testing.T) {
keyId, secret = testPluginOnCreateCatalog(
ctx,
t,
p,
zone,
projectId,
clientEmail.(string),
privateKeyIds[0].(string),
privateKeys[0].(string),
false,
)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Test rotation", func(t *testing.T) {
keyId, secret = testPluginOnCreateCatalog(
ctx,
t,
p,
zone,
projectId,
clientEmail.(string),
keyId,
secret,
true,
)
require.NotNil(keyId)
require.NotNil(secret)
})
})
t.Run("OnUpdateCatalog", func(t *testing.T) {
t.Run("Test no-op non-rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Test no-op with rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", true, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch credentials to next service account key. Don't rotate", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[1].(string), privateKeys[1].(string), true, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch credentials to next service account key. Add rotation", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[2].(string), privateKeys[2].(string), false, true)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to next service account key, with rotation disabled", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[3].(string), privateKeys[3].(string), true, false)
require.NotNil(keyId)
require.NotNil(secret)
})
t.Run("Switch to another service account key and keep rotation off", func(t *testing.T) {
keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[4].(string), privateKeys[4].(string), false, false)
require.NotNil(keyId)
require.NotNil(secret)
})
})
t.Run("OnDeleteCatalog", func(t *testing.T) {
t.Run("Test non-rotated", func(t *testing.T) {
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, false)
})
t.Run("Test as if we had rotated these credentials.", func(t *testing.T) {
testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, true)
})
})
t.Run("HostSet", func(t *testing.T) {
keyId, secret := privateKeyIds[5].(string), privateKeys[5].(string)
expectedLabelInstancesMap := make(map[string][]string)
for instanceId, instanceLabel := range instanceLabels {
for tagKey := range instanceLabel.(map[string]any) {
for _, expectedTag := range instanceLabels {
if tagKey == expectedTag {
expectedLabelInstancesMap[tagKey] = append(expectedLabelInstancesMap[tagKey], instanceId)
}
}
}
}
cases := map[string][]string{
"label-1": {"foo"},
"label-2": {"bar"},
"label-3": {"baz"},
"label-4": {"foo", "bar"},
"label-5": {"foo", "baz"},
"label-6": {"bar", "baz"},
"label-7": {"foo", "bar", "baz"},
}
for key, tc := range cases {
t.Run(fmt.Sprintf("Label=%s", key), func(t *testing.T) {
labels := make([]string, 0, len(tc))
for _, label := range tc {
labels = append(labels, fmt.Sprintf("labels.%s=%s", key, label))
}
testPluginOnCreateUpdateSet(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels)
testPluginListHosts(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels, expectedLabelInstancesMap)
})
}
})
}
func testPluginOnCreateCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
rotate bool) (string, string) {
t.Helper()
t.Logf("testing OnCreateCatalog (zone=%s, rotate=%t)", zone, rotate)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstClientEmail: clientEmail,
credential.ConstProjectId: projectId,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
})
require.NoError(err)
request := &pb.OnCreateCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
}
response, err := p.OnCreateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validatePersistedSecrets(t, persisted.GetSecrets(), projectId, clientEmail, privateKeyId, privateKey, rotate)
}
func testPluginOnUpdateCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
currentPrivateKeyId string,
currentPrivateKey string,
newPrivateKeyId string,
newPrivateKey string,
rotated bool,
rotate bool,
) (string, string) {
t.Helper()
t.Logf("testing OnUpdateCatalog (zone=%s, newcreds=%t, rotated=%t, rotate=%t)", zone, newPrivateKeyId != "" && newPrivateKey != "", rotated, rotate)
require := require.New(t)
// Take a timestamp of the current time to get a point in time to
// reference, ensuring that we are updating credential rotation
// timestamps.
currentCredsLastRotatedTime := time.Now()
reqCurrentAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqNewAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotate,
})
require.NoError(err)
var reqSecrets *structpb.Struct
if newPrivateKeyId != "" && newPrivateKey != "" {
reqSecrets, err = structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: newPrivateKeyId,
credential.ConstPrivateKey: newPrivateKey,
})
require.NoError(err)
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: currentPrivateKeyId,
credential.ConstPrivateKey: currentPrivateKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return currentCredsLastRotatedTime.Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
require.NotNil(reqPersistedSecrets)
request := &pb.OnUpdateCatalogRequest{
CurrentCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqCurrentAttrs,
},
},
NewCatalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqNewAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnUpdateCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
persisted := response.GetPersisted()
require.NotNil(persisted)
return validateUpdateSecrets(
t,
projectId,
clientEmail,
persisted.GetSecrets(),
currentCredsLastRotatedTime,
currentPrivateKeyId,
currentPrivateKey,
newPrivateKeyId,
newPrivateKey,
rotated,
rotate,
)
}
func testPluginOnDeleteCatalog(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
rotated bool) {
t.Helper()
t.Logf("testing OnDeleteCatalog (zone=%s, rotated=%t)", zone, rotated)
require := require.New(t)
reqAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: !rotated,
})
require.NoError(err)
reqSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: func() string {
if rotated {
return time.Now().Format(time.RFC3339Nano)
}
return (time.Time{}).Format(time.RFC3339Nano)
}(),
})
require.NoError(err)
request := &pb.OnDeleteCatalogRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: reqAttrs,
},
Secrets: reqSecrets,
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.OnDeleteCatalog(ctx, request)
require.NoError(err)
require.NotNil(response)
// We want to test the validity of the credentials post-deletion.
if rotated {
// The credentials should no longer be valid.
requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey)
} else {
// The credentials should still be valid. Sleep 10s first just to
// be sure, since we're not rotating.
time.Sleep(time.Second * 10)
requireCredentialsValid(t, projectId, clientEmail, privateKeyId, privateKey)
}
}
func testPluginOnCreateUpdateSet(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
labels []string) {
t.Helper()
t.Logf("testing OnCreateSet (zone=%s, labels=%v)", zone, labels)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstListInstancesFilter: []any{strings.Join(labels, " AND ")},
})
require.NoError(err)
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
createRequest := &pb.OnCreateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Set: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
createResponse, err := p.OnCreateSet(ctx, createRequest)
require.NoError(err)
require.NotNil(createResponse)
// Do an update test in the same function, as it's pretty much the
// same function right now.
t.Logf("testing OnUpdateSet (zone=%s, labels=%v)", zone, labels)
updateRequest := &pb.OnUpdateSetRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
CurrentSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
NewSet: &hostsets.HostSet{
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
},
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
updateResponse, err := p.OnUpdateSet(ctx, updateRequest)
require.NoError(err)
require.NotNil(updateResponse)
}
func testPluginListHosts(
ctx context.Context,
t *testing.T,
p *host.HostPlugin,
zone string,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
labels []string,
expected map[string][]string) {
t.Helper()
t.Logf("testing ListHosts (zone=%s, labels=%v)", zone, labels)
require := require.New(t)
catalogAttrs, err := structpb.NewStruct(map[string]any{
credential.ConstProjectId: projectId,
credential.ConstClientEmail: clientEmail,
credential.ConstZone: zone,
credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests
})
require.NoError(err)
sets := make([]*hostsets.HostSet, len(labels))
for i, label := range labels {
setAttrs, err := structpb.NewStruct(map[string]any{
host.ConstListInstancesFilter: []any{label},
})
require.NoError(err)
sets[i] = &hostsets.HostSet{
Id: fmt.Sprintf("hostset-%d", i),
Attrs: &hostsets.HostSet_Attributes{
Attributes: setAttrs,
},
}
}
reqPersistedSecrets, err := structpb.NewStruct(map[string]any{
credential.ConstPrivateKeyId: privateKeyId,
credential.ConstPrivateKey: privateKey,
credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano),
})
require.NoError(err)
request := &pb.ListHostsRequest{
Catalog: &hostcatalogs.HostCatalog{
Attrs: &hostcatalogs.HostCatalog_Attributes{
Attributes: catalogAttrs,
},
},
Sets: sets,
Persisted: &pb.HostCatalogPersisted{
Secrets: reqPersistedSecrets,
},
}
response, err := p.ListHosts(ctx, request)
require.NoError(err)
require.NotNil(response)
// Validate the returned instances by ID. Assemble the instance
// details from the expected set.
expectedInstances := make(map[string][]string)
for i, label := range labels {
for _, instanceId := range expected[label] {
expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i))
}
}
// Take the returned hosts by ID and create the same kind of map.
actualInstances := make(map[string][]string)
for _, host := range response.GetHosts() {
actualInstances[host.ExternalId] = host.SetIds
}
// Compare
require.Equal(expectedInstances, actualInstances)
// Success
t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", zone, labels, len(actualInstances), actualInstances)
}
-66
View File
@@ -1,66 +0,0 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
data "google_compute_zones" "available" {}
resource "google_compute_instance" "default" {
count = var.num_instances
name = "boundary-${count.index}"
machine_type = "n2-standard-2"
zone = data.google_compute_zones.available.names[0]
labels = var.labels
boot_disk {
initialize_params {
image = "ubuntu-os-cloud/ubuntu-2204-lts"
labels = var.labels
}
}
network_interface {
network = "default"
access_config {
// Ephemeral public IP
}
}
metadata = {
ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
}
dynamic "service_account" {
for_each = var.service_account_email != null ? [var.service_account_email] : []
content {
email = service_account.value
scopes = ["cloud-platform"]
}
}
}
resource "google_compute_instance_group" "webservers" {
name = "boundary-servers"
description = "Boundary server group"
instances = google_compute_instance.default.*.id
zone = data.google_compute_zones.available.names[0]
}
# RSA key of size 4096 bits
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "google_compute_firewall" "rules" {
name = "allow-ssh"
network = "default"
allow {
protocol = "tcp"
ports = ["22"]
}
source_ranges = [var.client_cidr_block]
}
-8
View File
@@ -1,8 +0,0 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
resource "local_file" "ssh_key" {
content = tls_private_key.ssh.private_key_openssh
filename = "${path.module}/secrets/id_rsa.pem"
file_permission = "0400"
}
-29
View File
@@ -1,29 +0,0 @@
# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: MPL-2.0
variable "labels" {
type = map(string)
default = {
repository = "boundary-plugin-google"
purpose = "testing"
}
}
variable "region" {
type = string
default = "us-central1"
}
variable "num_instances" {
type = number
default = 3
}
variable "service_account_email" {
type = string
default = null
}
variable "client_cidr_block" {
type = string
}
+77
View File
@@ -0,0 +1,77 @@
# Copyright IBM Corp. 2024, 2026
# SPDX-License-Identifier: MPL-2.0
resource "random_id" "service_account_client_email" {
prefix = "test-${var.user_prefix}"
byte_length = 2
}
resource "google_service_account" "test_service_account" {
project = var.project_id
account_id = random_id.service_account_client_email.dec
display_name = "test-${var.user_prefix}"
}
resource "google_project_iam_binding" "test_service_account_user" {
project = var.project_id
count = length(var.rolesList)
role = var.rolesList[count.index]
members = [
"serviceAccount:${google_service_account.test_service_account.email}"
]
}
resource "google_service_account_key" "test_service_account_key" {
service_account_id = google_service_account.test_service_account.name
public_key_type = "TYPE_X509_PEM_FILE"
private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE"
key_algorithm = "KEY_ALG_RSA_2048"
}
resource "google_service_account_key" "test_service_account_keys" {
count = var.num_service_account_keys
service_account_id = google_service_account.test_service_account.name
public_key_type = "TYPE_X509_PEM_FILE"
private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE"
key_algorithm = "KEY_ALG_RSA_2048"
}
resource "google_compute_instance" "instances" {
project = var.project_id
count = var.num_instances
name = "boundary-${count.index}"
machine_type = "n2-standard-2"
zone = var.zone
labels = var.labels
tags = ["test-instance-${count.index}"]
boot_disk {
initialize_params {
image = "ubuntu-os-cloud/ubuntu-2204-lts"
labels = var.labels
}
}
network_interface {
network = "default"
access_config {
// Ephemeral public IP
}
}
metadata = {
ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}"
}
service_account {
email = google_service_account.test_service_account.email
scopes = ["cloud-platform"]
}
}
# RSA key of size 4096 bits
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
+32
View File
@@ -0,0 +1,32 @@
# Copyright IBM Corp. 2024, 2026
# SPDX-License-Identifier: MPL-2.0
output "client_email" {
value = google_service_account.test_service_account.email
}
output "private_keys" {
value = [for key in google_service_account_key.test_service_account_keys : jsondecode(base64decode(key.private_key)).private_key]
sensitive = true
}
output "private_key_ids" {
value = [for key in google_service_account_key.test_service_account_keys : chomp(reverse(split("/", key.id))[0])]
sensitive = true
}
output "instance_ids" {
value = google_compute_instance.instances.*.id
}
output "instance_tags" {
value = {
for instance in google_compute_instance.instances : instance.id => instance.tags
}
}
output "instance_labels" {
value = {
for instance in google_compute_instance.instances : instance.id => instance.labels
}
}
@@ -1,4 +1,4 @@
# Copyright (c) HashiCorp, Inc.
# Copyright IBM Corp. 2024, 2026
# SPDX-License-Identifier: MPL-2.0
terraform {
@@ -12,4 +12,5 @@ terraform {
provider "google" {
region = var.region
project = var.project_id
}
+50
View File
@@ -0,0 +1,50 @@
# Copyright IBM Corp. 2024, 2026
# SPDX-License-Identifier: MPL-2.0
data "google_client_openid_userinfo" "current" {}
variable "labels" {
type = map(string)
default = {
repository = "boundary-plugin-gcp"
purpose = "testing"
}
}
variable "project_id" {
type = string
}
variable "region" {
type = string
}
variable "zone" {
type = string
}
variable "num_instances" {
type = number
default = 3
}
variable "service_account_email" {
type = string
default = null
}
variable "rolesList" {
type = list(string)
description = "List of roles to assign to the service account"
default = ["roles/compute.viewer","roles/iam.serviceAccountKeyAdmin"]
}
variable "user_prefix" {
type = string
default = "boundary-gcp-plugin"
}
variable "num_service_account_keys" {
type = number
default = 6
}
+513
View File
@@ -0,0 +1,513 @@
// Copyright IBM Corp. 2024, 2026
// SPDX-License-Identifier: MPL-2.0
package testing
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary-plugin-gcp/internal/credential"
"github.com/hashicorp/boundary-plugin-gcp/internal/values"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/require"
"google.golang.org/api/option"
"google.golang.org/protobuf/types/known/structpb"
)
const (
// expectedServiceAccountKeys is the number of service account keys
// expected to be created in the Terraform project.
expectedServiceAccountKeys = 6
)
// TestTerraformer is an object representing the lifecycle of a
// particular Terraform project.
//
// Note that Terraform must be installed in the system PATH (not to
// be confused with the working directory below). This object does
// not manage anything with regards to installation or version
// management.
type TestTerraformer struct {
// The full path to the Terraform binary, set during initialization
// to the binary found in the system path.
tfPath string
// The working directory for Terraform commands.
workDir string
// The Terraform state. Use the Output method to look up particular
// outputs from the Terraform state.
state *tfjson.State
// Keeps track of whether or not we have run the init command
// during this run of Terraform. This only needs to be done once.
// Do not use initErr directly; it stores the init error if one was
// encountered on init.
initOnce sync.Once
initErr error
}
// NewTestTerraformer initializes a Terraform directory. It just
// returns an initialized project, it does not perform any actions on
// it.
func NewTestTerraformer(workDir string) (*TestTerraformer, error) {
tfPath, err := exec.LookPath("terraform")
if err != nil {
return nil, fmt.Errorf("LookPath error: %w", err)
}
if workDir == "" {
return nil, errors.New("workDir is empty")
}
return &TestTerraformer{
tfPath: tfPath,
workDir: workDir,
}, nil
}
// Deploy runs the following commands, in order: init, apply, plan,
// json. The output of the JSON command is then imported to the
// internal state.
func (t *TestTerraformer) Deploy() error {
if err := t.init(); err != nil {
return fmt.Errorf("error running Terraform init: %w", err)
}
if err := t.apply(); err != nil {
return fmt.Errorf("error running Terraform apply: %w", err)
}
if err := t.plan(); err != nil {
return fmt.Errorf("error running Terraform plan: %w", err)
}
output, err := t.showJson()
if err != nil {
return fmt.Errorf("error getting Terraform plan JSON data: %w", err)
}
newState := new(tfjson.Plan)
if err := json.Unmarshal(output, newState); err != nil {
return fmt.Errorf("error reading new Terraform plan JSON data: %w", err)
}
t.state = newState.PriorState
return nil
}
// Destroy runs the following commands, in order: init, destroy. The
// state is also cleared.
func (t *TestTerraformer) Destroy() error {
if err := t.init(); err != nil {
return fmt.Errorf("error running Terraform init: %w", err)
}
if err := t.destroy(); err != nil {
return fmt.Errorf("error running Terraform destroy: %w", err)
}
t.state = nil
return nil
}
// GetOutput returns the value of the output located at key.
//
// Outputs for the root module are supported only. It's an error to
// run this when no state exists.
func (t *TestTerraformer) GetOutput(key string) (any, error) {
if key == "" {
return nil, errors.New("key must not be empty")
}
if t.state == nil {
return nil, errors.New("state is nil")
}
if t.state.Values == nil {
return nil, errors.New("state integrity error: no state values")
}
if t.state.Values.Outputs == nil {
return nil, errors.New("state integrity error: outputs is nil")
}
output, ok := t.state.Values.Outputs[key]
if !ok {
return nil, fmt.Errorf("output with key %q not found", key)
}
if output == nil {
return nil, fmt.Errorf("state integrity error: output at key %q is nil", key)
}
return output.Value, nil
}
// GetOutputString wraps GetOutput and returns the value as a string. It's an
// error if the return value is actually not a string.
func (t *TestTerraformer) GetOutputString(key string) (string, error) {
value, err := t.GetOutput(key)
if err != nil {
return "", err
}
str, ok := value.(string)
if !ok {
return "", fmt.Errorf("expected output value at key %q to be string, got %T", key, value)
}
return str, nil
}
// GetOutputMap wraps GetOutput and returns the value as a map. It's an
// error if the return value is actually not a map.
func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) {
value, err := t.GetOutput(key)
if err != nil {
return nil, err
}
m, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value)
}
return m, nil
}
// GetOutputSlice wraps GetOutput and returns the value as a slice. It's an
// error if the return value is actually not a slice.
func (t *TestTerraformer) GetOutputSlice(key string) ([]any, error) {
value, err := t.GetOutput(key)
if err != nil {
return nil, err
}
m, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value)
}
return m, nil
}
// init runs "terraform init" in the project's working directory.
//
// This is only ever done once.
func (t *TestTerraformer) init() error {
t.initOnce.Do(func() {
cmd := exec.Command(t.tfPath, "init")
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
t.initErr = fmt.Errorf("%s\n%s", stdoutStderr, err)
}
})
return t.initErr
}
// apply runs "terraform apply" in the project's working directory.
func (t *TestTerraformer) apply() error {
cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve")
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s\n%s", stdoutStderr, err)
}
return nil
}
// destroy runs "terraform destroy" in the project's working directory.
func (t *TestTerraformer) destroy() error {
cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve")
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s\n%s", stdoutStderr, err)
}
return nil
}
// plan runs "terraform plan" in the project's working directory.
//
// Note that plan is not run as part of the apply workflow; rather,
// it's run to get the data for for importing the state via
// terraform-json. The apply method ignores the plan generated here.
func (t *TestTerraformer) plan() error {
cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan")
cmd.Dir = t.workDir
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s\n%s", stdoutStderr, err)
}
return nil
}
// showJson runs "terraform show -json". The output is returned along
// with any error.
//
// Output is only the results of stdout and should be suitable for
// parsing.
//
// This method expects the plan.tfplan file to be generated in the
// working directory already. This can be done with the plan method.
func (t *TestTerraformer) showJson() ([]byte, error) {
cmd := exec.Command(t.tfPath, "show", "-json", "plan.tfplan")
cmd.Dir = t.workDir
output, err := cmd.Output()
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
return nil, err
}
return nil, fmt.Errorf("%s\n%s", exitErr.Stderr, exitErr)
}
return output, nil
}
func requireCredentialsInvalid(
t *testing.T,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string) {
t.Helper()
require := require.New(t)
c, err := credential.NewConfig(
credential.WithProjectId(projectId),
credential.WithClientEmail(clientEmail),
credential.WithPrivateKeyId(privateKeyId),
credential.WithPrivateKey(privateKey))
require.NoError(err)
creds, err := c.GenerateCredentials(context.Background())
require.NoError(err)
// We need to wait for invalidation as while gcp waits for
// credential creation, deletion of the old credentials returns
// immediately.
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
waitErr:
for {
_, err = c.ValidateIamPermissions(timeoutCtx, []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource))
if err != nil {
break
}
select {
case <-time.After(time.Second):
// pass
case <-timeoutCtx.Done():
break waitErr
}
}
require.Error(err)
require.Contains(err.Error(), "failed to validate IAM permissions")
}
func requireCredentialsValid(
t *testing.T,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string) {
t.Helper()
require := require.New(t)
c, err := credential.NewConfig(
credential.WithProjectId(projectId),
credential.WithClientEmail(clientEmail),
credential.WithPrivateKeyId(privateKeyId),
credential.WithPrivateKey(privateKey))
require.NoError(err)
creds, err := c.GenerateCredentials(context.Background())
require.NoError(err)
_, err = c.ValidateIamPermissions(context.Background(), []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource))
require.NoError(err)
}
func validatePersistedSecrets(
t *testing.T,
persistedSecrets *structpb.Struct,
projectId string,
clientEmail string,
privateKeyId string,
privateKey string,
rotate bool) (string, string) {
t.Helper()
require := require.New(t)
require.NotNil(persistedSecrets)
persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true)
require.NoError(err)
require.NotZero(persistedPrivateKeyId)
if rotate {
require.NotEqual(privateKeyId, persistedPrivateKeyId)
} else {
require.Equal(privateKeyId, persistedPrivateKeyId)
}
persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true)
require.NoError(err)
require.NotZero(persistedPrivateKey)
if rotate {
require.NotEqual(privateKey, persistedPrivateKey)
} else {
require.Equal(privateKey, persistedPrivateKey)
}
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
require.NoError(err)
if rotate {
require.NotZero(persistedCredsLastRotatedTime)
requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey)
} else {
require.Zero(persistedCredsLastRotatedTime)
}
return persistedPrivateKeyId, persistedPrivateKey
}
func validateUpdateSecrets(
t *testing.T,
projectId string,
clientEmail string,
persistedSecrets *structpb.Struct,
currentCredsLastRotatedTime time.Time,
currentPrivateKeyId string,
currentPrivateKey string,
newPrivateKeyId string,
newPrivateKey string,
rotated bool,
rotate bool) (string, string) {
t.Helper()
require := require.New(t)
// Complex checks based on the scenarios.
persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true)
require.NoError(err)
require.NotZero(persistedPrivateKeyId)
persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true)
require.NoError(err)
require.NotZero(persistedPrivateKey)
persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime)
require.NoError(err)
// Our test scenarios are complex due the multi-dimensional nature
// of criteria, so we lay them out in a switch below.
switch {
case newPrivateKeyId != "" && rotated && rotate:
// The new private key ID was provided, we had previously rotated
// the credentials before, and the new credential set is to be
// rotated as well. In this case, the old credentials should have
// been deleted, and the new credentials should have been rotated,
// hence, should not match the new credentials initially
// provided. Rotation time should be non-zero and updated.
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.NotEqual(persistedPrivateKeyId, newPrivateKeyId)
require.NotEqual(persistedPrivateKey, newPrivateKey)
require.NotZero(persistedCredsLastRotatedTime)
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
case newPrivateKeyId != "" && rotated && !rotate:
// The new private key ID was provided, we had previously rotated
// the credentials before, and the new credential is *not*
// rotated. In this case, the old credentials should have
// been deleted, But the new credentials should have not been
// rotated, and hence should be the same. Rotation time should be
// zero.
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.Equal(persistedPrivateKeyId, newPrivateKeyId)
require.Equal(persistedPrivateKey, newPrivateKey)
require.Zero(persistedCredsLastRotatedTime)
case newPrivateKeyId != "" && !rotated && rotate:
// The new private key was provided, we *have not* previously
// rotated the credentials, and the new credential set is to be
// rotated. In this case, the old credentials should have been
// left alone, and the new credentials should have been rotated,
// hence, should not match the new credentials initially
// provided. Rotation time should be non-zero, but updated.
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.NotEqual(persistedPrivateKeyId, newPrivateKeyId)
require.NotEqual(persistedPrivateKey, newPrivateKey)
require.NotZero(persistedCredsLastRotatedTime)
require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime))
case newPrivateKeyId != "" && !rotated && !rotate:
// The new private key was provided, but we have not rotated
// the credentials previously and we still don't plan on rotating
// them. In this case, the old credentials should still be valid,
// and the persisted ones should match the new ones provided.
// Rotation time should be zero.
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.Equal(persistedPrivateKeyId, newPrivateKeyId)
require.Equal(persistedPrivateKey, newPrivateKey)
require.Zero(persistedCredsLastRotatedTime)
case newPrivateKeyId == "" && rotated && rotate:
// No new credentials have been provided, but we have previously
// rotated and are still rotating credentials. This is a no-op.
// Existing credentials should still be valid and match the ones
// persisted to state. Rotation time should be identical since
// no new rotation occurred.
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.Equal(persistedPrivateKeyId, currentPrivateKeyId)
require.Equal(persistedPrivateKey, currentPrivateKey)
require.NotZero(persistedCredsLastRotatedTime)
require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime))
case newPrivateKeyId == "" && rotated && !rotate:
// No new credentials have been provided, and we have previously
// rotated the credentials. This is actually an error, but we
// don't test it here; it's covered in unit testing (see
// plugin_test.go).
require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper")
case newPrivateKeyId == "" && !rotated && rotate:
// No new credentials have been provided, and while we did not
// rotate before, we want to switch to rotation. In this case,
// the existing persisted credentials should have been rotated,
// with a new non-zero timestamp.
requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.NotEqual(persistedPrivateKeyId, currentPrivateKeyId)
require.NotEqual(persistedPrivateKey, currentPrivateKey)
require.NotZero(persistedCredsLastRotatedTime)
case newPrivateKeyId == "" && !rotated && !rotate:
// No new credentials have been provided and we have not, nor do
// not, plan on rotating the credentials. This is a no-op.
// Existing credentials should still be valid and match the ones
// persisted to state. Rotation time should remain at zero.
requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey)
require.Equal(persistedPrivateKeyId, currentPrivateKeyId)
require.Equal(persistedPrivateKey, currentPrivateKey)
require.Zero(persistedCredsLastRotatedTime)
default:
// Scenario was reached that was not covered by this function.
require.FailNow("unknown test scenario")
}
return persistedPrivateKeyId, persistedPrivateKey
}