diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1ca5337 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Let us know about a bug! +title: '' +labels: bug +assignees: '' + +--- + + + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run `boundary authenticate ...` +2. Run `boundary connect ...` +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..354eb79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +contact_links: + - name: Ask a question + url: https://discuss.hashicorp.com/c/boundary + about: For increased visibility, please post questions on the discussion forum. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c6308b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest something! +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Explain any additional use-cases** +If there are any use-cases that would help us understand the use/need/value please share them as they can help us decide on acceptance and prioritization. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9022b4b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: test + +on: + - workflow_dispatch + - push + +jobs: + test: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Set up Go Environment + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version-file: 'go.mod' + cache: true + - name: Run Tests + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59febf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +### IntelliJ IDEA project files ### +.idea +*.ipr +*.iml +*.iws + +### Visual Studio ### +.vscode/ + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +### TF files ### +.terraform* +terraform.tfstate* +plan.tfplan + +### Secrets used in testing ### +secrets/ +secrets.json diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..2a1f99a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,4 @@ +# These owners will be the default owners for everything in +# the repo, unless a later match takes precedence. + +@hashicorp/boundary diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15eba9d --- /dev/null +++ b/LICENSE @@ -0,0 +1,375 @@ +Copyright (c) 2021 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index e69de29..a876f08 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,90 @@ +# Google Plugin for HashiCorp Boundary + +This repo contains the google 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. + +As a result, it does not support rotation of the credentials since they are inferred +from the environment. + +## Dynamic Hosts + +This plugin supports dynamically sourcing instances from 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 +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. + +### Google IAM permissions + +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`. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..287e7fd --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module github.com/hashicorp/boundary-plugin-google + +go 1.21 + +require ( + 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 +) + +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 + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.5 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + 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 +) + +require ( + cloud.google.com/go/compute v1.27.3 + 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 + github.com/hashicorp/go-kms-wrapping/v2 v2.0.16 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 + 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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6b9e9fe --- /dev/null +++ b/go.sum @@ -0,0 +1,297 @@ +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= +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/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/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/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/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/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/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= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/eventlogger v0.2.9 h1:QO8hPXNngadMp72FVNTwIduLAG9fcLP7t59bSFd7gDY= +github.com/hashicorp/eventlogger v0.2.9/go.mod h1://CHt6/j+Q2lc0NlUB5af4aS2M0c0aVBg9/JfcpAyhM= +github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0 h1:iAb287bq0TaWTnhDYuN/zVqdD2EwanQg9ncVelC60Xc= +github.com/hashicorp/eventlogger/filters/encrypt v0.1.8-0.20231025104552-802587e608f0/go.mod h1:tMywUTIvdB/FXhwm6HMTt61C8/eODY6gitCHhXtyojg= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5 h1:jrnDfQm2hCQ0/hEselgqzV4fK16gpZoY0OWGZpVPNHM= +github.com/hashicorp/go-kms-wrapping/plugin/v2 v2.0.5/go.mod h1:psh1qKep5ukvuNobFY/hCybuudlkkACpmazOsCgX5Rg= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.16 h1:WZeXfD26QMWYC35at25KgE021SF9L3u9UMHK8fJAdV0= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.16/go.mod h1:ZiKZctjRTLEppuRwrttWkp71VYMbTTCkazK4xT7U/NQ= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.5.2 h1:aWv8eimFqWlsEiMrYZdPYl+FdHaBJSN4AWwGWfT1G2Y= +github.com/hashicorp/go-plugin v1.5.2/go.mod h1:w1sAEES3g3PuV/RzUrgow20W2uErMly84hhD3um1WL4= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.1/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.11 h1:uPW2Wn0YlmI9RGSkZpcIplnVRwJ7BCiGpk1vnF2TMw4= +github.com/hashicorp/go-secure-stdlib/configutil/v2 v2.0.11/go.mod h1:uis9dCmOzXuOaRyXq+1Foh31kcvXKoWogjNnhfjHfW8= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.9 h1:0S0ctJ7Ra8O7ap+/3fZUnzJ3VzJyirWS/WnNCuOYtZY= +github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.9/go.mod h1:TNNdgtjLgVDbrgFcyCKrlAicIl3dZF94swJltyGUX2M= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0= +github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.6 h1:ZYv2XA+tEfFXIToR2jmBgVqQU9gERt0APbWqmUoNGnY= +github.com/hashicorp/go-secure-stdlib/pluginutil/v2 v2.0.6/go.mod h1:ggFN8dlaLWS2R1gymBbCrvXM/bkZP7hEAa4seqDwhyg= +github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1 h1:SMGUnbpAcat8rIKHkBPjfv81yC46a8eCNZ2hsR2l1EI= +github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1/go.mod h1:Ch/bf00Qnx77MZd49JRgHYqHQjtEmTgGU2faufpVZb0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3 h1:xbrxd0U9XQW8qL1BAz2XrAjAF/P2vcqUTAues9c24B8= +github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.3/go.mod h1:LWq2Sy8UoKKuK4lFuCNWSjJj57MhNNf2zzBWMtkAIX4= +github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= +github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-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/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/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= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f h1:E87tDTVS5W65euzixn7clSzK66puSt1H4I5SC0EmHH4= +github.com/jefferai/isbadcipher v0.0.0-20190226160619-51d2077c035f/go.mod h1:3J2qVK16Lq8V+wfiL2lPeDZ7UWMxk5LemerHa1p6N00= +github.com/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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/cli v1.1.5 h1:OxRIeJXpAMztws/XHlN2vu6imG5Dpq+j61AzAX5fLng= +github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2cLtRR4= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw= +github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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/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/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= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/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/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= +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/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/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/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/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/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= +golang.org/x/tools v0.0.0-20210101214203-2dba1e4ea05c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/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= +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/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= diff --git a/internal/credential/attributes.go b/internal/credential/attributes.go new file mode 100644 index 0000000..cbfbaf9 --- /dev/null +++ b/internal/credential/attributes.go @@ -0,0 +1,44 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package credential + +import ( + "fmt" + + "github.com/hashicorp/boundary-plugin-google/internal/errors" + "github.com/hashicorp/boundary-plugin-google/internal/values" + "google.golang.org/protobuf/types/known/structpb" +) + +// CredentialAttributes contain attributes used for authenticating to Google Cloud +// and accessing a list of instances +type CredentialAttributes struct { + Project string + Zone string +} + +// GetCredentialAttributes checks attributes required by Google Cloud 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) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstProject)] = err.Error() + } + + zone, err := values.GetStringValue(in, ConstZone, true) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstZone)] = err.Error() + } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the attributes provider", badFields) + } + + return &CredentialAttributes{ + Project: project, + Zone: zone, + }, nil +} diff --git a/internal/credential/attributes_test.go b/internal/credential/attributes_test.go new file mode 100644 index 0000000..ba125ea --- /dev/null +++ b/internal/credential/attributes_test.go @@ -0,0 +1,66 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package credential + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGetCredentialAttributes(t *testing.T) { + cases := []struct { + name string + in map[string]any + expected *CredentialAttributes + expectedErrContains string + }{ + { + name: "missing project", + in: map[string]any{}, + expectedErrContains: "missing required value \"project\"", + }, + { + name: "missing zone", + in: map[string]any{}, + expectedErrContains: "missing required value \"zone\"", + }, + { + name: "valid project and zone", + in: map[string]any{ + ConstProject: "test-project", + ConstZone: "us-central-1", + }, + expected: &CredentialAttributes{ + Project: "test-project", + Zone: "us-central-1", + }, + }, + } + + 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 := GetCredentialAttributes(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.EqualValues(tc.expected.Project, actual.Project) + require.EqualValues(tc.expected.Zone, actual.Zone) + }) + } +} diff --git a/internal/credential/const.go b/internal/credential/const.go new file mode 100644 index 0000000..a56f694 --- /dev/null +++ b/internal/credential/const.go @@ -0,0 +1,12 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package credential + +const ( + // ConstProject defines the attribute name for a Google Cloud project + ConstProject = "project" + + // ConstZone defines the attribute name for a Google Cloud zone + ConstZone = "zone" +) diff --git a/internal/errors/error.go b/internal/errors/error.go new file mode 100644 index 0000000..92f8a6e --- /dev/null +++ b/internal/errors/error.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package errors + +import ( + "fmt" + "sort" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// InvalidArgumentError returns an grpc invalid argument status error. +func InvalidArgumentError(msg string, f map[string]string) error { + var fieldMsgs []string + for field, val := range f { + fieldMsgs = append(fieldMsgs, fmt.Sprintf("%s: %s", field, val)) + } + if len(fieldMsgs) > 0 { + sort.Strings(fieldMsgs) + msg = fmt.Sprintf("%s: [%s]", msg, strings.Join(fieldMsgs, ", ")) + } + return status.Error(codes.InvalidArgument, msg) +} diff --git a/internal/errors/error_test.go b/internal/errors/error_test.go new file mode 100644 index 0000000..a2bac93 --- /dev/null +++ b/internal/errors/error_test.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package errors + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInvalidArgumentError(t *testing.T) { + // No error message. + err := InvalidArgumentError("", map[string]string{ + "foo_key": "foo_value", + "bar_key": "bar_value", + "baz_key": "baz_value", + }) + require.EqualError(t, err, "rpc error: code = InvalidArgument desc = : [bar_key: bar_value, baz_key: baz_value, foo_key: foo_value]") + + // Nil fields + err = InvalidArgumentError("error message", nil) + require.EqualError(t, err, "rpc error: code = InvalidArgument desc = error message") + + // Empty fields. + err = InvalidArgumentError("error message", map[string]string{}) + require.EqualError(t, err, "rpc error: code = InvalidArgument desc = error message") + + // Everything. + err = InvalidArgumentError("error message", map[string]string{ + "foo_key": "foo_value", + "bar_key": "bar_value", + "baz_key": "baz_value", + }) + require.EqualError(t, err, "rpc error: code = InvalidArgument desc = error message: [bar_key: bar_value, baz_key: baz_value, foo_key: foo_value]") + +} diff --git a/internal/values/proto.go b/internal/values/proto.go new file mode 100644 index 0000000..552e02c --- /dev/null +++ b/internal/values/proto.go @@ -0,0 +1,123 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package values + +import ( + "fmt" + "time" + + "google.golang.org/protobuf/types/known/structpb" +) + +// GetStringValue returns a string value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a string. An error is +// returned if the parameter argument "required" is set to true +// and the value from the proto struct input is empty. +func GetStringValue(in *structpb.Struct, k string, required bool) (string, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + if required { + return "", fmt.Errorf("missing required value %q", k) + } + + return "", nil + } + + s, ok := v.AsInterface().(string) + if !ok { + return "", fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) + } + + if s == "" && required { + return "", fmt.Errorf("value %q cannot be empty", k) + } + + return s, nil +} + +// GetBoolValue returns a boolean value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a bool. +func GetBoolValue(in *structpb.Struct, k string, required bool) (bool, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + if required { + return false, fmt.Errorf("missing required value %q", k) + } + + return false, nil + } + + b, ok := v.AsInterface().(bool) + if !ok { + return false, fmt.Errorf("unexpected type for value %q: want bool, got %T", k, v.AsInterface()) + } + + return b, nil +} + +// GetTimeValue returns a time.Time value and no error if the given key +// is found in the provided proto struct input. An error is returned +// if the key is not found or the value type is not a parsable. The +// expected time layout is RFC3339Nano. +func GetTimeValue(in *structpb.Struct, k string) (time.Time, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + return time.Time{}, nil + } + + tRaw, ok := v.AsInterface().(string) + if !ok { + return time.Time{}, fmt.Errorf("unexpected type for value %q: want string, got %T", k, v.AsInterface()) + } + + t, err := time.Parse(time.RFC3339Nano, tRaw) + if err != nil { + return time.Time{}, fmt.Errorf("could not parse time in value %q: %w", k, err) + } + + return t, nil +} + +// GetMapStringString returns a map[string]string value and no error if the given key +// is found in the provided proto struct input. An error is returned if the key +// is not found or the value type is not map[string]string. +func GetMapStringString(in *structpb.Struct, k string, required bool) (map[string]string, error) { + mv := in.GetFields() + v, ok := mv[k] + if !ok { + if required { + return nil, fmt.Errorf("missing required value %q", k) + } + return nil, nil + } + result := map[string]string{} + m, ok := v.AsInterface().(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected type for value %q: want map[string]string, got %T", k, v.AsInterface()) + } + for mapKey, mapValue := range m { + e, ok := mapValue.(string) + if !ok { + return nil, fmt.Errorf("unexpected type for value in map[%q]: want string, got %T", mapKey, mapValue) + } + result[mapKey] = e + } + return result, nil +} + +// StructFields returns a map[string]struct{} of the +// proto struct input. +func StructFields(s *structpb.Struct) map[string]struct{} { + m := make(map[string]struct{}, len(s.GetFields())) + for k := range s.GetFields() { + m[k] = struct{}{} + } + + return m +} diff --git a/internal/values/proto_test.go b/internal/values/proto_test.go new file mode 100644 index 0000000..b38fdd9 --- /dev/null +++ b/internal/values/proto_test.go @@ -0,0 +1,210 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package values + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func TestGetStringValue(t *testing.T) { + cases := []struct { + name string + in map[string]any + key string + required bool + expected string + expectedErr string + }{ + { + name: "required missing", + in: map[string]any{}, + key: "foo", + required: true, + expectedErr: "missing required value \"foo\"", + }, + { + name: "optional missing", + in: map[string]any{}, + key: "foo", + expected: "", + }, + { + name: "non-string value", + in: map[string]any{ + "foo": 1, + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want string, got float64", + }, + { + name: "required empty", + in: map[string]any{ + "foo": "", + }, + key: "foo", + required: true, + expectedErr: "value \"foo\" cannot be empty", + }, + { + name: "good", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expected: "bar", + }, + } + + 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 := GetStringValue(input, tc.key, tc.required) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} + +func TestGetBoolValue(t *testing.T) { + cases := []struct { + name string + in map[string]any + key string + required bool + expected bool + expectedErr string + }{ + { + name: "required missing", + in: map[string]any{}, + key: "foo", + required: true, + expectedErr: "missing required value \"foo\"", + }, + { + name: "optional missing", + in: map[string]any{}, + key: "foo", + expected: false, + }, + { + name: "non-bool value", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want bool, got string", + }, + { + name: "good", + in: map[string]any{ + "foo": true, + }, + key: "foo", + expected: true, + }, + } + + 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 := GetBoolValue(input, tc.key, tc.required) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} + +func TestGetTimeValue(t *testing.T) { + staticTime := time.Now() + + cases := []struct { + name string + in map[string]any + key string + expected time.Time + expectedErr string + }{ + { + name: "missing", + in: map[string]any{}, + key: "foo", + expected: time.Time{}, + }, + { + name: "non-time value", + in: map[string]any{ + "foo": 1, + }, + key: "foo", + expectedErr: "unexpected type for value \"foo\": want string, got float64", + }, + { + name: "bad parse", + in: map[string]any{ + "foo": "bar", + }, + key: "foo", + expectedErr: "could not parse time in value \"foo\": parsing time \"bar\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bar\" as \"2006\"", + }, + { + name: "good", + in: map[string]any{ + "foo": staticTime.Format(time.RFC3339Nano), + }, + key: "foo", + expected: func() time.Time { + u, err := time.Parse(time.RFC3339Nano, staticTime.Format(time.RFC3339Nano)) + if err != nil { + panic(err) + } + + return u + }(), + }, + } + + 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 := GetTimeValue(input, tc.key) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} diff --git a/plugin/attributes.go b/plugin/attributes.go new file mode 100644 index 0000000..126b90d --- /dev/null +++ b/plugin/attributes.go @@ -0,0 +1,133 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plugin + +import ( + "fmt" + + 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" + "github.com/mitchellh/mapstructure" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +// CatalogAttributes defines a set of attributes for the host catalog +type CatalogAttributes struct { + *cred.CredentialAttributes +} + +func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { + unknownFields := values.StructFields(in) + badFields := make(map[string]string) + + var err error + credAttributes, err := cred.GetCredentialAttributes(in) + if err != nil { + return nil, err + } + + for s := range unknownFields { + switch s { + // Ignore knownFields from CredentialAttributes + case cred.ConstProject: + continue + case cred.ConstZone: + continue + default: + badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field" + } + } + + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Invalid arguments in catalog attributes", badFields) + } + + return &CatalogAttributes{ + CredentialAttributes: credAttributes, + }, nil +} + +// SetAttributes defines attributes fro the host set +type SetAttributes struct { + Filter string `mapstructure:"filter"` + InstanceGroup string `mapstructure:"instance_group"` +} + +func getSetAttributes(in *structpb.Struct) (*SetAttributes, error) { + var setAttrs SetAttributes + + 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" + } + if len(badFields) > 0 { + return nil, errors.InvalidArgumentError("Error in the attributes provided", badFields) + } + + // Mapstructure complains if it expects a slice as output and sees a scalar + // value. Rather than use WeakDecode and risk unintended consequences, I'm + // manually making this change if necessary. + inMap := in.AsMap() + 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) + } + } + + if err := mapstructure.Decode(inMap, &setAttrs); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "error decoding set attributes: %s", err) + } + + return &setAttrs, nil +} + +func buildListInstancesRequest(attributes *SetAttributes, catalog *CatalogAttributes) *computepb.ListInstancesRequest { + request := &computepb.ListInstancesRequest{ + Project: catalog.Project, + Zone: catalog.Zone, + } + + if len(attributes.Filter) > 1 { + request.Filter = &attributes.Filter + } + + 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 +} diff --git a/plugin/attributes_test.go b/plugin/attributes_test.go new file mode 100644 index 0000000..dfcac67 --- /dev/null +++ b/plugin/attributes_test.go @@ -0,0 +1,145 @@ +// 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) + }) + } +} diff --git a/plugin/client.go b/plugin/client.go new file mode 100644 index 0000000..0a7d20f --- /dev/null +++ b/plugin/client.go @@ -0,0 +1,128 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plugin + +import ( + "context" + "errors" + "path" + + 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" + "google.golang.org/grpc/codes" + "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) { + hosts := []*computepb.Instance{} + it := c.InstancesClient.List(c.Context, 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) + } + 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.GetName() == "" { + return nil, errors.New("response integrity error: missing instance name") + } + + result := new(pb.ListHostsResponseHost) + + result.ExternalId = instance.GetSelfLink() + result.ExternalName = instance.GetName() + + // 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 + // for the entire instance. + result.IpAddresses = appendDistinct(result.IpAddresses, iface.NetworkIP) + + for _, external := range iface.AccessConfigs { + result.IpAddresses = appendDistinct(result.IpAddresses, external.NatIP) + } + + // Add the IPv6 addresses. + result.IpAddresses = appendDistinct(result.IpAddresses, iface.Ipv6Address) + } + + // Done + return result, nil +} + +// appendDistinct will append the elements to the slice +// if an element is not nil, empty, and does not exist in slice. +func appendDistinct(slice []string, elems ...*string) []string { + for _, e := range elems { + if e == nil || *e == "" || stringInSlice(slice, *e) { + continue + } + slice = append(slice, *e) + } + return slice +} + +func stringInSlice(s []string, x string) bool { + for _, y := range s { + if x == y { + return true + } + } + return false +} diff --git a/plugin/client_test.go b/plugin/client_test.go new file mode 100644 index 0000000..6834cab --- /dev/null +++ b/plugin/client_test.go @@ -0,0 +1,148 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plugin + +import ( + "testing" + + "cloud.google.com/go/compute/apiv1/computepb" + 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" + examplePrivateIp := "10.0.0.1" + examplePrivateIp2 := "10.0.0.2" + examplePublicIp := "1.1.1.1" + examplePublicIp2 := "1.1.1.2" + exampleIPv6 := "some::fake::address" + exampleName := "test-instance" + + cases := []struct { + name string + instance *computepb.Instance + expected *pb.ListHostsResponseHost + expectedErr string + }{ + { + name: "missing instance self-link", + instance: &computepb.Instance{ + Name: &exampleName, + }, + expectedErr: "response integrity error: missing instance self-link", + }, + { + name: "missing instance name", + instance: &computepb.Instance{ + SelfLink: &exampleId, + }, + expectedErr: "response integrity error: missing instance name", + }, + { + name: "good, single private IP with public IP address", + instance: &computepb.Instance{ + SelfLink: &exampleId, + Name: &exampleName, + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: &examplePrivateIp, + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: &examplePublicIp, + }, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: exampleId, + ExternalName: exampleName, + IpAddresses: []string{examplePrivateIp, examplePublicIp}, + }, + }, + { + name: "good, single private IP address", + instance: &computepb.Instance{ + SelfLink: &exampleId, + Name: &exampleName, + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: &examplePrivateIp, + AccessConfigs: []*computepb.AccessConfig{}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: exampleId, + ExternalName: exampleName, + IpAddresses: []string{examplePrivateIp}, + }, + }, + { + name: "good, multiple interfaces", + instance: &computepb.Instance{ + SelfLink: &exampleId, + Name: &exampleName, + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: &examplePrivateIp, + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: &examplePublicIp, + }, + }, + }, + { + NetworkIP: &examplePrivateIp2, + AccessConfigs: []*computepb.AccessConfig{ + { + NatIP: &examplePublicIp2, + }, + }, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: exampleId, + ExternalName: exampleName, + IpAddresses: []string{examplePrivateIp, examplePublicIp, examplePrivateIp2, examplePublicIp2}, + }, + }, + { + name: "good, single private IP address with IPv6", + instance: &computepb.Instance{ + SelfLink: &exampleId, + Name: &exampleName, + NetworkInterfaces: []*computepb.NetworkInterface{ + { + NetworkIP: &examplePrivateIp, + Ipv6Address: &exampleIPv6, + AccessConfigs: []*computepb.AccessConfig{}, + }, + }, + }, + expected: &pb.ListHostsResponseHost{ + ExternalId: exampleId, + ExternalName: exampleName, + IpAddresses: []string{examplePrivateIp, exampleIPv6}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + actual, err := instanceToHost(tc.instance) + if tc.expectedErr != "" { + require.EqualError(err, tc.expectedErr) + return + } + + require.NoError(err) + require.Equal(tc.expected, actual) + }) + } +} diff --git a/plugin/const.go b/plugin/const.go new file mode 100644 index 0000000..fc5af9d --- /dev/null +++ b/plugin/const.go @@ -0,0 +1,17 @@ +// 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: {}, +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..8005d83 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,285 @@ +// Copyright (c) HashiCorp, Inc. +// 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" + 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 +} + +var ( + _ pb.HostPluginServiceServer = (*GooglePlugin)(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 +} + +// 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") + } + + 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 +} diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go new file mode 100644 index 0000000..e2bc4d4 --- /dev/null +++ b/plugin/plugin_test.go @@ -0,0 +1,701 @@ +// 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) + }) + } +} diff --git a/testing/terraform/main.tf b/testing/terraform/main.tf new file mode 100644 index 0000000..6448713 --- /dev/null +++ b/testing/terraform/main.tf @@ -0,0 +1,66 @@ +# 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] +} \ No newline at end of file diff --git a/testing/terraform/output.tf b/testing/terraform/output.tf new file mode 100644 index 0000000..4e37251 --- /dev/null +++ b/testing/terraform/output.tf @@ -0,0 +1,8 @@ +# 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" +} \ No newline at end of file diff --git a/testing/terraform/providers.tf b/testing/terraform/providers.tf new file mode 100644 index 0000000..eb48707 --- /dev/null +++ b/testing/terraform/providers.tf @@ -0,0 +1,15 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.22.0" + } + } +} + +provider "google" { + region = var.region +} \ No newline at end of file diff --git a/testing/terraform/variables.tf b/testing/terraform/variables.tf new file mode 100644 index 0000000..4ce8560 --- /dev/null +++ b/testing/terraform/variables.tf @@ -0,0 +1,29 @@ +# 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 +} \ No newline at end of file