pax_global_header00006660000000000000000000000064152414103640014512gustar00rootroot0000000000000052 comment=1adda70d2503a0f849a971cc6d14f79a302f2b36 google-guest-agent-20260819.00/000077500000000000000000000000001524141036400157025ustar00rootroot00000000000000google-guest-agent-20260819.00/CONTRIBUTING.md000066400000000000000000000057561524141036400201500ustar00rootroot00000000000000# How to become a contributor and submit your own code ## Before you begin ### Sign our Contributor License Agreement Contributions to this project must be accompanied by a [Contributor License Agreement](https://cla.developers.google.com/about) (CLA). You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project. If you or your current employer have already signed the Google CLA (even if it was for a different project), you probably don't need to do it again. Visit to see your current agreements or to sign a new one. ### Review our community guidelines This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). ## Contributing a patch 1. Submit an issue describing your proposed change to the repo in question. 1. The repo owner will respond to your issue promptly. 1. If your proposed change is accepted, and you haven't already done so, sign a Contributor License Agreement (see details above). 1. Fork the desired repo, develop and test your code changes. 1. Ensure that your code adheres to the existing style in the sample to which you are contributing. Refer to the [Google Cloud Platform Samples Style Guide] (https://github.com/GoogleCloudPlatform/Template/wiki/style.html) for the recommended coding standards for this organization. 1. Ensure that your code has an appropriate set of unit tests which all pass. 1. Submit a pull request. ## Contributing a new sample App 1. Submit an issue to the `GoogleCloudPlatform/Template` repo describing your proposed sample app. 1. The Template repo owner will respond to your enhancement issue promptly. Instructional value is the top priority when evaluating new app proposals for this collection of repos. 1. If your proposal is accepted, and you haven't already done so, sign a Contributor License Agreement (see details above). 1. Create your own repo for your app following this naming convention: * {product}-{app-name}-{language} * products: appengine, compute, storage, bigquery, prediction, cloudsql * example: appengine-guestbook-python * For multi-product apps, concatenate the primary products, like this: compute-appengine-demo-suite-python. * For multi-language apps, concatenate the primary languages like this: appengine-sockets-python-java-go. 1. Clone the `README.md`, `CONTRIB.md` and `LICENSE` files from the GoogleCloudPlatform/Template repo. 1. Ensure that your code adheres to the existing style in the sample to which you are contributing. Refer to the [Google Cloud Platform Samples Style Guide] (https://github.com/GoogleCloudPlatform/Template/wiki/style.html) for the recommended coding standards for this organization. 1. Ensure that your code has an appropriate set of unit tests which all pass. 1. Submit a request to fork your repo in GoogleCloudPlatform organization via your proposal issue. google-guest-agent-20260819.00/LICENSE000066400000000000000000000261341524141036400167150ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.google-guest-agent-20260819.00/Makefile000066400000000000000000000021171524141036400173430ustar00rootroot00000000000000# build/debugging ifeq ($(V),1) Q := else Q := @ endif include build/Makefile.gobin clean: clean-go-binaries clean-pbgo clean-binpb build: build-go-binaries gen: gen-binpb gen-pbgo test: go-unit-tests check: go-lint help: $(Q)echo "VARIABLES:" $(Q)echo " ARTIFACTS - Destination of test result files when invoking \"test\" target, a temporary" $(Q)echo " directory will be created if the variable is not set." $(Q)echo " V - Runs the build system in verbose mode i.e. V=1 make" $(Q)echo " VERSION - Sets the main.version flag for all binaries, defaults to \"dev\"" $(Q)echo " " $(Q)echo "GENERAL TARGETS:" $(Q)echo " build - Builds all binary artifacts" $(Q)echo " check - Runs linters and code checks" $(Q)echo " clean - Cleans up binaries and generated code" $(Q)echo " gen - Generates binpb and pb.go files" $(Q)echo " help - Prints this help message" $(Q)echo " test - Runs all unit tests" .PHONY: $(PHONY) .DEFAULT_GOAL = build google-guest-agent-20260819.00/README.md000066400000000000000000000015251524141036400171640ustar00rootroot00000000000000[Public Cloud Docs](https://docs.cloud.google.com/compute/docs/images/guest-agent) ## Guest Agent for Google Compute Engine. This repository contains the source code and packaging artifacts for the Google guest agent. These components are installed on Windows and Linux GCE VMs in order to enable GCE platform features. ## Building Guest Agent In the codebase there's a GNU Make based build system with targets to build and test the guest agent. In order to build both the `guest-agent` and `core_plugins`, run ```shell make ``` To build only one or the other, run the respective `make` target: ```shell make build cmd/google_guest_agent/google_guest_agent ``` ```shell make build cmd/core_plugin/core_plugin ``` To run all the go tests, run: ```shell make test ``` For more targets and info about the build system run: ```shell make help ```google-guest-agent-20260819.00/build/000077500000000000000000000000001524141036400170015ustar00rootroot00000000000000google-guest-agent-20260819.00/build/Makefile.gobin000066400000000000000000000126061524141036400215430ustar00rootroot00000000000000PBGO_FILES = \ cmd/core_plugin/agentcrypto/proto/guest_credentials.pb.go \ cmd/core_plugin/snapshot/proto/snapshot_service.pb.go \ internal/acp/proto/agent_controlplane.pb.go \ cmd/mwlid_extension/proto/mwlid/workload_identity.pb.go \ pkg/proto/plugin_comm.pb.go GO_BINARIES = \ cmd/core_plugin/core_plugin \ cmd/ggactl/ggactl_plugin \ cmd/google_authorized_keys/google_authorized_keys \ cmd/google_guest_agent/google_guest_agent \ cmd/google_guest_compat_manager/google_guest_compat_manager \ cmd/gce_metadata_script_runner/gce_metadata_script_runner \ cmd/metadata_script_runner_compat/gce_compat_metadata_script_runner \ cmd/google_authorized_keys_compat/google_authorized_keys_compat \ cmd/guest_telemetry_extension/guest_telemetry \ cmd/mwlid_extension/mwlid_extension LINUX_BINPB_FILES = \ build/configs/usr/lib/google/guest_agent/GuestAgentCorePlugin/manifest.binpb \ build/configs/usr/lib/google/guest_agent/GuestTelemetryExtension/manifest.binpb \ build/configs/usr/lib/google/guest_agent/ManagedWorkloadIdentityExtension/manifest.binpb WINDOWS_BINPB_FILES = \ build/configs/usr/lib/google/guest_agent/GuestAgentCorePlugin/manifest.windows.binpb \ build/configs/usr/lib/google/guest_agent/GuestTelemetryExtension/manifest.windows.binpb \ build/configs/usr/lib/google/guest_agent/ManagedWorkloadIdentityExtension/manifest.windows.binpb GO_TPM_TOOLS_URL = https://github.com/google/go-tpm-tools MWLID_PROTO_URL = https://github.com/GoogleCloudPlatform/managed-workload-identity GOOS ?= "linux" VERSION ?= "dev" # Create a temporary directory if ARTIFACTS is not provided. ifeq ($(ARTIFACTS),) ARTIFACTS_DIR = $(shell mktemp -d) else ARTIFACTS_DIR = "$(ARTIFACTS)" endif fetch-go-tpm-tools: $(Q)echo "Making proto dependencies dir..." $(Q)mkdir -p proto_deps $(Q)echo "Cloning/updating go-tpm-tools..." $(Q)git -C proto_deps/go-tpm-tools pull 2> /dev/null || \ git clone $(GO_TPM_TOOLS_URL) proto_deps/go-tpm-tools fetch-mwlid-proto: $(Q)echo "Making proto dependencies dir..." $(Q)mkdir -p proto_deps $(Q)echo "Cloning/updating mwlid-proto..." $(Q)git -C proto_deps/mwlid pull 2> /dev/null || \ git clone $(MWLID_PROTO_URL) proto_deps/mwlid $(Q)mkdir -p cmd/mwlid_extension/proto/mwlid $(Q)cp proto_deps/mwlid/proto/v1/workload-identity.proto cmd/mwlid_extension/proto/mwlid/workload_identity.proto SOURCE = $(subst .pb.go,.proto,$(@)) FILENAME = $(notdir $(SOURCE)) # The conditional -M flag. # This variable will be set to a value only for workload_identity.proto as this # proto is not managed within guest-agent repo and needs to be compiled in # separate directory. This is no-op for all other protos. M_FLAG = $(if $(filter workload_identity.proto,$(FILENAME)),--go_opt=M$(FILENAME)=/mwlid --go-grpc_opt=M$(FILENAME)=/mwlid,) $(PBGO_FILES): fetch-go-tpm-tools fetch-mwlid-proto $(Q)echo "[GEN] $(SOURCE)" $(Q)protoc --go_out=$(dir $(@)) --go-grpc_out=$(dir $(@)) --proto_path=proto_deps \ --experimental_allow_proto3_optional --proto_path=$(dir $(@)) $(M_FLAG) $(SOURCE) print-go-env: $(Q)echo "golang build environment:" $(Q)echo "#########################" $(Q)echo " GOOS=$(GOOS)" $(Q)echo " VERSION=$(VERSION)" $(Q)echo "#########################" $(GO_BINARIES): print-go-env $(Q)echo "[BIN] $(@)" $(Q)cd $(dir $(@)) && go build -o $(@F) -ldflags "-X main.version=$(VERSION)" gen-pbgo: $(PBGO_FILES) gen-binpb: $(LINUX_BINPB_FILES) $(WINDOWS_BINPB_FILES) TXTTOBIN = $(subst .binpb,.txtpb,$(@)) $(LINUX_BINPB_FILES) $(WINDOWS_BINPB_FILES): $(Q)echo "[GEN] $(@)" $(Q)cat $(TXTTOBIN) | protoc --encode agent_controlplane.ConfigurePluginStates.ConfigurePlugin internal/acp/proto/agent_controlplane.proto > $(@) build-go-binaries: $(GO_BINARIES) go-unit-tests: $(PBGO_FILES) $(BINPB_FILES) $(Q)echo "Running go unit tests..." $(Q)$(eval DEST := $(ARTIFACTS_DIR)) $(Q)go test -v -coverprofile=$(DEST)/coverage.out ./... | tee -a $(DEST)/go-test.log $(Q)cat $(DEST)/go-test.log | go-junit-report > $(DEST)/junit.xml $(Q)echo "Artifacts written to: $(DEST)" clean-pbgo: $(Q)echo "Cleaning up all generated pb.go and _grpc.pb.go files..." $(Q)find . -type f \( -name "*.pb.go" -o -name "*_grpc.pb.go" \) -not -path "./vendor/*" -not -path "./cmd/guest_telemetry_extension/*" \ -exec echo "[CLEAN] {}" \; \ -delete $(Q)rm -Rf proto_deps clean-go-binaries: $(Q)echo "Cleaning up go binaries..." $(Q)rm -f $(GO_BINARIES) clean-binpb: $(Q)echo "Cleaning up binpb files..." $(Q)rm -f $(LINUX_BINPB_FILES) $(WINDOWS_BINPB_FILES) # go-lint runs golint on all packages but ignores proto_deps as it contains # generated code and third party dependencies. go-lint: init-git $(PBGO_FILES) check-protos $(Q)echo "Running golint..." $(Q)go list ./... | grep -v proto_deps | xargs golint -set_exit_status $(Q)echo "Running gofmt..." $(Q)find . -name '*.go' -not -path "./proto_deps/*" -not -path "*.pb.go" | \ xargs gofmt -d $(Q)echo "Running go vet..." $(Q)go list ./... | grep -v proto_deps | xargs go vet --structtag=false init-git: $(Q)echo "Initializing git repository..." $(Q)git init && git add . $(Q)git diff-index --quiet HEAD || git commit -m "Baseline" check-protos: $(Q)echo "Checking for uncommitted proto changes..." $(Q)if git status --porcelain | grep ".pb.go$$"; then \ echo "Error: The proto files above are out of sync. Re-generate them and commit the changes."; \ git --no-pager diff; \ exit 1; \ else \ echo "Proto files are clean."; \ fi PHONY += $(GO_BINARIES) $(PBGO_FILES)google-guest-agent-20260819.00/build/configs/000077500000000000000000000000001524141036400204315ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/000077500000000000000000000000001524141036400212425ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/lib/000077500000000000000000000000001524141036400220105ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/lib/google/000077500000000000000000000000001524141036400232645ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/lib/google/guest_agent/000077500000000000000000000000001524141036400255715ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/lib/google/guest_agent/GuestAgentCorePlugin/000077500000000000000000000000001524141036400316275ustar00rootroot00000000000000manifest.binpb000077500000000000000000000001501524141036400343710ustar00rootroot00000000000000google-guest-agent-20260819.00/build/configs/usr/lib/google/guest_agent/GuestAgentCorePluginT GuestAgentCorePlugin" /dev/null || true systemctl restart 'google-guest-agent.service' > /dev/null || true figoogle-guest-agent-20260819.00/build/packaging/debian/prerm000066400000000000000000000003661524141036400232240ustar00rootroot00000000000000#!/bin/sh -e if [ "$1" = "purge" -o "$1" = "remove" ] ; then systemctl stop google-guest-agent >/dev/null 2>&1 || : ggactl_plugin coreplugin stop >/dev/null 2>&1 || : ggactl_plugin dynamic-cleanup >/dev/null 2>&1 || : fi #DEBHELPER#google-guest-agent-20260819.00/build/packaging/googet/000077500000000000000000000000001524141036400222115ustar00rootroot00000000000000google-guest-agent-20260819.00/build/packaging/googet/agent_install.ps1000066400000000000000000000042771524141036400254740ustar00rootroot00000000000000# Copyright 2017 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. $name = 'GCEAgent' $path = '"C:\Program Files\Google\Compute Engine\agent\GCEWindowsAgent.exe"' $display_name = 'Google Compute Engine Agent' $description = 'Google Compute Engine Agent' $initial_config = @' # GCE Instance Configuration # For details on what can be configured, see: # https://cloud.google.com/compute/docs/instances/windows/creating-managing-windows-instances#configure-windows-features # [accountManager] # disable=false # [addressManager] # disable=false '@ function Set-ServiceConfig { # Restart service after 1s, then 2s. Reset error counter after 60s. sc.exe failure $name reset= 60 actions= restart/1000/restart/2000 # Set dependency and delayed start cmd.exe /c "sc.exe config ${name} depend= `"samss`" start= delayed-auto binpath= \`"${path}\`"" # Create trigger to start the service on first IP address sc.exe triggerinfo $name start/networkon } try { if (-not (Get-Service $name -ErrorAction SilentlyContinue)) { New-Service -Name $name ` -DisplayName $display_name ` -BinaryPathName $path ` -StartupType Automatic ` -Description $description } else { Set-Service -Name $name ` -DisplayName $display_name ` -Description $description } Set-ServiceConfig $config = "${env:ProgramFiles}\Google\Compute Engine\instance_configs.cfg" if (-not (Test-Path $config)) { $initial_config | Set-Content -Path $config -Encoding ASCII } Restart-Service $name -Verbose } catch { Write-Output $_.InvocationInfo.PositionMessage Write-Output "Install failed: $($_.Exception.Message)" exit 1 }google-guest-agent-20260819.00/build/packaging/googet/agent_uninstall.ps1000066400000000000000000000012011524141036400260170ustar00rootroot00000000000000# Copyright 2017 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. Stop-Service GCEAgent -Verbose & sc.exe delete GCEAgentgoogle-guest-agent-20260819.00/build/packaging/googet/metadata_scripts_install.ps1000066400000000000000000000040441524141036400277150ustar00rootroot00000000000000# Copyright 2017 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. $install_dir = "${env:ProgramFiles}\Google\Compute Engine\metadata_scripts" $machine_env = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' $path = (Get-ItemProperty $machine_env).Path if ($path -notlike "*${install_dir}*") { Set-ItemProperty $machine_env -Name 'Path' -Value ($path + ";${install_dir}") } $run_startup_scripts = "${install_dir}\run_startup_scripts.cmd" $service = New-Object -ComObject('Schedule.Service') $service.Connect() $task = $service.NewTask(0) $task.Settings.Enabled = $true $task.Settings.AllowDemandStart = $true $task.Settings.Priority = 5 $action = $task.Actions.Create(0) $action.Path = "`"$run_startup_scripts`"" $trigger = $task.Triggers.Create(8) $folder = $service.GetFolder('\') $folder.RegisterTaskDefinition('GCEStartup',$task,6,'System',$null,5) | Out-Null $gpt_ini = "${env:SystemRoot}\System32\GroupPolicy\gpt.ini" $scripts_ini = "${env:SystemRoot}\System32\GroupPolicy\Machine\Scripts\scripts.ini" if ((Test-Path $gpt_ini) -or (Test-Path $scripts_ini)) { return } New-Item -Type Directory -Path "${env:SystemRoot}\System32\GroupPolicy\Machine\Scripts" -ErrorAction SilentlyContinue @' [General] gPCMachineExtensionNames= [{42B5FAAE-6536-11D2-AE5A-0000F87571E3}{40B6664F-4972-11D1-A7CA-0000F87571E3}] Version=1 '@ | Set-Content -Path $gpt_ini -Encoding ASCII @' [Shutdown] 0CmdLine=C:\Program Files\Google\Compute Engine\metadata_scripts\run_shutdown_scripts.cmd 0Parameters= '@ | Set-Content -Path $scripts_ini -Encoding ASCIIgoogle-guest-agent-20260819.00/build/packaging/googet/metadata_scripts_uninstall.ps1000066400000000000000000000016741524141036400302660ustar00rootroot00000000000000# Copyright 2017 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. $install_dir = "${env:ProgramFiles}\Google\Compute Engine\metadata_scripts" $machine_env = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' $path = (Get-ItemProperty $machine_env).Path if ($path -like "*${install_dir}*") { Set-ItemProperty $machine_env -Name 'Path' -Value $path.Replace(";$install_dir", '') } & schtasks /delete /tn GCEStartup /fgoogle-guest-agent-20260819.00/build/packaging/googet/run_shutdown_scripts.cmd000066400000000000000000000014351524141036400272070ustar00rootroot00000000000000@echo off REM Copyright 2015 Google LLC REM REM Licensed under the Apache License, Version 2.0 (the "License"); REM you may not use this file except in compliance with the License. REM You may obtain a copy of the License at REM REM https://www.apache.org/licenses/LICENSE-2.0 REM REM Unless required by applicable law or agreed to in writing, software REM distributed under the License is distributed on an "AS IS" BASIS, REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. REM See the License for the specific language governing permissions and REM limitations under the License. REM Run shutdown scripts that should happen as soon as the instance REM begins to power down "C:\Program Files\Google\Compute Engine\agent\ggactl.exe" "guestagent" "vmevent" "shutdown"google-guest-agent-20260819.00/build/packaging/googet/run_startup_scripts.cmd000066400000000000000000000017031524141036400270340ustar00rootroot00000000000000@echo off REM Copyright 2015 Google LLC REM REM Licensed under the Apache License, Version 2.0 (the "License"); REM you may not use this file except in compliance with the License. REM You may obtain a copy of the License at REM REM https://www.apache.org/licenses/LICENSE-2.0 REM REM Unless required by applicable law or agreed to in writing, software REM distributed under the License is distributed on an "AS IS" BASIS, REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. REM See the License for the specific language governing permissions and REM limitations under the License. REM Run startup scripts that should happen late at boot. REM A scheduled task may only run for up to three days before termination. REM We execute the startup script asynchronously so it may run without REM this three day maximum runtime limitation. start "" "C:\Program Files\Google\Compute Engine\agent\ggactl.exe" "guestagent" "vmevent" "startup"google-guest-agent-20260819.00/build/packaging/rpm/000077500000000000000000000000001524141036400215235ustar00rootroot00000000000000google-guest-agent-20260819.00/build/packaging/rpm/google-guest-agent.spec000066400000000000000000000102551524141036400260770ustar00rootroot00000000000000# Copyright 2023 Google Inc. # - pre processed rpm header, intended for automation use. %define debug_package %{nil} %if ! 0%{?prebuilt} Name: google-guest-agent Version: %{_version} Release: g1%{?dist} Source0: %{name}_%{version}.orig.tar.gz BuildArch: %{_arch} %endif Epoch: 2 Summary: Google Compute Engine guest agent. License: ASL 2.0 Url: https://cloud.google.com/compute/docs/images/guest-environment Vendor: Google, Inc. Requires: google-compute-engine-oslogin >= 1:20231003 Obsoletes: python-google-compute-engine, python3-google-compute-engine %description Contains the Google guest agent binary. %if ! 0%{?prebuilt} %prep %autosetup %build ./build/build.sh --version=%{_version} %install install -d %{buildroot}%{_bindir} install -p -m 0644 build/configs/etc/default/instance_configs.cfg %{buildroot}%/usr/share/google-guest-agent/instance_configs.cfg install -p -m 0644 build/configs/usr/lib/systemd/system/google-guest-agent.service %{buildroot}%/usr/lib/systemd/system/google-guest-agent.service install -p -m 0644 build/configs/usr/lib/systemd/system/google-guest-agent.service %{buildroot}%/usr/lib/systemd/system/google-startup-scripts.service install -p -m 0644 build/configs/usr/lib/systemd/system/google-guest-agent.service %{buildroot}%/usr/lib/systemd/system/google-shutdown-scripts.service install -p -m 0755 cmd/google_guest_agent/google_guest_agent %{buildroot}%{_bindir}/google_guest_agent install -p -m 0755 cmd/google_guest_agent/ggactl %{buildroot}%{_bindir}/ggactl install -p -m 0755 cmd/core_plugin/core_plugin %{buildroot}%{_libdir}/google/guest_agent/GuestAgentCorePlugin/core_plugin install -p -m 0644 build/configs/usr/lib/google/guest_agent/GuestAgentCorePlugin/manifest.linux.binpb %{buildroot}%/usr/lib/google/guest_agent/GuestAgentCorePlugin/manifest.binpb install -p -m 0755 cmd/guest_telemetry_extension/guest_telemetry %{buildroot}%{_libdir}/google/guest_agent/GuestTelemetryExtension/guest_telemetry install -p -m 0644 build/configs/usr/lib/google/guest_agent/GuestTelemetryExtension/manifest.binpb %{buildroot}%/usr/lib/google/guest_agent/GuestTelemetryExtension/manifest.binpb %files /etc/default/instance_configs.cfg /usr/lib/systemd/system/google-guest-agent.service /usr/lib/systemd/system/google-startup-scripts.service /usr/lib/systemd/system/google-shutdown-scripts.service %{_bindir}/google_guest_agent %{_bindir}/ggactl %{_libdir}/google/guest_agent/GuestAgentCorePlugin/core_plugin %{_libdir}/google/guest_agent/GuestAgentCorePlugin/manifest.binpb %{_libdir}/google/guest_agent/GuestTelemetryExtension/guest_telemetry %{_libdir}/google/guest_agent/GuestTelemetryExtension/manifest.binpb %endif %post # Initial installation if [ $1 -eq 1 ]; then # Install instance configs if not already present. if [ ! -f /etc/default/instance_configs.cfg ]; then cp -a /usr/share/google-guest-agent/instance_configs.cfg /etc/default/ fi # Use enable instead of preset because preset is not supported in # chroots. systemctl enable google-guest-agent.service >/dev/null 2>&1 || : systemctl enable google-startup-scripts.service >/dev/null 2>&1 || : systemctl enable google-shutdown-scripts.service >/dev/null 2>&1 || : if [ -d /run/systemd/system ]; then systemctl daemon-reload >/dev/null 2>&1 || : systemctl start google-guest-agent.service >/dev/null 2>&1 || : fi else # Package upgrade if [ -d /run/systemd/system ]; then systemctl try-restart google-guest-agent.service >/dev/null 2>&1 || : fi fi %preun if [ $1 -eq 0 ]; then # Package removal, not upgrade systemctl --no-reload disable google-guest-agent.service >/dev/null 2>&1 || : systemctl --no-reload disable google-startup-scripts.service >/dev/null 2>&1 || : systemctl --no-reload disable google-shutdown-scripts.service >/dev/null 2>&1 || : if [ -d /run/systemd/system ]; then systemctl stop google-guest-agent.service >/dev/null 2>&1 || : fi fi %postun if [ $1 -eq 0 ]; then # Package removal, not upgrade if [ -f /etc/default/instance_configs.cfg ]; then rm /etc/default/instance_configs.cfg fi if [ -d /run/systemd/system ]; then systemctl daemon-reload >/dev/null 2>&1 || : fi fi # - pre processed rpm footer, intended for automation use.google-guest-agent-20260819.00/build/tools/000077500000000000000000000000001524141036400201415ustar00rootroot00000000000000google-guest-agent-20260819.00/build/tools/gen-rpm-spec.sh000077500000000000000000000031121524141036400227720ustar00rootroot00000000000000#!/bin/bash # This script is used to generate the rpm spec file for the guest agent. # The scripts creates an adaptation layer between the publicly visible rpm spec # and the internal genrpm spec. # # The adaptation layer allows us to use blaze built artifacts rather than having' # the rpmbuild process actually build the artifacts - at the same time that the # same spec file can be used for both internal and external builds. # # We introduce a prebuilt variable exposed to the rpm spec so that one can guard # parts of the spec file that are not relevant for the internal build. # # It takes two arguments: # # 1. The template file to use (the .spec file in the packaging directory in #. the codebase); # 2. The output file to write to; # # The template file will have the following replacements made: # 1. A header that adds the genrpm's required macro add (they are applied so # we can use blaze's built artifacts - rather than having the rpmbuild # process actually building the artifacts) # 2. A footer that includes the %install and %files section (from genrpm's # macro) # # Example usage: # ./gen-rpm-spec.sh template.spec output-gen.spec set -e template=$1 output=$2 if [ ! -f $template ]; then echo "Template file not found: $template" exit 1 fi if [ ! -d $(dirname $output) ]; then echo "Output directory found: $output" exit 1 fi header=" %include %build_rpm_options %define prebuilt 1 " footer=" %install %include %build_rpm_install %files %include %build_rpm_files " echo "${header}" >> $output cat $template >> $output echo "${footer}" >> $outputgoogle-guest-agent-20260819.00/cmd/000077500000000000000000000000001524141036400164455ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/acs_cli/000077500000000000000000000000001524141036400200425ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/acs_cli/cli.go000066400000000000000000000177771524141036400211630ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main implements ACS CLI for testing dynamic plugins manually. package main import ( "bufio" "context" "flag" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "time" "github.com/GoogleCloudPlatform/galog" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/testserver" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" dpb "google.golang.org/protobuf/types/known/durationpb" ) const ( // galogShutdownTimeout is the period of time we should wait for galog to // shutdown. galogShutdownTimeout = time.Second ) var ( // archiveFile is the plugin archive that Guest Agent will be served for // downloading/installing a dynamic plugin. Normally this is served by GCS // signed URL but for local testing CLI stands up a HTTP server which serves // the plugin archive for download. archiveFile = flag.String("archive_file", "", "Path to the plugin archive file") // cliLogFile is the log file where Guest Agent sent messages will be captured. // These are agent sent messages on ACS channel. cliLogFile = flag.String("logfile", filepath.Join(os.TempDir(), "acs_cli.log"), "Path to the cli log file") // acsHost is the address to start test ACS server on. Make sure // agent [instance_configs.cfg] has a [ACS] section with [host] option set // with this same address to make sure Guest is communicating on same // overridden address. acsHost = flag.String("acs_host", filepath.Join(os.TempDir(), "acs_host.sock"), "Path to the ACS address") ) // serveArchive starts a [httptest.Server] which serves the plugin archive. func serveArchive() (*httptest.Server, error) { bytes, err := os.ReadFile(*archiveFile) if err != nil { return nil, fmt.Errorf("os.ReadFile(%s) failed: %w", *archiveFile, err) } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(bytes) })) return ts, nil } // captureLogs captures agent sent messages. Periodically it looks for any // new messages on the channel and writes to a [cliLogFile] if found. func captureLogs(ctx context.Context, s *testserver.Server) { ticker := time.NewTicker(time.Second * 5) defer ticker.Stop() read := 0 for { select { case <-ctx.Done(): return case <-ticker.C: msgs := s.AgentSentMessages() write := msgs[read:] for _, msg := range write { galog.Info(msg.String()) } read = len(msgs) } } } // requireFlags checks if any of the required flags is unset. func requireFlags() { switch "" { case *archiveFile: galog.Fatal("-archive_file flag is not set") case *cliLogFile: galog.Fatal("-logfile flag is not set") case *acsHost: galog.Fatal("-acs_host flag is not set") } } func main() { flag.Parse() ctx, cancel := context.WithCancel(context.Background()) defer cancel() logOpts := logger.Options{ Ident: filepath.Base(os.Args[0]), LogFile: *cliLogFile, Level: 4, } if err := logger.Init(ctx, logOpts); err != nil { fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) os.Exit(1) } defer galog.Shutdown(galogShutdownTimeout) requireFlags() if err := cfg.Load(nil); err != nil { galog.Fatalf("Failed to load Guest Agent configuration: %v", err) } cksum, err := file.SHA256FileSum(*archiveFile) if err != nil { galog.Fatalf("Failed to compute SHA256 hash of plugin archive: %v", err) } server, err := serveArchive() if err != nil { galog.Fatalf("Failed to serve plugin archive via httptest.Server: %v", err) } defer server.Close() url := server.URL galog.Infof("Serving plugin archive on: %s", url) s := testserver.NewTestServer(*acsHost) if err := s.Start(); err != nil { galog.Fatalf("Failed to start test ACS server on %s: %v", *acsHost, err) } // Start capturing logs periodically. This go routine exits when context is // cancelled. go captureLogs(ctx, s) reader := bufio.NewReader(os.Stdin) for { fmt.Print("Enter command(install, remove, apply , or list): ") text, err := reader.ReadString('\n') if err != nil { galog.Fatalf("Failed to read command: %v", err) } text = strings.TrimSpace(text) inputs := strings.Split(text, " ") var config string if len(inputs) == 2 { config = inputs[1] } switch inputs[0] { case "install": install(s, url, cksum) case "remove": remove(s) case "list": list(s) case "apply": apply(s, config) default: fmt.Println("Unknown command:", text) } } } func apply(s *testserver.Server, config string) { req := &acmpb.ConfigurePluginStates{ ConfigurePlugins: []*acmpb.ConfigurePluginStates_ConfigurePlugin{ &acmpb.ConfigurePluginStates_ConfigurePlugin{ Action: acmpb.ConfigurePluginStates_APPLY, Plugin: &acmpb.ConfigurePluginStates_Plugin{ Name: "test_plugin", RevisionId: "1", }, Manifest: &acmpb.ConfigurePluginStates_Manifest{ Config: &acmpb.ConfigurePluginStates_Manifest_StringConfig{StringConfig: config}, }, }, }, } if config == "" { req.GetConfigurePlugins()[0].GetManifest().Config = nil } labels := map[string]string{"message_type": "agent_controlplane.ConfigurePluginStates"} if err := s.SendToAgent(req, labels); err != nil { galog.Errorf("Failed to send ConfigurePluginStates apply request: %v", err) } } // list sends [ListPluginStates] request to Guest Agent. func list(s *testserver.Server) { req := &acmpb.ListPluginStates{} labels := map[string]string{"message_type": "agent_controlplane.ListPluginStates"} if err := s.SendToAgent(req, labels); err != nil { galog.Errorf("Failed to send ListPluginStates request: %v", err) } } // remove sends [ConfigurePluginStates] remove request to Guest Agent. func remove(s *testserver.Server) { req := &acmpb.ConfigurePluginStates{ ConfigurePlugins: []*acmpb.ConfigurePluginStates_ConfigurePlugin{ &acmpb.ConfigurePluginStates_ConfigurePlugin{ Action: acmpb.ConfigurePluginStates_REMOVE, Plugin: &acmpb.ConfigurePluginStates_Plugin{ Name: "test_plugin", RevisionId: "1", }}, }, } labels := map[string]string{"message_type": "agent_controlplane.ConfigurePluginStates"} if err := s.SendToAgent(req, labels); err != nil { galog.Errorf("Failed to send ConfigurePluginStates remove request: %v", err) } } // install sends [ConfigurePluginStates] install request to Guest Agent. func install(s *testserver.Server, url string, cksum string) { req := &acmpb.ConfigurePluginStates{ ConfigurePlugins: []*acmpb.ConfigurePluginStates_ConfigurePlugin{ &acmpb.ConfigurePluginStates_ConfigurePlugin{ Action: acmpb.ConfigurePluginStates_INSTALL, Plugin: &acmpb.ConfigurePluginStates_Plugin{ Name: "test_plugin", RevisionId: "1", GcsSignedUrl: url, EntryPoint: "basic_plugin", Checksum: cksum, }, Manifest: &acmpb.ConfigurePluginStates_Manifest{ DownloadTimeout: &dpb.Duration{Seconds: 60}, DownloadAttemptCount: 2, StartTimeout: &dpb.Duration{Seconds: 30}, StartAttemptCount: 2, StopTimeout: &dpb.Duration{Seconds: 30}, }, }, }, } labels := map[string]string{"message_type": "agent_controlplane.ConfigurePluginStates"} if err := s.SendToAgent(req, labels); err != nil { galog.Errorf("Failed to send ConfigurePluginStates install request: %v", err) } } google-guest-agent-20260819.00/cmd/core_plugin/000077500000000000000000000000001524141036400207535ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/000077500000000000000000000000001524141036400233125ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/agentcrypto.go000066400000000000000000000160041524141036400262010ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package agentcrypto provides various cryptography related utility functions // and a module for mds mtls setup. package agentcrypto import ( "context" "fmt" "sync/atomic" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" ) const ( // moduleID is the agentcrypto module ID. moduleID = "agentcrypto" ) // NewModule returns agentcrypto early initialization module. func NewModule(_ context.Context) *manager.Module { handler := &moduleHandler{metadata: metadata.New(), credsDir: defaultCredsDir} // TODO(b/490501678): Move back to early initialization once bug is fixed. return &manager.Module{ ID: moduleID, Setup: handler.setup, Description: "MDS/MTLS bootstrapping and certificate rotation", } } // moduleHandler is the handler for agentcrypto module. type moduleHandler struct { metadata metadata.MDSClientInterface failedPrevious atomic.Bool credsDir string } // setup is the early initialization function for agentcrypto module. func (m *moduleHandler) setup(ctx context.Context, _ any) error { galog.Debugf("Initializing %s module", moduleID) mds, err := m.metadata.Get(ctx) // If MDS mTLS is not enabled it ensures if any previous stale credentials // are present they are cleared. If MDS mTLS is enabled, the credentials will // anyways be generated in eventCallback. cleanupCreds(ctx, m.credsDir) // Schedules jobs that need to be started before notifying systemd Agent // process has started. // We want to generate MDS credentials as early as possible so that any // process in the Guest can use them. Processes may depend on the Guest Agent // at startup to ensure that the credentials are available for use. By // generating the credentials before notifying the systemd, we ensure that // they are generated for any process that depends on the Guest Agent. // Additionally, eventCallback will determine if the system's preconditions // are met for. _, _, err = m.eventCallback(ctx, metadata.LongpollEvent, mds, &events.EventData{Data: mds, Error: err}) if err != nil { galog.Errorf("Failed to initialize %s module: %v", moduleID, err) } events.FetchManager().Subscribe(metadata.LongpollEvent, events.EventSubscriber{Name: moduleID, Callback: m.eventCallback, MetricName: acmpb.GuestAgentModuleMetric_AGENT_CRYPTO_INITIALIZATION}) galog.Debugf("Successfully initialized %s module", moduleID) return nil } func (m *moduleHandler) eventCallback(ctx context.Context, evType string, _ any, evData *events.EventData) (bool, bool, error) { if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, will retry setup", evData.Error) } mds, ok := evData.Data.(*metadata.Descriptor) if !ok { return true, true, fmt.Errorf("event's data (%T) is not a metadata descriptor: %+v", evData.Data, evData.Data) } sched := scheduler.Instance() alreadyScheduled := sched.IsScheduled(MTLSSchedulerID) shouldSchedule := m.enableJob(ctx, mds) if !shouldSchedule && alreadyScheduled { sched.UnscheduleJob(MTLSSchedulerID) return true, false, nil } if shouldSchedule && !alreadyScheduled { job := New(useNativeStore(mds)) if err := sched.ScheduleJob(ctx, job); err != nil { return true, false, fmt.Errorf("failed to schedule job %q: %v", MTLSSchedulerID, err) } return true, false, nil } return true, true, nil } // useNativeStore returns true if the native store usage is enabled for mTLS MDS // based on the instance, project and config file attributes. func useNativeStore(mds *metadata.Descriptor) bool { var useNative bool if cfg.Retrieve().MDS != nil { useNative = cfg.Retrieve().MDS.HTTPSMDSEnableNativeStore galog.V(1).Debugf("Found instance config file attribute for use native store set to: %t", useNative) } if mds.Project().Attributes().HTTPSMDSEnableNativeStore() != nil { useNative = *mds.Project().Attributes().HTTPSMDSEnableNativeStore() galog.V(1).Debugf("Found project level attribute for use native store set to: %t", useNative) } if mds.Instance().Attributes().HTTPSMDSEnableNativeStore() != nil { useNative = *mds.Instance().Attributes().HTTPSMDSEnableNativeStore() galog.V(1).Debugf("Found instance level attribute for use native store set to: %t", useNative) } return useNative } // enableJob returns true if the credential refresher job should be enabled // based on the instance, project and config file attributes and if the client // credentials endpoint is reachable. func (m *moduleHandler) enableJob(ctx context.Context, mds *metadata.Descriptor) bool { var enable bool if cfg.Retrieve().MDS != nil { enable = !cfg.Retrieve().MDS.DisableHTTPSMdsSetup galog.V(1).Debugf("Found instance config file attribute for enable credential refresher set to: %t", enable) } if mds.Project().Attributes().DisableHTTPSMdsSetup() != nil { enable = !*mds.Project().Attributes().DisableHTTPSMdsSetup() galog.V(1).Debugf("Found project level attribute for enable credential refresher set to: %t", enable) } if mds.Instance().Attributes().DisableHTTPSMdsSetup() != nil { enable = !*mds.Instance().Attributes().DisableHTTPSMdsSetup() galog.V(1).Debugf("Found instance level attribute for enable credential refresher set to: %t", enable) } if !enable { // No need to make MDS call in case job is disabled by the user. return false } _, err := m.metadata.GetKey(ctx, clientCertsKey, nil) if err != nil { // This error is logged only once to prevent raising unnecessary alerts. // Repeated logging could be mistaken for a recurring issue, even if mTLS // MDS is indeed not supported. if !m.failedPrevious.Load() { galog.Warnf("Skipping scheduling credential generation job, unable to reach client credentials endpoint(%s): %v\nNote that this does not impact any functionality and you might see this message if HTTPS endpoint isn't supported by the Metadata Server on your VM. Refer to https://cloud.google.com/compute/docs/metadata/overview#https-mds for more details.", clientCertsKey, err) m.failedPrevious.Store(true) } enable = false } else { m.failedPrevious.Store(false) } return enable } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/agentcrypto_test.go000066400000000000000000000426101524141036400272420ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agentcrypto import ( "context" "fmt" "os" "path/filepath" "runtime" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module.ID != moduleID { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, moduleID) } if module.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if module.BlockSetup != nil { t.Errorf("NewModule() returned module with BlockSetup not nil, want nil") } if module.Description == "" { t.Errorf("NewModule() returned module with empty Description") } } const ( defaultTemplate = ` { "instance": { "attributes": { "hostname": "test" } }, "project": { "attributes": { "hostname": "test" } } }` instanceHTTPSTemplate = ` { "instance": { "attributes": { "disable-https-mds-setup": %q } }, "project": { "attributes": { } } }` instanceNativeStoreTemplate = ` { "instance": { "attributes": { "enable-https-mds-native-cert-store": %q } }, "project": { "attributes": { } } }` projectHTTPSTemplate = ` { "instance": { "attributes": { } }, "project": { "attributes": { "disable-https-mds-setup": %q } } }` projectNativeStoreTemplate = ` { "instance": { "attributes": { } }, "project": { "attributes": { "enable-https-mds-native-cert-store": %q } } }` bothHTTPSTemplate = ` { "instance": { "attributes": { "disable-https-mds-setup": %q } }, "project": { "attributes": { "disable-https-mds-setup": %q } } }` bothNativeStoreTemplate = ` { "instance": { "attributes": { "enable-https-mds-native-cert-store": %q } }, "project": { "attributes": { "enable-https-mds-native-cert-store": %q } } }` ) func buildDescriptor(t *testing.T, instanceAttr, projectAttr, template string) *metadata.Descriptor { t.Helper() if instanceAttr != "" && projectAttr != "" { desc, err := metadata.UnmarshalDescriptor(fmt.Sprintf(template, instanceAttr, projectAttr)) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly with error: %v", fmt.Sprintf(template, instanceAttr, projectAttr), err) } return desc } if instanceAttr != "" { desc, err := metadata.UnmarshalDescriptor(fmt.Sprintf(template, instanceAttr)) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly with error: %v", fmt.Sprintf(template, instanceAttr), err) } return desc } if projectAttr != "" { desc, err := metadata.UnmarshalDescriptor(fmt.Sprintf(template, projectAttr)) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly with error: %v", fmt.Sprintf(template, projectAttr), err) } return desc } desc, err := metadata.UnmarshalDescriptor(defaultTemplate) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly with error: %v", defaultTemplate, err) } return desc } func TestUseNativeStore(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } tests := []struct { name string mds *metadata.Descriptor cfgVal *cfg.MDS want bool }{ { name: "defaults", mds: buildDescriptor(t, "", "", ""), cfgVal: cfg.Retrieve().MDS, want: false, }, { name: "enable_from_cfg", mds: buildDescriptor(t, "", "", ""), cfgVal: &cfg.MDS{HTTPSMDSEnableNativeStore: true}, want: true, }, { name: "enable_from_instance_attr", mds: buildDescriptor(t, "true", "", instanceNativeStoreTemplate), want: true, }, { name: "disable_from_instance_attr", mds: buildDescriptor(t, "false", "", instanceNativeStoreTemplate), want: false, }, { name: "enable_from_project_attr", mds: buildDescriptor(t, "", "true", projectNativeStoreTemplate), want: true, }, { name: "disable_from_project_attr", mds: buildDescriptor(t, "", "false", projectNativeStoreTemplate), want: false, }, { name: "enable_instance_disable_project_attr", mds: buildDescriptor(t, "true", "false", bothNativeStoreTemplate), want: true, }, { name: "enable_project_disable_instance_attr", mds: buildDescriptor(t, "false", "true", bothNativeStoreTemplate), want: false, }, { name: "enable_both_attr_disable_cfg", mds: buildDescriptor(t, "true", "true", bothNativeStoreTemplate), cfgVal: &cfg.MDS{HTTPSMDSEnableNativeStore: false}, want: true, }, { name: "disable_both_attr_enable_cfg", mds: buildDescriptor(t, "false", "false", bothNativeStoreTemplate), cfgVal: &cfg.MDS{HTTPSMDSEnableNativeStore: true}, want: false, }, { name: "enable_proj_cfg_attr_disable_instance_attr", mds: buildDescriptor(t, "false", "true", bothNativeStoreTemplate), cfgVal: &cfg.MDS{HTTPSMDSEnableNativeStore: true}, want: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { cfg.Retrieve().MDS = test.cfgVal if got := useNativeStore(test.mds); got != test.want { t.Errorf("shouldUseNativeStore(%+v) = %t, want %t", test.mds, got, test.want) } }) } } type contextKey int const ( // MDSOverride is the context key used by fake MDS for getting test // conditions. MDSOverride contextKey = iota ) // MDSClient implements fake metadata server. type MDSClient struct { desc *metadata.Descriptor } // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", fmt.Errorf("GetKeyRecursive() not yet implemented") } // Get method implements fake Get on MDS. func (s MDSClient) Get(ctx context.Context) (*metadata.Descriptor, error) { switch ctx.Value(MDSOverride) { case "succeed": return s.desc, nil case "fail_mds_connect": return nil, fmt.Errorf("this is fake MDS error") default: return nil, nil } } // Watch method implements fake watcher on MDS. func (s MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } // GetKey implements fake GetKey MDS method. func (s MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { valid := ` { "encrypted_credentials": "q3u9avkCiXCgKAopiG3WFKmIfwidMq+ISLEIufPDBq0EdVRt+5XnEqz1dJyNuqdeRNmP24VlsXaZ77wQtF/6qcg4t0JhUqn18VkodIUvhz8zFdYGe9peu5EprcC/h8MvSrKXS6WmWRn1920/itPo4yPKl31mOGaOwRuPYqNLVUUu1iFZZ3VZTTDp5yh3AyvLoO41UoKi6siZM+xo+PB+qoHcARGctvNfsZv+jZYbAh6PRuJ2kI4aBBp2sUFWQhAZOoDYqLpcrtTe1d9LeQC/PN/PVz5FiLOwu87YsnOGgt7/K1ce2AxDGRJaINHarricVXaqx38h0u8zei7ynTsSZIemNo9SoR6dH7feRaSiH23htHryJQMx8TV32XHzuE0GdApTLkHIqc0eZGmoJ/PGYy6INaVC+kpk+7tlZ3ZwkKneXgroyy20Iig+wfKMcj8i7ncLP01PMep9d7uFaCuoshdxJbAEeqPCNr59D7zfRBDg+QBavLKv3aPSMqFOYF1tqj2mOB1EHsasZgtDslSwDN7EhkR2YbBi2HNSNFKzEnh5SsbXINSyAgaffoK+99YrLRXCQpdaqr9GIRug6HzMzQMsXhIxr4yErVbpPcv7GSC21vi4PWU62zhvWUZ8w4HXds3HjvpJk3ILrglM72xfkddEdr1Hd7KP1F3h6nG+9FFP4s6Z6j7uHPrL+ppd7Od4dDc05hA+Unifoyshb+IaCJGtzewQtofLhyZcoEZBzp1iMT5IwSCZm6eHSwCG9hS7S9eKJAcjLBwSxWZhwO4UXU3mJM0ZTZfxUxXtmR9Ombpm5xpIu5fa4rMi1DUCKK2vrYDR5hYJrEUsFLzyK+4EGuWz+FPgMXi6gXMZZYVQCjS3zcnfBsEL18EvlDHs2muuHWE/gEjGO0nFCUFuNwkOY2bW+BU8/eKwosYxYhQk+jwYJFEuSXqtm+wgCEyFvIbg42GDc+YrKPTxAzWiBH/RL/XrPR4InDZ6extmSYZbneLjT1YRAAfLR/MOiWuY2I38Q2VYBzMqZ6y1/1EgToNMW2viYlxEVmN1ys0msospzxCGwlR0DWkSzEDJmYT2SQcKFC9OrdMZ2o6BD4s315M8lv5v7ZsL7KuoYNZ4gMBN6MrxJYD6OwdLeytCmI71LdvgVw5gdDmoChu9dFDyzPKSoMYJnvTr5ktrYwxZIyWn8Sl3BjAaslZkAwL+c5oijCTCZ+oV9vzdD7tBnFx9y3fVVFtMC3nflyEjInEUPCupxh38O4TsYLLVl7tttL696kUKdlHL1SRAFCX1Wb5p4WNSBzQQtTGU1dsw904CncAj32sW32oGFWqb4Bom1OzoV/equ32Anef8J95mF+ahmf1BvTUMUq5Az2mSi2/dFBhuhy7rhGQyVWpwCEzpzVpVlysDr5aWr8CLbDOLzJv3MIDM3QQ=", "key_import_blob": { "duplicate": "ACAFYwCs8qzuSCCTvS1iCIHVTDuEXrP7WNNYPGl44ZPARLbhYVWaSkttYk1J2ChEEwG+u0fRxBVF95nEbe3xzN17+pppFFKelB9Jlf+PybtE0rRMyIJ0CB4HT9w=", "encrypted_seed": "ACBnqcxLycU+VUxeB89a7DCa0BSqOciydCReXia87EDLjQAgEUyXgTSjqA4tOxRNARnW5fw4B2p6AJFLD1nZx+llJP8=", "public_area": "AAgACwAAAEAAAAAQACCmhjk4ZFa6nbv58ya74lshnfNfGaCta6+hPIR5s+hZBw==" } } ` invalid := ` { "encrypted_credentials": "q3u9avkCLOwu87YsnOmNo9SoR6d/dFBhuhy7rhGQyVWpwCEzpzVpVlysDr5aWr8CLbDOLzJv3MIDM3QQ=", "key_import_blob": { "duplicate": "ACAFYwCs8qzuSCCTvS1iCIHVT9Jlf+PybtE0rRMyIJ0CB4HT9w=", "encrypted_seed": "ACBnqcxLycU+VUxeB8D1nZx+llJP8=", "public_area": "AAgACwAAAEAAAA+hPIR5s+hZBw==" } } ` switch ctx.Value(MDSOverride) { case "succeed": return valid, nil case "fail_mds_connect": return "", fmt.Errorf("this is fake MDS error") case "fail_unmarshal": return invalid, nil default: return "", nil } } func TestShouldEnableMTLS(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } defCfg := cfg.Retrieve().MDS tests := []struct { name string mds *metadata.Descriptor cfgVal *cfg.MDS overrideKey string previousErr bool currentErr bool want bool }{ { name: "defaults", mds: buildDescriptor(t, "", "", ""), cfgVal: defCfg, want: true, }, { name: "enable_from_cfg", mds: buildDescriptor(t, "", "", ""), cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: false}, want: true, }, { name: "enable_from_instance_attr", mds: buildDescriptor(t, "false", "", instanceHTTPSTemplate), want: true, }, { name: "disable_from_instance_attr", mds: buildDescriptor(t, "true", "", instanceHTTPSTemplate), want: false, }, { name: "enable_from_project_attr", mds: buildDescriptor(t, "", "false", projectHTTPSTemplate), want: true, }, { name: "disable_from_project_attr", mds: buildDescriptor(t, "", "true", projectHTTPSTemplate), want: false, }, { name: "enable_instance_disable_project_attr", mds: buildDescriptor(t, "false", "true", bothHTTPSTemplate), want: true, }, { name: "enable_project_disable_instance_attr", mds: buildDescriptor(t, "true", "false", bothHTTPSTemplate), want: false, }, { name: "enable_both_attr_disable_cfg", mds: buildDescriptor(t, "false", "false", bothHTTPSTemplate), cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: false}, want: true, }, { name: "disable_both_attr_enable_cfg", mds: buildDescriptor(t, "true", "true", bothHTTPSTemplate), cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: true}, want: false, }, { name: "enable_proj_cfg_attr_disable_instance_attr", mds: buildDescriptor(t, "true", "false", bothHTTPSTemplate), cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: false}, want: false, }, { name: "enable_both_no_key_reachable", mds: buildDescriptor(t, "false", "false", bothHTTPSTemplate), overrideKey: "fail_mds_connect", cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: false}, want: false, previousErr: false, currentErr: true, }, { name: "enable_both_key_reachable", mds: buildDescriptor(t, "false", "false", bothHTTPSTemplate), overrideKey: "succeed", cfgVal: &cfg.MDS{DisableHTTPSMdsSetup: false}, want: true, previousErr: true, currentErr: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { cfg.Retrieve().MDS = test.cfgVal ctx = context.WithValue(ctx, MDSOverride, test.overrideKey) handler := &moduleHandler{metadata: &MDSClient{}} handler.failedPrevious.Store(test.previousErr) if got := handler.enableJob(ctx, test.mds); got != test.want { t.Errorf("enableJob(ctx, %+v) = %t, want %t", test.mds, got, test.want) } if test.overrideKey == "" { return } if got := handler.failedPrevious.Load(); got != test.currentErr { t.Errorf("enableJob set failedPrevious = %t, want %t", got, test.currentErr) } }) } } func TestCallbackHandlerError(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } tests := []struct { name string evData *events.EventData }{ { name: "event_error", evData: &events.EventData{Error: fmt.Errorf("test error")}, }, { name: "invalid_event_type", evData: &events.EventData{Data: "test_data"}, }, } ctx := context.Background() for _, tc := range tests { m := &moduleHandler{} got, noop, err := m.eventCallback(ctx, "test_event", nil, tc.evData) if err == nil { t.Errorf("callbackHandler(test_event, nil, %v) returned error: [%v], want error: %t", tc.evData, err, true) } if !noop { t.Errorf("callbackHandler(test_event, nil, %v) returned noop: %t, want noop: %t", tc.evData, noop, false) } if !got { t.Errorf("callbackHandler(test_event, nil, %v) = continue: %t, want: %t", tc.evData, got, true) } } } func TestCallbackHandler(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } ctx := context.Background() tests := []struct { name string ev *events.EventData jobScheduled bool }{ { name: "enable_job", ev: &events.EventData{Data: buildDescriptor(t, "false", "", instanceHTTPSTemplate)}, jobScheduled: true, }, { name: "disable_job", ev: &events.EventData{Data: buildDescriptor(t, "true", "", instanceHTTPSTemplate)}, jobScheduled: false, }, } // Run tests in sequence for validating the job scheduling. for _, tc := range tests { m := &moduleHandler{metadata: &MDSClient{}} got, noop, err := m.eventCallback(ctx, "test_event", nil, tc.ev) if err != nil { t.Errorf("callbackHandler(test_event, nil, %v) returned error: [%v], want error: %t", tc.ev, err, false) } if noop { t.Errorf("callbackHandler(test_event, nil, %v) returned noop: %t, want noop: %t", tc.ev, noop, false) } if !got { t.Errorf("callbackHandler(test_event, nil, %v) = continue: %t, want: %t", tc.ev, got, false) } if scheduler.Instance().IsScheduled(MTLSSchedulerID) != tc.jobScheduled { t.Errorf("callbackHandler(test_event, nil, %v) scheduled job: %t, want: %t", tc.ev, scheduler.Instance().IsScheduled(MTLSSchedulerID), tc.jobScheduled) } } } func TestSetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } desc := buildDescriptor(t, "false", "", instanceHTTPSTemplate) credsDir := t.TempDir() m := &moduleHandler{metadata: &MDSClient{desc: desc}, credsDir: credsDir} ctx := context.Background() t.Cleanup(scheduler.Instance().Stop) files := []string{rootCACertFileName, clientCredsFileName} var presentAfterCleanup []string if runtime.GOOS == "windows" { files = append(files, "mds-mtls-client.key.pfx") presentAfterCleanup = []string{"other_file"} } credsExist := func() bool { if runtime.GOOS == "linux" { return file.Exists(credsDir, file.TypeDir) } for _, f := range files { if file.Exists(filepath.Join(credsDir, f), file.TypeFile) { t.Logf("File %q exists", filepath.Join(credsDir, f)) return true } } return false } createCreds := func() { for _, f := range files { if err := os.WriteFile(filepath.Join(credsDir, f), []byte("test"), 0644); err != nil { t.Fatalf("os.WriteFile() failed unexpectedly with error: %v", err) } } for _, f := range presentAfterCleanup { if err := os.WriteFile(filepath.Join(credsDir, f), []byte("test"), 0644); err != nil { t.Fatalf("os.WriteFile() failed unexpectedly with error: %v", err) } } } tests := []struct { name string overrideKey string credsExist bool }{ { name: "success", overrideKey: "succeed", credsExist: true, }, { name: "error", overrideKey: "fail_mds_connect", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx = context.WithValue(ctx, MDSOverride, tc.overrideKey) if tc.credsExist { createCreds() } if err := m.setup(ctx, nil); err != nil { t.Errorf("Setup() failed unexpectedly with error: %v", err) } if !events.FetchManager().IsSubscribed(metadata.LongpollEvent, moduleID) { t.Errorf("Setup() did not subscribe to longpoll event") } if credsExist() { t.Errorf("Setup() did not clean up credentials directory: %q", credsDir) } for _, f := range presentAfterCleanup { if !file.Exists(filepath.Join(credsDir, f), file.TypeFile) { t.Errorf("Setup() did not preserve file: %q", f) } } }) } } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/crypto_util.go000066400000000000000000000111701524141036400262160ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agentcrypto import ( "crypto/ecdsa" "crypto/x509" "encoding/pem" "fmt" "os" "github.com/GoogleCloudPlatform/galog" "github.com/google/tink/go/aead/subtle" ) // parseCertificate validates certificate is in valid PEM format. func parseCertificate(cert []byte) (*x509.Certificate, error) { galog.V(2).Debug("Parsing PEM certificate") block, _ := pem.Decode(cert) if block == nil { return nil, fmt.Errorf("failed to parse PEM certificate") } galog.V(2).Debug("Successfully parsed PEM certificate") galog.V(2).Debug("Parsing X509 certificate") x509Cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("failed to parse certificate: %w", err) } galog.V(2).Debug("Successfully parsed X509 certificate") return x509Cert, nil } // parsePvtKey validates the key is in valid format and returns the EC Private // Key. func parsePvtKey(pemKey []byte) (*ecdsa.PrivateKey, error) { galog.V(3).Debug("Parsing PEM private key") key, _ := pem.Decode(pemKey) if key == nil { return nil, fmt.Errorf("failed to decode PEM Key") } galog.V(3).Debug("Successfully parsed PEM private key") galog.V(3).Debug("Parsing EC Private Key") ecKey, err := x509.ParseECPrivateKey(key.Bytes) if err != nil { return nil, fmt.Errorf("failed to parse EC Private Key: %w", err) } galog.V(3).Debug("Successfully parsed EC Private Key") return ecKey, nil } // serialNumber reads the certificate from file and returns the serial number in // hex. func serialNumber(f string) (string, error) { galog.V(1).Debugf("Reading serial number from certificate %q", f) galog.V(2).Debugf("Reading certificate from %q", f) d, err := os.ReadFile(f) if err != nil { return "", fmt.Errorf("unable to read previous client credential file %q: %w", f, err) } galog.V(2).Debugf("Successfully read certificate from %q", f) galog.V(2).Debugf("Parsing certificate from file %q", f) crt, err := parseCertificate(d) if err != nil { return "", fmt.Errorf("unable to parse certificate at %q: %w", f, err) } galog.V(2).Debugf("Successfully parsed certificate from file %q", f) galog.V(1).Debugf("Successfully read serial number from certificate %q", f) return fmt.Sprintf("%x", crt.SerialNumber), nil } // verifySign verifies the client certificate is valid and signed by root CA. func verifySign(cert []byte, rootCAFile string) error { galog.V(2).Debugf("Verifying client certificate against root CA %q", rootCAFile) galog.V(3).Debugf("Reading CA PEM file(%q) for verifying signature", rootCAFile) caCertPEM, err := os.ReadFile(rootCAFile) if err != nil { return fmt.Errorf("failed to read CA PEM file for verifying signature: %w", err) } galog.V(3).Debugf("Successfully read CA PEM file(%q) for verifying signature", rootCAFile) x509Cert, err := parseCertificate(cert) if err != nil { return fmt.Errorf("failed to parse client certificate for verifying signature: %w", err) } roots := x509.NewCertPool() if !roots.AppendCertsFromPEM(caCertPEM) { return fmt.Errorf("failed to add %q to new certpool for verifying client certificate", rootCAFile) } opts := x509.VerifyOptions{ Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } galog.V(3).Debugf("Verifying client certificate with opts %+v", opts) if _, err := x509Cert.Verify(opts); err != nil { return fmt.Errorf("failed to verify client certificate against root CA %q: %w", rootCAFile, err) } galog.V(2).Debug("Successfully verified client certificate") return nil } // encrypt plaintext using AES-GCM. func encrypt(aesKey []byte, plaintext []byte, associatedData []byte) ([]byte, error) { cipher, err := subtle.NewAESGCM(aesKey) if err != nil { return nil, fmt.Errorf("failed to initialize cipher: %w", err) } return cipher.Encrypt(plaintext, associatedData) } // decrypt ciphertext using AES-GCM. func decrypt(aesKey []byte, ciphertext []byte, associatedData []byte) ([]byte, error) { cipher, err := subtle.NewAESGCM(aesKey) if err != nil { return nil, fmt.Errorf("failed to initialize cipher: %w", err) } return cipher.Decrypt(ciphertext, associatedData) } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/crypto_util_test.go000066400000000000000000000223361524141036400272630ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agentcrypto import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/pem" "os" "path/filepath" "testing" ) const validCertPEM = ` -----BEGIN CERTIFICATE----- MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfRrObuSW5T7q 5CnSEqefEmtH4CCv6+5EckuriNr1CjfVvqzwfAhopXkLrq45EQm8vkmf7W96XJhC 7ZM0dYi1/qOCAU8wggFLMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAa BgNVHREEEzARgg9tYWlsLmdvb2dsZS5jb20wCwYDVR0PBAQDAgeAMGgGCCsGAQUF BwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29tL0dJQUcy LmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5jb20vb2Nz cDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/BAIwADAf BgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHSAEEDAOMAwGCisG AQQB1nkCBQEwMAYDVR0fBCkwJzAloCOgIYYfaHR0cDovL3BraS5nb29nbGUuY29t L0dJQUcyLmNybDANBgkqhkiG9w0BAQUFAAOCAQEAH6RYHxHdcGpMpFE3oxDoFnP+ gtuBCHan2yE2GRbJ2Cw8Lw0MmuKqHlf9RSeYfd3BXeKkj1qO6TVKwCh+0HdZk283 TZZyzmEOyclm3UGFYe82P/iDFt+CeQ3NpmBg+GoaVCuWAARJN/KfglbLyyYygcQq 0SgeDh8dRKUiaW3HQSoYvTvdTuqzwK4CXsr3b5/dAOY8uMuG/IAR3FgwTbZ1dtoW RvOTa8hYiU6A475WuZKyEHcwnGYe57u2I2KbMgcKjPniocj4QzgYsVAVKW3IwaOh yE+vPxsiUkvQHdO2fojCkY8jg70jxM+gu59tPDNbw3Uh/2Ij310FgTHsnGQMyA== -----END CERTIFICATE-----` const invalidCertPEM = ` -----BEGIN CERTIFICATE----- MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw gtuBCHan2yE2GRbJ2Cw8Lw0MmuKqHlf9RSeYfd3BXeKkj1qO6TVKwCh+0HdZk283 TZZyzmEOyclm3UGFYe82P/iDFt+CeQ3NpmBg+GoaVCuWAARJN/KfglbLyyYygcQq yE+vPxsiUkvQHdO2fojCkY8jg70jxM+gu59tPDNbw3Uh/2Ij310FgTHsnGQMyA== -----END CERTIFICATE-----` func TestParseCertificate(t *testing.T) { if _, err := parseCertificate([]byte(validCertPEM)); err != nil { t.Errorf("parseCertificate(%s) failed unexpectedly with error: %v", validCertPEM, err) } } func TestParseCertificateError(t *testing.T) { if _, err := parseCertificate([]byte(invalidCertPEM)); err == nil { t.Errorf("parseCertificate(%s) succeeded unexpectedly for invalid certificate, want error", invalidCertPEM) } } func TestEncryptDecrypt(t *testing.T) { // 32 byte key. key := []byte("AES256Key-32Characters1234567890") plaintext := []byte("testplaintext") ciphertext, err := encrypt(key, plaintext, nil) if err != nil { t.Errorf("encrypt(%s,%s) failed unexpectedly with error: %v", key, plaintext, err) } got, err := decrypt(key, ciphertext, nil) if err != nil { t.Errorf("decrypt(%s,%s) failed unexpectedly with error: %v", string(key), string(ciphertext), err) } if !bytes.Equal(got, plaintext) { t.Errorf("decrypt(%s,%s) = %s want %s", string(key), string(ciphertext), string(got), string(plaintext)) } } const cacert = ` -----BEGIN CERTIFICATE----- MIIDbTCCAlWgAwIBAgIUFTF0rnA2LoffIJEKSh+rQcmehSIwDQYJKoZIhvcNAQEL BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yMzA3MjgyMjI3MTdaGA8zMDIy MTEyODIyMjcxN1owRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN AQEBBQADggEPADCCAQoCggEBAKiWs/hXZgTtFkpFvdXO/nLpLJSCq5rwqAJauTmj Y78Za1QmgaqCcguakKf/hb+MxRL9h9qJVBAQkNZv0nChoTJyD6YF5hh4DDrQCPuh 1wvVsUhUllIbKsJbjQmdkOb3A5fMoe1ki4BLsr1CtJfJVj1+ifR+7hNkD3fW2sls XZlrNZRmbMKq84KRBWTSSxhjYZGd2cCGpecJ2fWuva9QhairdnB4TORAfjiyH+5v GEwXWC9gyDIIXWDG/kxwDDnh7kub0UsMf/neLv0hejpW/pfmvt32IoMaTEGFDaj7 lhTo7UVQw/XCFWqElsi8gHXR+/UdzbON5a8GiyjWJq5SThsCAwEAAaNTMFEwHQYD VR0OBBYEFPvD/mUJgRgzLmWCD5zFNglzMb55MB8GA1UdIwQYMBaAFPvD/mUJgRgz LmWCD5zFNglzMb55MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB ABUr0RNasEZ39wM1CDE/qZDo+gBMGWH8gE/x152KPvzvJZmI96LkYuKzmbIrvogJ rfGYkAP2LYc8bX6zs4e2VycF0pml7ARKHyinzDdcwXOKzg9gGanoZw4wXEtxfWSl GbmNplmhmMpEnrtTNeDbqGWvmO/1fziNduimNVVu1iltNYEszE/ch8AlMT7flfNm JnhzvUUnGeXDiWUIJdneDfXopatOboL/0HimnfNK6//NKUlMCQOfNbNND+372jhK B3V0o4sGyoh8/Jlas+SqEtVKv+jfNfAG0urLzJc4Zn2uc2chpZnD8DxkmzA5nJCf +5xLOukYO2I5KMgyYkYNUXs= -----END CERTIFICATE----- ` func TestVerifySign(t *testing.T) { // Fake self signed ceritificates for testing. client := ` -----BEGIN CERTIFICATE----- MIIDADCCAegCAQEwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNV BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 ZDAgFw0yMzA3MjgyMjI3NDZaGA8zMDIyMTEyODIyMjc0NlowRTELMAkGA1UEBhMC QVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdp dHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANxAeYva w0J52P9e2IXPEyyJncOKOSGxCWqf0yHpuQ95STMMrgVSodN1Jdrpd2DPOqOYIriK uHw1L4DCm5/yGP7WznN/JOORoJTZ5qJXBXNNQZxf1d5qJeBWtFnVv2pAPwFM/c8j YNFCjTxAHjEMfZN0uXt1ELa6OkYCwxxiVq+Z6QT47xhvQHBzCFhPCaXy8ezvBanU m2AJ2O3HYu9JCy37baDsyVlhrt1qRTKG3JFCgqGEs2vkFo25ebv0Nq8crtT8J6wz YWbIpB56v+299f7jqStjljapG+nMrSbk8BRvMPAlg8Hg6mJ3RQgW5DgKE/7BSgue U2oF7ODsKjF5xesCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAnbriOFcw2b/1zqfr M3FK3TAjcD+InKpY/bNjhdbfCRgO3WYdnWsVU437vKkiJH0tAOAzdR3Yd5xpLkuS uUjIiY4VTR10tZxmFuyAq87NZx4zMCJ7XNRQtDU5o+EUyZVV7l2fwbS31unCZQn5 10nSg5TQE3kZ4u+3x4PZgSbMhIY8P5Q/ZjAKVKk0hnT1ClQ5LQwetcDR7KMq9DpE sC6BD/ElCi0RJrZqVVccAhumf9NBk/qWf1E4njlmYLmqNrfZGEfbxiKAOsZFYaCV p/45VCE10OS3mYEFwJmQjH5NoqaSGxWU28reovEEmrDFoGYfkMQbxZzay0LURXt7 aSrX4A== -----END CERTIFICATE----- ` root := filepath.Join(t.TempDir(), "root.ca") if err := os.WriteFile(root, []byte(cacert), 0644); err != nil { t.Fatalf("Failed to setup test CA cert file: %v", err) } if err := verifySign([]byte(client), root); err != nil { t.Errorf("verifySign failed unexpectedly with error: %v", err) } } func TestVerifySignError(t *testing.T) { // Fake invalid self signed ceritificates for testing. client := ` -----BEGIN CERTIFICATE----- MIIDADCCAegCAQEwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNV BAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0 ZDAgFw0yMzA3MjgyMjM4NTBaGA8zMDIyMTEyODIyMzg1MFowRTELMAkGA1UEBhMC QVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdp dHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKW78MTO TO6F5/68B/e3qQYHRJ1OYv43+1U503fTnkQIyf1KtZvABmPIXmckDJAlTmtD8WQp lKVxCtSJ0aNNwj2epFBo/CoO5gIuFWjjxkiTfneCDxTF4SxqzVzvNuT0JtsG/Ysd 2b2GCIhHbqM7YLCol6V++SSO+NTR2kUx6RQ+f4vvnKWfv2pRgl8jHhq29U71BKtY k1rH6kd13QOl71IMY3E2SRB9rONe0/lgrVyaKKJto5a0WVDgrjZP4e+0lpvtD3jN JOFcJYrrDHAdxjQMEqbT4b1+M/HEOwJMDI2nZAI2exDmN8R2Wburp7hKNeygA4AM 7x91qP9jNfmS/wUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAfv4sxcTTu66KU1h2 ol2DY2JQSywsWY37cfrdL9D1u2sf/MSyAN+i6XcwG/WReoPS8jLFPWJBVHYFQOWt OVw93lVfFlFfz1GojCiddGZxZTWLhKSVvnkRVuRlOD7ph6UjowTUe+JrK5bh/pT8 m+g/HmvC/0V5fgQFvtujjc3DkHzKk7HXj39OFsLVGvNDdI6f7+mdc7ib2qs5/uQt T+CR3W1LK08doMc8/SG74Q1i8eU1/AcX1QK1SQqX/TBF8EpCDII8BMTBp/KPp6JV GPQpdL4CXXRtVxz5wf/GuMKbgBe9nPh9bFoRrmH6B/LK9dckvZJG9wT7lzuCXZ3d zBbQ2g== -----END CERTIFICATE----- ` root := filepath.Join(t.TempDir(), "root.ca") if err := os.WriteFile(root, []byte(cacert), 0644); err != nil { t.Fatalf("Failed to setup test CA cert file: %v", err) } tests := []struct { name string client string }{ { name: "invalid_signed_client", client: client, }, { name: "incorrectly_formatted_client", client: invalidCertPEM, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := verifySign([]byte(test.client), root); err == nil { t.Errorf("verifySign succeeded unexpectedly for %s, want error", test.name) } }) } } func TestSerialNumber(t *testing.T) { f := filepath.Join(t.TempDir(), "cert") if err := os.WriteFile(f, []byte(validCertPEM), 0777); err != nil { t.Errorf("Failed to create test cert file: %v", err) } want := "137d4565568f5d35" got, err := serialNumber(f) if err != nil { t.Errorf("serialNumber(%s) failed unexpectedly with error: %v", f, err) } if got != want { t.Errorf("serialNumber(%s) = %s, want %s", f, got, want) } } func generatePrivateKey(t *testing.T) (*ecdsa.PrivateKey, []byte) { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) if err != nil { t.Fatalf("Failed to generate key: %v", err) } x509Encoded, err := x509.MarshalECPrivateKey(key) if err != nil { t.Fatalf("Failed to Marshal EC PrivateKey: %v", err) } return key, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509Encoded}) } func TestParseECPrivateKey(t *testing.T) { key, pem := generatePrivateKey(t) got, err := parsePvtKey(pem) if err != nil { t.Errorf("parsePvtKey(%s) failed unexpectedly with error: %v", string(pem), err) } if !key.Equal(got) { t.Errorf("parsePvtKey(%s) parsed private key incorrectly", string(pem)) } } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/mtls_mds.go000066400000000000000000000222571524141036400254730ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agentcrypto import ( "context" "fmt" "path/filepath" "sync/atomic" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/uefi" "github.com/google/go-tpm-tools/client" "github.com/google/go-tpm/legacy/tpm2" "google.golang.org/protobuf/encoding/protojson" pb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/agentcrypto/proto/credentials" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" tpmpb "github.com/google/go-tpm-tools/proto/tpm" ) const ( // UEFI variables are of format {VariableName}-{VendorGUID} // googleGUID is Google's (vendors/variable owners) GUID used to prevent name // collision with other vendors. googleGUID = "a2858e46-a37f-456a-8c79-0c1fe48b65ff" // googleRootCACertEFIVarName is predefined string part of the UEFI variable // name that holds Root CA cert. googleRootCACertEFIVarName = "InstanceRootCACertificate" // clientCertsKey is the metadata server key at which client identity // certificate is exposed. clientCertsKey = "instance/credentials/mds-client-certificate" // MTLSSchedulerID is the identifier used by job scheduler. MTLSSchedulerID = "MTLS_MDS_Credential_Boostrapper" // MTLSScheduleInterval is interval at which credential bootstrapper runs. MTLSScheduleInterval = 48 * time.Hour ) var ( googleRootCACertUEFIVar = uefi.VariableName{Name: googleRootCACertEFIVarName, GUID: googleGUID} ) // CredsJob implements job scheduler interface for generating/rotating // credentials. type CredsJob struct { client metadata.MDSClientInterface // bootStrapped is used to track if the credentials have been bootstrapped - // or even if the process/job has been run at least once. bootStrapped atomic.Bool // rootCertsInstalled tracks if MDS root certificates were installed // successfully at-least once. This allows to skip unnecessary work of // refreshing root certs which are updated only when instance stops/starts // which will restart agent as well. Allowing refresh on agent restarts // regardless of instance reboots allows to fix any issues encountered with // root certificate without having to restart compute instance. rootCertsInstalled atomic.Bool // useNativeStore tracks if native store should be used for current run or not. useNativeStore bool } // New initializer new job. func New(useNativeStore bool) *CredsJob { return &CredsJob{ client: metadata.New(), useNativeStore: useNativeStore, } } // readRootCACert reads Root CA cert from UEFI variable. func (j *CredsJob) readRootCACert(name uefi.VariableName) (*uefi.Variable, error) { galog.Debugf("Reading root CA cert from %+v", name) rootCACert, err := uefi.ReadVariable(name) if err != nil { return nil, fmt.Errorf("unable to read root CA cert file contents: %w", err) } if _, err := parseCertificate(rootCACert.Content); err != nil { return nil, fmt.Errorf("unable to verify Root CA cert: %w", err) } galog.Debugf("Successfully read root CA Cert from %+v", name) return rootCACert, nil } // getClientCredentials fetches encrypted credentials from MDS and unmarshal it // into GuestCredentialsResponse. func (j *CredsJob) getClientCredentials(ctx context.Context) (*pb.GuestCredentialsResponse, error) { galog.Debugf("Fetching client credentials from MDS") creds, err := j.client.GetKey(ctx, clientCertsKey, nil) if err != nil { return nil, fmt.Errorf("unable to get client credentials from MDS: %w", err) } res := &pb.GuestCredentialsResponse{} if err := protojson.Unmarshal([]byte(creds), res); err != nil { return nil, fmt.Errorf("unable to unmarshal MDS response(%+v): %w", creds, err) } galog.Debugf("Successfully fetched client credentials from MDS") return res, nil } // extractKey decrypts the key cipher text (Key encryption Key encrypted Data // Decryption Key) through vTPM and returns the key (DEK) as plain text. func (j *CredsJob) extractKey(importBlob *tpmpb.ImportBlob) ([]byte, error) { galog.Debugf("Extracting key from import blob") galog.V(2).Debugf("Opening a channel to TPM") rwc, err := tpm2.OpenTPM() if err != nil { return nil, fmt.Errorf("unable to open a channel to the TPM: %w", err) } defer rwc.Close() galog.V(2).Debugf("Successfully opened a channel to TPM") galog.V(2).Debugf("Loading a key from TPM") ek, err := client.EndorsementKeyECC(rwc) if err != nil { return nil, fmt.Errorf("failed to load a key from TPM: %w", err) } defer ek.Close() galog.V(2).Debugf("Successfully loaded a key from TPM") galog.V(2).Debugf("Importing import blob") dek, err := ek.Import(importBlob) if err != nil { return nil, fmt.Errorf("failed to decrypt import blob: %w", err) } galog.V(3).Debugf("Successfully decrypted import blob") galog.Debugf("Successfully extracted key from import blob") return dek, nil } // fetchClientCredentials fetches encrypted client credentials from MDS, // extracts Key Encryption Key (KEK) from vTPM, decrypts the client credentials // using KEK, and verifies that the certificate is signed by root CA. func (j *CredsJob) fetchClientCredentials(ctx context.Context, rootCA string) ([]byte, error) { resp, err := j.getClientCredentials(ctx) if err != nil { return []byte{}, err } dek, err := j.extractKey(resp.GetKeyImportBlob()) if err != nil { return []byte{}, err } plaintext, err := decrypt(dek, resp.GetEncryptedCredentials(), nil) if err != nil { return []byte{}, err } if err := verifySign(plaintext, rootCA); err != nil { return []byte{}, err } return plaintext, nil } // Run generates the required credentials for MTLS MDS workflow. // // 1. Fetches, verifies and writes Root CA cert from UEFI variable to // /run/google-mds-mtls/root.crt // 2. Fetches encrypted client credentials from MDS, decrypts it via vTPM and // writes it to /run/google-mds-mtls/client.key // // Note that these credentials are at // `C:\Program Files\Google\Compute Engine\certs\mds` on Windows. // Additionally agent also generates a PFX file on windows that can be used in // invoking HTTPS endpoint. // // Example usage of these credentials to call HTTPS endpoint of MDS: // // curl --cacert /run/google-mds-mtls/root.crt \ // -E /run/google-mds-mtls/client.key -H "MetadataFlavor: Google" \ // https://169.254.169.254 // // Windows example: // // $cert = Get-PfxCertificate \ // -FilePath "C:\ProgramData\Google\Compute Engine\mds-mtls-client.key.pfx" // // or // // $cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object \ // { $_.Issuer -like "*google.internal*" } // // Invoke-RestMethod -Uri https://169.254.169.254 -Method Get \ // -Headers @{"Metadata-Flavor"="Google"} -Certificate $cert func (j *CredsJob) Run(ctx context.Context) (bool, error) { defer func() { j.bootStrapped.Store(true) }() galog.Infof("Bootstrapping MDS mTLS credentials") if !j.rootCertsInstalled.Load() { galog.Infof("Installing Root CA cert...") v, err := j.readRootCACert(googleRootCACertUEFIVar) if err != nil { return true, fmt.Errorf("failed to read Root CA cert with an error: %w", err) } if err := j.writeRootCACert(ctx, v.Content, filepath.Join(defaultCredsDir, rootCACertFileName)); err != nil { return true, fmt.Errorf("failed to store Root CA cert with an error: %w", err) } galog.Infof("Successfully installed Root CA cert") } // Set only when agent has at-least one successful run for installing root // certs. j.rootCertsInstalled.Store(true) galog.Infof("Installing client credentials...") creds, err := j.fetchClientCredentials(ctx, filepath.Join(defaultCredsDir, rootCACertFileName)) if err != nil { return true, fmt.Errorf("failed to generate client credentials with an error: %w", err) } if err := j.writeClientCredentials(ctx, creds, filepath.Join(defaultCredsDir, clientCredsFileName)); err != nil { return true, fmt.Errorf("failed to store client credentials with an error: %w", err) } galog.Infof("Successfully installed client credentials") galog.Infof("Successfully bootstrapped MDS mTLS credentials") return true, nil } // ID returns the ID for this job. func (j *CredsJob) ID() string { return MTLSSchedulerID } // MetricName returns the metric name for the job. func (j *CredsJob) MetricName() acppb.GuestAgentModuleMetric_Metric { return acppb.GuestAgentModuleMetric_AGENT_CRYPTO_INITIALIZATION } // Interval returns the interval at which job is executed. func (j *CredsJob) Interval() (time.Duration, bool) { return MTLSScheduleInterval, true } // ShouldEnable always returns true. Module handler is responsible for // enabling/disabling the job. func (j *CredsJob) ShouldEnable(ctx context.Context) bool { return true } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/mtls_mds_linux.go000066400000000000000000000140461524141036400267070ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package agentcrypto import ( "context" "fmt" "os" "os/exec" "path/filepath" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // defaultCredsDir is the directory location for MTLS MDS credentials. defaultCredsDir = "/run/google-mds-mtls" // rootCACertFileName is the root CA cert. rootCACertFileName = "root.crt" // clientCredsFileName are client credentials, its basically the file // that has the EC private key and the client certificate concatenated. clientCredsFileName = "client.key" ) var ( // certUpdaters is a map of known CA certificate updaters with the local // directory paths for certificates. certUpdaters = map[string][]string{ // SUSE, Debian and Ubuntu distributions. // https://manpages.ubuntu.com/manpages/xenial/man8/update-ca-certificates.8.html // https://github.com/openSUSE/ca-certificates "update-ca-certificates": {"/usr/local/share/ca-certificates", "/usr/share/pki/trust/anchors"}, // CentOS, Fedora, RedHat distributions. // https://www.unix.com/man-page/centos/8/UPDATE-CA-TRUST "update-ca-trust": {"/etc/pki/ca-trust/source/anchors"}, } ) // cleanupCreds cleans up the credentials directory. This is done to ensure // that any stale credentials are removed. func cleanupCreds(ctx context.Context, credsDir string) { if !file.Exists(credsDir, file.TypeDir) { galog.Debugf("Credentials directory %q does not exist, skipping cleanup.", credsDir) return } updateSystemStore(ctx, filepath.Join(credsDir, rootCACertFileName), false) galog.Infof("Cleaning up MDS credentials directory: %q", credsDir) if err := os.RemoveAll(credsDir); err != nil { galog.Warnf("Failed to clean up credentials directory: %v", err) } } // writeRootCACert writes Root CA cert from UEFI variable to output file. func (j *CredsJob) writeRootCACert(ctx context.Context, content []byte, outputFile string) error { galog.Debugf("Writing root CA cert to %q", outputFile) // Write the root CA cert to the output file. if err := j.writeCredentials(ctx, content, outputFile); err != nil { return err } galog.Debugf("Successfully wrote root CA cert to %q", outputFile) if !j.useNativeStore { galog.Debug("Skipping system store update as it is disabled in the configuration") return nil } // Best effort to update system store, don't fail. if err := updateSystemStore(ctx, outputFile, true); err != nil { galog.Warnf("Failed to add Root MDS cert to system trust store with error: %v", err) } return nil } // writeClientCredentials stores client credentials (certificate and private // key). func (j *CredsJob) writeClientCredentials(ctx context.Context, plaintext []byte, outputFile string) error { galog.Debugf("Writing client credentials to %q", outputFile) return j.writeCredentials(ctx, plaintext, outputFile) } // writeCredentials stores the provided credentials to the output file. func (j *CredsJob) writeCredentials(ctx context.Context, certContent []byte, outputFile string) error { // The directory should be executable, but the file does not need to be. if err := os.MkdirAll(filepath.Dir(outputFile), 0655); err != nil { return err } return file.SaferWriteFile(ctx, certContent, outputFile, file.Options{Perm: 0644}) } // getCAStoreUpdater iterates over known system trust store updaters and returns // the first found. func getCAStoreUpdater() (string, error) { var errs []string for u := range certUpdaters { _, err := exec.LookPath(u) if err == nil { galog.V(3).Debugf("Found updater %q", u) return u, nil } errs = append(errs, fmt.Sprintf("lookup for %q failed with error: %v", u, err)) } return "", fmt.Errorf("no known trust updaters were found: %v", errs) } // certificateDirFromUpdater returns directory of local CA certificates for the // given updater tool. func certificateDirFromUpdater(updater string) (string, error) { dirs, ok := certUpdaters[updater] if !ok { return "", fmt.Errorf("unknown updater %q, no local trusted CA certificate directory found", updater) } for _, dir := range dirs { if file.Exists(dir, file.TypeDir) { galog.V(3).Debugf("Found directory %q for updater %q", dir, updater) return dir, nil } } return "", fmt.Errorf("none of the known directories %v found for updater %q", dirs, updater) } // updateSystemStore updates the local system store with the cert. func updateSystemStore(ctx context.Context, cert string, add bool) error { action := "add" if !add { action = "remove" } galog.Infof("Performing %q action for cert %q on local system store.", action, cert) cmd, err := getCAStoreUpdater() if err != nil { return err } dir, err := certificateDirFromUpdater(cmd) if err != nil { return err } dest := filepath.Join(dir, filepath.Base(cert)) if add { if err := file.CopyFile(ctx, cert, dest, file.Options{Perm: 0644}); err != nil { return err } } else { if !file.Exists(dest, file.TypeFile) { galog.Debugf("Certificate %q not found in system store, skipping removal.", dest) return nil } if err := os.Remove(dest); err != nil { return fmt.Errorf("failed to remove certificate %q from system store: %w", dest, err) } } opts := run.Options{Name: cmd, OutputType: run.OutputStdout} res, err := run.WithContext(ctx, opts) if err != nil { return fmt.Errorf("command %q failed with error: %w", cmd, err) } galog.Infof("Successfully updated local system store for %q: %s", cert, res.Output) return nil } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/mtls_mds_linux_test.go000066400000000000000000000122431524141036400277430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package agentcrypto import ( "context" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/uefi" ) func TestReadAndWriteRootCACert(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } ctx := context.Background() root := t.TempDir() v := uefi.VariableName{Name: "testname", GUID: "testguid", RootDir: root} j := &CredsJob{} fakeUefi := []byte("attr" + validCertPEM) path := filepath.Join(root, "testname-testguid") if err := os.WriteFile(path, fakeUefi, 0644); err != nil { t.Fatalf("Failed to write test file: %v", err) } defer os.Remove(path) crt := filepath.Join(root, "root.crt") ca, err := j.readRootCACert(v) if err != nil { t.Errorf("readRootCACert(%+v) failed unexpectedly with error: %v", v, err) } tests := []struct { name string enabled bool }{ { name: "update_ca_certs_enabled", enabled: true, }, { name: "update_ca_certs_disabled", enabled: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg.Retrieve().MDS.HTTPSMDSEnableNativeStore = tc.enabled if err := j.writeRootCACert(ctx, ca.Content, crt); err != nil { t.Errorf("writeRootCACert(%s, %s) failed unexpectedly with error: %v", string(ca.Content), crt, err) } got, err := os.ReadFile(crt) if err != nil { t.Errorf("Failed to read expected root cert file: %v", err) } if string(got) != validCertPEM { t.Errorf("readAndWriteRootCACert(%+v, %s) = %s, want %s", v, crt, string(got), validCertPEM) } }) } } func TestReadAndWriteRootCACertError(t *testing.T) { root := t.TempDir() v := uefi.VariableName{Name: "not", GUID: "exist", RootDir: root} j := &CredsJob{} // Non-existent UEFI variable. if _, err := j.readRootCACert(v); err == nil { t.Errorf("readRootCACert(%+v) succeeded unexpectedly for non-existent UEFI variable, want error", v) } // Invalid PEM certificate. fakeUefi := []byte("attr" + invalidCertPEM) path := filepath.Join(root, "testname-testguid") if err := os.WriteFile(path, fakeUefi, 0644); err != nil { t.Fatalf("Failed to write test file: %v", err) } defer os.Remove(path) if _, err := j.readRootCACert(v); err == nil { t.Errorf("readRootCACert(%+v) succeeded unexpectedly for invalid PEM certificate, want error", v) } } func TestGetClientCredentials(t *testing.T) { ctx := context.WithValue(context.Background(), MDSOverride, "succeed") j := &CredsJob{ client: &MDSClient{}, } if _, err := j.getClientCredentials(ctx); err != nil { t.Errorf("getClientCredentials(ctx, client) failed unexpectedly with error: %v", err) } } func TestGetClientCredentialsError(t *testing.T) { ctx := context.Background() j := &CredsJob{ client: &MDSClient{}, } tests := []string{"fail_mds_connect", "fail_unmarshal"} for _, test := range tests { t.Run(test, func(t *testing.T) { ctx = context.WithValue(ctx, MDSOverride, test) if _, err := j.getClientCredentials(ctx); err == nil { t.Errorf("getClientCredentials(ctx, client) succeeded for %s, want error", test) } }) } } func TestShouldEnable(t *testing.T) { ctx := context.Background() j := &CredsJob{} if !j.ShouldEnable(ctx) { t.Error("ShouldEnable(ctx) = false, want true") } } func TestCertificateDirFromUpdater(t *testing.T) { updater1Dir := t.TempDir() updater2Dir := t.TempDir() certUpdaters = map[string][]string{ "updater1": {updater1Dir}, "updater2": {"/does/not/exist", updater2Dir}, } tests := []struct { updater string want string }{ { updater: "updater1", want: updater1Dir, }, { updater: "updater2", want: updater2Dir, }, } for _, test := range tests { t.Run(test.updater, func(t *testing.T) { got, err := certificateDirFromUpdater(test.updater) if err != nil { t.Errorf("certificateDirFromUpdater(%s) failed unexpectedly with error: %v", test.updater, err) } if got != test.want { t.Errorf("certificateDirFromUpdater(%s) = %s, want %s", test.updater, got, test.want) } }) } } func TestCertificateDirFromUpdaterError(t *testing.T) { // Fail for unknown updater. _, err := certificateDirFromUpdater("unknown") if err == nil { t.Errorf("certificateDirFromUpdater(unknown) succeeded for unknown updater, want error") } // Fail for missing known cert dir. certUpdaters = map[string][]string{ "updater1": {"/no/dir/exist"}, } _, err = certificateDirFromUpdater("updater1") if err == nil { t.Errorf("certificateDirFromUpdater(unknown) succeeded for missing cert dir, want error") } } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/mtls_mds_test.go000066400000000000000000000036511524141036400265270ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agentcrypto import ( "context" "fmt" "testing" "time" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" ) func TestScheduleJob(t *testing.T) { checkInitMaxAttempts := 10 if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } s := scheduler.Instance() job := New(false) if job.MetricName() != acppb.GuestAgentModuleMetric_AGENT_CRYPTO_INITIALIZATION { t.Errorf("MetricName() = %s, want %s", job.MetricName().String(), acppb.GuestAgentModuleMetric_AGENT_CRYPTO_INITIALIZATION.String()) } ctx := context.Background() if err := s.ScheduleJob(ctx, job); err != nil { t.Fatalf("ScheduleJob(ctx, %+v) failed unexpectedly with error: %v", job, err) } defer s.UnscheduleJob(job.ID()) checkInit := func() error { if !job.bootStrapped.Load() { return fmt.Errorf("job not bootstrapped") } return nil } policy := retry.Policy{MaxAttempts: checkInitMaxAttempts, BackoffFactor: 1, Jitter: time.Second} if err := retry.Run(ctx, policy, checkInit); err != nil { t.Fatalf("Job bootstrap failed with error: %v", err) } } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/mtls_mds_windows.go000066400000000000000000000356431524141036400272500ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package agentcrypto import ( "context" "crypto/rand" "crypto/x509" "encoding/pem" "fmt" "maps" "os" "path/filepath" "slices" "syscall" "unsafe" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "golang.org/x/sys/windows" pkcs12 "software.sslmate.com/src/go-pkcs12" ) const ( // rootCACertFileName is the root CA cert. rootCACertFileName = "mds-mtls-root.crt" // clientCredsFileName are client credentials, its basically the file // that has the EC private key and the client certificate concatenated. clientCredsFileName = "mds-mtls-client.key" // pfxFile stores client credentials in PFX format. pfxFile = "mds-mtls-client.key.pfx" // https://learn.microsoft.com/en-us/windows/win32/seccrypto/system-store-locations // my is predefined personal cert store. my = "MY" // root is predefined cert store for root trusted CA certs. root = "ROOT" // certificateIssuer is the issuer of client/root certificates for MDS mTLS. certificateIssuer = "google.internal" // maxCertEnumeration specifies the maximum number of times to search for a // certificate with a serial number from a given issuer before giving up. maxCertEnumeration = 5 ) var ( // defaultCredsDir is the directory location for MTLS MDS credentials. defaultCredsDir = filepath.Join(os.Getenv("ProgramData"), "Google", "Compute Engine") prevCtx *windows.CertContext ) // cleanupCreds cleans up the credentials directory. This is done to ensure // that any stale credentials are removed. func cleanupCreds(ctx context.Context, credsDir string) { removeFromStore := map[string]string{ rootCACertFileName: root, clientCredsFileName: my, } removeFiles := append([]string{pfxFile}, slices.Collect(maps.Keys(removeFromStore))...) for f, store := range removeFromStore { fp := filepath.Join(credsDir, f) if !file.Exists(fp, file.TypeFile) { galog.Debugf("File %q does not exist, skipping cleanup.", fp) continue } galog.Infof("Removing MDS credential %q from store %q", fp, store) num, err := serialNumber(fp) if err != nil { galog.Warnf("Failed to get serial number for file %q, error: %v", fp, err) continue } certCtx, err := findCert(store, certificateIssuer, num) if err != nil { galog.Warnf("Failed to find previous certificate with error: %v", err) continue } if err := deleteCert(certCtx, store); err != nil { galog.Warnf("Failed to delete previous certificate(%s) from store %s with error: %v", num, store, err) } } for _, f := range removeFiles { fp := filepath.Join(credsDir, f) if !file.Exists(fp, file.TypeFile) { galog.Debugf("File %q does not exist, skipping cleanup.", fp) continue } if err := os.Remove(fp); err != nil { galog.Warnf("Failed to remove file %q, error: %v", fp, err) } } } // writeRootCACert writes Root CA cert from UEFI variable to output file. func (j *CredsJob) writeRootCACert(ctx context.Context, cacert []byte, outputFile string) error { galog.Debugf("Writing root CA cert to %q", outputFile) // Try to fetch previous certificate's serial number before it gets // overwritten. num, err := serialNumber(outputFile) if err != nil { galog.Debugf("No previous MDS root certificate was found, will skip cleanup: %v", err) } if err := file.SaferWriteFile(ctx, cacert, outputFile, file.Options{Perm: 0644}); err != nil { return err } galog.Debugf("Successfully wrote root CA cert to %q", outputFile) if !j.useNativeStore { galog.Debugf("Skipping system store update as it is disabled in the configuration") return nil } x509Cert, err := parseCertificate(cacert) if err != nil { return fmt.Errorf("failed to parse root CA cert: %w", err) } // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certcreatecertificatecontext galog.V(3).Debug("Creating certificate context for root CA cert") certContext, err := windows.CertCreateCertificateContext( windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING, &x509Cert.Raw[0], uint32(len(x509Cert.Raw))) if err != nil { return fmt.Errorf("CertCreateCertificateContext returned: %w", err) } galog.V(3).Debug("Successfully created certificate context for root CA cert") // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfreecertificatecontext defer windows.CertFreeCertificateContext(certContext) // Adds certificate to Root Trusted certificates. if err := addCtxToLocalSystemStore(root, certContext, uint32(windows.CERT_STORE_ADD_REPLACE_EXISTING)); err != nil { return fmt.Errorf("failed to store root cert ctx in store: %w", err) } // MDS root cert was not refreshed or there's no previous cert, nothing to do, // return. if num == "" || fmt.Sprintf("%x", x509Cert.SerialNumber) == num { return nil } // Certificate is refreshed. Best effort to find the cert context and delete // it. Don't throw error here, it would skip client credential generation // which may be about to expire. oldCtx, err := findCert(root, certificateIssuer, num) if err != nil { galog.Warnf("Failed to find previous MDS root certificate with error: %v", err) return nil } if err := deleteCert(oldCtx, root); err != nil { galog.Warnf("Failed to delete previous MDS root certificate(%s) with error: %v", num, err) return nil } return nil } // findCert finds and returns certificate issued by issuer with the serial // number in the given the store. func findCert(storeName, issuer, certID string) (*windows.CertContext, error) { galog.Debugf("Searching for certificate with serial number %s in store %s by issuer %s", certID, storeName, issuer) st, err := windows.CertOpenStore( windows.CERT_STORE_PROV_SYSTEM, 0, 0, windows.CERT_SYSTEM_STORE_LOCAL_MACHINE, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(storeName)))) if err != nil { return nil, fmt.Errorf("failed to open cert store: %w", err) } defer windows.CertCloseStore(st, 0) // prev is used for enumerating through all the certificates that matches the // issuer. On the first call to the function this parameter is NULL. On all // subsequent calls, this parameter is the last CertContext pointer returned // by the CertFindCertificateInStore function. var prev *windows.CertContext // maxCertEnumeration would avoid requiring a infinite loop that relies on // enumerating until we get nil crt. for i := 1; i <= maxCertEnumeration; i++ { galog.Debugf("Attempt %d, searching certificate...", i) // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certfindcertificateinstore crt, err := windows.CertFindCertificateInStore( st, windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING, 0, windows.CERT_FIND_ISSUER_STR, unsafe.Pointer(syscall.StringToUTF16Ptr(issuer)), prev) if err != nil { return nil, fmt.Errorf("unable to find certificate: %w", err) } if crt == nil { return nil, fmt.Errorf("no certificate by issuer %s with ID %s", issuer, certID) } x509Cert, err := certContextToX509(crt) if err != nil { return nil, fmt.Errorf("failed to parse certificate context: %w", err) } if fmt.Sprintf("%x", x509Cert.SerialNumber) == certID { galog.Debugf("Found certificate with serial number %s in store %s by issuer %s", certID, storeName, issuer) return crt, nil } prev = crt } return nil, nil } // writeClientCredentials stores client credentials (certificate and private // key). func (j *CredsJob) writeClientCredentials(ctx context.Context, creds []byte, outputFile string) error { galog.Debugf("Writing client credentials to %q", outputFile) num, err := serialNumber(outputFile) if err != nil { galog.Warnf("Could not get previous serial number, will skip cleanup: %v", err) } if err := file.SaferWriteFile(ctx, creds, outputFile, file.Options{Perm: 0644}); err != nil { return fmt.Errorf("failed to write client key: %w", err) } galog.Debugf("Successfully wrote client credentials to %q", outputFile) galog.V(1).Debug("Generating PFX data from client credentials") pfx, err := generatePFX(creds) if err != nil { return fmt.Errorf("failed to generate PFX data from client credentials: %w", err) } galog.V(1).Debug("Successfully generated PFX data from client credentials") galog.V(1).Debugf("Writing PFX file to %q", pfxFile) p := filepath.Join(filepath.Dir(outputFile), pfxFile) if err := file.SaferWriteFile(ctx, pfx, p, file.Options{Perm: 0644}); err != nil { return fmt.Errorf("failed to write PFX file: %w", err) } galog.V(1).Debugf("Successfully wrote PFX file to %q", p) if !j.useNativeStore { galog.Info("Skipping client credentials write to system store update as it is disabled in the configuration") return nil } galog.Debugf("Writing client credentials to system store") blob := windows.CryptDataBlob{ Size: uint32(len(pfx)), Data: &pfx[0], } // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-pfximportcertstore galog.V(1).Debug("Importing PFX into cert store") handle, err := windows.PFXImportCertStore(&blob, syscall.StringToUTF16Ptr(""), windows.CRYPT_MACHINE_KEYSET) if err != nil { return fmt.Errorf("failed to import PFX in cert store: %w", err) } galog.V(1).Debug("Successfully imported PFX into cert store") defer windows.CertCloseStore(handle, 0) var crtCtx *windows.CertContext // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certenumcertificatesinstore galog.V(1).Debug("Fetching cert context for PFX from store") crtCtx, err = windows.CertEnumCertificatesInStore(handle, crtCtx) if err != nil { return fmt.Errorf("failed to get cert context for PFX from store: %w", err) } galog.V(1).Debug("Successfully fetched cert context for PFX from store") defer windows.CertFreeCertificateContext(crtCtx) // Add certificate to personal store. galog.V(1).Debug("Adding PFX cert to local system store") if err := addCtxToLocalSystemStore(my, crtCtx, uint32(windows.CERT_STORE_ADD_NEWER)); err != nil { return fmt.Errorf("failed to store pfx cert context: %w", err) } galog.V(1).Debug("Successfully added PFX cert to local system store") // Search for previous certificate if its not already in memory. if prevCtx == nil && num != "" { prevCtx, err = findCert(my, certificateIssuer, num) if err != nil { galog.Warnf("Failed to find previous certificate with error: %v", err) } } // Remove previous certificate only after successful refresh. galog.V(1).Debugf("Deleting previous certificate %v from local system store", prevCtx) if err := deleteCert(prevCtx, my); err != nil { galog.Warnf("Failed to delete previous certificate(%s) with error: %v", num, err) } else { galog.V(1).Debugf("Successfully deleted previous certificate %v from local system store", prevCtx) } prevCtx = windows.CertDuplicateCertificateContext(crtCtx) galog.Debug("Successfully wrote certificate to system store") return nil } // certContextToX509 creates an x509 Certificate from a Windows cert context. func certContextToX509(ctx *windows.CertContext) (*x509.Certificate, error) { der := unsafe.Slice(ctx.EncodedCert, int(ctx.Length)) return x509.ParseCertificate(der) } // generatePFX accepts certificate concatenated with private key and generates a // PFX out of it. // https://learn.microsoft.com/en-us/windows-hardware/drivers/install/personal-information-exchange---pfx--files func generatePFX(creds []byte) (pfxData []byte, err error) { cert, key := pem.Decode(creds) x509Cert, err := x509.ParseCertificate(cert.Bytes) if err != nil { return []byte{}, fmt.Errorf("failed to parse client certificate: %w", err) } ecpvt, err := parsePvtKey(key) if err != nil { return []byte{}, fmt.Errorf("failed to parse EC PrivateKey from client credentials: %w", err) } return pkcs12.Encode(rand.Reader, ecpvt, x509Cert, nil, "") } // addCtxToLocalSystemStore adds the certificate context to the local system // store. func addCtxToLocalSystemStore(storeName string, certContext *windows.CertContext, disposition uint32) error { // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certopenstore // https://learn.microsoft.com/en-us/windows-hardware/drivers/install/local-machine-and-current-user-certificate-stores // https://learn.microsoft.com/en-us/windows/win32/seccrypto/system-store-locations#cert_system_store_local_machine galog.V(2).Debugf("Adding certificate context(%v) to store %s", certContext, storeName) st, err := windows.CertOpenStore( windows.CERT_STORE_PROV_SYSTEM, 0, 0, windows.CERT_SYSTEM_STORE_LOCAL_MACHINE, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(storeName)))) if err != nil { return fmt.Errorf("failed to open cert store: %w", err) } // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certclosestore defer windows.CertCloseStore(st, 0) // https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certaddcertificatecontexttostore if err := windows.CertAddCertificateContextToStore(st, certContext, disposition, nil); err != nil { return fmt.Errorf("failed to add certificate context to store: %w", err) } galog.V(2).Debugf("Successfully added certificate context(%v) to store %s", certContext, storeName) return nil } // deleteCert deletes the certificate from the given store. func deleteCert(crtCtx *windows.CertContext, storeName string) error { if crtCtx == nil { return nil } galog.V(2).Debugf("Deleting certificate context(%v) from store %s", crtCtx, storeName) st, err := windows.CertOpenStore( windows.CERT_STORE_PROV_SYSTEM, 0, 0, windows.CERT_SYSTEM_STORE_LOCAL_MACHINE, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(storeName)))) if err != nil { return fmt.Errorf("failed to open cert store: %w", err) } defer windows.CertCloseStore(st, 0) galog.V(3).Debugf("Finding certificate context(%v) in store %s", crtCtx, storeName) var dlCtx *windows.CertContext dlCtx, err = windows.CertFindCertificateInStore( st, windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING, 0, windows.CERT_FIND_EXISTING, unsafe.Pointer(crtCtx), dlCtx, ) if err != nil { return fmt.Errorf("unable to find the certificate in %q store to delete: %w", storeName, err) } galog.V(3).Debugf("Successfully found certificate context(%v) in store %s", crtCtx, storeName) galog.V(3).Debugf("Deleting certificate context(%v) from store %s", crtCtx, storeName) err = windows.CertDeleteCertificateFromStore(dlCtx) if err == nil { galog.V(2).Debugf("Successfully deleted certificate context(%v) from store %s", crtCtx, storeName) } return err } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/proto/000077500000000000000000000000001524141036400244555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/proto/credentials/000077500000000000000000000000001524141036400267525ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/proto/credentials/guest_credentials.pb.go000066400000000000000000000124001524141036400334020ustar00rootroot00000000000000// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.12 // protoc v7.35.1 // source: guest_credentials.proto package credentials import ( tpm "github.com/google/go-tpm-tools/proto/tpm" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // A server response containing client credentials. type GuestCredentialsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` EncryptedCredentials []byte `protobuf:"bytes,1,opt,name=encrypted_credentials,json=encryptedCredentials,proto3" json:"encrypted_credentials,omitempty"` KeyImportBlob *tpm.ImportBlob `protobuf:"bytes,2,opt,name=key_import_blob,json=keyImportBlob,proto3" json:"key_import_blob,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GuestCredentialsResponse) Reset() { *x = GuestCredentialsResponse{} mi := &file_guest_credentials_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GuestCredentialsResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GuestCredentialsResponse) ProtoMessage() {} func (x *GuestCredentialsResponse) ProtoReflect() protoreflect.Message { mi := &file_guest_credentials_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GuestCredentialsResponse.ProtoReflect.Descriptor instead. func (*GuestCredentialsResponse) Descriptor() ([]byte, []int) { return file_guest_credentials_proto_rawDescGZIP(), []int{0} } func (x *GuestCredentialsResponse) GetEncryptedCredentials() []byte { if x != nil { return x.EncryptedCredentials } return nil } func (x *GuestCredentialsResponse) GetKeyImportBlob() *tpm.ImportBlob { if x != nil { return x.KeyImportBlob } return nil } var File_guest_credentials_proto protoreflect.FileDescriptor const file_guest_credentials_proto_rawDesc = "" + "\n" + "\x17guest_credentials.proto\x12\x17agentcrypto.credentials\x1a\x1cgo-tpm-tools/proto/tpm.proto\"\x88\x01\n" + "\x18GuestCredentialsResponse\x123\n" + "\x15encrypted_credentials\x18\x01 \x01(\fR\x14encryptedCredentials\x127\n" + "\x0fkey_import_blob\x18\x02 \x01(\v2\x0f.tpm.ImportBlobR\rkeyImportBlobB\x0eZ\f/credentialsb\x06proto3" var ( file_guest_credentials_proto_rawDescOnce sync.Once file_guest_credentials_proto_rawDescData []byte ) func file_guest_credentials_proto_rawDescGZIP() []byte { file_guest_credentials_proto_rawDescOnce.Do(func() { file_guest_credentials_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_guest_credentials_proto_rawDesc), len(file_guest_credentials_proto_rawDesc))) }) return file_guest_credentials_proto_rawDescData } var file_guest_credentials_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_guest_credentials_proto_goTypes = []any{ (*GuestCredentialsResponse)(nil), // 0: agentcrypto.credentials.GuestCredentialsResponse (*tpm.ImportBlob)(nil), // 1: tpm.ImportBlob } var file_guest_credentials_proto_depIdxs = []int32{ 1, // 0: agentcrypto.credentials.GuestCredentialsResponse.key_import_blob:type_name -> tpm.ImportBlob 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name } func init() { file_guest_credentials_proto_init() } func file_guest_credentials_proto_init() { if File_guest_credentials_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_guest_credentials_proto_rawDesc), len(file_guest_credentials_proto_rawDesc)), NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_guest_credentials_proto_goTypes, DependencyIndexes: file_guest_credentials_proto_depIdxs, MessageInfos: file_guest_credentials_proto_msgTypes, }.Build() File_guest_credentials_proto = out.File file_guest_credentials_proto_goTypes = nil file_guest_credentials_proto_depIdxs = nil } google-guest-agent-20260819.00/cmd/core_plugin/agentcrypto/proto/guest_credentials.proto000066400000000000000000000017341524141036400312530ustar00rootroot00000000000000// Copyright 2023 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; package agentcrypto.credentials; option go_package = "/credentials"; // Specify import path to "github.com/google/go-tpm-tools/proto/tpm.proto" // during compilation. import "go-tpm-tools/proto/tpm.proto"; // A server response containing client credentials. message GuestCredentialsResponse { bytes encrypted_credentials = 1; tpm.ImportBlob key_import_blob = 2; } google-guest-agent-20260819.00/cmd/core_plugin/clock/000077500000000000000000000000001524141036400220465ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_freebsd.go000066400000000000000000000043611524141036400251660ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build freebsd package clock import ( "context" "fmt" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) // wallClockPath is the path to the wall clock file on freebsd. Exists if the // RTC is not in UTC mode. // https://man.freebsd.org/cgi/man.cgi?query=adjkerntz const wallClockPath = "/etc/wall_cmos_clock" // platformImpl implements freebsd's specific clock skew setup. func platformImpl(ctx context.Context) error { // Sanity check ntpd service. cmd := []string{"service", "ntpd", "status"} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to query ntpd service status: %w", err) } // Stop and start ntpd service. set := run.CommandSet{ run.CommandSpec{ Command: "service ntpd stop", Error: "failed to stop ntpd service", }, run.CommandSpec{ Command: "service ntpd start", Error: "failed to start ntpd service", }, } if err := set.WithContext(ctx, nil); err != nil { return err } return nil } func isEnabled(_ context.Context) bool { clockSkewEnabled := cfg.Retrieve().Daemons.ClockSkewDaemon galog.Debugf("Clock skew daemon is enabled: [%t] from config", clockSkewEnabled) if !clockSkewEnabled { return false } // https://man.freebsd.org/cgi/man.cgi?query=adjkerntz isUTC := !file.Exists(wallClockPath, file.TypeFile) galog.Infof("Identified RTC mode isUTC to be: [%t] from %q", isUTC, wallClockPath) return isUTC } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_linux.go000066400000000000000000000064361524141036400247200ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package clock import ( "bufio" "context" "fmt" "os" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) var adjtimePath = "/etc/adjtime" func platformImpl(ctx context.Context) error { cmd := []string{"/sbin/hwclock", "--hctosys", "-u", "--noadjfile"} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to run hwclock: %w", err) } return nil } func isEnabled(ctx context.Context) bool { clockSkewEnabled := cfg.Retrieve().Daemons.ClockSkewDaemon galog.Debugf("Clock skew daemon is enabled: [%t] from config", clockSkewEnabled) if !clockSkewEnabled { return false } isUTC := isRTCModeUTC(ctx) galog.Infof("Identified RTC mode isUTC to be: [%t] from %q", isUTC, adjtimePath) return isUTC } func isRTCModeUTC(ctx context.Context) bool { if file.Exists(adjtimePath, file.TypeFile) { galog.Infof("Adjtime file %q exists, checking rtc mode", adjtimePath) return checkAdjtimeFile(adjtimePath) } galog.Infof("Adjtime file %q does not exist, checking timedatectl status for rtc mode", adjtimePath) return checkTimedatectl(ctx) } func checkAdjtimeFile(path string) bool { data, err := os.ReadFile(path) if err != nil { galog.Warnf("Failed to read %q: %v, assuming RTC mode to be not UTC", path, err) return false } // For more details on the format of the file, see: // https://man7.org/linux/man-pages/man5/adjtime_config.5.html lines := strings.Split(string(data), "\n") if len(lines) < 3 { galog.Warnf("Invalid format for %q: %v, assuming RTC mode to be not UTC", path, err) return false } // The third line contains the setting. modeStr := strings.TrimSpace(lines[2]) return strings.ToLower(modeStr) == "utc" } func checkTimedatectl(ctx context.Context) bool { // https://www.freedesktop.org/software/systemd/man/latest/timedatectl.html#status cmd := []string{"timedatectl", "status"} opts := run.Options{ Name: cmd[0], Args: cmd[1:], OutputType: run.OutputCombined, } out, err := run.WithContext(ctx, opts) if err != nil { galog.Warnf("Failed to run timedatectl: %v, assuming RTC mode to be not UTC", err) return false } scanner := bufio.NewScanner(strings.NewReader(out.Output)) for scanner.Scan() { line := scanner.Text() if strings.Contains(line, "RTC in local TZ") { // The line looks like: " RTC in local TZ: no" if strings.HasSuffix(strings.TrimSpace(line), "no") { return true } return false } } return false } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_linux_test.go000066400000000000000000000130621524141036400257500ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package clock import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) func createTestAdjtimeFile(t *testing.T, content string) string { t.Helper() dir := t.TempDir() filePath := filepath.Join(dir, "adjtime") if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { t.Fatalf("Failed to write test adjtime file: %v", err) } return filePath } func adjtimeContent(mode string) string { return fmt.Sprintf("%s\n%s\n%s\n", "0.0 0 0.0", "0", mode) } func TestIsEnabled(t *testing.T) { ctx := context.Background() orig := run.Client t.Cleanup(func() { run.Client = orig }) if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned error %v", err) } tests := []struct { name string adjtimeContent string cmdOutput string throwErr bool clockSkewDaemon bool want bool }{ { name: "enabled-utc", adjtimeContent: adjtimeContent("UTC"), clockSkewDaemon: true, want: true, }, { name: "enabled-local", adjtimeContent: adjtimeContent("LOCAL"), clockSkewDaemon: true, want: false, }, { name: "disabled-utc", adjtimeContent: adjtimeContent("UTC"), clockSkewDaemon: false, want: false, }, { name: "adjtime-invalid", adjtimeContent: "invalid", clockSkewDaemon: true, want: false, }, { name: "adjtime-empty", adjtimeContent: "", clockSkewDaemon: true, want: false, }, { name: "fallback-to-timedatectl", clockSkewDaemon: true, want: true, cmdOutput: "RTC in local TZ: no", throwErr: false, }, { name: "fallback-error", clockSkewDaemon: true, want: false, cmdOutput: "RTC in local TZ: no", throwErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.cmdOutput == "" { oldAdjtimePath := adjtimePath adjtimePath = createTestAdjtimeFile(t, tc.adjtimeContent) t.Cleanup(func() { adjtimePath = oldAdjtimePath }) } else { run.Client = &testRunner{output: tc.cmdOutput, throwErr: tc.throwErr} } cfg.Retrieve().Daemons.ClockSkewDaemon = tc.clockSkewDaemon if got := isEnabled(ctx); got != tc.want { t.Errorf("isEnabled() = %v, want %v", got, tc.want) } }) } } func TestIsRTCModeUTC(t *testing.T) { ctx := context.Background() tests := []struct { name string adjtimeContent string want bool }{ { name: "utc", adjtimeContent: adjtimeContent("UTC"), want: true, }, { name: "local", adjtimeContent: adjtimeContent("LOCAL"), want: false, }, { name: "missing-file", adjtimeContent: "", want: false, }, { name: "invalid-format", adjtimeContent: "0.0 0 0.0\n0\n", want: false, }, { name: "lowercase-utc", adjtimeContent: "0.0 0 0.0\n0\nutc\n", want: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { origAdjtimePath := adjtimePath t.Cleanup(func() { adjtimePath = origAdjtimePath }) if tc.adjtimeContent != "" { adjtimePath = createTestAdjtimeFile(t, tc.adjtimeContent) } else { adjtimePath = filepath.Join(t.TempDir(), "nonexistent") } if got := isRTCModeUTC(ctx); got != tc.want { t.Errorf("isRTCModeUTC() = %t, want %t", got, tc.want) } }) } } func TestCheckTimedatectl(t *testing.T) { orig := run.Client t.Cleanup(func() { run.Client = orig }) validOutput := ` Local time: Mon 2025-10-20 16:52:53 UTC Universal time: Mon 2025-10-20 16:52:53 UTC RTC time: Mon 2025-10-20 16:52:53 Time zone: Etc/UTC (UTC, +0000) System clock synchronized: yes NTP service: active RTC in local TZ: no ` ctx := context.Background() tests := []struct { name string content string throwErr bool want bool }{ { name: "utc", content: "RTC in local TZ: no", want: true, }, { name: "utc-spacing", content: " RTC in local TZ: no ", want: true, }, { name: "local", content: "RTC in local TZ: yes", want: false, }, { name: "missing-line", content: "", want: false, }, { name: "command-error", content: "RTC in local TZ: no", throwErr: true, want: false, }, { name: "timedatectl-full-output", content: validOutput, want: true, throwErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { run.Client = &testRunner{output: tc.content, throwErr: tc.throwErr} if got := checkTimedatectl(ctx); got != tc.want { t.Errorf("checkTimedatectl() = %t, want %t", got, tc.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_unix.go000066400000000000000000000105741524141036400245420ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !windows // Package clock is a package responsible for managing clock skew. package clock import ( "context" "fmt" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" ) const ( // clockSkewModuleID is the ID of the clock skew module. clockSkewModuleID = "clock-skew" ) var ( // module is the clock skew implementation instance. module = &clockSkew{} ) // clockSkew is the internal representation of the clock skew module and wraps // internal context data. type clockSkew struct { // prevMetadata is the previously seen metadata descriptor. prevMetadata *metadata.Descriptor } // NewModule returns the clock skew module. It is a no-op on windows. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: clockSkewModuleID, Enabled: &cfg.Retrieve().Daemons.ClockSkewDaemon, Setup: module.moduleSetup, Description: "Setup the underlying OS hardware clock", } } // moduleSetup is the module's Setup callback. It registers a subscriber to // metadata's longpoll event. func (mod *clockSkew) moduleSetup(ctx context.Context, data any) error { desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("clock skew module expects a metadata descriptor in the data pointer") } if !isEnabled(ctx) { galog.Infof("Clock skew configuration is disabled, skipping module setup.") return nil } // Do the initial first setup execution in the module initialization, it will // be handled by the metadata longpoll event handler/subscriber after the // first setup. _, _, err := mod.clockSetup(ctx, desc) if err != nil { galog.Errorf("Failed to run clock skew setup: %v", err) } sub := events.EventSubscriber{Name: clockSkewModuleID, Callback: module.metadataSubscriber, MetricName: acmpb.GuestAgentModuleMetric_CLOCK_INITIALIZATION} events.FetchManager().Subscribe(metadata.LongpollEvent, sub) return nil } // metadataSubscriber is the callback for the metadata event and handles the // platform clock skew's configuration changes. func (mod *clockSkew) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type we log it and // don't renew the handler. if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } // If the event manager is passing/reporting an error we log it and keep // renewing the handler. if evData.Error != nil { galog.Debugf("Metadata event watcher reported error: %s, skiping.", evData.Error) return true, true, nil } return mod.clockSetup(ctx, desc) } // clockSetup is the actual clockSkew's configuration entry point. func (mod *clockSkew) clockSetup(ctx context.Context, desc *metadata.Descriptor) (bool, bool, error) { defer func() { mod.prevMetadata = desc }() // Ignore/return metadata virtual clock's descriptor hasn't changed. if !mod.metadataChanged(desc) { return true, true, nil } galog.Info("Clock drift token has changed, resetting clock skew.") return true, false, platformImpl(ctx) } // metadataChanged returns true if the metadata has changed or if it's being // called on behalf of the first handler's execution. func (mod *clockSkew) metadataChanged(desc *metadata.Descriptor) bool { return mod.prevMetadata == nil || mod.prevMetadata.Instance().VirtualClock().DriftToken() != desc.Instance().VirtualClock().DriftToken() } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_unix_test.go000066400000000000000000000201761524141036400256000ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !windows package clock import ( "context" "errors" "fmt" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) func TestNewModule(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned error %v", err) } module := NewModule(context.Background()) if module.ID != clockSkewModuleID { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, clockSkewModuleID) } if module.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if module.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } if module.Description == "" { t.Errorf("NewModule() returned module with empty Description") } } func TestModuleSetup(t *testing.T) { mdsJSON := ` { "instance": { } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned error %v", mdsJSON, err) } oldAdjtimePath := adjtimePath adjtimePath = createTestAdjtimeFile(t, adjtimeContent("UTC")) defer func() { adjtimePath = oldAdjtimePath }() tests := []struct { name string data any clockSkewDaemon bool wantSubscription bool wantError bool }{ { name: "empty-mds", data: desc, clockSkewDaemon: true, wantSubscription: true, wantError: false, }, { name: "nil-data", data: nil, clockSkewDaemon: true, wantSubscription: false, wantError: true, }, { name: "invalid-data", data: &clockSkew{}, clockSkewDaemon: true, wantError: true, wantSubscription: false, }, { name: "daemon-disabled", data: desc, clockSkewDaemon: false, wantError: false, wantSubscription: false, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned error %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { events.FetchManager().Unsubscribe(metadata.LongpollEvent, clockSkewModuleID) }) cfg.Retrieve().Daemons.ClockSkewDaemon = tc.clockSkewDaemon mod := &clockSkew{} err = mod.moduleSetup(context.Background(), tc.data) if err != nil && !tc.wantError { t.Errorf("moduleSetup() returned error %v, want nil", err) } if got := events.FetchManager().IsSubscribed(metadata.LongpollEvent, clockSkewModuleID); got != tc.wantSubscription { t.Errorf("moduleSetup() subscribed to metadata longpoll event: %t, want %t", got, tc.wantSubscription) } }) } } func TestMetadataSubscriber(t *testing.T) { mdsJSON := ` { "instance": { } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned error %v", mdsJSON, err) } mdsWithTokenJSON := ` { "instance": { "virtualClock": { "driftToken": "token" } } }` descWithToken, err := metadata.UnmarshalDescriptor(mdsWithTokenJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned error %v", mdsWithTokenJSON, err) } tests := []struct { name string data any prevDesc *metadata.Descriptor err error wantError bool wantNoop bool want bool }{ { name: "empty-mds", data: desc, want: true, wantError: true, wantNoop: false, }, { name: "empty-mds-with-error", data: desc, err: errors.New("error"), want: true, wantError: false, wantNoop: true, }, { name: "nil-data", data: nil, want: false, wantNoop: true, wantError: true, }, { name: "invalid-data", data: &clockSkew{}, want: false, wantNoop: true, wantError: true, }, { name: "same-mds", data: descWithToken, prevDesc: descWithToken, want: true, wantError: false, wantNoop: true, }, } cfg.Load(nil) cfg.Retrieve().Daemons.ClockSkewDaemon = true for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := &clockSkew{prevMetadata: tc.prevDesc} res, noop, err := mod.metadataSubscriber(context.Background(), "evType", nil, &events.EventData{Data: tc.data, Error: tc.err}) if res != tc.want { t.Errorf("metadataSubscriber() returned %v, want %v", res, tc.want) } if noop != tc.wantNoop { t.Errorf("metadataSubscriber() returned noop %t, want false", noop) } if (err != nil) != tc.wantError { t.Errorf("metadataSubscriber() returned error %v, want error: %t", err, tc.wantError) } }) } } type testRunner struct { throwErr bool output string } func (tr *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { if tr.throwErr { return nil, errors.New("error") } return &run.Result{Output: tr.output}, nil } func TestClockSetup(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } mdsJSON := ` { "instance": { "virtualClock": { "driftToken": "%s" } } }` newDesc := func(token string) *metadata.Descriptor { d, err := metadata.UnmarshalDescriptor(fmt.Sprintf(mdsJSON, token)) if err != nil { t.Fatalf("Failed to unmarshal descriptor: %v", err) } return d } orig := run.Client t.Cleanup(func() { run.Client = orig }) tests := []struct { name string clockSkewDaemon bool mod *clockSkew desc *metadata.Descriptor wantRenew bool wantNoop bool runClient run.RunnerInterface wantToken string wantErr bool }{ { name: "metadata_unchanged", mod: &clockSkew{prevMetadata: newDesc("token1")}, desc: newDesc("token1"), wantToken: "token1", wantRenew: true, wantNoop: true, runClient: &testRunner{throwErr: false}, wantErr: false, }, { name: "metadata_changed", mod: &clockSkew{prevMetadata: newDesc("token1")}, desc: newDesc("token2"), wantToken: "token2", wantRenew: true, wantNoop: false, runClient: &testRunner{throwErr: false}, wantErr: false, }, { name: "metadata_changed_err", mod: &clockSkew{prevMetadata: newDesc("token1")}, desc: newDesc("token2"), wantToken: "token2", wantRenew: true, wantNoop: false, runClient: &testRunner{throwErr: true}, wantErr: true, }, { name: "no_prev_metadata", mod: &clockSkew{}, desc: newDesc("token1"), wantToken: "token1", wantRenew: true, wantNoop: false, runClient: &testRunner{throwErr: false}, wantErr: false, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned error %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { run.Client = tc.runClient renew, noop, err := tc.mod.clockSetup(ctx, tc.desc) if renew != tc.wantRenew { t.Errorf("clockSetup() renew got %t, want %t", renew, tc.wantRenew) } if noop != tc.wantNoop { t.Errorf("clockSetup() noop got %t, want %t", noop, tc.wantNoop) } if got := tc.mod.prevMetadata.Instance().VirtualClock().DriftToken(); got != tc.wantToken { t.Errorf("clockSetup() prevMetadata got %v, want %v", got, tc.wantToken) } if (err != nil) != tc.wantErr { t.Errorf("clockSetup() err got %v, want error: %t", err, tc.wantErr) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_windows.go000066400000000000000000000016541524141036400252500ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package clock import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule is no-op on windows. func NewModule(context.Context) *manager.Module { galog.V(2).Debug("Clock skew module is not supported on windows.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/clock/clock_windows_test.go000066400000000000000000000014441524141036400263040ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package clock import ( "context" "testing" ) func TestNewModule(t *testing.T) { if NewModule(context.Background()) != nil { t.Error("NewModule() returned non-nil, want nil") } } google-guest-agent-20260819.00/cmd/core_plugin/command/000077500000000000000000000000001524141036400223715ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/command/command.go000066400000000000000000000031051524141036400243350ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package command contains the core-plugin's command monitor module // registration. package command import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" ) const ( // moduleID is the command monitor module ID. moduleID = "command-monitor" ) // NewModule returns the command monitor module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: moduleID, Setup: setup, Quit: command.Close, Description: "A generic command monitor/handler", } } // setup is the command monitor module setup function wrapper. func setup(ctx context.Context, _ any) error { galog.Debug("Initializing command monitor module.") err := command.Setup(ctx, command.ListenerCorePlugin) if err == nil { galog.Debug("Successfully initialized command monitor module.") } return err } google-guest-agent-20260819.00/cmd/core_plugin/command/command_test.go000066400000000000000000000023061524141036400253760ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package command import ( "context" "testing" ) func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module.ID != moduleID { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, moduleID) } if module.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if module.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } if module.Description == "" { t.Errorf("NewModule() returned module with empty Description") } if module.Quit == nil { t.Errorf("NewModule() returned module with nil Quit") } } google-guest-agent-20260819.00/cmd/core_plugin/core_plugin.go000066400000000000000000000166331524141036400236210ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the implementation of the guest agent's core plugin. package main import ( "context" "flag" "fmt" "os" "strings" "sync/atomic" "text/template" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages/early" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages/late" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" ) var ( // logOpts holds the logger options. It's mapped to command line flags. logOpts = logger.Options{ // Core plugin uses the same "local" ident as the guest agent. For example, // With linux's syslog both core plugin and guest-agent will be stored under // the same "name space". Ident: logger.LocalLoggerIdent, // Since core plugin and guest agent are using the same ident, use a prefix // to differentiate their entries in the logs. Prefix: logger.CorePluginLogPrefix, // CloudIdent is the cloud logging's logId attribute - or logName field // visible to the user. CloudIdent: logger.CloudLoggingLogID, } // version is the version of the binary. version = "unknown" // protocol is the protocol to use tcp/uds. protocol string // address is the address to start server listening on. address string // listModules is a flag that forces the plugin to list the modules and exit. listModules bool // errorLogFile is the path to the error log file. errorLogFile string // loggerInitialized is a flag that indicates if the logger has been // initialized. This is used to make sure we don't log anything before the // logger is initialized. loggerInitialized atomic.Bool ) const ( // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second ) func setupFlags() { enableCloudLogging := cfg.Retrieve().Core.CloudLoggingEnabled // In test environments we don't have access to the cloud logging API, in such // a scenario we inject CORE_PLUGIN_CLOUD_LOGGING_ENABLED=false to disable // core-plugin cloud logging support avoiding tests hanging. In some // environments we don't have direct access to core-plugin's cli directly // hence the environment variable. if val := os.Getenv("CORE_PLUGIN_CLOUD_LOGGING_ENABLED"); val != "" { val = strings.ToLower(val) for _, v := range []string{"0", "false", "no", "off"} { if val == v { enableCloudLogging = false break } } } // Log flags. When running in a plugin context these flags will be propagated // by the guest agent's plugin manager. flag.StringVar(&logOpts.LogFile, "logfile", cfg.Retrieve().Core.LogFile, "path to the log file") flag.BoolVar(&logOpts.LogToStderr, "logtostderr", false, "write logs to stderr") flag.BoolVar(&logOpts.LogToCloudLogging, "logtocloud", enableCloudLogging, "write logs to cloud logging") flag.IntVar(&logOpts.Level, "loglevel", cfg.Retrieve().Core.LogLevel, "log level: "+galog.ValidLevels()) flag.IntVar(&logOpts.Verbosity, "logverbosity", cfg.Retrieve().Core.LogVerbosity, "log verbosity") flag.StringVar(&protocol, "protocol", "", "protocol to use uds/tcp") flag.StringVar(&address, "address", "", "address to start server listening on") flag.BoolVar(&listModules, "listmodules", false, "list available modules and exit") flag.StringVar(&errorLogFile, "errorlogfile", "", "path to the fatal error log file") // Ident is propagated by the guest agent's plugin manager so all the logs can // be consolidated in the same syslog/event log bucket. flag.StringVar(&logOpts.Ident, "ident", logOpts.Ident, "ident used to record local system log entries") flag.Parse() } func main() { // Loads the default config definitions and merges them with the user defined // ones. if err := cfg.Load(nil); err != nil { logAndExit(fmt.Sprintf("Failed to load config: %v", err)) } // Set the version of the binary as soon as config is loaded for any other // modules to use. Setting value explicitly after cfg load makes sure version // is as expected and its not coming from instance config or any other files. cfg.Retrieve().Core.Version = version // Setup flag pointers and parse the provided values. setupFlags() depends on // having the cfg package initialized as it depends on some default values // coming from user's configuration. setupFlags() // Initialize the logger. logOpts.ProgramVersion = version if err := logger.Init(context.Background(), logOpts); err != nil { logAndExit(fmt.Sprintf("Failed to initialize logger: %v", err)) } loggerInitialized.Store(true) // List available modules and exit. if listModules { exitCode, err := displayModules() if err != nil { fmt.Fprintln(os.Stderr, "Failed to list modules:", err) } os.Exit(exitCode) } // Start the plugin server, the plugin actual entry point is implemented on // behalf of the Start() operation. The plugin is not considered started until // a "start" rpc call is issued, the application "main" context is defined by // plugin's Start() operation. if err := initPluginServer(); err != nil { logAndExit(fmt.Sprintf("Failed to start plugin server: %v", err)) } } // displayModules displays the list of available modules. func displayModules() (int, error) { const tmpl = `List of currently available and enabled modules: Early stage modules: {{range .EarlyModules}} + {{.Display}} {{end}} Late stage modules: {{range .LateModules}} + {{.Display}} {{end}}` data := struct { EarlyModules []*manager.Module LateModules []*manager.Module }{ early.Retrieve().ListModules(), late.Retrieve().ListModules(), } t, err := template.New("").Parse(tmpl) if err != nil { return 1, fmt.Errorf("failed to parse modules list template: %w", err) } buffer := new(strings.Builder) err = t.Execute(buffer, data) if err != nil { return 1, fmt.Errorf("failed to execute modules list template: %w", err) } fmt.Println(buffer.String()) return 0, nil } // logAndExit logs the message and exits the core-plugin. This is a helper // function which also writes the message to the error log file which is // captured by the guest agent and sent to the ACS. This is not using galog as // we want to capture only fatal errors and not every log message here. // Also, it doesn't support file rotation yet which mean file will grow // indefinitely. Galog file logger will be used only if user has enabled it. func logAndExit(msg string) { var err error if errorLogFile != "" { // Ignore the error if logger is not initialized. As we are exiting anyways // and there's nothing we can do about the error. err = os.WriteFile(errorLogFile, []byte(msg), 0644) } if loggerInitialized.Load() { if err != nil { galog.Errorf("Failed to write error log file %q: %v", errorLogFile, err) } galog.Fatal(msg) } fmt.Fprintln(os.Stderr, msg) os.Exit(1) } google-guest-agent-20260819.00/cmd/core_plugin/diagnostics/000077500000000000000000000000001524141036400232625ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/diagnostics/diagnostics_linux.go000066400000000000000000000016551524141036400273460ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package diagnostics import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule is no-op on linux. func NewModule(context.Context) *manager.Module { galog.V(2).Debug("Diagnostics module is not supported on linux.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/diagnostics/diagnostics_linux_test.go000066400000000000000000000014421524141036400303770ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package diagnostics import ( "context" "testing" ) func TestNewModule(t *testing.T) { if NewModule(context.Background()) != nil { t.Errorf("NewModule() = nil, want non-nil") } } google-guest-agent-20260819.00/cmd/core_plugin/diagnostics/diagnostics_windows.go000066400000000000000000000207701524141036400277000ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows // Package diagnostics implements the diagnostics tooling module. package diagnostics import ( "context" "encoding/json" "errors" "fmt" "slices" "sync/atomic" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/ssh" "golang.org/x/sys/windows/registry" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" ) const ( // diagnosticsCmd is the path to the diagnostics executable, the program used // to collect diagnostics metrics data. diagnosticsCmd = `C:\Program Files\Google\Compute Engine\diagnostics\diagnostics.exe` // diagnosticsRegKey is the registry key used to store the list of diagnostics // entries. diagnosticsRegKey = "Diagnostics" // diagnosticsModuleID is the ID of the diagnostics module. diagnosticsModuleID = "diagnostics" ) var ( // module is the diagnostics implementation instance. module = &diagnosticsModule{} ) type diagnosticsModule struct { // Indicate whether an existing job is running to collect logs. isDiagnosticsRunning atomic.Bool // prevMetadata is the previously seen metadata descriptor. prevMetadata *metadata.Descriptor } // NewModule returns the diagnostic module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: diagnosticsModuleID, Setup: module.moduleSetup, Description: "Collects diagnostics data from the system and uploads it to the specified URL", } } // moduleSetup is the module's Setup callback. It registers a subscriber to // metadata's longpoll event. func (mod *diagnosticsModule) moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing %s module", diagnosticsModuleID) eManager := events.FetchManager() desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("diagnostics module expects a metadata descriptor in the data pointer") } // Do the initial first setup execution in the module initialization, it will // be handled by the metadata longpoll event handler/subscriber after the // first setup. if _, err := mod.handleDiagnosticsRequest(ctx, cfg.Retrieve(), desc); err != nil { galog.Errorf("Failed to handle diagnostics request on setup: %v", err) } sub := events.EventSubscriber{Name: diagnosticsModuleID, Callback: mod.metadataSubscriber, MetricName: acmpb.GuestAgentModuleMetric_DIAGNOSTICS_INITIALIZATION} eManager.Subscribe(metadata.LongpollEvent, sub) galog.Debugf("Finished initializing %s module", diagnosticsModuleID) return nil } // metadataSubscriber is the callback for the metadata event and handles the // diagnostics configuration changes or execution. func (mod *diagnosticsModule) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type we log it and // don't renew the handler. if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } // If the event manager is passing/reporting an error we log it and keep // renewing the handler. if evData.Error != nil { galog.Debugf("Metadata event watcher reported error: %s, skiping.", evData.Error) return true, true, nil } noop, err := mod.handleDiagnosticsRequest(ctx, cfg.Retrieve(), desc) return true, noop, err } // diagnosticsEntry is the structure of the diagnostics metadata entry. type diagnosticsEntry struct { // SignedURL is the URL to the signed URL to upload the logs to. SignedURL string // ExpireOn is the expiration time of the diagnostics request. ExpireOn string // Trace is the flag to enable tracing. Trace bool } // handleDiagnosticsRequest is the actual diagnostics configuration entry point. func (mod *diagnosticsModule) handleDiagnosticsRequest(ctx context.Context, config *cfg.Sections, desc *metadata.Descriptor) (bool, error) { defer func() { mod.prevMetadata = desc }() // If there is an existing job running, reject the request. if mod.isDiagnosticsRunning.Load() { galog.Infof("Diagnostics: reject the request, as an existing process is collecting logs from the system") return true, nil } // Ignore/return if diagnostics configuration is disabled or the // metadata flags haven't changed. if !mod.diagnosticsEnabled(desc, config) || !mod.metadataChanged(desc) { return true, nil } // Check if we've dealt with this entry already. metadataNewEntry := desc.Instance().Attributes().Diagnostics() if metadataNewEntry == "" { galog.Debugf("Diagnostics: request is empty, ignoring.") return true, nil } // Fetch from the registry the list of the existing/seen request entries. regEntries, err := reg.ReadMultiString(diagnosticsRegKey, diagnosticsRegKey) if err != nil && !errors.Is(err, registry.ErrNotExist) { return false, fmt.Errorf("failed to read diagnostics registry key: %v", err) } if slices.Contains(regEntries, metadataNewEntry) { galog.Debugf("Diagnostics: request already seen %q, ignoring.", metadataNewEntry) return false, nil } galog.Infof("Diagnostics: logs export requested.") // Unmarshall the new entry to extract the request details. var entry diagnosticsEntry if err := json.Unmarshal([]byte(metadataNewEntry), &entry); err != nil { return false, fmt.Errorf("failed to unmarshal diagnostics entry: %w", err) } expired, err := ssh.CheckExpired(entry.ExpireOn) if err != nil { return false, fmt.Errorf("failed to check diagnostics request expiration(%v): %w", entry, err) } // Has the request already expired or is it malformed (no signed URL)? if entry.SignedURL == "" || expired { return false, fmt.Errorf("diagnostics: request %v is malformed or expired, ignoring", metadataNewEntry) } cmd := []string{diagnosticsCmd, "-signedUrl", entry.SignedURL} if entry.Trace { cmd = append(cmd, "-trace") } // Set flag job is running only when it is about to start. mod.isDiagnosticsRunning.Store(true) go func() { galog.Infof("Diagnostics: collecting logs from the system.") // Job is done, unblock the upcoming requests. defer func() { mod.isDiagnosticsRunning.Swap(false) }() // Actually run the diagnostics command. opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputCombined} res, err := run.WithContext(ctx, opts) if err != nil { galog.Errorf("Error collecting logs: %v", err) return } galog.Info(res.Output) }() regEntries = append(regEntries, metadataNewEntry) if err := reg.WriteMultiString(reg.GCEKeyBase, diagnosticsRegKey, regEntries); err != nil { return false, fmt.Errorf("failed to write diagnostics registry key: %v", err) } return false, nil } // metadataChanged returns true if the diagnostics metadata flags have changed. func (mod *diagnosticsModule) metadataChanged(desc *metadata.Descriptor) bool { return mod.prevMetadata == nil || desc.Instance().Attributes().Diagnostics() != mod.prevMetadata.Instance().Attributes().Diagnostics() } // diagnosticsEnabled returns true if the diagnostics feature is enabled. func (mod *diagnosticsModule) diagnosticsEnabled(desc *metadata.Descriptor, config *cfg.Sections) bool { // Diagnostics are opt-in and enabled by default. if config.Diagnostics != nil { return config.Diagnostics.Enable } if desc.Instance().Attributes().EnableDiagnostics() != nil { return *desc.Instance().Attributes().EnableDiagnostics() } if desc.Project().Attributes().EnableDiagnostics() != nil { return *desc.Project().Attributes().EnableDiagnostics() } // By default, diagnostics are enabled. return true } google-guest-agent-20260819.00/cmd/core_plugin/diagnostics/diagnostics_windows_test.go000066400000000000000000000220011524141036400307240ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package diagnostics import ( "context" "encoding/json" "errors" "reflect" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) func TestNewModule(t *testing.T) { mod := NewModule(context.Background()) if mod.ID != diagnosticsModuleID { t.Errorf("NewModule() returned module with ID %q, want %q", mod.ID, diagnosticsModuleID) } if mod.Description == "" { t.Errorf("NewModule() returned module with empty Description") } if mod.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if mod.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } } func TestEventSubscriberInvalidData(t *testing.T) { type invalidDataType struct { handle string } tests := []struct { name string data any }{ { name: "invalid_data_type", data: &events.EventData{Data: &invalidDataType{}}, }, { name: "nil", data: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := &diagnosticsModule{} evData := &events.EventData{Data: tc.data} ctx := context.Background() evType := "evType" gotContinue, gotNoop, err := mod.metadataSubscriber(ctx, evType, nil, evData) if err == nil { t.Errorf("metadataSubscriber(context.Background(), %q, nil, %v) succeeded, want error", evType, evData) } if !gotNoop { t.Errorf("metadataSubscriber(context.Background(), %q, nil, %v) returned noop = false, want true", evType, evData) } if gotContinue { t.Errorf("metadataSubscriber(context.Background(), %q, nil, %v) returned continue = true, want false", evType, evData) } }) } } func TestDiagnosticsEnabled(t *testing.T) { tests := []struct { name string config *cfg.Sections want bool mdsJSON string }{ { name: "config_disabled", config: &cfg.Sections{ Diagnostics: &cfg.Diagnostics{ Enable: false, }, }, want: false, }, { name: "config_enabled", config: &cfg.Sections{ Diagnostics: &cfg.Diagnostics{ Enable: true, }, }, want: true, }, { name: "instance_enabled", config: &cfg.Sections{}, want: true, mdsJSON: `{"instance": {"attributes": {"enable-diagnostics": "true"}}}`, }, { name: "instance_disabled", config: &cfg.Sections{}, want: false, mdsJSON: `{"instance": {"attributes": {"enable-diagnostics": "false"}}}`, }, { name: "project_enabled", config: &cfg.Sections{}, want: true, mdsJSON: `{"project": {"attributes": {"enable-diagnostics": "true"}}}`, }, { name: "project_disabled", config: &cfg.Sections{}, want: false, mdsJSON: `{"project": {"attributes": {"enable-diagnostics": "false"}}}`, }, { name: "no_config", config: &cfg.Sections{}, want: true, mdsJSON: `{"project": {"attributes": {}}}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var ( desc *metadata.Descriptor err error ) mod := &diagnosticsModule{} if tc.mdsJSON != "" { desc, err = metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.mdsJSON, err) } } got := mod.diagnosticsEnabled(desc, tc.config) if got != tc.want { t.Errorf("diagnosticsEnabled(%v, %v) = %v, want %v", desc, tc.config, got, tc.want) } }) } } func TestMetadataChanged(t *testing.T) { tests := []struct { name string mdsJSON string prevMdsJSON string want bool }{ { name: "first_execution", want: true, }, { name: "different_attributes", mdsJSON: `{"instance": {"attributes": {"diagnostics": "AAA"}}}`, prevMdsJSON: `{"instance": {"attributes": {"diagnostics": "BBB"}}}`, want: true, }, { name: "same_attributes", mdsJSON: `{"instance": {"attributes": {"diagnostics": "AAA"}}}`, prevMdsJSON: `{"instance": {"attributes": {"diagnostics": "AAA"}}}`, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var ( desc *metadata.Descriptor prevDesc *metadata.Descriptor err error ) mod := &diagnosticsModule{} if tc.mdsJSON != "" { desc, err = metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.mdsJSON, err) } } if tc.prevMdsJSON != "" { prevDesc, err = metadata.UnmarshalDescriptor(tc.prevMdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.prevMdsJSON, err) } mod.prevMetadata = prevDesc } if got := mod.metadataChanged(desc); got != tc.want { t.Errorf("metadataChanged(%v) = %v, want %v", desc, got, tc.want) } }) } } func TestHandleRequest(t *testing.T) { tests := []struct { name string mdsJSON string expectedError any wantNoop bool }{ { name: "invalid_diagnostics_json", mdsJSON: ` { "instance": { "attributes": { "enable-diagnostics": "true", "diagnostics": "{'signed-url': 'foobar', 'expire-on': 'foobar'}" } } }`, expectedError: &json.SyntaxError{}, }, { name: "invalid_diagnostics_json", mdsJSON: ` { "instance": { "attributes": { "enable-diagnostics": "true", "diagnostics": "{\"signedUrl\": \"foobar\", \"expireOn\": \"foobar\"}" } } }`, expectedError: &time.ParseError{}, }, { name: "no_signed_url", mdsJSON: ` { "instance": { "attributes": { "enable-diagnostics": "true", "diagnostics": "{\"signedUrl\": \"\", \"expireOn\": \"2300-01-02T15:04:05-0700\"}" } } }`, expectedError: errors.New(""), }, { name: "success", mdsJSON: ` { "instance": { "attributes": { "enable-diagnostics": "true", "diagnostics": "{\"signedUrl\": \"http://foobar\", \"expireOn\": \"2300-01-02T15:04:05-0700\"}" } } }`, }, { name: "empty_diagnostics", wantNoop: true, mdsJSON: ` { "instance": { "attributes": { "diagnostics": "" } } }`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { desc, err := metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.mdsJSON, err) } config := &cfg.Sections{} mod := &diagnosticsModule{} noop, err := mod.handleDiagnosticsRequest(context.Background(), config, desc) if err != nil && tc.expectedError != nil { // Error may not be wrapped. xerr := errors.Unwrap(err) if xerr == nil { xerr = err } if got, want := reflect.TypeOf(xerr), reflect.TypeOf(tc.expectedError); got != want { t.Errorf("handleDiagnosticsRequest(context.Background(), %v, %v) failed: %v", config, desc, err) } } if noop != tc.wantNoop { t.Errorf("handleDiagnosticsRequest(context.Background(), %v, %v) returned noop = %t, want %t", config, desc, noop, tc.wantNoop) } }) } } func TestRunningControlFlag(t *testing.T) { tests := []struct { name string flag bool want bool wantNoop bool }{ { name: "running", flag: true, want: true, wantNoop: true, }, { name: "not_running", flag: false, want: true, wantNoop: false, }, } mdsJSON := ` { "instance": { "attributes": { "enable-diagnostics": "true", "diagnostics": "{\"signedUrl\": \"http://foobar\", \"expireOn\": \"2300-01-02T15:04:05-0700\"}" } } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", mdsJSON, err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := &diagnosticsModule{} mod.isDiagnosticsRunning.Store(tc.flag) config := &cfg.Sections{} noop, err := mod.handleDiagnosticsRequest(context.Background(), config, desc) if err != nil { t.Fatalf("handleDiagnosticsRequest(context.Background(), %v, %v) failed: %v", config, desc, err) } if got := mod.isDiagnosticsRunning.Load(); got != tc.want { t.Errorf("handleDiagnosticsRequest(context.Background(), %v, %v) = %v, want %v", config, desc, got, tc.want) } if noop != tc.wantNoop { t.Errorf("handleDiagnosticsRequest(context.Background(), %v, %v) returned noop = %t, want %t", config, desc, noop, tc.wantNoop) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/dnsprobe/000077500000000000000000000000001524141036400225675ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/dnsprobe/dnsprobe.go000066400000000000000000000124701524141036400247360ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package dnsprobe implements the scheduler for probing GCE DNS server. package dnsprobe import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "net" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" ) var ( newMDSClient func() metadata.MDSClientInterface = func() metadata.MDSClientInterface { return metadata.New() } ) const ( // moduleID is the module ID for DNS probe scheduler. moduleID = "dnsprobe" // probeInterval is the interval at which DNS probe is executed. probeInterval = 1 * time.Minute // dnsProbeTimeout is the timeout for DNS lookups. dnsProbeTimeout = 5 * time.Second // gceDNSServer is the IP address of GCE DNS server. gceDNSServer = "169.254.169.254:53" ) // lookupHost is a function type for DNS lookups, can be replaced in tests. type lookupHost func(ctx context.Context, host string) ([]string, error) // Job implements job scheduler interface for probing DNS. type Job struct { // client is the MDS client. client metadata.MDSClientInterface // lastProbeFailed indicates whether the last DNS probe timed out. lastProbeFailed bool // lastProbeErrored indicates whether the last DNS probe returned an unexpected error. lastProbeErrored bool // lookup is the function to use for DNS lookups. lookup lookupHost } // NewModule returns the first boot module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: moduleID, Setup: moduleSetup, Quit: teardown, Description: "DNS probe module probes GCE DNS server", } } // teardown unschedules the DNS probe job. func teardown(context.Context) { scheduler.Instance().UnscheduleJob(moduleID) } // moduleSetup schedules a job to probe DNS. func moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing DNS probe module.") resolver := &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{ Timeout: dnsProbeTimeout, } return d.DialContext(ctx, "udp", gceDNSServer) }, } job := &Job{ client: newMDSClient(), lookup: resolver.LookupHost, } err := scheduler.Instance().ScheduleJob(ctx, job) if err == nil { galog.Debugf("Successfully initialized DNS probe job.") } return err } // ID returns the ID for this job. func (j *Job) ID() string { return moduleID } // MetricName returns the metric name for the job. func (j *Job) MetricName() acppb.GuestAgentModuleMetric_Metric { return acppb.GuestAgentModuleMetric_MODULE_UNSPECIFIED } func randomHex(n int) (string, error) { bytes := make([]byte, n) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } func isTimeout(err error) bool { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return dnsErr.IsTimeout } return false } // Run probes DNS. func (j *Job) Run(ctx context.Context) (bool, error) { status := "nxdomain" if j.lastProbeFailed { status = "timeout" } else if j.lastProbeErrored { status = "error" } random, err := randomHex(8) if err != nil { j.lastProbeFailed = true // Consider inability to generate random as failure. return j.ShouldEnable(ctx), fmt.Errorf("failed to generate random string: %w", err) } domain := fmt.Sprintf("%s.%s.probes.google.internal", random, status) ctxTimeout, cancel := context.WithTimeout(ctx, dnsProbeTimeout) defer cancel() _, err = j.lookup(ctxTimeout, domain) j.lastProbeFailed = isTimeout(err) // We expect "no such host" error, which means DNS server responded with NXDOMAIN. // A timeout is indicated by j.lastProbeFailed=true. // Any other error or no error is unexpected but we only track timeouts. if err != nil && !j.lastProbeFailed { var dnsErr *net.DNSError if !errors.As(err, &dnsErr) || !dnsErr.IsNotFound { galog.Debugf("DNS probe for %s returned unexpected result: %v", domain, err) j.lastProbeErrored = true } } if err == nil { galog.Debugf("DNS probe for %s unexpectedly resolved", domain) } return j.ShouldEnable(ctx), nil } // Interval returns the interval at which job is executed. func (j *Job) Interval() (time.Duration, bool) { return probeInterval, true } // ShouldEnable returns true as long as disable-dns-probe is not set in metadata. func (j *Job) ShouldEnable(ctx context.Context) bool { md, err := j.client.Get(ctx) if err != nil { return false } return !md.Instance().Attributes().DisableDNSProbe() && !md.Project().Attributes().DisableDNSProbe() } google-guest-agent-20260819.00/cmd/core_plugin/dnsprobe/dnsprobe_test.go000066400000000000000000000222541524141036400257760ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dnsprobe import ( "context" "fmt" "net" "strings" "testing" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" ) func TestNewModule(t *testing.T) { m := NewModule(context.Background()) if m.ID != moduleID { t.Errorf("m.ID = %s, want %s", m.ID, moduleID) } if m.Description == "" { t.Errorf("m.Description = empty, want non-empty") } if m.Setup == nil { t.Errorf("m.Setup = nil, want non-nil") } if m.Quit == nil { t.Errorf("m.Quit = nil, want non-nil") } } // MDSClient implements fake metadata server. type MDSClient struct { projectDisable bool instanceDisable bool enableBoth bool throwErr bool } const attrJSON = `{"instance": {"attributes": {"disable-dns-probe": "%s"}}, "project": {"attributes": {"disable-dns-probe": "%s"}}}` // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s *MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", nil } // GetKey implements fake GetKey MDS method. func (s *MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { return "", nil } // Get method implements fake Get on MDS. func (s *MDSClient) Get(context.Context) (*metadata.Descriptor, error) { if s.throwErr { return nil, fmt.Errorf("test error") } var jsonData string if s.instanceDisable { jsonData = fmt.Sprintf(attrJSON, "true", "false") } else if s.projectDisable { jsonData = fmt.Sprintf(attrJSON, "false", "true") } else { jsonData = fmt.Sprintf(attrJSON, "false", "false") } return metadata.UnmarshalDescriptor(jsonData) } // Watch method implements fake watcher on MDS. func (s *MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s *MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } func TestJobInterface(t *testing.T) { j := &Job{} if j.ID() != moduleID { t.Errorf("j.ID() = %s, want %s", j.ID(), moduleID) } if j.MetricName() != acppb.GuestAgentModuleMetric_MODULE_UNSPECIFIED { t.Errorf("j.MetricName() = %s, want %s", j.MetricName().String(), acppb.GuestAgentModuleMetric_MODULE_UNSPECIFIED.String()) } interval, enable := j.Interval() if interval != probeInterval { t.Errorf("j.Interval() = interval %v, want %v", interval, probeInterval) } if !enable { t.Errorf("j.Interval() = enable %t, want true", enable) } } func TestShouldEnable(t *testing.T) { ctx := context.Background() tests := []struct { name string client *MDSClient want bool }{ { name: "enabled", client: &MDSClient{enableBoth: true}, want: true, }, { name: "mds_error", client: &MDSClient{throwErr: true}, want: false, }, { name: "project_disable", client: &MDSClient{projectDisable: true}, want: false, }, { name: "instance_disabled", client: &MDSClient{instanceDisable: true}, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { job := &Job{client: tc.client} if got := job.ShouldEnable(ctx); got != tc.want { t.Errorf("job.ShouldEnable(ctx) = %t, want %t", got, tc.want) } }) } } type fakeLookup struct { lookupErr error host string } func (f *fakeLookup) Lookup(ctx context.Context, host string) ([]string, error) { f.host = host return nil, f.lookupErr } func TestRun(t *testing.T) { tests := []struct { name string initialFail bool lookupErr error wantHostPart string wantFail bool wantRerun bool disableDNSProbe bool wantRerunIfNormal bool }{ { name: "success_after_success", initialFail: false, lookupErr: &net.DNSError{IsNotFound: true, Err: "not found"}, wantHostPart: "nxdomain.probes.google.internal", wantFail: false, wantRerunIfNormal: true, }, { name: "success_after_fail", initialFail: true, lookupErr: &net.DNSError{IsNotFound: true, Err: "not found"}, wantHostPart: "timeout.probes.google.internal", wantFail: false, wantRerunIfNormal: true, }, { name: "timeout_after_success", initialFail: false, lookupErr: &net.DNSError{IsTimeout: true, Err: "timeout"}, wantHostPart: "nxdomain.probes.google.internal", wantFail: true, wantRerunIfNormal: true, }, { name: "timeout_after_fail", initialFail: true, lookupErr: &net.DNSError{IsTimeout: true, Err: "timeout"}, wantHostPart: "timeout.probes.google.internal", wantFail: true, wantRerunIfNormal: true, }, { name: "other_error", initialFail: false, lookupErr: fmt.Errorf("other error"), wantHostPart: "nxdomain.probes.google.internal", wantFail: false, wantRerunIfNormal: true, }, { name: "telemetry_disabled", initialFail: false, lookupErr: &net.DNSError{IsNotFound: true, Err: "not found"}, wantHostPart: "nxdomain.probes.google.internal", wantFail: false, disableDNSProbe: true, wantRerunIfNormal: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fl := &fakeLookup{lookupErr: tc.lookupErr} client := &MDSClient{instanceDisable: tc.disableDNSProbe} if !tc.disableDNSProbe { client.enableBoth = true } job := &Job{ client: client, lastProbeFailed: tc.initialFail, lookup: fl.Lookup, } rerun, err := job.Run(context.Background()) if err != nil { t.Errorf("job.Run(ctx) = %v, want nil error", err) } if rerun != tc.wantRerunIfNormal { t.Errorf("job.Run(ctx) rerun = %t, want %t", rerun, tc.wantRerunIfNormal) } if !strings.Contains(fl.host, tc.wantHostPart) { t.Errorf("lookup host = %s, want host containing %s", fl.host, tc.wantHostPart) } if job.lastProbeFailed != tc.wantFail { t.Errorf("job.lastProbeFailed = %t, want %t", job.lastProbeFailed, tc.wantFail) } }) } } func TestIsTimeout(t *testing.T) { tests := []struct { name string err error want bool }{ {"nil", nil, false}, {"timeout", &net.DNSError{IsTimeout: true}, true}, {"not found", &net.DNSError{IsNotFound: true}, false}, {"other error", fmt.Errorf("other"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isTimeout(tt.err); got != tt.want { t.Errorf("isTimeout() = %v, want %v", got, tt.want) } }) } } func TestRandomHex(t *testing.T) { got, err := randomHex(8) if err != nil { t.Fatalf("randomHex(8) failed: %v", err) } if len(got) != 16 { t.Errorf("randomHex(8) returned string of length %d, want 16", len(got)) } } func TestModuleSetupTeardown(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } oldNewMDSClient := newMDSClient defer func() { newMDSClient = oldNewMDSClient }() newMDSClient = func() metadata.MDSClientInterface { return &MDSClient{enableBoth: true} } ctx := context.Background() if err := moduleSetup(ctx, nil); err != nil { t.Fatalf("moduleSetup failed: %v", err) } if !scheduler.Instance().IsScheduled(moduleID) { t.Errorf("Job %s is not scheduled after moduleSetup", moduleID) } teardown(ctx) if scheduler.Instance().IsScheduled(moduleID) { t.Errorf("Job %s is still scheduled after teardown", moduleID) } } func TestRunProbeError(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } fl := &fakeLookup{lookupErr: fmt.Errorf("other error")} client := &MDSClient{enableBoth: true} job := &Job{ client: client, lookup: fl.Lookup, } // First run, lookup returns "other error", should set lastProbeErrored to true. job.Run(context.Background()) // Second run, lookup returns success, domain should contain "error" status // because last probe errored. fl.lookupErr = &net.DNSError{IsNotFound: true, Err: "not found"} job.Run(context.Background()) if !strings.Contains(fl.host, "error.probes.google.internal") { t.Errorf("lookup host = %s, want host containing error.probes.google.internal", fl.host) } } func TestIPv6Support(t *testing.T) { // TODO(b/455951140) Add IPv6 support to the DNS probe and add tests. t.Skip("IPv6 support is not implemented yet.") } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/000077500000000000000000000000001524141036400227665ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot.go000066400000000000000000000143361524141036400253370ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package firstboot provides a module to setup instance id, generate host ssh // keys and generate boto config file. package firstboot import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // firstbootModuleID is the ID of the iosched module. firstbootModuleID = "firstboot" // instanceIDFile is the name of the instance id file. instanceIDFile = "google_instance_id" ) // NewModule returns the first boot module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: firstbootModuleID, Setup: moduleSetup, Description: "Set up instance id, generates host ssh keys and generates boto config file", } } // NewEarlyModule returns the first boot module for early stage registration. func NewEarlyModule(context.Context) *manager.Module { return &manager.Module{ ID: firstbootModuleID, BlockSetup: moduleSetupEarly, Description: "Set up instance id, generates host ssh keys and generates boto config file", } } // moduleSetupEarly attempts to setup the firstboot module in the early stage. // If the metadata descriptor is not available, it will attempt to get it from // the metadata client. This is done to ensure hostkeys are generated as early // as possible to minimize the ssh downtime because of missing hostkeys. If this // succeeds, the late module will be noop and if it fails we just log the error // to retry in the late stage. func moduleSetupEarly(ctx context.Context, data any) error { galog.Debugf("Attempting to setup firstboot module in early stage.") var err error desc, ok := data.(*metadata.Descriptor) if !ok { desc, err = metadata.New().Get(ctx) if err != nil { galog.Errorf("Failed to get metadata descriptor: %v, skipping firstboot module setup", err) return nil } } if err := moduleSetup(ctx, desc); err != nil { galog.Errorf("Failed to setup firstboot module in early stage: %v", err) } return nil } // moduleSetup sets up the firstboot module. func moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing firstboot module.") desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("firstboot module expects a metadata descriptor in the data pointer") } instanceID := desc.Instance().ID().String() projectID := desc.Project().ID() config := cfg.Retrieve() if err := runFirstboot(ctx, instanceID, projectID, config); err != nil { return fmt.Errorf("failed to run firstboot: %v", err) } galog.Debugf("Finished firstboot module setup.") return nil } // runFirstboot runs the firstboot module setup. func runFirstboot(ctx context.Context, instanceID string, projectID string, config *cfg.Sections) error { isFirstboot, err := firstbootRun(instanceID, projectID, config) if err != nil { return fmt.Errorf("failed to check if we are in firstboot: %v", err) } if !isFirstboot { return nil } galog.Infof("Running firstboot setup...") // InstanceID writing path is common between linux and windows so have it done // before platform specific setup. if err := writeInstanceID(config.Instance.InstanceIDDir, instanceID); err != nil { return err } if err := platformSetup(ctx, projectID, config); err != nil { return fmt.Errorf("failed to setup instance id: %v", err) } galog.Infof("Finished firstboot setup.") return nil } // firstbootRun returns true if the instance is being booted for the first time, // or in a broader sense if the instance id has changed. func firstbootRun(instanceID string, projectID string, config *cfg.Sections) (bool, error) { fPath := filepath.Join(config.Instance.InstanceIDDir, instanceIDFile) var currentInstanceID string // If the instance id file exists, read the current instance id from it. if file.Exists(fPath, file.TypeFile) { data, err := os.ReadFile(fPath) if err != nil { return false, fmt.Errorf("failed to read instance id file: %w", err) } currentInstanceID = strings.TrimSpace(string(data)) } // If the current instance id is empty, use the instance id from the config. // The instance id in the config file is the legacy instance id configuration // method, we try to honor it if the current instance id is empty. if currentInstanceID == "" { currentInstanceID = config.Instance.InstanceID } // No need to update the instance id file if the current instance id is the // same as the new one. if currentInstanceID == instanceID { galog.Infof("Instance id is already set, no update required.") return false, nil } return true, nil } // writeInstanceID writes the instance id to the file. func writeInstanceID(fPath string, newInstanceID string) error { // Make parent directories if they don't exist. if err := os.MkdirAll(fPath, 0755); err != nil { return fmt.Errorf("failed to create instance id file: %w", err) } // Write the instance id to the file. instanceIDFilePath := filepath.Join(fPath, instanceIDFile) f, err := os.OpenFile(instanceIDFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return fmt.Errorf("failed to open instance id file: %w", err) } defer f.Close() n, err := f.WriteString(newInstanceID) if err != nil { return fmt.Errorf("failed to write instance id file: %w", err) } if n != len(newInstanceID) { return fmt.Errorf("failed to write instance id file: %w", err) } galog.Debugf("Successfully wrote instance id file: %s", instanceIDFilePath) return nil } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot_linux.go000066400000000000000000000147741524141036400265640ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package firstboot import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "gopkg.in/ini.v1" ) const ( // hostKeyFilePrefix is the common/known prefix of the host key file name. hostKeyFilePrefix = "ssh_host_" // hostKeyFileSuffix is the common/known suffix of the host key file name. hostKeyFileSuffix = "_key" ) var ( // botoConfigFile is the path to the boto config file. botoConfigFile = "/etc/boto.cfg" ) // platformSetup runs the actual firstboot setup for linux. func platformSetup(ctx context.Context, projectID string, config *cfg.Sections) error { // Generate host SSH keys and upload them to guest attributes. if config.InstanceSetup.SetHostKeys { if err := writeSSHKeys(ctx, config.InstanceSetup); err != nil { return err } } // Write the boto config file. if config.InstanceSetup.SetBotoConfig { if err := writeBotoConfig(projectID); err != nil { return err } } return nil } // writeBotoConfig overwrites the boto config file with the provided project id, // sets the default_api_version to 2, and sets the service_account to default. func writeBotoConfig(projectID string) error { galog.Debugf("Writing boto config file: %s", botoConfigFile) templatePath := botoConfigFile + ".template" botoCfg, err := ini.LooseLoad(botoConfigFile, templatePath) if err != nil { return fmt.Errorf("failed to load boto config: %w", err) } botoCfg.Section("GSUtil").Key("default_project_id").SetValue(projectID) botoCfg.Section("GSUtil").Key("default_api_version").SetValue("2") botoCfg.Section("GoogleCompute").Key("service_account").SetValue("default") if err := botoCfg.SaveTo(botoConfigFile); err != nil { return fmt.Errorf("failed to save boto config: %w", err) } galog.Debugf("Successfully wrote boto config file: %s", botoConfigFile) return nil } // writeSSHKeys generates host SSH keys and uploads them to guest attributes. func writeSSHKeys(ctx context.Context, instanceSetup *cfg.InstanceSetup) error { if instanceSetup == nil { galog.V(2).Debug("No instance setup config, skipping SSH key generation") return nil } galog.Debugf("Generating SSH host keys") hostKeyDir := instanceSetup.HostKeyDir dir, err := os.Open(hostKeyDir) if err != nil { return fmt.Errorf("failed to open host key dir: %w", err) } defer dir.Close() files, err := dir.Readdirnames(0) if err != nil { return fmt.Errorf("failed to read host key dir: %w", err) } keytypes := make(map[string]bool) // Find keys present on disk, and deduce their type from filename. for _, file := range files { if !hostKeyFile(file) { galog.V(2).Debugf("Skipping file %q, not a key file", file) continue } keytype := file keytype = strings.TrimPrefix(keytype, hostKeyFilePrefix) keytype = strings.TrimSuffix(keytype, hostKeyFileSuffix) keytypes[keytype] = true } // List keys we should generate, according to the config. configKeys := instanceSetup.HostKeyTypes for _, keytype := range strings.Split(configKeys, ",") { keytypes[keytype] = true } client := metadata.New() // Generate new keys and upload to guest attributes. for keytype := range keytypes { keyfile := filepath.Join(hostKeyDir, fmt.Sprintf("%s%s%s", hostKeyFilePrefix, keytype, hostKeyFileSuffix)) pubKeyFile := keyfile + ".pub" tmpKeyFile := keyfile + ".temp" tmpPubKeyFile := keyfile + ".temp.pub" galog.Debugf("Generating %s type SSH host key at %q", keytype, tmpKeyFile) cmd := []string{"ssh-keygen", "-t", keytype, "-f", tmpKeyFile, "-N", "", "-q"} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { galog.Warnf("Failed to generate SSH host key %q: %v", keyfile, err) continue } if err := os.Chmod(tmpKeyFile, 0600); err != nil { galog.Errorf("Failed to chmod SSH host key %q: %v", tmpKeyFile, err) continue } if err := os.Chmod(tmpPubKeyFile, 0644); err != nil { galog.Errorf("Failed to chmod SSH host key %q: %v", tmpPubKeyFile, err) continue } if err := os.Rename(tmpKeyFile, keyfile); err != nil { galog.Errorf("Failed to overwrite %q: %v", keyfile, err) continue } if err := os.Rename(tmpPubKeyFile, pubKeyFile); err != nil { galog.Errorf("Failed to overwrite %q: %v", keyfile+".pub", err) continue } pubKey, err := os.ReadFile(pubKeyFile) if err != nil { galog.Errorf("Can't read %s public key: %v", keytype, err) continue } vals := strings.Split(string(pubKey), " ") if len(vals) < 2 { galog.Warnf("Generated key(%q) is malformed, not uploading", keytype) continue } galog.Infof("Successfully generated %s type public key at %q", keytype, pubKeyFile) if err := client.WriteGuestAttributes(ctx, "hostkeys/"+vals[0], vals[1]); err != nil { galog.Errorf("Failed to upload %s key to guest attributes: %v", keytype, err) } galog.V(1).Debugf("Successfully uploaded %s type public key to guest attributes", keytype) } _, err = exec.LookPath("restorecon") if err != nil { galog.Debugf("restorecon not found, skipping SELinux context restoration") galog.Debugf("Finished generating SSH host keys") return nil } cmd := []string{"restorecon", "-FR", hostKeyDir} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to restore SELinux context for: %s, %w", hostKeyDir, err) } galog.Debugf("Finished generating SSH host keys") return nil } // hostKeyFile returns true if the file name matches the pattern of a host key // file. func hostKeyFile(fName string) bool { return strings.HasPrefix(fName, hostKeyFilePrefix) && strings.HasSuffix(fName, hostKeyFileSuffix) && len(fName) > len(hostKeyFilePrefix+hostKeyFileSuffix) } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot_linux_test.go000066400000000000000000000222661524141036400276160ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package firstboot import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) func TestSetupExistingFileSuccess(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() instanceFilePath := filepath.Join(tmpDir, "instanceid") botoConfigFile = filepath.Join(tmpDir, "boto.cfg") config := &cfg.Sections{ Instance: &cfg.Instance{ InstanceID: "pre-defined-instance-id", InstanceIDDir: tmpDir, }, InstanceSetup: &cfg.InstanceSetup{ SetBotoConfig: false, SetHostKeys: false, }, } f, err := os.Create(instanceFilePath) if err != nil { t.Errorf("Create(%q) = %v, want nil", instanceFilePath, err) } if err := f.Close(); err != nil { t.Errorf("Close() = %v, want nil", err) } instanceID := "foobar" projectID := "fake-project-id" if err := runFirstboot(context.Background(), instanceID, projectID, config); err != nil { t.Errorf("runFirstboot(%q, %q, %v) = %v, want nil", instanceID, projectID, config, err) } } func TestSetupSuccess(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() botoConfigFile = filepath.Join(tmpDir, "boto.cfg") config := &cfg.Sections{ Instance: &cfg.Instance{ InstanceID: "pre-defined-instance-id", InstanceIDDir: tmpDir, }, InstanceSetup: &cfg.InstanceSetup{ SetBotoConfig: false, SetHostKeys: false, }, } instanceID := "foobar" projectID := "fake-project-id" if err := runFirstboot(context.Background(), instanceID, projectID, config); err != nil { t.Errorf("runFirstboot(%q, %q, %v) = %v, want nil", instanceID, projectID, config, err) } } func TestSetupLinuxFailure(t *testing.T) { tests := []struct { name string invalidBotoConfigFile bool invalidHostKeyDir bool invalidInstanceIDDir bool }{ { name: "invalid-boto-config-file", invalidBotoConfigFile: true, invalidHostKeyDir: false, invalidInstanceIDDir: false, }, { name: "invalid-hostkey-dir", invalidBotoConfigFile: false, invalidHostKeyDir: true, invalidInstanceIDDir: false, }, { name: "invalid-instanceid-dir", invalidBotoConfigFile: false, invalidHostKeyDir: false, invalidInstanceIDDir: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() botoConfigFile = filepath.Join(tmpDir, "boto.cfg") if tc.invalidBotoConfigFile { botoConfigFile = filepath.Join(tmpDir, "boto-config-dir", "boto.cfg") } config := &cfg.Sections{ Instance: &cfg.Instance{ InstanceID: "pre-defined-instance-id", InstanceIDDir: tmpDir, }, InstanceSetup: &cfg.InstanceSetup{ SetBotoConfig: true, SetHostKeys: true, }, } if tc.invalidInstanceIDDir { config.Instance.InstanceIDDir = filepath.Join("/dev/null", "invalid-dir") } if tc.invalidHostKeyDir { config.InstanceSetup.HostKeyDir = filepath.Join(tmpDir, "invalid-host-key-dir") } instanceID := "foobar" projectID := "fake-project-id" if err := runFirstboot(context.Background(), instanceID, projectID, config); err == nil { t.Errorf("runFirstboot(%q, %q, %v) = nil, want non-nil", instanceID, projectID, config) } }) } } func TestSetupSameIDSuccess(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() botoConfigFile = filepath.Join(tmpDir, "boto.cfg") config := &cfg.Sections{ Instance: &cfg.Instance{ InstanceID: "foobar", InstanceIDDir: tmpDir, }, InstanceSetup: &cfg.InstanceSetup{ SetBotoConfig: false, SetHostKeys: false, }, } instanceID := "foobar" projectID := "fake-project-id" if err := runFirstboot(context.Background(), instanceID, projectID, config); err != nil { t.Errorf("runFirstboot(%q, %q, %v) = %v, want nil", instanceID, projectID, config, err) } } func TestGenerateSSHKeysSuccess(t *testing.T) { tests := []struct { name string restoreCon bool }{ { name: "with-restorecon", restoreCon: true, }, { name: "without-restorecon", restoreCon: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() restoreConPath := filepath.Join(tmpDir, "restorecon") hostKeysDir := filepath.Join(tmpDir, "host_keys") pathVal := os.Getenv("PATH") t.Setenv("PATH", pathVal+":"+tmpDir) config := &cfg.Sections{ InstanceSetup: &cfg.InstanceSetup{ HostKeyDir: hostKeysDir, HostKeyTypes: "rsa,ecdsa", }, } if tc.restoreCon { data := []byte("#!/bin/bash") if err := os.WriteFile(restoreConPath, data, 0777); err != nil { t.Errorf("WriteFile(%q) = %v, want nil", restoreConPath, err) } } if err := os.MkdirAll(hostKeysDir, 0755); err != nil { t.Errorf("MkdirAll(%q) = %v, want nil", hostKeysDir, err) } if err := writeSSHKeys(context.Background(), config.InstanceSetup); err != nil { t.Errorf("writeSSHKeys(%v) = %v, want nil", config.InstanceSetup, err) } // Test refresh use case. if err := writeSSHKeys(context.Background(), config.InstanceSetup); err != nil { t.Errorf("writeSSHKeys(%v) = %v, want nil", config.InstanceSetup, err) } files, err := os.ReadDir(hostKeysDir) if err != nil { t.Fatalf("ReadDir(%q) = %v, want nil", hostKeysDir, err) } var foundFiles []string for _, file := range files { foundFiles = append(foundFiles, file.Name()) } for _, keyType := range []string{"rsa", "ecdsa"} { keyFile := filepath.Join(hostKeysDir, fmt.Sprintf("ssh_host_%s_key.pub", keyType)) if !file.Exists(keyFile, file.TypeFile) { t.Errorf("File(%q) does not exist after writeSSHKeys, found %v files in %q", keyFile, foundFiles, hostKeysDir) } } }) } } func TestGenerateSSHKeysFailure(t *testing.T) { tests := []struct { name string restoreConFailure bool createHostDir bool createInvalidHostDir bool }{ { name: "restorecon-failure", restoreConFailure: true, createHostDir: true, createInvalidHostDir: false, }, { name: "no-hostdir", restoreConFailure: false, createHostDir: false, createInvalidHostDir: false, }, { name: "invalid-hostdir", restoreConFailure: false, createHostDir: true, createInvalidHostDir: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() restoreConPath := filepath.Join(tmpDir, "restorecon") hostKeysDir := filepath.Join(tmpDir, "host_keys") pathVal := os.Getenv("PATH") t.Setenv("PATH", pathVal+":"+tmpDir) config := &cfg.Sections{ InstanceSetup: &cfg.InstanceSetup{ HostKeyDir: hostKeysDir, HostKeyTypes: "rsa,ecdsa", }, } if tc.restoreConFailure { data := []byte("#!/bin/bash\n\nexit 1") if err := os.WriteFile(restoreConPath, data, 0777); err != nil { t.Errorf("WriteFile(%q) = %v, want nil", restoreConPath, err) } } if tc.createHostDir { if tc.createInvalidHostDir { if err := os.WriteFile(hostKeysDir, []byte("invalid-host-dir"), 0755); err != nil { t.Errorf("WriteFile(%q) = %v, want nil", hostKeysDir, err) } } else { if err := os.MkdirAll(hostKeysDir, 0755); err != nil { t.Errorf("MkdirAll(%q) = %v, want nil", hostKeysDir, err) } } } if err := writeSSHKeys(context.Background(), config.InstanceSetup); err == nil { t.Errorf("writeSSHKeys(%v) = nil, want non-nil", config.InstanceSetup) } }) } } func TestWriteBotoConfigSuccess(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() botoConfigFile = filepath.Join(tmpDir, "boto.cfg") if err := writeBotoConfig("fake-project-id"); err != nil { t.Errorf("writeBotoConfig() = %v, want nil", err) } } func TestWriteBotoConfigFailure(t *testing.T) { oldBotoConfigFile := botoConfigFile t.Cleanup(func() { botoConfigFile = oldBotoConfigFile }) tmpDir := t.TempDir() botoConfigFile = filepath.Join(tmpDir, "boto-config-dir", "boto.cfg") projectID := "fake-project-id" if err := writeBotoConfig(projectID); err == nil { t.Errorf("writeBotoConfig(%q) = nil, want non-nil", projectID) } } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot_test.go000066400000000000000000000043061524141036400263720ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package firstboot import ( "context" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module.ID != firstbootModuleID { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, firstbootModuleID) } if module.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if module.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } if module.Description == "" { t.Errorf("NewModule() returned module with empty Description") } } func TestSetupFailure(t *testing.T) { tests := []struct { name string arg any }{ { name: "invalid-arg", arg: &manager.Module{}, }, { name: "nil-arg", arg: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := moduleSetup(context.Background(), tc.arg); err == nil { t.Error("moduleSetup() succeeded, want error") } }) } } func TestWriteInstanceIDSuccess(t *testing.T) { tmp := t.TempDir() tests := []struct { name string instanceIDFile string }{ { name: "existing-parent-dir", instanceIDFile: filepath.Join(tmp, "test-instance-id"), }, { name: "non-existing-parent-dir", instanceIDFile: filepath.Join(tmp, "configs", "test-instance-id"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := writeInstanceID(tc.instanceIDFile, "test-instance-id"); err != nil { t.Errorf("writeInstanceID() failed: %v", err) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot_windows.go000066400000000000000000000020741524141036400271050ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package firstboot import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" ) // platformSetup is a no-op on windows. The instance ID configuration is handled // by the common multi platform code path. func platformSetup(_ context.Context, _ string, _ *cfg.Sections) error { galog.V(2).Debug("First boot module doesn't implement windows specific setup, skipping initialization.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/firstboot/firstboot_windows_test.go000066400000000000000000000015101524141036400301360ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package firstboot import ( "context" "testing" ) func TestSuccess(t *testing.T) { if err := platformSetup(context.Background(), "fake-project-id", nil); err != nil { t.Fatalf("platformSetup() failed: %v", err) } } google-guest-agent-20260819.00/cmd/core_plugin/iosched/000077500000000000000000000000001524141036400223715ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/iosched/iosched_linux.go000066400000000000000000000052531524141036400255620ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package iosched provides a module to setup the underlying OS's io scheduler. package iosched import ( "context" "fmt" "os" "path/filepath" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // ioschedModuleID is the ID of the iosched module. ioschedModuleID = "iosched" ) var ( // sysBlockPath is the path to the linux block directory within sys fs. sysBlockPath = "/sys/block" ) // NewModule returns a new iosched module. func NewModule(_ context.Context) *manager.Module { return &manager.Module{ ID: ioschedModuleID, BlockSetup: moduleSetup, Description: "Setup io scheduler acordingly to the platform expectations", } } // moduleSetup runs the actual io scheduler setup for linux. func moduleSetup(ctx context.Context, _ any) error { galog.Debug("Initializing IO scheduler module.") dir, err := os.Open(sysBlockPath) if err != nil { return fmt.Errorf("failed to open %s: %w", sysBlockPath, err) } defer dir.Close() devs, err := dir.Readdirnames(0) if err != nil { return fmt.Errorf("failed to read %s: %w", sysBlockPath, err) } for _, dev := range devs { // Detect if device is using MQ subsystem. fpath := filepath.Join(sysBlockPath, dev, "mq") if !file.Exists(fpath, file.TypeDir) { galog.Debugf("Device %s has no mq entry", dev) continue } schedPath := filepath.Join(sysBlockPath, dev, "queue", "scheduler") galog.V(1).Debugf("Writing scheduler file for %s to %s", dev, schedPath) f, err := os.OpenFile(schedPath, os.O_WRONLY|os.O_TRUNC, 0700) if err != nil { return fmt.Errorf("failed to open scheduler file: %w", err) } defer f.Close() data := []byte("none") n, err := f.Write(data) if err != nil { return fmt.Errorf("failed to write to scheduler file: %w", err) } if n != len(data) { return fmt.Errorf("failed to write scheduler file: %d bytes written, want %d", n, len(data)) } } galog.Debug("Finished initializing IO scheduler module.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/iosched/iosched_linux_test.go000066400000000000000000000050011524141036400266100ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package iosched import ( "context" "os" "path/filepath" "testing" ) func TestNewModule(t *testing.T) { mod := NewModule(context.Background()) if mod.ID != ioschedModuleID { t.Errorf("NewModule().ID = %q, want %q", mod.ID, ioschedModuleID) } if mod.BlockSetup == nil { t.Errorf("NewModule().BlockSetup = nil, want non-nil") } } func TestNoSysblock(t *testing.T) { tmpDir := t.TempDir() oldSysBlockPath := sysBlockPath sysBlockPath = filepath.Join(tmpDir, "sys", "block") t.Cleanup(func() { sysBlockPath = oldSysBlockPath }) if err := moduleSetup(context.Background(), nil); err == nil { t.Fatal("moduleSetup() succeeded, want error") } } func TestIOSchedSuccess(t *testing.T) { tmpDir := t.TempDir() oldSysBlockPath := sysBlockPath sysBlockPath = filepath.Join(tmpDir, "sys", "block") t.Cleanup(func() { sysBlockPath = oldSysBlockPath }) entries := []struct { devName string hasMq bool }{ { devName: "sda", hasMq: true, }, { devName: "sdb", hasMq: false, }, } prepareDir := func(devName string, hasMq bool) { if err := os.MkdirAll(filepath.Join(sysBlockPath, devName), 0755); err != nil { t.Fatalf("Failed to create dev directory: %v", err) } if !hasMq { return } if err := os.MkdirAll(filepath.Join(sysBlockPath, devName, "mq"), 0755); err != nil { t.Fatalf("Failed to create mq directory: %v", err) } queueDir := filepath.Join(sysBlockPath, devName, "queue") if err := os.MkdirAll(queueDir, 0755); err != nil { t.Fatalf("Failed to create queue directory: %v", err) } schedPath := filepath.Join(queueDir, "scheduler") f, err := os.Create(schedPath) if err != nil { t.Fatalf("Failed to create scheduler file: %v", err) } defer f.Close() } for _, tc := range entries { prepareDir(tc.devName, tc.hasMq) } if err := moduleSetup(context.Background(), nil); err != nil { t.Fatalf("Failed to setup module: %v", err) } } google-guest-agent-20260819.00/cmd/core_plugin/iosched/iosched_windows.go000066400000000000000000000016551524141036400261170ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package iosched import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule is no-op on windows. func NewModule(_ context.Context) *manager.Module { galog.V(2).Debug("IOsched module is not supported on windows.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/iosched/iosched_windows_test.go000066400000000000000000000014521524141036400271510ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package iosched import ( "context" "testing" ) func TestIOSched(t *testing.T) { if mod := NewModule(context.Background()); mod != nil { t.Fatalf("NewModule() = %v, want nil", mod) } } google-guest-agent-20260819.00/cmd/core_plugin/manager/000077500000000000000000000000001524141036400223655ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/manager/manager.go000066400000000000000000000236071524141036400243360ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package manager is the module manager, it wraps the initialization and // mass notification of core-plugin's modules. package manager import ( "context" "errors" "fmt" "maps" "sync" "time" "github.com/GoogleCloudPlatform/galog" ) // ModuleStage is the stage in which a module should be grouped in. type ModuleStage int // InitType is the type of initialization a module should be executed. type InitType int // ModuleStatus is the status of a module initialization. type ModuleStatus int const ( // EarlyStage represents the early stage of core-plugin execution, modules in // such stage are executed on behalf of early platform initialization, it's // assumed that the OS is still not yet fully compatible/configured with GCE // platform. EarlyStage ModuleStage = iota // LateStage represents the late(r) stage of core-plugin execution, it takes // off after the early stage has finished and we've notified Guest Agent. LateStage // BlockingInit represents the module initialization executed in a blocking // manner. BlockingInit InitType = iota // ConcurrentInit represents the module initialization executed in a // concurrent manner. ConcurrentInit // StatusSkipped represents a module initialization that was skipped. StatusSkipped ModuleStatus = iota // StatusFailed represents a module initialization that failed. StatusFailed // StatusSucceeded represents a module initialization that succeeded. StatusSucceeded ) var ( // modManager is the module manager instance. modManager = moduleManager{ modules: make(map[ModuleStage][]*Module), metrics: make(map[string]*ModuleMetric), } ) // moduleManager is the module manager's context structure. type moduleManager struct { // mux is a mutex to protect the modules map. mux sync.Mutex // modules is a map of modules registered for a given stage. modules map[ModuleStage][]*Module // metrics is a map of module metrics. metrics map[string]*ModuleMetric } // ModuleMetric contains the module's initialization metrics. type ModuleMetric struct { // Module is the module that was initialized. Module *Module // Stage is the stage in which the module was initialized. Stage ModuleStage // Start is the time the module initialization started. Start time.Time // End is the time the module initialization ended. End time.Time // Err is the error/status of the module initialization. Err error // Status is the status of the module initialization. Status ModuleStatus // InitType is the type of initialization the module was executed. InitType InitType } // Metrics returns/exposes the modules metrics. The returned map is a copy of // the internal map and is safe to be modified after returned (both internally // and externally). func Metrics() map[string]*ModuleMetric { modManager.mux.Lock() defer modManager.mux.Unlock() res := make(map[string]*ModuleMetric) maps.Copy(res, modManager.metrics) return res } // newModuleMetric creates a new module metric. func newModuleMetric(mod *Module, stage ModuleStage, initType InitType) *ModuleMetric { metric := &ModuleMetric{ Module: mod, Stage: stage, Start: time.Now(), InitType: initType, } modManager.metrics[mod.ID] = metric return metric } // finish records the module initialization finishing time and status. func (mm *ModuleMetric) finish(status ModuleStatus, err error) { mm.End = time.Now() mm.Err = err mm.Status = status } // Module is the configuration structure of a module. type Module struct { // Enabled is a flag to indicate if the module is enabled/disabled in the // local configuration. Enabled *bool // ID is a string representation of a module identification. ID string // Description is a string representation of a module description. Description string // Setup is the function to initialize the module. Modules implementing this // function will have its execution ran in parallel with other modules - every // module Setup will be executed in a different goroutine. Setup func(ctx context.Context, data any) error // BlockSetup is equivalent to Setup but the modules initialization will // happen in sequence, meaning, a module initialization blocks the // initialization of all the non-initialized modules. BlockSetup func(ctx context.Context, data any) error // Quit is the function implemented by the module to get notifications to // "nicely" quit, the manager will wait for these executions to finish. When // returned from Quit the module is communicating that it's fully done. Quit func(ctx context.Context) } // Display returns a nice string with id and description of the module and is // used to display the module in the list of modules. func (mod *Module) Display() string { desc := mod.Description if desc == "" { desc = "No description available" } ID := mod.ID if ID == "" { ID = "No ID available" } return fmt.Sprintf("%s: %s.", ID, desc) } // modulesLen returns the number of modules registered for a given stage. func modulesLen(stage ModuleStage) int { modManager.mux.Lock() defer modManager.mux.Unlock() return len(modManager.modules[stage]) } // Register registers modules in a execution/initialization stage. The order the // modules are registered is honored. func Register(mods []*Module, stage ModuleStage) { modManager.mux.Lock() defer modManager.mux.Unlock() for _, mod := range mods { if mod == nil { continue } if mod.Enabled != nil && *mod.Enabled == false { galog.Debugf("Module %q is disabled, skipping.", mod.ID) continue } modManager.modules[stage] = append(modManager.modules[stage], mod) } } // List returns the list of modules registered for a given stage. func List(stage ModuleStage) []*Module { modManager.mux.Lock() defer modManager.mux.Unlock() return modManager.modules[stage] } // NotifyQuit notifies modules registered for a stage that they should nicely // quit. func NotifyQuit(ctx context.Context, stage ModuleStage) { modManager.mux.Lock() defer modManager.mux.Unlock() for _, mod := range modManager.modules[stage] { if mod.Quit == nil { galog.Debugf("Module %q has no Quit function, skipping.", mod.ID) continue } mod.Quit(ctx) } } // RunBlocking runs all modules in a given stage in a blocking manner. The // selection of modules is based on the existence of the BlockSetup function // implementation. It returns an error wrapping all errors returned by the // modules. func RunBlocking(ctx context.Context, stage ModuleStage, data any) error { modManager.mux.Lock() defer modManager.mux.Unlock() for _, mod := range modManager.modules[stage] { metric := newModuleMetric(mod, stage, BlockingInit) if mod.BlockSetup == nil { galog.V(2).Debugf("Module %q has no BlockSetup function, skipping.", mod.ID) metric.finish(StatusSkipped, nil) continue } if err := mod.BlockSetup(ctx, data); err != nil { metric.finish(StatusFailed, err) return fmt.Errorf("failed to initialize module(%s): %w", mod.ID, err) } metric.finish(StatusSucceeded, nil) } return nil } // Errors is a collection of module initialization/setup errors. type Errors struct { // mu is a mutex to protect the Errors slice. mu sync.Mutex // reportedErrors is the list of module initialization errors. reportedErrors []*moduleError } // moduleError is a module initialization error. type moduleError struct { // module is the module that failed to initialize. module *Module // err is the error returned by the module. err error } // Each runs a function for each module initialization error. func (e *Errors) Each(fc func(moduleID string, err error)) { e.mu.Lock() defer e.mu.Unlock() for _, err := range e.reportedErrors { fc(err.module.ID, err.err) } } // add adds a module initialization error to the list. func (e *Errors) add(mod *Module, err error) { e.mu.Lock() defer e.mu.Unlock() e.reportedErrors = append(e.reportedErrors, &moduleError{module: mod, err: err}) } // len returns the number of module initialization errors. func (e *Errors) len() int { e.mu.Lock() defer e.mu.Unlock() return len(e.reportedErrors) } // join returns an error joining all module initialization errors. func (e *Errors) join() error { e.mu.Lock() defer e.mu.Unlock() var errs []error for _, err := range e.reportedErrors { errs = append(errs, err.err) } return errors.Join(errs...) } // RunConcurrent runs all modules in a given stage in parallel. The selection of // of modules is based on the existence of the Setup function implementation. It // returns an Errors wrapping all errors returned by the modules. func RunConcurrent(ctx context.Context, stage ModuleStage, data any) *Errors { modManager.mux.Lock() defer modManager.mux.Unlock() var wg sync.WaitGroup errors := &Errors{} for _, mod := range modManager.modules[stage] { metrics := newModuleMetric(mod, stage, ConcurrentInit) if mod.Setup == nil { galog.V(2).Debugf("Module %q has no Setup function, skipping.", mod.ID) metrics.finish(StatusSkipped, nil) continue } wg.Add(1) go func(metrics *ModuleMetric) { defer wg.Done() if err := mod.Setup(ctx, data); err != nil { errors.add(mod, err) metrics.finish(StatusFailed, err) return } metrics.finish(StatusSucceeded, nil) }(metrics) } wg.Wait() if errors.len() > 0 { return errors } return nil } // Shutdown shuts down the module manager. func Shutdown() { modManager.mux.Lock() defer modManager.mux.Unlock() modManager.modules = make(map[ModuleStage][]*Module) } google-guest-agent-20260819.00/cmd/core_plugin/manager/manager_test.go000066400000000000000000000242451524141036400253740ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package manager import ( "context" "errors" "fmt" "sync" "sync/atomic" "testing" ) func TestRegister(t *testing.T) { t.Cleanup(func() { Shutdown() }) falseValue := false mods := []*Module{ &Module{ID: "test-1", Enabled: &falseValue}, &Module{ID: "test-2"}, &Module{ID: "test-3"}, nil, } data := List(EarlyStage) if len(data) != 0 { t.Errorf("List(%d) = %d, want %d", EarlyStage, len(data), 0) } want := 2 Register(mods, EarlyStage) if modulesLen(EarlyStage) != want { t.Errorf("modulesLen(%d) = %d, want %d", EarlyStage, modulesLen(EarlyStage), want) } data = List(EarlyStage) if len(data) != want { t.Errorf("List(%v) = %d, want %d", EarlyStage, len(data), want) } } func TestRunBlockingSuccess(t *testing.T) { t.Cleanup(func() { Shutdown() }) execMap := make(map[string]bool) setExec := func(id string) { execMap[id] = true } mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", BlockSetup: func(context.Context, any) error { setExec("test-2"); return nil }, }, &Module{ ID: "test-3", BlockSetup: func(context.Context, any) error { setExec("test-3"); return nil }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, } Register(mods, EarlyStage) if err := RunBlocking(context.Background(), EarlyStage, nil); err != nil { t.Errorf("RunBlocking(%d) = %d, want nil", EarlyStage, err) } metrics := Metrics() for _, mod := range mods { if mod.BlockSetup == nil { continue } if _, found := metrics[mod.ID]; !found { t.Errorf("metrics[%v] = false, want true", mod.ID) } if !execMap[mod.ID] { t.Errorf("execMap[%v] = false, want true", mod.ID) } } } func TestRunBlockingFailure(t *testing.T) { t.Cleanup(func() { Shutdown() }) mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", BlockSetup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-3", BlockSetup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, } Register(mods, EarlyStage) if err := RunBlocking(context.Background(), EarlyStage, nil); err == nil { t.Errorf("RunBlocking(%d) = nil, want no-nil", EarlyStage) } } func TestRunConcurrentSuccess(t *testing.T) { t.Cleanup(func() { Shutdown() }) execMap := make(map[string]bool) var mu sync.Mutex setExec := func(id string) { mu.Lock() defer mu.Unlock() execMap[id] = true } mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { setExec("test-2"); return nil }, }, &Module{ ID: "test-3", Setup: func(context.Context, any) error { setExec("test-3"); return nil }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, } Register(mods, EarlyStage) if err := RunConcurrent(context.Background(), EarlyStage, nil); err != nil { t.Errorf("RunConcurrent(%d) = %v, want nil", EarlyStage, err.join()) } metrics := Metrics() for _, mod := range mods { if mod.Setup == nil { continue } if _, found := metrics[mod.ID]; !found { t.Errorf("metrics[%v] = false, want true", mod.ID) } if !execMap[mod.ID] { t.Errorf("execMap[%v] = false, want true", mod.ID) } } } func TestRunConcurrentFailure(t *testing.T) { t.Cleanup(func() { Shutdown() }) execMap := make(map[string]bool) var mu sync.Mutex setExec := func(id string) { mu.Lock() defer mu.Unlock() execMap[id] = true } want := 2 mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { setExec("test-2"); return fmt.Errorf("error") }, }, &Module{ ID: "test-3", Setup: func(context.Context, any) error { setExec("test-3"); return fmt.Errorf("error") }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, } Register(mods, EarlyStage) err := RunConcurrent(context.Background(), EarlyStage, nil) if err == nil { t.Errorf("RunConcurrent(%d) = nil, want non-nil", EarlyStage) } else if err.len() != want { t.Errorf("len(err) = %d, want %d", err.len(), want) } for _, mod := range mods { if mod.Setup == nil { continue } if !execMap[mod.ID] { t.Errorf("execMap[%v] = false, want true", mod.ID) } } } func TestRunBlockingChain(t *testing.T) { tests := []struct { // name is the name of the test. name string // mods is the modules chain to register. mods []*Module // wantedModules is the list of modules at the end of the test, the modules // should have been successfully run. wantedModules map[string]bool // shouldFail is true if the RunBlocking() should fail. shouldFail bool }{ { name: "all-modules-success", mods: []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { return nil }, }, &Module{ ID: "test-3", Setup: func(context.Context, any) error { return nil }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, }, wantedModules: map[string]bool{ "test-1": true, "test-2": true, "test-3": true, "test-4": true, }, shouldFail: false, }, { name: "some-modules-failure", mods: []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-3", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-4", Quit: func(context.Context) {}, }, }, wantedModules: map[string]bool{ "test-1": true, "test-4": true, }, shouldFail: true, }, { name: "all-modules-failure", mods: []*Module{ &Module{ ID: "test-1", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-3", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, &Module{ ID: "test-4", Setup: func(context.Context, any) error { return fmt.Errorf("error") }, }, }, wantedModules: make(map[string]bool), shouldFail: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { Shutdown() }) Register(tc.mods, EarlyStage) err := RunConcurrent(context.Background(), EarlyStage, nil) if err == nil && tc.shouldFail { t.Errorf("RunConcurrent(%d) = nil, want non-nil", EarlyStage) } else if err != nil && !tc.shouldFail { t.Errorf("RunConcurrent(%d) = %d, want nil", EarlyStage, err.join()) } else if err != nil && tc.shouldFail { err.Each(func(moduleID string, err error) { if tc.wantedModules[moduleID] { t.Errorf("failed module %q, want success", moduleID) } }) } }) } } func TestNotifyQuit(t *testing.T) { var quitCount atomic.Int32 want := int32(2) t.Cleanup(func() { Shutdown() }) mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", Setup: func(context.Context, any) error { return nil }, }, &Module{ ID: "test-3", Quit: func(context.Context) { quitCount.Add(1) }, }, &Module{ ID: "test-4", Quit: func(context.Context) { quitCount.Add(1) }, }, } Register(mods, EarlyStage) NotifyQuit(context.Background(), EarlyStage) if quitCount.Load() != want { t.Errorf("NotifyQuit(%d) = %d, want %d", EarlyStage, quitCount.Load(), want) } for i, mod := range modManager.modules[EarlyStage] { if mod.ID != mods[i].ID { t.Errorf("mod[%d] = %v, want %v", i, mod, mods[i]) } } } func TestRegistrationOrder(t *testing.T) { t.Cleanup(func() { Shutdown() }) mods := []*Module{ &Module{ ID: "test-1", }, &Module{ ID: "test-2", }, &Module{ ID: "test-3", }, &Module{ ID: "test-4", }, } Register(mods, EarlyStage) for i, mod := range modManager.modules[EarlyStage] { if mod.ID != mods[i].ID { t.Errorf("mod[%d] = %v, want %v", i, mod, mods[i]) } } } func TestErrorsJoin(t *testing.T) { errs := &Errors{} errs.add(&Module{ID: "test-1"}, fmt.Errorf("error-test-1")) errs.add(&Module{ID: "test-2"}, fmt.Errorf("error-test-2")) errs.add(&Module{ID: "test-3"}, fmt.Errorf("error-test-3")) wrapped := errs.join() wanted := 3 var inspected int errs.Each(func(_ string, err error) { inspected++ if !errors.Is(wrapped, err) { t.Fatalf("wrapped error %v is not equal to %v", wrapped, err) } }) if inspected != wanted { t.Errorf("inspected = %d, want %d", inspected, wanted) } } func TestDisplay(t *testing.T) { tests := []struct { name string modID string desc string want string }{ { name: "no-description", modID: "no-description", desc: "", want: "no-description: No description available.", }, { name: "with-description", modID: "with-description", desc: "Some random description", want: "with-description: Some random description.", }, { name: "no-id-with-description", modID: "", desc: "Some random description", want: "No ID available: Some random description.", }, { name: "no-id-no-description", modID: "", desc: "", want: "No ID available: No description available.", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := Module{ID: tc.modID, Description: tc.desc} got := mod.Display() if got != tc.want { t.Errorf("Display(%v) = %q, want %q", mod, got, tc.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/000077500000000000000000000000001524141036400237625ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey.go000066400000000000000000000210271524141036400273220ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package metadatasshkey provides a module for setting up user accounts from // ssh keys in instance and project metadata. package metadatasshkey import ( "context" "errors" "fmt" "slices" "sort" "strings" "sync" "sync/atomic" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/ssh" ) // userKeyMap is a map of a username to the user's SSH keys. type userKeyMap map[string][]string var ( // A map of username to group to add new metadata ssh key users to. supplementalGroups = make(map[string]*accounts.Group) // onetimePlatformSetupFinished indicates that platform specific one-time // system configuration has been performed successfully. On windows, this means // starting SSHd, on linux this means creating groups and configuring sudo // access. onetimePlatformSetupFinished atomic.Bool // metadataSSHKeyMu is a mutex protecting management of ssh keys. Do not // write keys to disk or modify the following variables without holding it. metadataSSHKeyMu sync.Mutex // lastUserKeyMap is the last seen set of valid user keys in metadata. lastUserKeyMap = make(userKeyMap) // lastEnabled is the last seen value of whether metadata ssh key was // enabled. lastEnabled bool // invalidKeys is a list of invalid keys that should be ignored. This is to // prevent spam from invalid keys that are not actionable. invalidKeys = make(map[string]bool) ) // ensureGroupExists will check if a group exists, and create it locally if it // doesn't. func ensureGroupExists(ctx context.Context, gname string) error { _, err := accounts.FindGroup(ctx, gname) if err == nil { return nil } galog.Debugf("Group %s does not exist (lookup returned %v), creating.", gname, err) return accounts.CreateGroup(ctx, gname) } // NewModule constructs a core_plugin module. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: "metadatasshkey", Enabled: &cfg.Retrieve().Daemons.AccountsDaemon, Description: "metadatasshkey creates local accounts from ssh keys stored in instance and project metadata", Setup: moduleSetup, } } func moduleSetup(ctx context.Context, data any) error { // Legacy agent documentation refers to metadata ssh key functionality as // accounts management. if !cfg.Retrieve().Daemons.AccountsDaemon { galog.Infof("Accounts daemon is disabled, skipping metadata ssh key setup.") return nil } galog.Debug("Initializing Metadata SSH Key module.") desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("expected metadata descriptor data in moduleSetup call") } _, errs := metadataSSHKeySetup(ctx, cfg.Retrieve(), desc) for _, err := range errs { galog.Errorf("error setting initial metadatasshkey configuration: %v", err) } sub := events.EventSubscriber{Name: "metadatasshkey", Callback: handleMetadataChange, MetricName: acmpb.GuestAgentModuleMetric_METADATA_SSH_KEY_INITIALIZATION} events.FetchManager().Subscribe(metadata.LongpollEvent, sub) galog.Debug("Finished initializing Metadata SSH Key module.") return nil } func handleMetadataChange(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, skiping ssh key setup", evData.Error) } noop, errs := metadataSSHKeySetup(ctx, cfg.Retrieve(), desc) return true, noop, errors.Join(errs...) } // metadataSSHKeySetup performs necessary configuration to setup metadata ssh // key system requirements, create/remove users, and write ssh keys as // necessary. func metadataSSHKeySetup(ctx context.Context, config *cfg.Sections, desc *metadata.Descriptor) (bool, []error) { metadataSSHKeyMu.Lock() defer metadataSSHKeyMu.Unlock() if !metadataChanged(config, desc, lastUserKeyMap, lastEnabled) { galog.V(2).Debugf("Metadata ssh key has no difference from enablement or keys on disk, nothing to do.") return true, nil } enabled := enableMetadataSSHKey(config, desc) lastEnabled = enabled newKeys := findValidKeys(desc) lastUserKeyMap = newKeys if !enabled { galog.Debugf("Accounts management is disabled or oslogin is enabled, disabling metadata ssh key.") return false, deprovisionUnusedUsers(ctx, config, make(userKeyMap)) } var errs []error if !onetimePlatformSetupFinished.Load() { galog.Debug("Setting platform configuration") if errs = setPlatformConfiguration(ctx, config, desc); len(errs) == 0 { onetimePlatformSetupFinished.Store(true) } galog.Debug("Finished setting platform configuration") } errs = append(errs, addSystemUsers(ctx, config, newKeys)...) return false, errs } // addSystemUsers will create users on the local system and add keys from // metadata to their account. Calling this function will update lastValidKeys. func addSystemUsers(ctx context.Context, config *cfg.Sections, newKeys userKeyMap) []error { var errs []error for username, keys := range newKeys { galog.V(1).Debugf("Adding user %s", username) userAccount, err := ensureUserExists(ctx, username) if err != nil { errs = append(errs, fmt.Errorf("giving up on ssh keys for %s, failed to find or create user: %v", username, err)) continue } if err := updateSSHKeys(ctx, userAccount, keys); err != nil { errs = append(errs, fmt.Errorf("failed to update SSH keys for %s: %v", userAccount.Username, err)) } } for _, err := range deprovisionUnusedUsers(ctx, config, newKeys) { errs = append(errs, fmt.Errorf("error removing unused users: %v", err)) } return errs } // metadataChanged reports whether the state of metadata ssh key enablement or // keys have changed and should be reconfigured. func metadataChanged(config *cfg.Sections, desc *metadata.Descriptor, lastValidKeys userKeyMap, lastEnabled bool) bool { return enableMetadataSSHKey(config, desc) != lastEnabled || !isUserKeysMapEqual(findValidKeys(desc), lastValidKeys) } func isUserKeysMapEqual(m1, m2 userKeyMap) bool { if len(m1) != len(m2) { return false } for userName, userKeys := range m1 { slice2, ok := m2[userName] if !ok { return false } // Clone the slices to avoid modifying the original slices in case of sort. clonedSlice1 := slices.Clone(userKeys) clonedSlice2 := slices.Clone(slice2) sort.Strings(clonedSlice1) sort.Strings(clonedSlice2) if !slices.Equal(clonedSlice1, clonedSlice2) { return false } } return true } func findValidKeys(desc *metadata.Descriptor) userKeyMap { keyMap := make(userKeyMap) keyList := desc.Instance().Attributes().SSHKeys() if !desc.Instance().Attributes().BlockProjectKeys() { keyList = append(keyList, desc.Project().Attributes().SSHKeys()...) } for _, key := range keyList { key := strings.TrimSpace(key) if key == "" { continue } // Ignore invalid keys that we've already seen. if _, ok := invalidKeys[key]; ok { continue } // Parse the key and validate the user and key. username, keycontent, err := ssh.GetUserKey(key) if err != nil { invalidKeys[key] = true galog.Warnf("Incorrectly formatted key %q in metadata: %v.", key, err) continue } if err := ssh.ValidateUserKey(username, keycontent); err != nil { // Add the key to the invalid keys list so we don't spam the logs with it. invalidKeys[key] = true galog.Warnf("Invalid user %q or key %q in metadata: %v.", username, keycontent, err) continue } keyMap[username] = append(keyMap[username], keycontent) } return keyMap } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey_linux.go000066400000000000000000000227051524141036400305450ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package metadatasshkey import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) var ( // googleSudoersConfig is the configuration file used for granting metadata // ssh key users NOPASSWD sudo permission. googleSudoersConfig = "/etc/sudoers.d/google_sudoers" // googleSudoersGroup is the group used for granting metadata ssh key users // NOPASSWD sudo permission. All users created by metadata ssh key are added // to this group. googleSudoersGroup = "google-sudoers" // execLookPath is the function used to look up the path to an executable. // This is overridden in tests. execLookPath = exec.LookPath // listGoogleUsers is a function to list google users, overridden in tests. listGoogleUsers = accounts.ListGoogleUsers // deprovisionUnusedUsers is a function to deprovision unused users, // overridden in tests. deprovisionUnusedUsers = defaultDeprovisionUnusedUsers ) // defaultDeprovisionUnusedUsers removes accounts which were removed from ssh key // metadata from the local system. Depending on user configuration, the account // may not be deleted but instead have ssh keys removed. func defaultDeprovisionUnusedUsers(ctx context.Context, config *cfg.Sections, activeUsers userKeyMap) []error { googleUsers, err := listGoogleUsers(ctx) if err != nil { return []error{fmt.Errorf("could not determine which users are unused, failed to list google users: %w", err)} } var errs []error for _, guser := range googleUsers { if _, ok := activeUsers[guser]; ok || guser == "" { continue } guserAccount, err := accounts.FindUser(ctx, guser) if err != nil { errs = append(errs, fmt.Errorf("not deprovisioning unused user %q, could not find local account: %w", guser, err)) continue } // A user is only effectively removed when the configuration has the // deprovision_remove flag set to true. If the flag is not set, we only // remove the user from the sudoers group and remove the ssh keys. if config.Accounts.DeprovisionRemove { galog.Debugf("Deprovisioning user %s", guser) if err := accounts.DelUser(ctx, guserAccount); err != nil { errs = append(errs, fmt.Errorf("error removing user account %s from system: %w", guser, err)) } continue } if err := updateSSHKeys(ctx, guserAccount, nil); err != nil { errs = append(errs, fmt.Errorf("failed to remove user %s's ssh keys: %w", guser, err)) continue } if err := accounts.RemoveUserFromGroup(ctx, guserAccount, supplementalGroups[googleSudoersGroup]); err != nil { errs = append(errs, fmt.Errorf("failed to remove user %s from %s: %w", guser, googleSudoersGroup, err)) continue } } return errs } // write SSH keys to the user's $HOME/.ssh/authorized_keys file func updateSSHKeys(ctx context.Context, user *accounts.User, keys []string) error { gComment := "# Added by Google" if user.HomeDir == "" { return fmt.Errorf("user %s has no homedir set", user.Username) } if user.Shell == "/sbin/nologin" { return nil } galog.V(2).Debugf("Updating keys for user %s to %v", user.Username, keys) sshPath := filepath.Join(user.HomeDir, ".ssh") if !file.Exists(sshPath, file.TypeDir) { if err := os.Mkdir(sshPath, 0700); err != nil { return err } if err := os.Chown(sshPath, user.UnixUID(), user.UnixGID()); err != nil { return err } } authorizedKeysPath := filepath.Join(sshPath, "authorized_keys") // Remove empty file. if len(keys) == 0 { os.Remove(authorizedKeysPath) return nil } authorizedKeysContents, err := os.ReadFile(authorizedKeysPath) if err != nil && !os.IsNotExist(err) { return err } var isGoogle bool var userKeys []string for _, key := range strings.Split(string(authorizedKeysContents), "\n") { if key == "" { continue } if isGoogle { isGoogle = false continue } if key == gComment { isGoogle = true continue } userKeys = append(userKeys, key) } authorizedKeysOutput := strings.Join(userKeys, "\n") if len(userKeys) > 0 { authorizedKeysOutput += "\n" } for _, k := range keys { authorizedKeysOutput += fmt.Sprintf("%s\n%s\n", gComment, k) } writeOpts := file.Options{ Perm: 0600, Owner: &file.GUID{ UID: user.UnixUID(), GID: user.UnixGID(), }, } galog.V(2).Debugf("Writing authorized_keys file for user %s", user.Username) if err := file.SaferWriteFile(ctx, []byte(authorizedKeysOutput), authorizedKeysPath, writeOpts); err != nil { return fmt.Errorf("failed to write authorized_keys file: %w", err) } // Always ensure we have the user added to google-sudoers group, that ensures // that even a user being "re-enabled" will have the sudoers permission. // // A re-enabled user is the one who only got their ssh keys removed from the // authorized_keys file and google-sudoers group removed due to configuration // key drepovision_remove being set to false. galog.V(2).Debugf("Adding user %s to %s", user.Username, googleSudoersGroup) if err := accounts.AddUserToGroup(ctx, user, supplementalGroups[googleSudoersGroup]); err != nil { return fmt.Errorf("failed to add user %s to %s: %w", user.Username, googleSudoersGroup, err) } return selinuxRestoreCon(ctx, authorizedKeysPath) } // selinuxRestoreCon restores selinux context using the restorecon binary. func selinuxRestoreCon(ctx context.Context, path string) error { galog.V(2).Debugf("Restoring selinux context for %s", path) execPath, err := execLookPath("restorecon") if err != nil { galog.Debug("restorecon not found, skipping selinux context restore") return nil } opts := run.Options{ExecMode: run.ExecModeSync, OutputType: run.OutputCombined, Name: execPath, Args: []string{path}} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to restore selinux context: %w", err) } galog.V(2).Debugf("Finished restoring selinux context for %s", path) return nil } // ensureUserExists finds the named user, creating it locally if it doesn't // exist. Wraps errors from accounts package. func ensureUserExists(ctx context.Context, username string) (*accounts.User, error) { u, err := accounts.FindUser(ctx, username) if err == nil { return u, nil } galog.Debugf("User %s does not exist (lookup returned %v), creating.", username, err) err = accounts.CreateUser(ctx, &accounts.User{Username: username, GID: "-1", UID: "-1"}) if err != nil { return nil, fmt.Errorf("failed to create user %s: %w", username, err) } u, err = accounts.FindUser(ctx, username) if err != nil { return nil, fmt.Errorf("could not find user %s after creation: %w", username, err) } for _, group := range supplementalGroups { if err := accounts.AddUserToGroup(ctx, u, group); err != nil { galog.Errorf("Failed to add user %s to group %s: %v.", u.Username, group.Name, err) } } galog.Infof("Created user %s", username) return u, nil } // setPlatformConfiguration creates supplemental groups and google-sudoers if // necessary, and writes the google-sudoers sudo configuration file. func setPlatformConfiguration(ctx context.Context, config *cfg.Sections, _ *metadata.Descriptor) []error { // If you are adding new configuration behavior, prefer to return early // rather than compounding errors. The compounded errors here now are present // to maintain existing behavior. This should be avoided in the future. var errs []error configline := fmt.Sprintf("%%%s ALL=(ALL:ALL) NOPASSWD:ALL", googleSudoersGroup) if err := os.WriteFile(googleSudoersConfig, []byte(fmt.Sprintf("%s\n", configline)), 0440); err != nil { errs = append(errs, fmt.Errorf("could not write sudo configuration for %s: %v", googleSudoersGroup, err)) } galog.V(2).Debugf("Wrote sudo configuration for %s to %s", googleSudoersGroup, googleSudoersConfig) // Legacy agent continues on error and attempts to add users to groups even // if they might not exist, this preserves the same behavior while still // reporting errors back to the caller. g := &accounts.Group{Name: googleSudoersGroup} supplementalGroups[g.Name] = g if err := ensureGroupExists(ctx, googleSudoersGroup); err != nil { errs = append(errs, fmt.Errorf("could not find or create %s group: %v", googleSudoersGroup, err)) } for _, gname := range strings.Split(config.Accounts.Groups, ",") { g := &accounts.Group{Name: gname} supplementalGroups[g.Name] = g if err := ensureGroupExists(ctx, gname); err != nil { errs = append(errs, fmt.Errorf("could not find or create %s group: %v", gname, err)) } } return errs } // enableMetadataSSHKey reports whether metadata ssh keys should be managed. func enableMetadataSSHKey(config *cfg.Sections, mdsdesc *metadata.Descriptor) bool { return !mdsdesc.OSLoginEnabled() } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey_linux_test.go000066400000000000000000000572201524141036400316040ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package metadatasshkey import ( "context" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "slices" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestDeprovisionUnusedUsers(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } tests := []struct { name string config *cfg.Sections activeUsers userKeyMap googleUsers []string systemUsers []*accounts.User want []error wantDeprovisioned []string wantRemovedFromSudoers []string }{ { name: "deprovision_success", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: false, GPasswdRemoveCmd: "removeFromGroup {group} {user}", }, }, activeUsers: userKeyMap{"user1": nil}, systemUsers: []*accounts.User{ &accounts.User{Username: "user2"}, &accounts.User{Username: "user3"}, }, googleUsers: []string{"user1", "user2", "user3"}, want: nil, wantDeprovisioned: []string{"user2", "user3"}, wantRemovedFromSudoers: []string{"user2", "user3"}, }, { name: "find_user_partial_failure", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: false, GPasswdRemoveCmd: "removeFromGroup {group} {user}", }, }, activeUsers: userKeyMap{"user1": nil}, systemUsers: []*accounts.User{ &accounts.User{Username: "user2"}, }, googleUsers: []string{"user1", "user2", "user3"}, want: []error{cmpopts.AnyError}, wantDeprovisioned: []string{"user2"}, wantRemovedFromSudoers: []string{"user2"}, }, { name: "deprovision_group_remove_failure", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: false, GPasswdRemoveCmd: "failure", }, }, activeUsers: userKeyMap{"user1": nil}, systemUsers: []*accounts.User{ &accounts.User{Username: "user2"}, }, googleUsers: []string{"user1", "user2"}, want: []error{cmpopts.AnyError}, wantDeprovisioned: []string{"user2"}, wantRemovedFromSudoers: nil, }, { name: "deprovision_sshkey_remove_failure", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: false, GPasswdRemoveCmd: "removeFromGroup {group} {user}", }, }, activeUsers: userKeyMap{}, systemUsers: []*accounts.User{ &accounts.User{Username: "user1", HomeDir: "/dev/null"}, }, googleUsers: []string{"user1"}, want: []error{cmpopts.AnyError}, wantDeprovisioned: nil, wantRemovedFromSudoers: nil, }, { name: "deprovision_remove_success", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: true, UserDelCmd: "removeUser {user}", }, }, activeUsers: userKeyMap{"user1": nil}, systemUsers: []*accounts.User{ &accounts.User{Username: "user2"}, }, googleUsers: []string{"user1", "user2"}, want: nil, wantDeprovisioned: []string{"user2"}, wantRemovedFromSudoers: nil, }, { name: "deprovision_remove_failure", config: &cfg.Sections{ Accounts: &cfg.Accounts{ DeprovisionRemove: true, UserDelCmd: "failure", }, }, activeUsers: userKeyMap{"user1": nil}, systemUsers: []*accounts.User{ &accounts.User{Username: "user2"}, }, googleUsers: []string{"user1", "user2"}, want: []error{cmpopts.AnyError}, wantDeprovisioned: nil, wantRemovedFromSudoers: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { list := func(ctx context.Context) ([]string, error) { return tc.googleUsers, nil } swapForTest(t, &listGoogleUsers, list) swapForTest(t, cfg.Retrieve(), *tc.config) swapForTest(t, &supplementalGroups, map[string]*accounts.Group{googleSudoersGroup: &accounts.Group{Name: googleSudoersGroup}}) home := filepath.Join(t.TempDir(), "home") for _, user := range tc.systemUsers { if user.HomeDir == "" { user.HomeDir = filepath.Join(home, user.Username) } dotssh := filepath.Join(home, user.Username, ".ssh") if err := os.MkdirAll(dotssh, 0750); err != nil { t.Fatalf("os.MkdirAll(%s, 0660) = %v, want nil", dotssh, err) } authorizedKeys := filepath.Join(dotssh, "authorized_keys") if err := os.WriteFile(authorizedKeys, nil, 0700); err != nil { t.Fatalf("os.WriteFile(%s, nil, 0700) = %v, want nil", authorizedKeys, err) } } removedFromSudoers := make(map[string]bool) testRunClient := &mockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { println(fmt.Sprintf("run command %q", opts.Name)) res := &run.Result{ OutputType: opts.OutputType, } switch opts.Name { case "failure": return nil, errors.New("mock failure") case "removeUser": username := opts.Args[0] if err := os.RemoveAll(filepath.Join(home, username)); err != nil { return nil, err } case "getent": for _, user := range tc.systemUsers { if user.Username == opts.Args[1] { res.Output = fmt.Sprintf("%s:x:-1:-1::%s:/bin/bash\n", user.Username, user.HomeDir) break } } case "removeFromGroup": println(fmt.Sprintf("removeFromGroup %+v", opts)) if opts.Args[0] == googleSudoersGroup { removedFromSudoers[opts.Args[1]] = true } } return res, nil }, } defaultRunClient := run.Client run.Client = testRunClient t.Cleanup(func() { run.Client = defaultRunClient }) got := deprovisionUnusedUsers(context.Background(), tc.config, tc.activeUsers) if diff := cmp.Diff(tc.want, got, cmpopts.EquateErrors()); diff != "" { t.Errorf("deprovisionUnusedUsers(ctx, %+v, %v) returned unexpected diff (-want +got):\n%s", tc.config, tc.activeUsers, diff) } for _, user := range tc.wantDeprovisioned { if authorizedKeys := filepath.Join(home, user, ".ssh", "authorized_keys"); file.Exists(authorizedKeys, file.TypeFile) { t.Errorf("file.Exists(%s, file.TypeFile) = true want false", authorizedKeys) } } for _, user := range tc.wantRemovedFromSudoers { if !removedFromSudoers[user] { t.Errorf("removedFromSudoers(%+v)[%q] = false, want true", removedFromSudoers, user) } } }) } } func TestEnsureUserExists(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } want, _ := currentUserAndGroup(ctx, t) name := want.Username got, err := ensureUserExists(ctx, name) if err != nil { t.Fatalf("ensureUserExists(%q) = %v, want nil", name, err) } if diff := cmp.Diff(want, got, cmp.AllowUnexported(accounts.User{})); diff != "" { t.Errorf("ensureUserExists(%q) returned diff\n(-want +got): %v", name, diff) } newuserHomedir := filepath.Join(t.TempDir(), "home") want = &accounts.User{ Username: "user_user", Password: "x", GID: "-1", UID: "-1", Name: "New User", HomeDir: newuserHomedir, Shell: "/usr/sbin/nologin", } var userCreated, userAddedToGroup bool testRunClient := &mockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { res := &run.Result{ OutputType: opts.OutputType, } switch opts.Name { case "getent": if userCreated { res.Output = fmt.Sprintf("%s:x:%s:%s:%s:%s:%s\n", want.Username, want.UID, want.GID, want.Name, want.HomeDir, want.Shell) } case "mkuser": if slices.Contains(opts.Args, want.Username) { userCreated = true } case "addtogroup": if slices.Contains(opts.Args, "new_group") { userAddedToGroup = true } } return res, nil }, } swapForTest(t, &cfg.Retrieve().Accounts.UserAddCmd, "mkuser {user}") swapForTest(t, &cfg.Retrieve().Accounts.GPasswdAddCmd, "addtogroup {group}") swapForTest(t, &supplementalGroups, map[string]*accounts.Group{"new_group": {Name: "new_group"}}) // swapForTest doesn't work with interfaces defaultRunClient := run.Client run.Client = testRunClient t.Cleanup(func() { run.Client = defaultRunClient }) got, err = ensureUserExists(ctx, want.Username) if err != nil { t.Fatalf("ensureUserExists(%q) = %v, want nil", want.Username, err) } if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(accounts.User{})); diff != "" { t.Errorf("ensureUserExists(%q) returned diff\n(-want +got):\n%v", name, diff) } if !userAddedToGroup { t.Fatalf("userAddedToGroup = %t, want true", userAddedToGroup) } } func TestEnsureGroupExists(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } _, currentGroup := currentUserAndGroup(ctx, t) err := ensureGroupExists(ctx, currentGroup.Name) if err != nil { t.Fatalf("ensureGroupExists(%q) = %v, want nil", currentGroup.Name, err) } groupAddTxt := filepath.Join(t.TempDir(), "groupadd.txt") groupAdd := filepath.Join(t.TempDir(), "groupadd") script := []byte(fmt.Sprintf("#!/bin/sh\necho -n $@ >> %s", groupAddTxt)) err = os.WriteFile(groupAdd, script, 0755) if err != nil { t.Fatalf("os.WriteFile(%s, %q, 0755) = %v want nil", groupAdd, script, err) } old := cfg.Retrieve().Accounts.GroupAddCmd cfg.Retrieve().Accounts.GroupAddCmd = groupAdd + " {group}" t.Cleanup(func() { cfg.Retrieve().Accounts.GroupAddCmd = old }) newgroup := "new_group" err = ensureGroupExists(ctx, newgroup) if err != nil { t.Fatalf("ensureGroupExists(%q) = %v, want nil", newgroup, err) } // Make sure groupadd_cmd was run with the right argument. out, err := os.ReadFile(groupAddTxt) if err != nil { t.Fatalf("os.ReadFile(%s) = %v, want nil", groupAddTxt, err) } if string(out) != newgroup { t.Fatalf("GroupAddCmd run with unexpected args, got %q, want %q", out, newgroup) } } func TestUpdateSSHKeys(t *testing.T) { tests := []struct { name string user *accounts.User keys []string authorizedKeysContents string wantAuthorizedKeysContents string }{ { name: "add_keys", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "write_keys"), UID: "-1", GID: "-1", }, authorizedKeysContents: "key3\n# Added by Google\nkey4\n", keys: []string{"key1", "key2"}, wantAuthorizedKeysContents: "key3\n# Added by Google\nkey1\n# Added by Google\nkey2\n", }, { name: "no_ssh_dir", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "write_keys"), UID: "-1", GID: "-1", }, authorizedKeysContents: "", keys: []string{"key1", "key2"}, wantAuthorizedKeysContents: "# Added by Google\nkey1\n# Added by Google\nkey2\n", }, { name: "no_keys", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "no_keys"), UID: "-1", GID: "-1", }, authorizedKeysContents: "key3\n", keys: nil, wantAuthorizedKeysContents: "", }, { name: "login_disallowed", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "login_disallowed"), UID: "-1", GID: "-1", Shell: "/sbin/nologin", }, keys: []string{"key1", "key2"}, wantAuthorizedKeysContents: "", }, } ctx := context.Background() g := &accounts.Group{Name: googleSudoersGroup} supplementalGroups[g.Name] = g cfg.Load(nil) cfg.Retrieve().Accounts.GroupAddCmd = "echo {group} > /dev/null" cfg.Retrieve().Accounts.GPasswdAddCmd = "echo {user} {group} > /dev/null" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := os.MkdirAll(tc.user.HomeDir, 0750); err != nil && tc.user.HomeDir != "" { t.Fatalf("os.MkdirAll(%s) = %v want nil", tc.user.HomeDir, err) } akFile := filepath.Join(tc.user.HomeDir, ".ssh", "authorized_keys") akDir := filepath.Dir(akFile) if tc.authorizedKeysContents != "" { if err := os.MkdirAll(akDir, 0700); err != nil { t.Fatalf("os.MkdirAll(%s) = %v want nil", akDir, err) } if err := os.WriteFile(akFile, []byte(tc.authorizedKeysContents), 0600); err != nil { t.Fatalf("os.WriteFile(%s) = %v want nil", akFile, err) } } gotErr := updateSSHKeys(ctx, tc.user, tc.keys) if gotErr != nil { t.Errorf("updateSSHKeys(%v, %v) = %v, want nil", tc.user, tc.keys, gotErr) } got, err := os.ReadFile(akFile) // Treat missing files as just having empty contents for test comparison // purposes. if err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatalf("os.ReadFile(%s) = err %v, want nil", akFile, err) } if string(got) != tc.wantAuthorizedKeysContents { t.Errorf("got contents of authorized_keys: %q want: %q", got, tc.wantAuthorizedKeysContents) } }) } } func TestUpdateSSHKeysError(t *testing.T) { tests := []struct { name string user *accounts.User keys []string restoreconCmd string skipIfRoot bool }{ { name: "no_homedir", user: &accounts.User{}, }, { name: "chown_failure", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "write_keys"), UID: "0", GID: "0", }, keys: []string{"key1", "key2"}, skipIfRoot: true, }, { name: "restorecon_failure", user: &accounts.User{ HomeDir: filepath.Join(t.TempDir(), "write_keys"), UID: "-1", GID: "-1", }, keys: []string{"key1", "key2"}, restoreconCmd: "#!/bin/sh\nexit 1", }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.skipIfRoot { currUser, err := user.Current() if err != nil { t.Fatalf("could not get current user: %v", err) } if currUser.Uid == "0" && currUser.Gid == "0" { t.Skip("skipping test because it fails when run as root") } } if err := os.MkdirAll(tc.user.HomeDir, 0750); err != nil && tc.user.HomeDir != "" { t.Fatalf("os.MkdirAll(%s) = %v want nil", tc.user.HomeDir, err) } if tc.restoreconCmd != "" { bindir := filepath.Join(t.TempDir(), "bin") if err := os.MkdirAll(bindir, 0700); err != nil { t.Fatalf("os.MkdirAll(%s) = %v want nil", bindir, err) } if err := os.WriteFile(filepath.Join(bindir, "restorecon"), []byte(tc.restoreconCmd), 0755); err != nil { t.Fatalf("os.WriteFile(%s) = %v want nil", filepath.Join(bindir, "restorecon"), err) } newpath := fmt.Sprintf("%s:%s", bindir, os.Getenv("PATH")) if err := os.Setenv("PATH", newpath); err != nil { t.Fatalf("os.Setenv(%q, %s) = %v want nil", "PATH", newpath, err) } } gotErr := updateSSHKeys(ctx, tc.user, tc.keys) if gotErr == nil { t.Errorf("updateSSHKeys(%v, %v) = %v, want non-nil", tc.user, tc.keys, gotErr) } }) } } func TestEnableMetadataSSHKey(t *testing.T) { tests := []struct { mdsjson string want bool }{ { mdsjson: `{"instance":{"attributes":{"enable-oslogin": "true"}},"project":{"attributes":{"enable-oslogin": "true"}}}`, want: false, }, { mdsjson: `{"instance":{"attributes":{"enable-oslogin": "false"}},"project":{"attributes":{"enable-oslogin": "true"}}}`, want: true, }, { mdsjson: `{"project":{"attributes":{"enable-oslogin": "false"}}}`, want: true, }, } for _, tc := range tests { mdsdesc := descriptorFromJSON(t, tc.mdsjson) got := enableMetadataSSHKey(&cfg.Sections{}, mdsdesc) if got != tc.want { t.Errorf("enableMetadataSSHKey(cfg, %v) = %v, want: %v", mdsdesc, got, tc.want) } } } func TestMetadataSSHKeySetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } ctx := context.Background() _, currentGroup := currentUserAndGroup(ctx, t) deprovisionUnusedUsers = func(ctx context.Context, config *cfg.Sections, activeUsers userKeyMap) []error { return nil } t.Cleanup(func() { deprovisionUnusedUsers = defaultDeprovisionUnusedUsers }) tests := []struct { name string config *cfg.Sections desc *metadata.Descriptor lastEnabled bool lastValidKeys userKeyMap googleSudoersContents string googleSudoersGroup string onetimePlatformSetupFinished bool want []error wantSudoersConfig string wantSupplementalGroups map[string]*accounts.Group wantNoop bool }{ { name: "set_configuration_successfully", config: &cfg.Sections{Accounts: &cfg.Accounts{Groups: currentGroup.Name}}, desc: descriptorFromJSON(t, `{}`), onetimePlatformSetupFinished: false, lastEnabled: false, lastValidKeys: make(userKeyMap), googleSudoersContents: "", googleSudoersGroup: currentGroup.Name, want: nil, wantSudoersConfig: fmt.Sprintf("%%%s ALL=(ALL:ALL) NOPASSWD:ALL\n", currentGroup.Name), wantSupplementalGroups: map[string]*accounts.Group{ currentGroup.Name: &accounts.Group{Name: currentGroup.Name}, }, wantNoop: false, }, { name: "fail_to_create_groups", config: &cfg.Sections{ Accounts: &cfg.Accounts{ Groups: "newgroup", GroupAddCmd: "false", }, }, desc: descriptorFromJSON(t, `{}`), onetimePlatformSetupFinished: false, lastEnabled: false, lastValidKeys: make(userKeyMap), googleSudoersContents: "over-write me", googleSudoersGroup: "new_admin_group", // Exec doesn't export an error type which can be compared with errors.Is // but cmp will at least compare that we got the right number. want: []error{cmpopts.AnyError, cmpopts.AnyError}, wantSudoersConfig: fmt.Sprintf("%%%s ALL=(ALL:ALL) NOPASSWD:ALL\n", "new_admin_group"), wantSupplementalGroups: map[string]*accounts.Group{ "new_admin_group": &accounts.Group{Name: "new_admin_group"}, "newgroup": &accounts.Group{Name: "newgroup"}, }, wantNoop: false, }, { name: "noop_platform_setup_finished", config: &cfg.Sections{ Accounts: &cfg.Accounts{ Groups: "newgroup", GroupAddCmd: "false", }, }, desc: descriptorFromJSON(t, `{}`), onetimePlatformSetupFinished: true, lastEnabled: false, lastValidKeys: make(userKeyMap), googleSudoersContents: "don't over-write me", googleSudoersGroup: "new_admin_group", want: nil, wantSudoersConfig: "don't over-write me", wantSupplementalGroups: map[string]*accounts.Group{}, wantNoop: false, }, { name: "noop_no_diff", config: &cfg.Sections{ Accounts: &cfg.Accounts{ Groups: "newgroup", GroupAddCmd: "false", }, }, desc: descriptorFromJSON(t, `{}`), onetimePlatformSetupFinished: false, lastEnabled: true, lastValidKeys: make(userKeyMap), googleSudoersContents: "don't over-write me", googleSudoersGroup: "new_admin_group", want: nil, wantSudoersConfig: "don't over-write me", wantSupplementalGroups: map[string]*accounts.Group{}, wantNoop: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { onetimePlatformSetupFinishedOld := onetimePlatformSetupFinished.Load() onetimePlatformSetupFinished.Store(tc.onetimePlatformSetupFinished) t.Cleanup(func() { onetimePlatformSetupFinished.Store(onetimePlatformSetupFinishedOld) }) swapForTest(t, &supplementalGroups, make(map[string]*accounts.Group)) swapForTest(t, cfg.Retrieve(), *tc.config) swapForTest(t, &googleSudoersGroup, tc.googleSudoersGroup) swapForTest(t, &lastEnabled, tc.lastEnabled) swapForTest(t, &lastUserKeyMap, tc.lastValidKeys) testGoogleSudoers := filepath.Join(t.TempDir(), "google_sudoers") swapForTest(t, &googleSudoersConfig, testGoogleSudoers) if tc.googleSudoersContents != "" { if err := os.WriteFile(testGoogleSudoers, []byte(tc.googleSudoersContents), 0600); err != nil { t.Fatalf("os.WriteFile(%s, %q, 0600) = %v, want nil", testGoogleSudoers, tc.googleSudoersContents, err) } } noop, got := metadataSSHKeySetup(ctx, tc.config, tc.desc) if diff := cmp.Diff(tc.want, got, cmpopts.EquateErrors()); diff != "" { t.Errorf("metadataSSHKeySetup(ctx, %v, %v) returned an unexpected diff (-want +got):\n%s", tc.config, tc.desc, diff) } if noop != tc.wantNoop { t.Errorf("metadataSSHKeySetup(ctx, %v, %v) returned noop = %t, want %t", tc.config, tc.desc, noop, tc.wantNoop) } gotSudoersConfig, err := os.ReadFile(testGoogleSudoers) if err != nil && !os.IsNotExist(err) { t.Fatalf("os.ReadFile(%s) = %v, want nil", testGoogleSudoers, err) } if tc.wantSudoersConfig != string(gotSudoersConfig) { t.Errorf("unexpected sudoers config contents, got %q want %q", tc.wantSudoersConfig, gotSudoersConfig) } if diff := cmp.Diff(tc.wantSupplementalGroups, supplementalGroups); diff != "" { t.Errorf("supplementalGroups has unexpected diff (-want +got):\n%s", diff) } }) } } func TestSelinuxRestoreCon(t *testing.T) { tests := []struct { name string failLookup bool failExec bool wantErr bool wantCommand string fpath string }{ { name: "success", wantCommand: "/usr/bin/restorecon /usr/bin/binary", fpath: "/usr/bin/binary", }, { name: "fail-lookup", failLookup: true, wantErr: false, fpath: "/usr/bin/binary", }, { name: "fail-exec", failExec: true, wantErr: true, fpath: "/usr/bin/binary", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { execLookPath = func(fpath string) (string, error) { if tc.failLookup { return "", errors.New("fail lookup") } return "/usr/bin/restorecon", nil } runClientOld := run.Client var command string run.Client = &mockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.failExec { return nil, errors.New("fail exec") } command = strings.Join(append([]string{opts.Name}, opts.Args...), " ") return nil, nil }, } t.Cleanup(func() { execLookPath = exec.LookPath run.Client = runClientOld }) err := selinuxRestoreCon(context.Background(), tc.fpath) if (err == nil) == tc.wantErr { t.Errorf("selinuxRestoreCon(%q) = %v, want %v", tc.fpath, err, tc.wantErr) } if command != tc.wantCommand { t.Errorf("selinuxRestoreCon(%q) ran command %q, want %q", tc.fpath, command, tc.wantCommand) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey_test.go000066400000000000000000000376141524141036400303720ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadatasshkey import ( "context" "errors" "os/user" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" ) func swapForTest[T any](t *testing.T, old *T, new T) { t.Helper() saved := *old t.Cleanup(func() { *old = saved }) *old = new } // The mock Runner client to use for testing. type mockRunner struct { // callback is the test's mock implementation. callback func(context.Context, run.Options) (*run.Result, error) } func (m *mockRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { return m.callback(ctx, opts) } func currentUserAndGroup(ctx context.Context, t *testing.T) (*accounts.User, *accounts.Group) { t.Helper() currentUser, err := user.Current() if err != nil { t.Fatalf("user.Current() = %v, want nil", err) } accountsUser, err := accounts.FindUser(ctx, currentUser.Username) if err != nil { t.Fatalf("accounts.FindUser(ctx, %s) = err %v want nil", currentUser.Username, err) } gids, err := currentUser.GroupIds() if err != nil { t.Fatalf("currentUser.GroupIds() = %v, want nil", err) } if len(gids) == 0 { t.Fatalf("len(currentUser.GroupIds()) = 0, want non-zero") } currentGroup, err := user.LookupGroupId(gids[0]) if err != nil { t.Fatalf("user.LookupGroupId(%s) = %v want nil", gids[0], err) } accountsGroup, err := accounts.FindGroup(ctx, currentGroup.Name) if err != nil { t.Fatalf("accounts.FindGroup(ctx, %s) = err %v want nil", currentGroup.Name, err) } return accountsUser, accountsGroup } func descriptorFromJSON(t *testing.T, j string) *metadata.Descriptor { t.Helper() desc, err := metadata.UnmarshalDescriptor(j) if err != nil { t.Fatalf("metadata.UnmarshalJSON(%s) = %v, want nil", j, err) } return desc } func TestDiff(t *testing.T) { tests := []struct { name string config *cfg.Sections desc *metadata.Descriptor lastValidKeys userKeyMap lastEnabled bool want bool }{ { name: "no_changes", config: &cfg.Sections{ Daemons: &cfg.Daemons{AccountsDaemon: true}, }, desc: descriptorFromJSON(t, `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "enable-windows-ssh": "true" } } }`), lastValidKeys: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, lastEnabled: true, want: false, }, { name: "newly_disabled", config: &cfg.Sections{ Daemons: &cfg.Daemons{AccountsDaemon: true}, }, desc: descriptorFromJSON(t, `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "enable-oslogin": "true", "enable-windows-ssh": "false" } } }`), lastValidKeys: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, lastEnabled: true, want: true, }, { name: "new_key", config: &cfg.Sections{ Daemons: &cfg.Daemons{AccountsDaemon: true}, }, desc: descriptorFromJSON(t, `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "enable-windows-ssh": "true" } } }`), lastValidKeys: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", }, }, lastEnabled: true, want: true, }, { name: "key_on_disk_expired", config: &cfg.Sections{ Daemons: &cfg.Daemons{AccountsDaemon: true}, }, desc: descriptorFromJSON(t, `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z google-ssh {\"userName\":\"test_user\",\"expireOn\":\"`+time.Now().AddDate(-1, -1, -1).Format(time.RFC3339)+`\"}\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "enable-windows-ssh": "true" } } }`), lastValidKeys: userKeyMap{ "testuser": []string{ `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z google-ssh {"userName":"test_user","expireOn":"` + time.Now().AddDate(-1, -1, -1).Format(time.RFC3339) + `"}`, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, lastEnabled: true, want: true, }, { name: "key_on_disk_expires_in_future", config: &cfg.Sections{ Daemons: &cfg.Daemons{AccountsDaemon: true}, }, desc: descriptorFromJSON(t, `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z google-ssh {\"userName\":\"test_user\",\"expireOn\":\"`+time.Now().AddDate(1, 1, 1).Format(time.RFC3339)+`\"}\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "enable-windows-ssh": "true" } } }`), lastValidKeys: userKeyMap{ "testuser": []string{ `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z google-ssh {"userName":"test_user","expireOn":"` + time.Now().AddDate(1, 1, 1).Format(time.RFC3339) + `"}`, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, lastEnabled: true, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := metadataChanged(tc.config, tc.desc, tc.lastValidKeys, tc.lastEnabled) if got != tc.want { t.Errorf("metadataChanged(%v, %v, %v, %v) = %v, want: %v", tc.config, tc.desc, tc.lastValidKeys, tc.lastEnabled, got, tc.want) } }) } } func TestNewModule(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } mod := NewModule(context.Background()) if mod == nil { t.Fatalf("NewModule() = nil, want non-nil") } if mod.ID != "metadatasshkey" { t.Errorf("NewModule().ID = %q, want %q", mod.ID, "metadatasshkey") } } func TestModuleSetupDaemonDisabled(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } cfg.Retrieve().Daemons.AccountsDaemon = false if err := moduleSetup(context.Background(), descriptorFromJSON(t, "{}")); err != nil { t.Fatalf("moduleSetup(ctx, {}) = %v, want nil", err) } if events.FetchManager().IsSubscribed(metadata.LongpollEvent, "metadatasshkey") { t.Errorf("moduleSetup(ctx, {}) = subscribed to metadata.LongpollEvent, want not subscribed") } } func TestModuleSetupInputValidity(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } // Unsubscribe from metadata.LongpollEvent after the test is done. t.Cleanup(func() { events.FetchManager().Unsubscribe(metadata.LongpollEvent, "metadatasshkey") }) if err := moduleSetup(context.Background(), descriptorFromJSON(t, "{}")); err != nil { t.Fatalf("moduleSetup(ctx, {}) = %v, want nil", err) } if err := moduleSetup(context.Background(), ""); err == nil { t.Fatalf("moduleSetup(ctx, \"\") = %v, want non-nil", err) } } func TestHandleMetadataChangeInputValidity(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } tests := []struct { name string data *events.EventData wantContinue bool wantError bool }{ { name: "empty_descriptor", data: &events.EventData{Data: descriptorFromJSON(t, "{}")}, wantContinue: true, wantError: false, }, { name: "error_descriptor", data: &events.EventData{Data: descriptorFromJSON(t, "{}"), Error: errors.New("some error")}, wantContinue: true, wantError: true, }, { name: "invalid_data", data: &events.EventData{Data: ""}, wantContinue: false, wantError: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gotContinue, gotNoop, err := handleMetadataChange(ctx, "", nil, tc.data) if (err != nil) != tc.wantError { t.Fatalf("handleMetadataChange(ctx, '', nil, %+v) error = %v, want error: %t", tc.data, err, tc.wantError) } if !gotNoop { t.Errorf("handleMetadataChange(ctx, '', nil, %+v) returned noop = %t, want true", tc.data, gotNoop) } if gotContinue != tc.wantContinue { t.Fatalf("handleMetadataChange(ctx, '', nil, %+v) = %t, want %t", tc.data, gotContinue, tc.wantContinue) } }) } } func TestFindValidKeys(t *testing.T) { tests := []struct { name string descJSON string want userKeyMap wantInvalidKeys map[string]bool }{ { name: "get_user_keys", descJSON: `{ "instance": { "attributes": { "ssh-keys": "testuser:invalidkey\n\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost" } }, "project": { "attributes": { "ssh-keys": "testuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAvUrq+1G/m+F8Us4GQkl0d72nh8Sr4xDcUWwx+Ji1oi testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIhv/faXnlsh3DnFb29wXET7lAsLDaNZ+MNny8p10sez testuser@fakehost" } } }`, want: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAvUrq+1G/m+F8Us4GQkl0d72nh8Sr4xDcUWwx+Ji1oi testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIhv/faXnlsh3DnFb29wXET7lAsLDaNZ+MNny8p10sez testuser@fakehost", }, }, wantInvalidKeys: map[string]bool{ "testuser:invalidkey": true, }, }, { name: "block_project_keys", descJSON: `{ "instance": { "attributes": { "block-project-ssh-keys": "true", "ssh-keys": "testuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost" } }, "project": { "attributes": { "ssh-keys": "testuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAvUrq+1G/m+F8Us4GQkl0d72nh8Sr4xDcUWwx+Ji1oi testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIhv/faXnlsh3DnFb29wXET7lAsLDaNZ+MNny8p10sez testuser@fakehost" } } }`, want: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, wantInvalidKeys: map[string]bool{}, }, { name: "deprecated_ssh_keys", descJSON: `{ "instance": { "attributes": { "sshKeys": "testuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost" } }, "project": { "attributes": { "ssh-keys": "testuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAvUrq+1G/m+F8Us4GQkl0d72nh8Sr4xDcUWwx+Ji1oi testuser@fakehost\ntestuser:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIhv/faXnlsh3DnFb29wXET7lAsLDaNZ+MNny8p10sez testuser@fakehost" } } }`, want: userKeyMap{ "testuser": []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILFYqqo4wCPyk9GZX1spzptpTEOnhouAP276pHr1Sv7z testuser@fakehost", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIECi36p6+wxL2B/f4/EBn49ucI3creKuVEH9IhLt6gDM testuser@fakehost", }, }, wantInvalidKeys: map[string]bool{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldInvalidKeys := invalidKeys t.Cleanup(func() { invalidKeys = oldInvalidKeys }) invalidKeys = make(map[string]bool) desc := descriptorFromJSON(t, tc.descJSON) got := findValidKeys(desc) if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("findValidKeys(%v) returned an unexpected diff (-want +got):\n%v", tc.descJSON, diff) } if diff := cmp.Diff(tc.wantInvalidKeys, invalidKeys); diff != "" { t.Errorf("findValidKeys(%v) returned an unexpected diff for invalidKeys (-want +got):\n%v", tc.descJSON, diff) } }) } } func TestIsUserKeysMapEqual(t *testing.T) { tests := []struct { name string m1 userKeyMap m2 userKeyMap want bool }{ { name: "equal_maps", m1: userKeyMap{ "user1": {"key1", "key2"}, "user2": {"key3"}, }, m2: userKeyMap{ "user1": {"key1", "key2"}, "user2": {"key3"}, }, want: true, }, { name: "equal_maps_different_value_order", m1: userKeyMap{ "user1": {"key2", "key1"}, "user2": {"key3"}, }, m2: userKeyMap{ "user1": {"key1", "key2"}, "user2": {"key3"}, }, want: true, }, { name: "different_lengths", m1: userKeyMap{ "user1": {"key1"}, }, m2: userKeyMap{ "user1": {"key1"}, "user2": {"key2"}, }, want: false, }, { name: "different_keys", m1: userKeyMap{ "user1": {"key1"}, }, m2: userKeyMap{ "user2": {"key1"}, }, want: false, }, { name: "different_values", m1: userKeyMap{ "user1": {"key1"}, }, m2: userKeyMap{ "user1": {"key2"}, }, want: false, }, { name: "one_empty_map", m1: userKeyMap{}, m2: userKeyMap{ "user1": {"key1"}, }, want: false, }, { name: "both_empty_maps", m1: userKeyMap{}, m2: userKeyMap{}, want: true, }, { name: "one_map_nil", m1: nil, m2: userKeyMap{}, want: true, }, { name: "both_maps_are_nil", m1: nil, m2: nil, want: true, }, { name: "one_map_is_nil_other_is_not", m1: nil, m2: userKeyMap{ "user1": {"key1"}, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isUserKeysMapEqual(tt.m1, tt.m2); got != tt.want { t.Errorf("isUserKeysMapEqual(%v, %v) = %v, want %v", tt.m1, tt.m2, got, tt.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey_windows.go000066400000000000000000000154171524141036400311020ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package metadatasshkey import ( "context" "fmt" "strconv" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) const ( // Minimum major version of the use of AuthorizedKeysCommand. minSSHMajorVersion = 8 // Minimum minor version of the use of AuthorizedKeysCommand. minSSHMinorVersion = 6 // The registry key where the sshd service is kept. Used to look up the path // of the binary, which is check against the minimum version. sshdRegKey = `SYSTEM\CurrentControlSet\Services\sshd` ) // deprovisionUnusedUsers removes accounts which were removed from ssh key // metadata from the local system. Depending on user configuration, the account // may not be deleted but instead have ssh keys removed. func deprovisionUnusedUsers(context.Context, *cfg.Sections, userKeyMap) []error { galog.V(2).Info("Metadata ssh key called deprovisionUnusedUsers() but users are never removed on windows. Not doing anything.") return nil } func updateSSHKeys(context.Context, *accounts.User, []string) error { galog.V(2).Info("Metadata ssh key called updateSSHKey() but all keys on windows come from authorized keys command. Not doing anything.") return nil } // ensureUserExists finds the named user, creating it locally if it doesn't // exist. Wraps errors from accounts package. func ensureUserExists(ctx context.Context, username string) (*accounts.User, error) { u, err := accounts.FindUser(ctx, username) if err == nil { return u, nil } galog.Debugf("User %s does not exist (lookup returned %v), creating.", username, err) pwd, err := accounts.GeneratePassword(20) if err != nil { return nil, fmt.Errorf("could not generate password for new user %s: %v", username, err) } u = &accounts.User{ Name: username, Password: pwd, } err = accounts.CreateUser(ctx, u) if err != nil { return nil, fmt.Errorf("failed to create user %s: %w", username, err) } u, err = accounts.FindUser(ctx, username) if err != nil { return nil, fmt.Errorf("could not find user %s after creation: %w", username, err) } for _, group := range supplementalGroups { if err := accounts.AddUserToGroup(ctx, u, group); err != nil { galog.Errorf("Failed to add user %s to group %s: %v.", u.Name, group.Name, err) } } galog.Infof("Created user %s", username) return u, nil } // enableMetadataSSHKey reports whether metadata ssh keys should be managed. func enableMetadataSSHKey(config *cfg.Sections, mdsdesc *metadata.Descriptor) bool { if config.AccountManager != nil { return !config.AccountManager.Disable && mdsdesc.WindowsSSHEnabled() } return !mdsdesc.AccountManagerDisabled() && mdsdesc.WindowsSSHEnabled() } // setPlatformConfiguration adds the local Administrators group as a // supplemental group for new users, and logs a warning if sshd is not running. func setPlatformConfiguration(ctx context.Context, config *cfg.Sections, desc *metadata.Descriptor) []error { // If you are adding new configuration behavior, prefer to return early // rather than compounding errors. The compounded errors here now are present // to maintain existing behavior. This should be avoided in the future. supplementalGroups[accounts.AdminGroup.Name] = accounts.AdminGroup major, minor, err := sshdVersion(ctx) if err != nil { galog.Warnf("Could not determine if openssh version is compatible: could not find version: %v.", err) } else if major < minSSHMajorVersion || (major == minSSHMajorVersion && minor < minSSHMinorVersion) { // We warn users about incompatibilities but this is only actionable for // the user, nothing the guest agent can do about it. galog.Warnf("Detected openssh version may be incompatible with enable_windows_ssh. Found version %d.%d, need version %d.%d.\nSee the windows ssh documentation for instructions on enabling ssh: https://cloud.google.com/compute/docs/connect/windows-ssh.", major, minor, minSSHMajorVersion, minSSHMinorVersion) } galog.V(2).Debug("Not configuring SSH, configuration is done by google-compute-engine-ssh googet package, not the agent.") opts := run.Options{ OutputType: run.OutputStdout, Name: "sc", Args: []string{"query", "sshd"}, ExecMode: run.ExecModeSync, } if out, err := run.WithContext(ctx, opts); err == nil && !strings.Contains(out.Output, "RUNNING") { opts := run.Options{ OutputType: run.OutputCombined, Name: "powershell", Args: []string{"-c", "Start-Service -Name sshd"}, ExecMode: run.ExecModeSync, } if _, err := run.WithContext(ctx, opts); err != nil { return []error{fmt.Errorf("failed to start sshd: %v", err)} } } return nil } // sshdVersion finds the major and minor versions of the sshd binary. func sshdVersion(ctx context.Context) (int, int, error) { image, err := reg.ReadString(sshdRegKey, "ImagePath") if err != nil { return 0, 0, err } image = strings.Trim(string(image), `"`) opts := run.Options{ OutputType: run.OutputStdout, ExecMode: run.ExecModeSync, Name: "powershell.exe", Args: []string{ "-c", fmt.Sprintf(`(Get-Item "%s").VersionInfo.FileVersion`, image), }, } res, err := run.WithContext(ctx, opts) if err != nil { return 0, 0, fmt.Errorf("failed to run powershell command (Get-Item %q).VersionInfo.FileVersion: %v", image, err) } galog.V(2).Debugf("Got version info string %s querying for service image path %q.", res.Output, sshdRegKey) fields := strings.Split(strings.TrimSpace(res.Output), ".") if len(fields) < 2 { return 0, 0, fmt.Errorf("service image path %q: not enough values in version %q (split to %v) to determine major and minor version", sshdRegKey, res.Output, fields) } major, err := strconv.Atoi(fields[0]) if err != nil { return 0, 0, fmt.Errorf("service image path %q: major version %q is not an int", sshdRegKey, fields[0]) } minor, err := strconv.Atoi(fields[1]) if err != nil { return 0, 0, fmt.Errorf("service image path %q: minor version %q is not an int", sshdRegKey, fields[1]) } return major, minor, nil } google-guest-agent-20260819.00/cmd/core_plugin/metadatasshkey/metadatasshkey_windows_test.go000066400000000000000000000326051524141036400321370ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package metadatasshkey import ( "context" "os/user" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestDeprovisionUnusedUsers(t *testing.T) { if err := deprovisionUnusedUsers(context.Background(), nil, nil); err != nil { t.Fatalf("deprovisionUnusedUsers(ctx, nil, nil) = %v, want nil", nil) } } func TestEnsureUserExists(t *testing.T) { ctx := context.Background() pwd, err := accounts.GeneratePassword(20) if err != nil { t.Fatalf("accounts.GeneratePassword(20) = err %v, want nil", err) } u := &accounts.User{ Name: "existing_user", Password: pwd, } if err := accounts.CreateUser(ctx, u); err != nil { t.Fatalf("accounts.CreateUser(ctx, %+v) = %v, want nil", u, err) } got, err := ensureUserExists(ctx, "existing_user") if err != nil { t.Fatalf("ensureUserExists(ctx, %q) = err %v, want nil", "existing_user", err) } if got.Name != "existing_user" { t.Fatalf("ensureUserExists(ctx, %q) = user %+v, want %s", "existing_user", got, "existing_user") } got, err = ensureUserExists(ctx, "new_user") if err != nil { t.Fatalf("ensureUserExists(ctx, %q) = err %v, want nil", "new_user", err) } if got.Name != "new_user" { t.Fatalf("ensureUserExists(ctx, %q) = user %+v, want %s", "new_user", got, "new_user") } } func TestEnsureGroupExists(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } currentUser, err := user.Current() if err != nil { t.Fatalf("user.Current() = %v, want nil", err) } gids, err := currentUser.GroupIds() if err != nil { t.Fatalf("testrunnerUser.GroupIds() = %v, want nil", err) } currentGroup, err := user.LookupGroupId(gids[0]) if err != nil { t.Fatalf("user.LookupGroupId(%s) = %v want nil", gids[0], err) } err = ensureGroupExists(ctx, currentGroup.Name) if err != nil { t.Fatalf("ensureGroupExists(%q) = %v, want nil", currentGroup.Name, err) } newgroup := "new_group" err = ensureGroupExists(ctx, newgroup) if err != nil { t.Fatalf("ensureGroupExists(%q) = %v, want nil", newgroup, err) } _, err = user.LookupGroup(newgroup) if err != nil { t.Fatalf("user.LookupGroup(%q) = %v, want nil", newgroup, err) } } func TestUpdateSSHKeys(t *testing.T) { if err := updateSSHKeys(context.Background(), nil, nil); err != nil { t.Fatalf("updateSSHKeys(ctx, nil, nil) = %v, want nil", err) } } func TestEnableMetadataSSHKey(t *testing.T) { tests := []struct { config *cfg.Sections mdsjson string want bool }{ { config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: true}}, mdsjson: `{"instance":{"attributes":{"disable-account-manager": "false","enable-windows-ssh":"true"}},"project":{"attributes":{"disable-account-manager": "false"}}}`, want: false, }, { config: &cfg.Sections{}, mdsjson: `{"instance":{"attributes":{"disable-account-manager": "true","enable-windows-ssh":"true"}},"project":{"attributes":{"disable-account-manager": "false"}}}`, want: false, }, { config: &cfg.Sections{}, mdsjson: `{"project":{"attributes":{"disable-account-manager": "true","enable-windows-ssh":"true"}}}`, want: false, }, { config: &cfg.Sections{}, mdsjson: `{"project":{"attributes":{"enable-windows-ssh":"true"}}}`, want: true, }, { config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, mdsjson: `{}`, want: false, }, { config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, mdsjson: `{"instance":{"attributes":{"disable-account-manager": "false","enable-windows-ssh":"true"}},"project":{"attributes":{"disable-account-manager": "true","enable-windows-ssh":"false"}}}`, want: true, }, } for _, tc := range tests { mdsdesc := descriptorFromJSON(t, tc.mdsjson) got := enableMetadataSSHKey(tc.config, mdsdesc) if got != tc.want { t.Errorf("enableMetadataSSHKey(%+v, %v) = %v, want: %v", tc.config.AccountManager, tc.mdsjson, got, tc.want) } } } func createSSHService(ctx context.Context, t *testing.T) { t.Helper() opts := run.Options{ OutputType: run.OutputNone, ExecMode: run.ExecModeSync, Name: "reg", Args: []string{ "query", `HKLM\` + sshdRegKey, }, } _, err := run.WithContext(ctx, opts) if err != nil { // sshd service does not exist, create it for test. opts = run.Options{ OutputType: run.OutputCombined, ExecMode: run.ExecModeSync, Name: "powershell", Args: []string{ "-c", `New-Service -Name sshd -BinaryPathName '"C:\windows\system32\svchost.exe"'`, }, } if _, err := run.WithContext(ctx, opts); err != nil { t.Fatalf("run.WithContext(ctx, %+v) = %v want nil", opts, err) } t.Cleanup(func() { opts = run.Options{ OutputType: run.OutputNone, ExecMode: run.ExecModeSync, Name: "powershell", Args: []string{ "-c", `Remove-Service -Name sshd'`, }, } if _, err := run.WithContext(ctx, opts); err != nil { t.Logf("Failed to cleanup sshd service after test: run.WithContext(ctx, %+v) = %v want nil", opts, err) } }) } } func TestServiceImageExeVersion(t *testing.T) { ctx := context.Background() createSSHService(ctx, t) major, minor, err := sshdVersion(ctx) if err != nil { t.Fatalf(`sshdVersion(ctx) = err %v, want nil`, err) } // The version of the binary in test should never be 0.0, if this happens it // was probably parsed incorrectly. // To make this test deterministic, figure out how to stamp the test binary // with FileVersionInfo information and check for that. if major == 0 && minor == 0 { t.Errorf(`sshdVersion(ctx) = %d, %d want one of them to be non-zero`, major, minor) } } func TestMetadataSSHKeySetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } ctx := context.Background() createSSHService(ctx, t) tests := []struct { name string config *cfg.Sections desc *metadata.Descriptor lastEnabled bool lastValidKeys userKeyMap sshImagePath string onetimePlatformSetupFinished bool want []error wantSSHDState string wantSupplementalGroups map[string]*accounts.Group wantNoop bool }{ { name: "set_configuration_successfully", config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, lastEnabled: false, lastValidKeys: make(userKeyMap), desc: descriptorFromJSON(t, `{"instance":{"attributes":{"enable-windows-ssh":"true"}}}`), onetimePlatformSetupFinished: false, want: nil, wantSSHDState: "Running", wantSupplementalGroups: map[string]*accounts.Group{ accounts.AdminGroup.Name: accounts.AdminGroup, }, }, { name: "fail_to_start_ssh", config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, lastEnabled: false, lastValidKeys: make(userKeyMap), desc: descriptorFromJSON(t, `{"instance":{"attributes":{"enable-windows-ssh":"true"}}}`), sshImagePath: "powershell.exe -c \"exit 1\"", onetimePlatformSetupFinished: false, want: []error{cmpopts.AnyError}, wantSSHDState: "Stopped", wantSupplementalGroups: map[string]*accounts.Group{ accounts.AdminGroup.Name: accounts.AdminGroup, }, }, { name: "noop_platform_setup_finished", config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, lastEnabled: false, lastValidKeys: make(userKeyMap), desc: descriptorFromJSON(t, `{"instance":{"attributes":{"enable-windows-ssh":"true"}}}`), sshImagePath: "powershell.exe -c \"exit 1\"", onetimePlatformSetupFinished: true, want: nil, wantSSHDState: "Stopped", wantSupplementalGroups: map[string]*accounts.Group{}, }, { name: "noop_metadatasshkey_disabled", config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: true}}, lastEnabled: true, lastValidKeys: make(userKeyMap), desc: descriptorFromJSON(t, `{"instance":{"attributes":{"enable-windows-ssh":"false"}}}`), sshImagePath: "powershell.exe -c \"exit 1\"", onetimePlatformSetupFinished: false, want: nil, wantSSHDState: "Stopped", wantSupplementalGroups: map[string]*accounts.Group{}, }, { name: "noop_no_diff", config: &cfg.Sections{AccountManager: &cfg.AccountManager{Disable: false}}, lastEnabled: true, lastValidKeys: make(userKeyMap), desc: descriptorFromJSON(t, `{"instance":{"attributes":{"enable-windows-ssh":"true"}}}`), sshImagePath: "powershell.exe -c \"exit 1\"", onetimePlatformSetupFinished: false, want: nil, wantSSHDState: "Stopped", wantSupplementalGroups: map[string]*accounts.Group{}, wantNoop: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { onetimePlatformSetupFinishedOld := onetimePlatformSetupFinished.Load() onetimePlatformSetupFinished.Store(tc.onetimePlatformSetupFinished) t.Cleanup(func() { onetimePlatformSetupFinished.Store(onetimePlatformSetupFinishedOld) }) swapForTest(t, &supplementalGroups, make(map[string]*accounts.Group)) swapForTest(t, &lastEnabled, tc.lastEnabled) swapForTest(t, &lastUserKeyMap, tc.lastValidKeys) if tc.sshImagePath != "" { opts := run.Options{ OutputType: run.OutputCombined, Name: "powershell", Args: []string{"-c", "Stop-Service -Name sshd"}, ExecMode: run.ExecModeSync, } if _, err := run.WithContext(ctx, opts); err != nil { t.Fatalf("Failed to stop sshd: %v", err) } old, err := reg.ReadString(sshdRegKey, "ImagePath") if err != nil { t.Fatalf(`reg.ReadString(%q, ImagePath) = %v, want nil`, sshdRegKey, err) } if err = reg.WriteString(sshdRegKey, "ImagePath", tc.sshImagePath); err != nil { t.Fatalf(`reg.WriteString(%q, ImagePath, %s) = %v, want nil`, sshdRegKey, tc.sshImagePath, err) } t.Cleanup(func() { opts := run.Options{ OutputType: run.OutputCombined, Name: "powershell", Args: []string{"-c", "Stop-Service -Name sshd"}, ExecMode: run.ExecModeSync, } if _, err := run.WithContext(ctx, opts); err != nil { t.Logf("Failed to restart sshd: failed to stop sshd: %v", err) } if err := reg.WriteString(sshdRegKey, "ImagePath", old); err != nil { t.Logf("Failed to restore sshd ImagePath: %v", err) } opts = run.Options{ OutputType: run.OutputCombined, Name: "powershell", Args: []string{"-c", "Start-Service -Name sshd"}, ExecMode: run.ExecModeSync, } if _, err := run.WithContext(ctx, opts); err != nil { t.Logf("Failed to restart: failed to start sshd: %v", err) } }) } noop, got := metadataSSHKeySetup(ctx, tc.config, tc.desc) if diff := cmp.Diff(tc.want, got, cmpopts.EquateErrors()); diff != "" { t.Errorf("metadataSSHKeySetup(ctx, %v, %v) returned an unexpected diff (-want +got):\n%s", tc.config, tc.desc, diff) } if noop != tc.wantNoop { t.Errorf("metadataSSHKeySetup(ctx, %v, %v) = noop %v, want %v", tc.config, tc.desc, noop, tc.wantNoop) } if diff := cmp.Diff(tc.wantSupplementalGroups, supplementalGroups); diff != "" { t.Errorf("supplementalGroups has unexpected diff (-want +got):\n%s", diff) } opts := run.Options{ OutputType: run.OutputStdout, Name: "powershell", Args: []string{"-c", "(Get-Service -Name sshd).Status"}, ExecMode: run.ExecModeSync, } res, err := run.WithContext(ctx, opts) if err != nil { t.Fatalf("run.WithContext(ctx, %+v) = err %v, want nil", opts, err) } if strings.TrimSpace(res.Output) != tc.wantSSHDState { t.Errorf("%s %v = %s, want %s", opts.Name, opts.Args, strings.TrimSpace(res.Output), tc.wantSSHDState) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/000077500000000000000000000000001524141036400224445ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/dhclient/000077500000000000000000000000001524141036400242365ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/dhclient/dhclient.go000066400000000000000000000057111524141036400263630ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package dhclient provides dhclient configuration handler for Linux. Although // we are not imposing OS build constraints this package is only reachable on // Linux. package dhclient import ( "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) const ( // The base directory for dhclient files managed by guest agent. // For finer control of the execution, dhclient is invoked for // each interface individually such that each call will have its // own PID file. This is where those PID and lease files are // expected to be written. defaultBaseDhclientDir = "/run" // obtainLease is enum used to identify we should run dhclient to obtain // a lease. obtainLease dhclientOperation = iota // releaseLease is enum used to identify we should run dhclient to release // a lease. releaseLease // This is the number of attempts to run the tentative ip command for ipv6 // enabled interfaces before setting it up. tentativeIPCommandAttempts = 5 // serviceID is the service ID for dhclient. serviceID = "dhclient" ) // vlanIPConfig wraps the interface's configuration as well as the IP // configuration. type vlanIPConfig struct { // Interface is the interface configuration. Interface *ethernet.VlanInterface // IPVersion is either ipv4 or ipv6. IPVersion ipVersion // IPAddress is the IP address to set. IPAddress *address.IPAddr // Command is the set of commands to run to setup the interface. Command run.CommandSet } // ipVersion is a wrapper containing the human-readable version string and // the respective dhclient argument. type ipVersion struct { // Desc is the human-readable IP protocol version. Desc string // Flag is the respective argument for DHClient invocation. Flag string } // dhclientOperation is the operation to perform on the dhclient process - i.e. // obtain a lease, release a lease. type dhclientOperation int // dhclientService implements dhclient configuration handler for Linux. type dhclientService struct { // baseDhclientDir points to the base directory for DHClient leases and PIDs. baseDhclientDir string } // NewService returns a new dhclient service handler. func newModule() *dhclientService { return &dhclientService{ baseDhclientDir: defaultBaseDhclientDir, } } google-guest-agent-20260819.00/cmd/core_plugin/network/dhclient/dhclient_linux.go000066400000000000000000000502431524141036400276020ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package dhclient import ( "context" "errors" "fmt" "os/exec" "path" "slices" "strings" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/ps" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) var ( // execLookPath points to the function to check if a path exists. execLookPath = exec.LookPath // ipv4 is a wrapper containing the protocol version and its respective // dhclient argument. ipv4 = ipVersion{"ipv4", "-4"} // ipv6 is a wrapper containing the protocol version and its respective // dhclient argument. ipv6 = ipVersion{"ipv6", "-6"} // vlanDeleteLinkCmd is a command spec dedicated to deleting ethernet links. vlanDeleteLinkCmd = run.CommandSpec{ Command: "ip link delete {{.InterfaceName}}", Error: "vlan({{.InterfaceName}}): failed to delete link", } // vlanIfaceCommonSet is a set of commands to setup common elements of a vlan // interface it sets link and dev level configurations. vlanIfaceCommonSet = run.CommandSet{ { Command: "ip link add link {{.Parent.Name}} name {{.InterfaceName}} type vlan id {{.Vlan}} reorder_hdr off", Error: "vlan({{.InterfaceName}}): failed to add link", }, { Command: "ip link set dev {{.InterfaceName}} address {{.MacAddr}}", Error: "vlan({{.InterfaceName}}): failed to set interface's mac address", }, { Command: "ip link set dev {{.InterfaceName}} mtu {{.MTU}}", Error: "vlan({{.InterfaceName}}): failed to set interface's MTU", }, { Command: "ip link set up {{.InterfaceName}}", Error: "vlan({{.InterfaceName}}): failed to bring interface up", }, } // ipAddressSet is a set of commands used to setup the ip address both in the ipv4 and // ipv6 cases. ipAddressSet = run.CommandSet{ { Command: "ip {{.IPVersion.Flag}} addr add dev {{.Interface.InterfaceName}} {{.Interface.IPAddress}}", Error: "vlan({{.Interface.InterfaceName}}): failed to set ip address {{.Interface.IPAddress}}", }, } // commonRouteSet is a set of commands used to setup routes both in the ipv4 and ipv6 cases. commonRouteSet = run.CommandSet{ { Command: "ip {{.IPVersion.Flag}} route add {{.Interface.Gateway}} dev {{.Interface.InterfaceName}}", Error: "vlan({{.Interface.InterfaceName}}): failed to add {{.IPVersion.Desc}} route to gateway {{.Interface.Gateway}}", }, } // ipv4RouteCommand is a set of commands relevant only for setting routes for ipv4 networks. ipv4RouteCommand = run.CommandSet{ { Command: "ip route add {{.Interface.IPAddress}} via {{.Interface.Gateway}}", Error: "vlan({{.Interface.InterfaceName}}): failed to set gateway route", }, } ) // NewService returns a new dhclient service handler. func NewService() *service.Handle { mod := newModule() return &service.Handle{ ID: serviceID, IsManaging: mod.IsManaging, Setup: mod.Setup, Rollback: mod.Rollback, } } // IsManaging checks whether dhclient managing the network interfaces. func (ds *dhclientService) IsManaging(_ context.Context, _ *service.Options) (bool, error) { return dhclientInstalled() } // Setup sets up the network interfaces using dhclient. func (ds *dhclientService) Setup(ctx context.Context, opts *service.Options) error { galog.Info("Setting up dhclient interfaces.") // Setup regular ethernet interfaces. if err := ds.setupEthernet(ctx, opts, cfg.Retrieve()); err != nil { return err } // Setup VLAN interfaces. for _, nicConfig := range opts.FilteredNICConfigs() { if err := ds.setupVlanInterfaces(ctx, nicConfig); err != nil { return err } } galog.Info("Finished setting up dhclient interfaces.") return nil } // setupVlan sets up the VLAN interfaces. func (ds *dhclientService) setupVlanInterfaces(ctx context.Context, nic *nic.Configuration) error { galog.Debugf("Setting up vlan interfaces for NIC %s.", nic.Interface.Name()) sysInterfaces, err := ethernet.Interfaces() if err != nil { return fmt.Errorf("failed to list systems interfaces: %w", err) } interfaceMap := make(map[string]*ethernet.Interface) for _, iface := range sysInterfaces { interfaceMap[iface.Name()] = iface } var keepMe []*ethernet.VlanInterface for _, vlan := range nic.VlanInterfaces { // For dhclient/native implementation we use a "gcp." prefix to the interface name // so we can determine it is a guest agent managed vlan interface. existingIface, found := interfaceMap[vlan.InterfaceName()] // If the interface already exists and has the same configuration just keep it. if found && existingIface.HardwareAddr().String() == vlan.MacAddr && existingIface.MTU() == vlan.MTU { keepMe = append(keepMe, vlan) continue } // If the vlan interface exists but the configuration has changed we recreate it. if found { if err := vlanDeleteLinkCmd.WithContext(ctx, vlan); err != nil { return fmt.Errorf("failed to remove pre existing vlan interface: %w", err) } } // Setup common elements of the vlan interface. if err := vlanIfaceCommonSet.WithContext(ctx, vlan); err != nil { return err } var batch []vlanIPConfig addBatch := func(ipVersion ipVersion, address *address.IPAddr, set run.CommandSet) { batch = append(batch, vlanIPConfig{vlan, ipVersion, address, set}) } // ipv4 specific configurations. if vlan.IPAddress != nil { addBatch(ipv4, vlan.IPAddress, ipAddressSet) addBatch(ipv4, vlan.IPAddress, commonRouteSet) addBatch(ipv4, vlan.IPAddress, ipv4RouteCommand) } // ipv6 specific configurations. for _, address := range vlan.IPv6Addresses { addBatch(ipv6, address, ipAddressSet) addBatch(ipv6, address, commonRouteSet) } // Run the command batch. for _, ipConfig := range batch { if err := ipConfig.Command.WithContext(ctx, ipConfig); err != nil { return fmt.Errorf("failed to setup vlan interface commands: %w", err) } } keepMe = append(keepMe, vlan) } if err := ds.removeVlanInterfaces(ctx, nic, keepMe); err != nil { return fmt.Errorf("failed to remove uninstalled vlan interfaces: %w", err) } galog.Debugf("Finished setting up vlan interfaces for NIC %s.", nic.Interface.Name()) return nil } // removeVlanInterfaces removes the vlan interfaces that are not in the keepMe // list. func (ds *dhclientService) removeVlanInterfaces(ctx context.Context, nic *nic.Configuration, keepMe []*ethernet.VlanInterface) error { galog.Debugf("Removing installed vlan interfaces.") for _, vlan := range nic.VlanInterfaces { // If the vlan interface is in the keepMe list means the the vlan interfaces // hasn't changed and doesn't need to be removed/reinstalled. if slices.Contains(keepMe, vlan) { continue } // Run the delete link command. if err := vlanDeleteLinkCmd.WithContext(ctx, vlan); err != nil { return fmt.Errorf("failed to remove no longer wanted vlan interface: %w", err) } } galog.Debugf("Finished removing vlan interfaces.") return nil } // setupEthernet sets up the Ethernet interfaces. func (ds *dhclientService) setupEthernet(ctx context.Context, opts *service.Options, config *cfg.Sections) error { galog.Debugf("Setting up ethernet interfaces.") // If the dhclient command is configured, run it and return the error. if ok, err := runConfiguredCommand(ctx, config); ok { return err } partitions, err := newInterfacePartitions(opts.FilteredNICConfigs()) if err != nil { return fmt.Errorf("error partitioning interfaces: %w", err) } // Release IPv6 leases. if len(partitions.releaseIpv6) != 0 { galog.Debugf("Releasing IPv6 leases for interfaces: %v", partitions.releaseIpv6) } for _, nicConfig := range partitions.releaseIpv6 { if err := ds.runDhclient(ctx, nicConfig.Interface.Name(), ipv6, releaseLease); err != nil { return fmt.Errorf("failed to run dhclient: %w", err) } } // Setup IPV4. if len(partitions.obtainIpv4) != 0 { galog.Debugf("Obtaining IPv4 leases for interfaces: %v", partitions.obtainIpv4) } for _, nic := range partitions.obtainIpv4 { if err := ds.runDhclient(ctx, nic.Interface.Name(), ipv4, obtainLease); err != nil { return fmt.Errorf("failed to run dhclient: %w", err) } } // Setup IPV6. if len(partitions.ipv6Interfaces) != 0 { if len(partitions.obtainIpv6) != 0 { galog.Debugf("Obtaining IPv6 leases for interfaces: %v", partitions.obtainIpv6) } if err := ds.setupIPV6Interfaces(ctx, opts, partitions); err != nil { return fmt.Errorf("failed to setup IPv6 interfaces: %w", err) } } galog.Debugf("Finished setting up ethernet interfaces.") return nil } // setupIPV6Interfaces sets up the IPv6 interfaces. func (ds *dhclientService) setupIPV6Interfaces(ctx context.Context, opts *service.Options, partitions *interfacePartitions) error { // Wait for tentative IPs to resolve as part of SLAAC for primary network // interface. primaryNIC, err := opts.GetPrimaryNIC() if err != nil { return fmt.Errorf("failed to get primary NIC: %w", err) } primaryInterface := primaryNIC.Interface.Name() tentative := []string{"-6", "-o", "a", "s", "dev", primaryInterface, "scope", "link", "tentative"} // Run the ip command in a retry loop to wait for the tentative IP to resolve. runTentative := func() error { opts := run.Options{OutputType: run.OutputNone, Name: "ip", Args: tentative, ExecMode: run.ExecModeSync} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to run ip: %v", err) } return nil } policy := retry.Policy{MaxAttempts: tentativeIPCommandAttempts, BackoffFactor: 1, Jitter: time.Second} if err := retry.Run(ctx, policy, runTentative); err != nil { return fmt.Errorf("tentative IP setup for interface: %q; error: %w", primaryInterface, err) } // Set sysctl values for all interfaces that support IPv6. for _, iface := range partitions.ipv6Interfaces { val := fmt.Sprintf("net.ipv6.conf.%s.accept_ra_rt_info_max_plen=128", iface.Interface.Name()) opts := run.Options{OutputType: run.OutputNone, Name: "sysctl", Args: []string{val}, ExecMode: run.ExecModeSync} if _, err := run.WithContext(ctx, opts); err != nil { return err } } // Obtain leases for all interfaces that support IPv6 and don't already have // a lease. for _, iface := range partitions.obtainIpv6 { ifaceName := iface.Interface.Name() if err := ds.runDhclient(ctx, ifaceName, ipv6, obtainLease); err != nil { return fmt.Errorf("failed to obtain lease for %s: %w", ifaceName, err) } } return nil } // pidFilePath gets the expected file path for the PID pertaining to the provided // interface and IP version. func (ds *dhclientService) pidFilePath(iface string, ipVersion ipVersion) string { return path.Join(ds.baseDhclientDir, fmt.Sprintf("dhclient.google-guest-agent.%s.%s.pid", iface, ipVersion.Desc)) } // leaseFilePath gets the expected file path for the leases pertaining to the provided // interface and IP version. func (ds *dhclientService) leaseFilePath(iface string, ipVersion ipVersion) string { return path.Join(ds.baseDhclientDir, fmt.Sprintf("dhclient.google-guest-agent.%s.%s.lease", iface, ipVersion.Desc)) } // runDhclient obtains a lease with the provided IP version for the given // network interface. If release is set, this will release leases instead. func (ds *dhclientService) runDhclient(ctx context.Context, nicName string, ipVersion ipVersion, op dhclientOperation) error { pidFile := ds.pidFilePath(nicName, ipVersion) leaseFile := ds.leaseFilePath(nicName, ipVersion) dhclientArgs := []string{ipVersion.Flag, "-pf", pidFile, "-lf", leaseFile} opts := run.Options{OutputType: run.OutputNone, Name: "dhclient", ExecMode: run.ExecModeSync} var errMsg string if op == releaseLease { dhclientArgs = append(dhclientArgs, "-r", nicName) galog.Debugf("Releasing %s lease for %s", ipVersion.Desc, nicName) errMsg = fmt.Sprintf("error releasing lease for %s", nicName) } else if op == obtainLease { dhclientArgs = append(dhclientArgs, nicName) galog.Debugf("Obtaining %s lease for %s", ipVersion.Desc, nicName) errMsg = fmt.Sprintf("error obtaining lease for %s", nicName) } else { return fmt.Errorf("invalid operation: %v", op) } opts.Args = dhclientArgs if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("%s: %w", errMsg, err) } return nil } // runConfiguredCommand runs the command configured in the dhclient section of // the config file - if it's not defined it returns false and no error. func runConfiguredCommand(ctx context.Context, config *cfg.Sections) (bool, error) { dhcpCommand := config.NetworkInterfaces.DHCPCommand if dhcpCommand == "" { return false, nil } tokens := strings.Split(dhcpCommand, " ") opts := run.Options{OutputType: run.OutputNone, Name: tokens[0], Args: tokens[1:], ExecMode: run.ExecModeSync} _, err := run.WithContext(ctx, opts) if err != nil { return true, fmt.Errorf("error running dhclient command: %w", err) } return true, nil } // Rollback rolls back the changes created in Setup. func (ds *dhclientService) Rollback(ctx context.Context, opts *service.Options, _ bool) error { galog.Infof("Rolling back changes for dhclient.") // Determine if we can even rollback dhclient processes. if isInstalled, err := dhclientInstalled(); !isInstalled || err != nil { galog.Debugf("No preconditions met for dhclient roll back, skipping.") return nil } // Release all the interface leases from dhclient. for _, iface := range opts.FilteredNICConfigs() { ifaceName := iface.Interface.Name() // Release IPv4 leases. ipv4Exists, err := dhclientProcessExists(iface, ipv4) if err != nil { return fmt.Errorf("failed to check if IPv4 process exists for %s: %w", ifaceName, err) } // Only release IPv4 leases if the process exists. if ipv4Exists { if err := ds.runDhclient(ctx, ifaceName, ipv4, releaseLease); err != nil { return fmt.Errorf("failed to release IPv4 lease for %s: %w", ifaceName, err) } } // Release IPv6 leases. if iface.SupportsIPv6 { ipv6Exists, err := dhclientProcessExists(iface, ipv6) if err != nil { return fmt.Errorf("failed to check if IPv6 process exists for %s: %w", ifaceName, err) } // Only release IPv6 leases if the process exists. if ipv6Exists { if err := ds.runDhclient(ctx, ifaceName, ipv6, releaseLease); err != nil { return fmt.Errorf("failed to release IPv6 lease for %s: %w", ifaceName, err) } } } // This prevents incorrect rollback by dhclient where NICs are managed by // netplan. // VLAN interfaces does not have dhclient process running and IPs are // assigned directly by running [ipAddressSet] command. Attempt to rollback // any VLAN interfaces only if network stack is managed by dhclient // (at-least one dhclient process for known ethernet interfaces). Simple // dhclient existence does not prove this its managed by dhclient as in case // of Debian-12 we have dhclient but NICs are managed by netplan/networkd. ipv6DhclientProcess, err := dhclientProcessExists(iface, ipv6) if err != nil { return fmt.Errorf("failed to check if IPv6 dhclient process exists for %s: %w", ifaceName, err) } ipv4DhclientProcess, err := dhclientProcessExists(iface, ipv4) if err != nil { return fmt.Errorf("failed to check if IPv4 dhclient process exists for %s: %w", ifaceName, err) } if ipv6DhclientProcess || ipv4DhclientProcess { if err := ds.removeVlanInterfaces(ctx, iface, nil); err != nil { return fmt.Errorf("failed to remove vlan interfaces: %w", err) } } } return nil } // dhclientInstalled returns true if the dhclient binary/executable is // installed in the running system. func dhclientInstalled() (bool, error) { if _, err := execLookPath("dhclient"); err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil } return false, fmt.Errorf("error looking up dhclient path: %w", err) } return true, nil } // interfacePartitions contains lists of interfaces for which to obtain an IPv4 // lease, obtain an IPv6 lease, and release their IPv6 lease. type interfacePartitions struct { // obtainIpv4 contains interfaces for which to obtain an IPv4 lease. obtainIpv4 []*nic.Configuration // obtainIpv6 contains interfaces for which to obtain an IPv6 lease. obtainIpv6 []*nic.Configuration // releaseIpv6 contains interfaces for which to release their IPv6 lease. releaseIpv6 []*nic.Configuration // ipv6Interfaces contains interfaces that support IPv6. ipv6Interfaces []*nic.Configuration } // paritionInterfaces returns a list of interfaces for which to obtain an IPv4 // lease, obtain an IPv6 lease, and release their IPv6 lease. func newInterfacePartitions(nics []*nic.Configuration) (*interfacePartitions, error) { var obtainIpv4 []*nic.Configuration var obtainIpv6 []*nic.Configuration var releaseIpv6 []*nic.Configuration var ipv6Interfaces []*nic.Configuration for _, nicConfig := range nics { if !nicConfig.ShouldManage() { continue } // Check for IPv4 interfaces for which to obtain a lease. processExists, err := dhclientProcessExists(nicConfig, ipv4) if err != nil { return nil, err } if !processExists { obtainIpv4 = append(obtainIpv4, nicConfig) } // Check for IPv6 interfaces for which to obtain a lease. processExists, err = dhclientProcessExists(nicConfig, ipv6) if err != nil { return nil, err } if nicConfig.SupportsIPv6 { ipv6Interfaces = append(ipv6Interfaces, nicConfig) } if nicConfig.SupportsIPv6 && !processExists { // Obtain a lease and spin up the DHClient process. obtainIpv6 = append(obtainIpv6, nicConfig) } else if !nicConfig.SupportsIPv6 && processExists { // Release the lease since the DHClient IPv6 process is running, // but the interface is no longer IPv6. releaseIpv6 = append(releaseIpv6, nicConfig) } } return &interfacePartitions{obtainIpv4, obtainIpv6, releaseIpv6, ipv6Interfaces}, nil } // dhclientProcessExists checks if a dhclient process for the provided interface // and IP version exists. func dhclientProcessExists(nicConfig *nic.Configuration, ipVersion ipVersion) (bool, error) { galog.V(2).Debugf("Checking for dhclient process for interface: %s, ipVersion: %s", nicConfig.Interface.Name(), ipVersion.Desc) processes, err := ps.FindRegex(".*dhclient.*") if err != nil { return false, fmt.Errorf("error finding dhclient process: %w", err) } galog.V(3).Debugf("Found %d dhclient processes: %+v", len(processes), processes) // Check for any dhclient process that contains the iface and IP version // provided. Make sure to look through all processes to find one that // matches both the interface and IP version. var found bool for _, process := range processes { galog.V(3).Debugf("Process: %+v", process) commandLine := process.CommandLine containsInterface := slices.Contains(commandLine, nicConfig.Interface.Name()) containsProtocolArg := slices.Contains(commandLine, ipVersion.Flag) galog.V(3).Debugf("Contains Interface: %t, Contains Protocol Arg: %t", containsInterface, containsProtocolArg) if containsInterface { if ipVersion == ipv6 { found = found || containsProtocolArg } // IPv4 DHClient calls don't necessarily have the '-4' flag set. // This can return early if the IPv4 process is found. if ipVersion == ipv4 && !slices.Contains(commandLine, ipv6.Flag) { found = true } // We can break early if a matching process is found. if found { break } } } galog.V(2).Debugf("Found %s dhclient process for interface %q: %t", ipVersion.Desc, nicConfig.Interface.Name(), found) return found, nil } google-guest-agent-20260819.00/cmd/core_plugin/network/dhclient/dhclient_linux_test.go000066400000000000000000001145341524141036400306450ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package dhclient import ( "context" "errors" "fmt" "net" "os/exec" "slices" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/ps" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" ) // The mock Runner client to use for this test. type dhclientMockRunner struct { // callback is the test's mock implementation. callback func(context.Context, run.Options) (*run.Result, error) } func (d *dhclientMockRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { return d.callback(ctx, opts) } // The mock Ps client to use for this test. type dhclientMockPs struct { // FindRegexCallback is the callback to use for FindRegex. FindRegexCallback func(exematch string) ([]ps.Process, error) } func (d *dhclientMockPs) KillProcess(pid int, mode ps.KillMode) error { return errors.New("not implemented") } func (d *dhclientMockPs) IsProcessAlive(pid int) (bool, error) { return false, nil } func (d *dhclientMockPs) Memory(pid int) (int, error) { return 0, nil } func (d *dhclientMockPs) FindPid(_ int) (ps.Process, error) { return ps.Process{}, nil } func (d *dhclientMockPs) CPUUsage(ctx context.Context, pid int) (float64, error) { return 0, nil } func (d *dhclientMockPs) FindRegex(exematch string) ([]ps.Process, error) { return d.FindRegexCallback(exematch) } func TestNewService(t *testing.T) { ss := NewService() if ss == nil { t.Fatalf("NewService() returned nil") } if ss.ID != serviceID { t.Fatalf("NewService() returned service with ID %v, want %v", ss.ID, serviceID) } if ss.IsManaging == nil { t.Fatalf("NewService() returned service with IsManaging = nil") } if ss.Setup == nil { t.Fatalf("NewService() returned service with Setup = nil") } if ss.Rollback == nil { t.Fatalf("NewService() returned service with Rollback = nil") } } func TestIsManaging(t *testing.T) { tests := []struct { name string returnError error wantError bool want bool }{ { name: "not-found", returnError: exec.ErrNotFound, wantError: false, want: false, }, { name: "error", returnError: errors.New("error"), wantError: true, want: false, }, { name: "success", wantError: false, want: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ss := NewService() execLookPath = func(string) (string, error) { if tc.returnError == nil { return "", tc.returnError } return "", tc.returnError } t.Cleanup(func() { execLookPath = exec.LookPath }) val, err := ss.IsManaging(context.Background(), nil) if (err == nil) == tc.wantError { t.Fatalf("IsManaging() returned %v, want %v", err, tc.wantError) } if val != tc.want { t.Fatalf("IsManaging() returned %v, want %v", val, tc.want) } }) } } // TestDhclientProcessExists tests whether dhclientProcessExists behaves // correctly given a mock environment setup. func TestDhclientProcessExists(t *testing.T) { tests := []struct { // name is the name of the test. name string // ipVersion is the ipVersion to use in this test. ipVersion ipVersion // processes are the processes to return from the findProcess mock. processes []ps.Process // returnError determines if findProcess should return an error. returnError bool // expectBool is the expected return value of dhclientProcessExists() expectBool bool // expectErr dictates whether an error is expected. expectErr bool }{ // Process exists ipv4. { name: "ipv4", ipVersion: ipv4, processes: []ps.Process{ ps.Process{ PID: 2, Exe: "/random/path", CommandLine: []string{ "dhclient", "-4", "iface", }, }, }, expectBool: true, }, // Process exists ipv6. { name: "ipv6", ipVersion: ipv6, processes: []ps.Process{ ps.Process{ PID: 2, Exe: "/random/path", CommandLine: []string{ "dhclient", "-6", "iface", }, }, }, expectBool: true, }, // Process exists ipv6 with multiple processes. { name: "ipv6-multiple-processes", ipVersion: ipv6, processes: []ps.Process{ ps.Process{ PID: 2, Exe: "/random/path", CommandLine: []string{ "dhclient", "-4", "iface", }, }, ps.Process{ PID: 2, Exe: "/random/path", CommandLine: []string{ "dhclient", "-6", "iface", }, }, }, expectBool: true, }, // Process not exist. { name: "not-exist", ipVersion: ipv4, processes: []ps.Process{}, expectBool: false, }, // Error finding process. { name: "error", ipVersion: ipv6, returnError: true, expectBool: false, expectErr: true, }, } for _, tc := range tests { t.Run(fmt.Sprintf("test-dhclient-process-exists-%s", tc.name), func(t *testing.T) { // We have to mock dhclientProcessExists as we cannot mock where the ps // package checks for processes here. oldPsClient := ps.Client ps.Client = &dhclientMockPs{ FindRegexCallback: func(exematch string) ([]ps.Process, error) { if tc.returnError { return nil, fmt.Errorf("mock error") } return tc.processes, nil }, } oldRunClient := run.Client run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.returnError { // Error every time to see the command being run. msg := opts.Name for _, arg := range opts.Args { msg += fmt.Sprintf(" %v", arg) } return nil, fmt.Errorf("%s", msg) } return nil, nil }, } t.Cleanup(func() { ps.Client = oldPsClient run.Client = oldRunClient execLookPath = exec.LookPath }) iface := ðernet.Interface{ NameOp: func() string { return "iface" }, } nicConfig := &nic.Configuration{Interface: iface} res, err := dhclientProcessExists(nicConfig, tc.ipVersion) if err != nil { if !tc.expectErr { t.Fatalf("unexpected error: %v", err) } return } if tc.expectErr { t.Fatalf("no error returned when error expected") } if res != tc.expectBool { t.Fatalf("incorrect return value. Expected: %v, Actual: %v", tc.expectBool, res) } }) } } func TestPidFilePath(t *testing.T) { tests := []struct { name string iface string ipVersion ipVersion want string }{ { name: "foobar-ipv4", iface: "foobar", ipVersion: ipv4, want: "/run/dhclient.google-guest-agent.foobar.ipv4.pid", }, { name: "foobar-ipv6", iface: "foobar", ipVersion: ipv6, want: "/run/dhclient.google-guest-agent.foobar.ipv6.pid", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ds := &dhclientService{baseDhclientDir: "/run"} got := ds.pidFilePath(tc.iface, tc.ipVersion) if got != tc.want { t.Fatalf("pidFilePath(%q, %+v) returned %v, want %v", tc.iface, tc.ipVersion, got, tc.want) } }) } } func TestLeaseFilePath(t *testing.T) { tests := []struct { name string iface string ipVersion ipVersion want string }{ { name: "foobar-ipv4", iface: "foobar", ipVersion: ipv4, want: "/run/dhclient.google-guest-agent.foobar.ipv4.lease", }, { name: "foobar-ipv6", iface: "foobar", ipVersion: ipv6, want: "/run/dhclient.google-guest-agent.foobar.ipv6.lease", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ds := &dhclientService{baseDhclientDir: "/run"} got := ds.leaseFilePath(tc.iface, tc.ipVersion) if got != tc.want { t.Fatalf("leaseFilePath(%q, %+v) returned %v, want %v", tc.iface, tc.ipVersion, got, tc.want) } }) } } func TestRunDhclient(t *testing.T) { tests := []struct { name string nicName string ipVersion ipVersion op dhclientOperation wantError bool wantCommand string }{ { name: "fail-ipv4-obtain-lease", nicName: "eth0", ipVersion: ipv4, op: obtainLease, wantError: true, }, { name: "fail-ipv6-obtain-lease", nicName: "eth0", ipVersion: ipv6, op: obtainLease, wantError: true, }, { name: "fail-ipv4-release-lease", nicName: "eth0", ipVersion: ipv4, op: releaseLease, wantError: true, }, { name: "fail-ipv6-release-lease", nicName: "eth0", ipVersion: ipv6, op: releaseLease, wantError: true, }, { name: "fail-ipv6-invalid-op", nicName: "eth0", ipVersion: ipv6, op: 100, wantError: true, }, { name: "fail-ipv4-invalid-op", nicName: "eth0", ipVersion: ipv4, op: 100, wantError: true, }, { name: "success-ipv4-obtain-lease", nicName: "eth0", ipVersion: ipv4, op: obtainLease, wantError: false, wantCommand: "dhclient -4 -pf /run/dhclient.google-guest-agent.eth0.ipv4.pid -lf /run/dhclient.google-guest-agent.eth0.ipv4.lease eth0", }, { name: "success-ipv6-obtain-lease", nicName: "eth0", ipVersion: ipv6, op: obtainLease, wantError: false, wantCommand: "dhclient -6 -pf /run/dhclient.google-guest-agent.eth0.ipv6.pid -lf /run/dhclient.google-guest-agent.eth0.ipv6.lease eth0", }, { name: "success-ipv4-release-lease", nicName: "eth0", ipVersion: ipv4, op: releaseLease, wantError: false, wantCommand: "dhclient -4 -pf /run/dhclient.google-guest-agent.eth0.ipv4.pid -lf /run/dhclient.google-guest-agent.eth0.ipv4.lease -r eth0", }, { name: "success-ipv6-release-lease", nicName: "eth0", ipVersion: ipv6, op: releaseLease, wantError: false, wantCommand: "dhclient -6 -pf /run/dhclient.google-guest-agent.eth0.ipv6.pid -lf /run/dhclient.google-guest-agent.eth0.ipv6.lease -r eth0", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldRunClient := run.Client t.Cleanup(func() { run.Client = oldRunClient }) var executedComand string run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.wantError { return nil, fmt.Errorf("error") } tokens := []string{opts.Name} tokens = append(tokens, opts.Args...) executedComand = strings.Join(tokens, " ") return nil, nil }, } nicConfig := &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return tc.nicName }, }, } ds := &dhclientService{baseDhclientDir: defaultBaseDhclientDir} err := ds.runDhclient(context.Background(), nicConfig.Interface.Name(), tc.ipVersion, tc.op) if (err == nil) == tc.wantError { t.Fatalf("runDhclient(ctx, %+v, %+v, %#v) returned %v, want %v", nicConfig, tc.ipVersion, tc.op, err, tc.wantError) } if executedComand != tc.wantCommand { t.Fatalf("runDhclient(ctx, %+v, %+v, %#v) executed command %q, want %q", nicConfig, tc.ipVersion, tc.op, executedComand, tc.wantCommand) } }) } } func TestRunConfiguredCommand(t *testing.T) { tests := []struct { name string dhcpCommand string want bool wantError bool }{ { name: "not-defined", want: false, wantError: false, }, { name: "fail-command", want: true, wantError: true, dhcpCommand: "foo bar foobar", }, { name: "success", want: true, wantError: false, dhcpCommand: "foo bar foobar", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { config := &cfg.Sections{ NetworkInterfaces: &cfg.NetworkInterfaces{DHCPCommand: tc.dhcpCommand}, } oldRunClient := run.Client t.Cleanup(func() { run.Client = oldRunClient }) var executedComand string run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.wantError { return nil, fmt.Errorf("error") } tokens := []string{opts.Name} tokens = append(tokens, opts.Args...) executedComand = strings.Join(tokens, " ") return nil, nil }, } got, err := runConfiguredCommand(context.Background(), config) if (err == nil) == tc.wantError { t.Fatalf("runConfiguredCommand(ctx, %+v) returned %v, want %v", tc.dhcpCommand, err, tc.wantError) } if got != tc.want { t.Fatalf("runConfiguredCommand(ctx, %+v) returned %v, want %v", tc.dhcpCommand, got, tc.want) } if tc.dhcpCommand != "" && !tc.wantError && executedComand != tc.dhcpCommand { t.Fatalf("runConfiguredCommand(ctx, %+v) executed command %q, want %q", tc.dhcpCommand, executedComand, tc.dhcpCommand) } }) } } func TestNewInterfacePartitions(t *testing.T) { tests := []struct { name string iface string nicSupportIpv6 bool processToken []string findRegexErrors []error wantObtainIpv4 bool wantObtainIpv6 bool wantReleaseIpv6 bool wantIpv6 bool wantError bool }{ { // No interface is defined, should return empty interfacePartitions. name: "no-interface", }, { // Error when finding ipv4 process. name: "error-on-find-ipv4-process", iface: "eth0", findRegexErrors: []error{errors.New("error"), nil}, wantError: true, }, { // Error when finding ipv6 process, no ipv4 process found. name: "error-on-find-ipv6-process", iface: "eth0", processToken: []string{"eth0 -4", ""}, findRegexErrors: []error{nil, errors.New("error")}, wantError: true, }, { // No ipv4 process found, should have a obtainIpv4 nic, ipv6 process is // found, should have a releaseIpv6 nic. name: "obtain-ipv4", iface: "eth0", processToken: []string{"", "eth0 -6"}, findRegexErrors: []error{nil, nil}, wantObtainIpv4: true, wantReleaseIpv6: true, wantError: false, }, { // IPv4 process found, no ipv6 process found, should have a // obtainIpv6 nic. name: "obtain-ipv6", iface: "eth0", nicSupportIpv6: true, processToken: []string{"eth0 -4", ""}, findRegexErrors: []error{nil, nil}, wantObtainIpv4: false, wantObtainIpv6: true, wantIpv6: true, wantError: false, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } cfg.Retrieve().NetworkInterfaces.ManagePrimaryNIC = true for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldPsClient := ps.Client callCount := 0 ps.Client = &dhclientMockPs{ FindRegexCallback: func(exematch string) ([]ps.Process, error) { defer func() { callCount++ }() if tc.findRegexErrors[callCount] != nil { return nil, tc.findRegexErrors[callCount] } tokens := strings.Split(tc.processToken[callCount], " ") process := ps.Process{CommandLine: tokens} return []ps.Process{process}, nil }, } t.Cleanup(func() { ps.Client = oldPsClient }) var nics []*nic.Configuration if tc.iface != "" { nics = []*nic.Configuration{ &nic.Configuration{ SupportsIPv6: tc.nicSupportIpv6, Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, }, } } got, err := newInterfacePartitions(nics) if (err == nil) == tc.wantError { t.Fatalf("newInterfacePartitions(%v) returned %v, want %v", nics, err, tc.wantError) } if tc.wantObtainIpv4 { if len(got.obtainIpv4) == 0 { t.Fatalf("newInterfacePartitions(%v) returned %v, want 1 nic", nics, got.obtainIpv4) } if got.obtainIpv4[0].Interface.Name() != nics[0].Interface.Name() { t.Fatalf("newInterfacePartitions(%v) returned %v, want %v", nics, got.obtainIpv4, nics) } } if tc.wantObtainIpv6 { if len(got.obtainIpv6) == 0 { t.Fatalf("newInterfacePartitions(%v) returned %v, want 1 nic", nics, got.obtainIpv4) } if got.obtainIpv6[0].Interface.Name() != nics[0].Interface.Name() { t.Fatalf("newInterfacePartitions(%v) returned %v, want %v", nics, got.obtainIpv6, nics) } } if tc.wantReleaseIpv6 { if len(got.releaseIpv6) == 0 { t.Fatalf("newInterfacePartitions(%v) returned %v, want 1 nic", nics, got.releaseIpv6) } if got.releaseIpv6[0].Interface.Name() != nics[0].Interface.Name() { t.Fatalf("newInterfacePartitions(%v) returned %v, want %v", nics, got.releaseIpv6, nics) } } if tc.wantIpv6 { if len(got.ipv6Interfaces) == 0 { t.Fatalf("newInterfacePartitions(%v) returned %v, want 1 nic", nics, got.ipv6Interfaces) } if got.ipv6Interfaces[0].Interface.Name() != nics[0].Interface.Name() { t.Fatalf("newInterfacePartitions(%v) returned %v, want %v", nics, got.ipv6Interfaces, nics) } } }) } } func TestRollback(t *testing.T) { tests := []struct { name string noDhclientCommand bool wantIpv4Error bool wantIpv6Error bool wantError bool }{ { name: "no-dhcp-command", noDhclientCommand: true, }, { name: "fail-ipv4", wantIpv4Error: true, wantError: true, }, { name: "fail-ipv6", wantIpv6Error: true, wantError: true, }, { name: "success", }, } if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { execLookPath = func(string) (string, error) { if tc.noDhclientCommand { return "", errors.New("no dhclient command found") } return "", nil } t.Cleanup(func() { execLookPath = exec.LookPath }) oldRunClient := run.Client t.Cleanup(func() { run.Client = oldRunClient }) run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.wantIpv4Error && slices.Contains(opts.Args, "-4") { return nil, errors.New("error dhclient command for ipv4") } if tc.wantIpv6Error && slices.Contains(opts.Args, "-6") { return nil, errors.New("error dhclient command for ipv6") } return &run.Result{}, nil }, } oldPsClient := ps.Client t.Cleanup(func() { ps.Client = oldPsClient }) ps.Client = &dhclientMockPs{ FindRegexCallback: func(exematch string) ([]ps.Process, error) { commandLine := []string{"dhclient", "eth1"} if tc.wantIpv6Error { commandLine = append(commandLine, "-6") } return []ps.Process{ { CommandLine: commandLine, }, }, nil }, } nicConfigs := []*nic.Configuration{ { SupportsIPv6: true, Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, }, { SupportsIPv6: true, Interface: ðernet.Interface{NameOp: func() string { return "eth1" }}, Index: 1, }, } ds := &dhclientService{} opts := service.NewOptions(nil, nicConfigs) err := ds.Rollback(context.Background(), opts, false) if (err == nil) == tc.wantError { t.Fatalf("Rollback(ctx, %+v) returned %v, want %v", opts, err, tc.wantError) } }) } } func TestSetupIPV6Interfaces(t *testing.T) { tests := []struct { name string wantTentativeIPError bool wantSysctlError bool wantDhclientError bool wantError bool }{ { name: "fail-tentative-ip", wantTentativeIPError: true, wantError: true, }, { name: "fail-sysctl", wantTentativeIPError: false, wantSysctlError: true, wantError: true, }, { name: "fail-dhclient", wantTentativeIPError: false, wantSysctlError: false, wantDhclientError: true, wantError: true, }, { name: "success", wantTentativeIPError: false, wantSysctlError: false, wantDhclientError: false, wantError: false, }, } nics := []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldRunClient := run.Client t.Cleanup(func() { run.Client = oldRunClient }) run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { cmdArgs := append([]string{opts.Name}, opts.Args...) nicName := nics[0].Interface.Name() // Not a extensive list of args, but enough for this test. tentativeIPArgs := []string{"ip", nicName, "scope", "link", "tentative"} // Check if list is a subset of opts.Args. sliceSubset := func(list []string) bool { for _, arg := range list { if !slices.Contains(cmdArgs, arg) { return false } } return true } if tc.wantTentativeIPError && sliceSubset(tentativeIPArgs) { return nil, errors.New("error tentative ip command") } sysctlEntry := fmt.Sprintf("net.ipv6.conf.%s.accept_ra_rt_info_max_plen=128", nicName) sysctlArgs := []string{"sysctl", sysctlEntry} if tc.wantSysctlError && sliceSubset(sysctlArgs) { return nil, errors.New("error sysctl command") } dhclientArgs := []string{"dhclient", nicName, "-pf", "-lf"} if tc.wantDhclientError && sliceSubset(dhclientArgs) { return nil, errors.New("error dhclient command") } return nil, nil }, } t.Cleanup(func() { run.Client = oldRunClient }) partitions := &interfacePartitions{ obtainIpv6: nics, ipv6Interfaces: nics, } ds := &dhclientService{} opts := service.NewOptions(nil, nics) err := ds.setupIPV6Interfaces(context.Background(), opts, partitions) if (err == nil) == tc.wantError { t.Fatalf("setupIPV6Interfaces(ctx, %+v, %+v) returned %v, want error", opts, partitions, err) } }) } } func TestSetupEthernet(t *testing.T) { tests := []struct { name string configDhclientCommand string wantConfigDhclientError bool wantFindProcessError bool wantDhclientError bool reportIpv6ProcessFound bool reportIpv4ProcessFound bool supportIpv6 bool wantError bool }{ { name: "fail-config-dhclient", configDhclientCommand: "dhclient foobar", wantConfigDhclientError: true, wantError: true, }, { name: "fail-find-process", wantConfigDhclientError: false, wantFindProcessError: true, wantError: true, }, { name: "fail-release-lease-ipv6", wantConfigDhclientError: false, wantFindProcessError: false, wantDhclientError: true, reportIpv6ProcessFound: true, wantError: true, }, { name: "fail-obtain-lease-ipv6", wantConfigDhclientError: false, wantFindProcessError: false, wantDhclientError: true, reportIpv6ProcessFound: false, reportIpv4ProcessFound: true, supportIpv6: true, wantError: true, }, { name: "fail-dhclient-calls", wantConfigDhclientError: false, wantFindProcessError: false, wantDhclientError: true, wantError: true, }, { name: "success", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { nicConfig := &nic.Configuration{ SupportsIPv6: tc.supportIpv6, Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, Index: 1, } oldPsClient := ps.Client ps.Client = &dhclientMockPs{ FindRegexCallback: func(exematch string) ([]ps.Process, error) { if tc.wantFindProcessError { return nil, errors.New("error find process") } if tc.reportIpv6ProcessFound { return []ps.Process{ ps.Process{CommandLine: []string{nicConfig.Interface.Name(), "-6"}}, }, nil } if tc.reportIpv4ProcessFound { return []ps.Process{ ps.Process{CommandLine: []string{nicConfig.Interface.Name(), "-4"}}, }, nil } return nil, nil }, } oldRunClient := run.Client run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { command := strings.Join(append([]string{opts.Name}, opts.Args...), " ") if tc.wantConfigDhclientError && tc.configDhclientCommand == command { return nil, errors.New("error configured dhclient command") } if tc.wantDhclientError && opts.Name == "dhclient" { return nil, errors.New("error dhclient command") } return nil, nil }, } t.Cleanup(func() { run.Client = oldRunClient ps.Client = oldPsClient }) ds := &dhclientService{} opts := service.NewOptions(nil, []*nic.Configuration{ { SupportsIPv6: true, Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, }, nicConfig, }) config := &cfg.Sections{ NetworkInterfaces: &cfg.NetworkInterfaces{DHCPCommand: tc.configDhclientCommand}, } err := ds.setupEthernet(context.Background(), opts, config) if (err == nil) == tc.wantError { t.Fatalf("setupEthernet(ctx, %+v, %+v) returned %v, want error? %v", opts, config, err, tc.wantError) } }) } } func TestSetup(t *testing.T) { tests := []struct { name string nicConfig *nic.Configuration wantFindProcessError bool wantError bool }{ { name: "fail", nicConfig: &nic.Configuration{ Interface: ðernet.Interface{NameOp: func() string { return "eth1" }}, Index: 1, }, wantFindProcessError: true, wantError: true, }, { name: "success", }, } if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ds := &dhclientService{} opts := service.NewOptions(nil, nil) if tc.nicConfig != nil { opts = service.NewOptions(nil, []*nic.Configuration{ { Interface: ðernet.Interface{NameOp: func() string { return "eth0" }}, }, tc.nicConfig, }) } oldPsClient := ps.Client t.Cleanup(func() { ps.Client = oldPsClient }) ps.Client = &dhclientMockPs{ FindRegexCallback: func(exematch string) ([]ps.Process, error) { if tc.wantFindProcessError { return nil, errors.New("failed to find process") } return nil, nil }, } err := ds.Setup(context.Background(), opts) if (err == nil) == tc.wantError { t.Fatalf("Setup(ctx, %+v) returned %v, want error? %v", opts, err, tc.wantError) } }) } } func TestRemoveVlanInterfaces(t *testing.T) { tests := []struct { name string mdsJSON string ethernetName string wantError bool skipIndexes map[int]bool wantCommands []string }{ { name: "success", wantError: false, ethernetName: "eth0", wantCommands: []string{"ip link delete gcp.eth0.10"}, mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": { "0": { "10": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 10, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" } } } } }`, }, { name: "fail-command", wantError: true, ethernetName: "eth1", wantCommands: []string{"ip link delete gcp.eth1.10"}, mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": { "0": { "10": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 10, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" } } } } }`, }, { name: "skip-vlans", wantError: false, skipIndexes: map[int]bool{10: true, 33: true}, ethernetName: "eth2", wantCommands: []string{"ip link delete gcp.eth2.66"}, mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": { "0": { "10": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 10, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" }, "33": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 33, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" }, "66": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 66, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" } } } } }`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", tc.mdsJSON, err) } oldRunClient := run.Client var commands []string run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if tc.wantError { return nil, errors.New("error running command") } commands = append(commands, strings.Join(append([]string{opts.Name}, opts.Args...), " ")) return nil, nil }, } oldEthernetOps := ethernet.DefaultInterfaceOps // Mock the interfaces returned by the ethernet package. ethernet.DefaultInterfaceOps = ðernet.InterfaceOps{ Interfaces: func() ([]*ethernet.Interface, error) { var res []*ethernet.Interface for _, nic := range mds.Instance().NetworkInterfaces() { hwAddr, err := net.ParseMAC(nic.MAC()) if err != nil { return nil, fmt.Errorf("failed to parse MAC address %q: %v", nic.MAC(), err) } iface := ðernet.Interface{ NameOp: func() string { return tc.ethernetName }, HardwareAddr: func() net.HardwareAddr { return hwAddr }, } res = append(res, iface) } return res, nil }, } t.Cleanup(func() { run.Client = oldRunClient ethernet.DefaultInterfaceOps = oldEthernetOps }) config := &cfg.Sections{ IPForwarding: &cfg.IPForwarding{}, NetworkInterfaces: &cfg.NetworkInterfaces{ ManagePrimaryNIC: true, VlanSetupEnabled: true, }, } nics, err := nic.NewConfigs(mds, config, nil) if err != nil { t.Fatalf("NewConfigs(%+v, %+v, %+v) returned an unexpected error: %v", mds, config, nil, err) } var skip []*ethernet.VlanInterface for key := range tc.skipIndexes { for _, vlan := range nics[0].VlanInterfaces { if vlan.Vlan == key { skip = append(skip, vlan) } } } ds := &dhclientService{} err = ds.removeVlanInterfaces(context.Background(), nics[0], skip) if (err == nil) == tc.wantError { t.Fatalf("removeVlanInterfaces(ctx, %+v, %+v) returned %v, want error? %v", mds, config, err, tc.wantError) } if !tc.wantError && !slices.Equal(commands, tc.wantCommands) { t.Fatalf("NewConfigs(%+v, %+v, %+v) executed command %v, want %v", mds, config, nil, commands, tc.wantCommands) } }) } } func TestSetupVlanInterfaces(t *testing.T) { tests := []struct { name string mdsJSON string ethernetName string wantCommands []string wantErrGetInterfaceOps bool vlanAlreadyExist bool wantError bool }{ { name: "success", wantError: false, ethernetName: "eth0", wantCommands: []string{ "ip link add link eth0 name gcp.eth0.10 type vlan id 10 reorder_hdr off", "ip link set dev gcp.eth0.10 address 00:00:5e:00:53:01", "ip link set dev gcp.eth0.10 mtu 0", "ip link set up gcp.eth0.10", "ip -4 addr add dev gcp.eth0.10 10.0.0.1", "ip -4 route add 10.0.0.1 dev gcp.eth0.10", "ip route add 10.0.0.1 via 10.0.0.1", "ip -6 addr add dev gcp.eth0.10 10.0.0.1", "ip -6 route add 10.0.0.1 dev gcp.eth0.10", }, mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": { "0": { "10": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 10, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" } } } } }`, }, { name: "success-no-vlan-changed", wantError: false, ethernetName: "eth2", vlanAlreadyExist: true, mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": { "0": { "10": { "parentInterface": "/computeMetadata/v1/instance/network-interfaces/0/", "VLAN": 10, "MAC": "00:00:5e:00:53:01", "IP": "10.0.0.1", "IPv6": [ "2001:db8:a0b:12f0::1" ], "Gateway": "10.0.0.1", "GatewayIPv6": "2001:db8:a0b:12f0::1" } } } } }`, }, { name: "fail", wantError: true, wantErrGetInterfaceOps: true, ethernetName: "eth1", mdsJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "00:00:5e:00:53:01", "DHCPv6Refresh": "not-empty" } ], "vlanNetworkInterfaces": {} } }`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", tc.mdsJSON, err) } oldRunClient := run.Client var commands []string run.Client = &dhclientMockRunner{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { commands = append(commands, strings.Join(append([]string{opts.Name}, opts.Args...), " ")) return nil, nil }, } oldEthernetOps := ethernet.DefaultInterfaceOps var wantErrGetInterfaceOps bool var vlanAlreadyExist bool // Mock the interfaces returned by the ethernet package. ethernet.DefaultInterfaceOps = ðernet.InterfaceOps{ Interfaces: func() ([]*ethernet.Interface, error) { var res []*ethernet.Interface if wantErrGetInterfaceOps { return nil, errors.New("error getting interfaces") } // Fake all vlan interfaces already exist. if vlanAlreadyExist { for _, slice := range mds.Instance().VlanInterfaces() { for key, value := range slice { hwAddr, err := net.ParseMAC(value.MAC()) if err != nil { return nil, fmt.Errorf("failed to parse MAC address %q: %v", value.MAC(), err) } iface := ðernet.Interface{ NameOp: func() string { return fmt.Sprintf("gcp.%s.%d", tc.ethernetName, key) }, HardwareAddr: func() net.HardwareAddr { return hwAddr }, MTU: func() int { return value.MTU() }, } res = append(res, iface) } } return res, nil } // Regular flow and returns the ethernet interface. for _, nic := range mds.Instance().NetworkInterfaces() { hwAddr, err := net.ParseMAC(nic.MAC()) if err != nil { return nil, fmt.Errorf("failed to parse MAC address %q: %v", nic.MAC(), err) } iface := ðernet.Interface{ NameOp: func() string { return tc.ethernetName }, HardwareAddr: func() net.HardwareAddr { return hwAddr }, } res = append(res, iface) } return res, nil }, } t.Cleanup(func() { run.Client = oldRunClient ethernet.DefaultInterfaceOps = oldEthernetOps }) config := &cfg.Sections{ IPForwarding: &cfg.IPForwarding{}, NetworkInterfaces: &cfg.NetworkInterfaces{ ManagePrimaryNIC: true, VlanSetupEnabled: true, }, } nics, err := nic.NewConfigs(mds, config, nil) if err != nil { t.Fatalf("NewConfigs(%+v, %+v, %+v) returned an unexpected error: %v", mds, config, nil, err) } // We need to be able to get interfaces for NewConfigs() wantErrGetInterfaceOps = tc.wantErrGetInterfaceOps vlanAlreadyExist = tc.vlanAlreadyExist ds := &dhclientService{} err = ds.setupVlanInterfaces(context.Background(), nics[0]) if (err == nil) == tc.wantError { t.Fatalf("setupVlanInterfaces(ctx, %+v) returned %v, want error? %v", nics[0], err, tc.wantError) } if !tc.wantError { if diff := cmp.Diff(tc.wantCommands, commands); diff != "" { t.Errorf("setupVlanInterfaces(ctx, %+v) returned unexpected diff (-want +got):\n%s", nics[0], diff) } } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/dhclient/dhclient_test.go000066400000000000000000000016461524141036400274250ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dhclient import ( "testing" ) func TestNewModule(t *testing.T) { mod := newModule() if mod == nil { t.Fatalf("newModule() returned nil, want non-nil") } if mod.baseDhclientDir != defaultBaseDhclientDir { t.Errorf("newModule() returned module with baseDhclientDir = %q, want %q", mod.baseDhclientDir, defaultBaseDhclientDir) } } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/000077500000000000000000000000001524141036400242625ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname.go000066400000000000000000000161761524141036400264420ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package hostname reconfigures the guest hostname (linux only) and fqdn (linux // and windows) as necessary. package hostname import ( "context" "encoding/json" "fmt" "net" "os" "runtime" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" ) const ( hostnameModuleID = "hostname" // ReconfigureHostnameCommand is the command id registered for hostname // configuration. ReconfigureHostnameCommand = "agent.hostname.reconfigurehostname" ) var ( disallowedConfigurations = map[string]bool{"": true, "metadata.google.internal": true} hostname string fqdn string ) // ReconfigureHostnameRequest is the structure of requests to the // ReconfigureHostnameCommand. type ReconfigureHostnameRequest struct { command.Request } // ReconfigureHostnameResponse is the structure of responses from the // ReconfigureHostnameCommand. // Status code meanings: // 0: everything ok // 1: error setting hostname // 2: error setting fqdn // 3: error setting hostname and fqdn type ReconfigureHostnameResponse struct { command.Response // Hostname is the hostname which was set. Empty if unset, either due to // configuration or error. Hostname string // Fqdn is the hostname which was set. Empty if unset, either due to // configuration or error. Fqdn string } // NewModule returns the hostname module for late stage registration. func NewModule(context.Context) *manager.Module { enabled := cfg.Retrieve().Unstable.SetHostname || cfg.Retrieve().Unstable.SetFQDN return &manager.Module{ ID: hostnameModuleID, Enabled: &enabled, Setup: moduleSetup, Description: "Handles setting instance hostname and FQDN according to the metadata hostname.", Quit: moduleClose, } } func moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing %s module", hostnameModuleID) desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("expected metadata descriptor data in moduleSetup call") } fqdn = desc.Instance().Attributes().Hostname() if cfg.Retrieve().Unstable.FQDNAsHostname { hostname = fqdn } else { hostname, _, _ = strings.Cut(fqdn, ".") } b, err := ReconfigureHostname(ctx, nil) if err != nil { galog.Errorf("Failed to ReconfigureHostname during setup: %v", err) } else { var resp ReconfigureHostnameResponse err := json.Unmarshal(b, &resp) if err != nil { galog.Errorf("Malformed response from reconfigurehostname: %v", err) } if resp.Status != 0 { galog.Errorf("Error %d reconfiguring hostname: %s", resp.Status, resp.StatusMessage) } } if err := command.CurrentMonitor().RegisterHandler(ReconfigureHostnameCommand, ReconfigureHostname); err != nil { return fmt.Errorf("failed to register command handler %q: %v", ReconfigureHostnameCommand, err) } err = initPlatform(ctx) if err == nil { galog.Debugf("Finished initializing %s module", hostnameModuleID) } return err } func moduleClose(ctx context.Context) { if err := command.CurrentMonitor().UnregisterHandler(ReconfigureHostnameCommand); err != nil { galog.Errorf("Failed to unregister hostname command handler: %v", err) } closePlatform() } // ReconfigureHostname takes a ReconfigureHostnameRequest as a []byte-encoded // json blob and returns a ReconfigureHostnameResponse []byte-encoded json blob. func ReconfigureHostname(ctx context.Context, _ []byte) ([]byte, error) { var resp ReconfigureHostnameResponse if cfg.Retrieve().Unstable.SetHostname { if disallowedConfigurations[hostname] { resp.Status++ resp.StatusMessage += fmt.Sprintf("Disallowed hostname: %q", hostname) } else if err := setHostname(ctx, hostname); err != nil { resp.Status++ resp.StatusMessage += err.Error() } else { resp.Hostname = hostname } } if cfg.Retrieve().Unstable.SetFQDN { galog.V(1).Debugf("SetFQDN is enabled.") h := hostname var err error if runtime.GOOS != "windows" { // Get the hostname from the OS in case we are configured to manage only the // fqdn. Don't do this on windows because: // 1) The hostname is always managed on Windows (albeit not by the agent: see // https://github.com/GoogleCloudPlatform/compute-image-windows/blob/master/sysprep/activate_instance.ps1) // 2) Windows truncates hostnames to 15 characters when they are set so we // cannot rely on the OS to report the full hostname. h, err = os.Hostname() } if disallowedConfigurations[fqdn] { err = fmt.Errorf("Disallowed fqdn: %q", fqdn) } if err == nil { err = setFQDN(ctx, h, fqdn) } if err != nil { resp.Status += 2 resp.StatusMessage += err.Error() } else { resp.Fqdn = fqdn } } return json.Marshal(resp) } var setFQDN = func(ctx context.Context, hostname, fqdn string) error { interfaces, err := ethernet.Interfaces() if err != nil { return fmt.Errorf("could not get interfaces: %w", err) } idx := cfg.Retrieve().Unstable.FQDNAddressInterfaceIndex if idx >= len(interfaces) { return fmt.Errorf("can't set FQDN using address %d with %d interfaces, found :%v", idx, len(interfaces), interfaces) } addrs, err := interfaces[idx].Addrs() if err != nil { return fmt.Errorf("could not get addrs for interface %d: %w", idx, err) } return writeHosts(ctx, hostname, fqdn, platformHostsFile, addrs) } func writeHosts(ctx context.Context, hostname, fqdn, hostsFile string, addrs []net.Addr) error { var gcehosts []byte var aliases string hosts, err := os.ReadFile(hostsFile) if err != nil { return err } for _, l := range strings.Split(string(hosts), newline) { if strings.HasSuffix(l, "# Added by Google") || l == "" { continue } gcehosts = append(gcehosts, []byte(l)...) gcehosts = append(gcehosts, []byte(newline)...) } for _, a := range strings.Split(cfg.Retrieve().Unstable.AdditionalAliases, ",") { aliases += a + " " } gcehosts = append(gcehosts, []byte(fmt.Sprintf("169.254.169.254 metadata.google.internal # Added by Google%s", newline))...) for _, addr := range addrs { ip, _, err := net.ParseCIDR(addr.String()) if err != nil { galog.Errorf("Could not parse address %s: %v", addr, err) continue } if !ip.IsLoopback() { gcehosts = append(gcehosts, []byte(fmt.Sprintf("%s %s %s %s # Added by Google%s", ip, fqdn, hostname, aliases, newline))...) } } return overwrite(ctx, hostsFile, gcehosts) } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname_linux.go000066400000000000000000000065671524141036400276640ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package hostname import ( "context" "fmt" "os" "os/exec" "syscall" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) var ( platformHostsFile = "/etc/hosts" syscallSethostname = syscall.Sethostname ) const newline = "\n" func initPlatform(context.Context) error { return nil } func closePlatform() {} var setHostname = func(ctx context.Context, hostname string) error { if err := syscallSethostname([]byte(hostname)); err != nil { return err } // Set hostname in various network management stacks to avoid changes being overwritten. if _, err := exec.LookPath("nmcli"); err == nil { opts := run.Options{ OutputType: run.OutputCombined, Name: "nmcli", Args: []string{"general", "hostname", hostname}, } if _, err := run.WithContext(ctx, opts); err != nil { galog.Errorf("Error running %q %q: %v", opts.Name, opts.Args, err) } } if _, err := exec.LookPath("hostnamectl"); err == nil { opts := run.Options{ OutputType: run.OutputCombined, Name: "hostnamectl", Args: []string{"hostname", hostname}, } if _, err := run.WithContext(ctx, opts); err != nil { // Fall back to deprecated set-hostname sub-command. opts := run.Options{ OutputType: run.OutputCombined, Name: "hostnamectl", Args: []string{"set-hostname", hostname}, } if _, err := run.WithContext(ctx, opts); err != nil { galog.Errorf("Error running %q %q: %v", opts.Name, opts.Args, err) } } } // Restart rsyslog or syslogd to update hostname in logging. if _, err := exec.LookPath("systemctl"); err == nil { ok, err := daemon.CheckUnitExists(ctx, "rsyslog") if err != nil { return fmt.Errorf("failed to check for rsyslog: %v", err) } if ok { return daemon.RestartService(ctx, "rsyslog", daemon.Restart) } } else { opts := run.Options{ OutputType: run.OutputCombined, Name: "pkill", Args: []string{"-HUP", "syslogd"}, } if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to send SIGHUP to syslogd: %v", err) } } return nil } // Make the write as atomic as possible by creating a temp file, restoring // permissions & ownership, writing data, syncing, and then overwriting. func overwrite(ctx context.Context, dst string, contents []byte) error { stat, err := os.Stat(dst) if err != nil { return err } statT, ok := stat.Sys().(*syscall.Stat_t) if !ok { return fmt.Errorf("could not determine owner of %s", dst) } return file.SaferWriteFile(ctx, contents, dst, file.Options{Perm: stat.Mode(), Owner: &file.GUID{UID: int(statT.Uid), GID: int(statT.Gid)}}) } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname_linux_test.go000066400000000000000000000111231524141036400307030ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package hostname import ( "context" "errors" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) type testRunner struct { runFunc func(context.Context, run.Options) (*run.Result, error) } func (t *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { return t.runFunc(ctx, opts) } func setupTestRunner(t *testing.T, runFunc func(context.Context, run.Options) (*run.Result, error)) *testRunner { testRunner := &testRunner{ runFunc: runFunc, } oldClient := run.Client run.Client = testRunner t.Cleanup(func() { run.Client = oldClient }) return testRunner } func TestSetHostname(t *testing.T) { testcases := []struct { name string hostname string syscallFunc func([]byte) error runFunc func(context.Context, run.Options) (*run.Result, error) commandsInPath []string }{ { name: "success", hostname: "host1", syscallFunc: func(b []byte) error { if string(b) != "host1" { return errors.New("syscall error") } return nil }, runFunc: func(_ context.Context, opts run.Options) (*run.Result, error) { switch opts.Name { case "nmcli": return &run.Result{OutputType: opts.OutputType}, nil case "hostnamectl": return &run.Result{OutputType: opts.OutputType}, nil case "systemctl": return &run.Result{OutputType: opts.OutputType}, nil default: return nil, fmt.Errorf("unknown command %v", opts.Name) } }, commandsInPath: []string{"hostnamectl", "nmcli", "systemctl"}, }, { name: "success-command-failure", hostname: "host1", syscallFunc: func(b []byte) error { if string(b) != "host1" { return errors.New("syscall error") } return nil }, runFunc: func(_ context.Context, opts run.Options) (*run.Result, error) { switch opts.Name { case "nmcli": return nil, errors.New("run error") case "hostnamectl": return nil, errors.New("run error") case "systemctl": return &run.Result{OutputType: opts.OutputType}, nil default: return nil, fmt.Errorf("unknown command %v", opts.Name) } }, commandsInPath: []string{"hostnamectl", "nmcli", "systemctl"}, }, { name: "success-with-no-systemd", hostname: "host1", syscallFunc: func(b []byte) error { if string(b) != "host1" { return errors.New("syscall error") } return nil }, runFunc: func(_ context.Context, opts run.Options) (*run.Result, error) { switch opts.Name { case "nmcli": return &run.Result{OutputType: opts.OutputType}, nil case "hostnamectl": return &run.Result{OutputType: opts.OutputType}, nil case "pkill": return &run.Result{OutputType: opts.OutputType}, nil default: return nil, fmt.Errorf("unknown command %v", opts.Name) } }, commandsInPath: []string{"hostnamectl", "nmcli"}, }, } ctx := context.Background() for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { oldSyscallSethostname := syscallSethostname syscallSethostname = tc.syscallFunc t.Cleanup(func() { syscallSethostname = oldSyscallSethostname }) bindir := filepath.Join(t.TempDir(), "bin") if err := os.MkdirAll(bindir, 0700); err != nil { t.Fatalf("os.MkdirAll(bindir) = %v, want nil", err) } oldpath := os.Getenv("PATH") newpath := fmt.Sprintf("%s:%s", bindir, os.Getenv("PATH")) if err := os.Setenv("PATH", newpath); err != nil { t.Fatalf("os.Setenv(%q, %s) = %v want nil", "PATH", newpath, err) } t.Cleanup(func() { os.Setenv("PATH", oldpath) }) for _, cmd := range tc.commandsInPath { if err := os.WriteFile(filepath.Join(bindir, cmd), []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatalf("os.WriteFile(%q, %q, 0755) = %v, want nil", filepath.Join(bindir, cmd), "#!/bin/sh", err) } } setupTestRunner(t, tc.runFunc) if err := setHostname(ctx, tc.hostname); err != nil { t.Fatalf("setHostname(ctx, %q) = %v, want nil", tc.hostname, err) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname_test.go000066400000000000000000000546651524141036400275060ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package hostname import ( "context" "encoding/json" "fmt" "net" "os" "path/filepath" "runtime" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/google/go-cmp/cmp" ) func TestNewModule(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } mod := NewModule(context.Background()) if mod == nil { t.Fatalf("NewModule() = nil, want non-nil") } if mod.ID != "hostname" { t.Errorf("NewModule().ID = %q, want %q", mod.ID, "hostname") } } func TestReconfigureHostname(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } SetFQDNOrig := setFQDN setHostnameOrig := setHostname t.Cleanup(func() { setFQDN = SetFQDNOrig; setHostname = setHostnameOrig }) testcases := []struct { name string cfg *cfg.Sections hostname string fqdn string setFQDNFunc func(context.Context, string, string) error setHostnameFunc func(context.Context, string) error req ReconfigureHostnameRequest expectedResp ReconfigureHostnameResponse }{ { name: "successful_reconfigure_all", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Hostname: "host1", Fqdn: "host1.example.com", }, hostname: "host1", fqdn: "host1.example.com", }, { name: "reconfigure_hostname", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: false, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Hostname: "host1", }, hostname: "host1", fqdn: "host1.example.com", }, { name: "reconfigure_fqdn", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: false, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Fqdn: "host1.example.com", }, hostname: "host1", fqdn: "host1.example.com", }, { name: "fail_to_reconfigure_hostname", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return fmt.Errorf("hostname failure") }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 1, StatusMessage: "hostname failure"}, Fqdn: "host1.example.com", }, hostname: "host1", fqdn: "host1.example.com", }, { name: "fail_to_reconfigure_fqdn", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return fmt.Errorf("fqdn failure") }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 2, StatusMessage: "fqdn failure"}, Hostname: "host1", }, hostname: "host1", fqdn: "host1.example.com", }, { name: "fail_to_reconfigure_hostname_and_fqdn", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return fmt.Errorf("fqdn failure") }, setHostnameFunc: func(context.Context, string) error { return fmt.Errorf("hostname failure") }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 3, StatusMessage: "hostname failurefqdn failure"}, }, hostname: "host1", fqdn: "host1.example.com", }, { name: "empty_hostname", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 1, StatusMessage: "Disallowed hostname: \"\""}, Fqdn: "host1.example.com", }, hostname: "", fqdn: "host1.example.com", }, { name: "empty_fqdn", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 2, StatusMessage: "Disallowed fqdn: \"\""}, Hostname: "host1", }, hostname: "host1", fqdn: "", }, { name: "mds_name_as_hostname", cfg: &cfg.Sections{ Unstable: &cfg.Unstable{ FQDNAsHostname: false, SetHostname: true, SetFQDN: true, }, }, setFQDNFunc: func(context.Context, string, string) error { return nil }, setHostnameFunc: func(context.Context, string) error { return nil }, req: ReconfigureHostnameRequest{}, expectedResp: ReconfigureHostnameResponse{ Response: command.Response{Status: 3, StatusMessage: "Disallowed hostname: \"metadata.google.internal\"Disallowed fqdn: \"metadata.google.internal\""}, }, hostname: "metadata.google.internal", fqdn: "metadata.google.internal", }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() cfg.Retrieve().Unstable = tc.cfg.Unstable setFQDN = tc.setFQDNFunc setHostname = tc.setHostnameFunc hostname = tc.hostname fqdn = tc.fqdn b, err := json.Marshal(tc.req) if err != nil { t.Fatalf("json.Marshal(%v) = %v, want nil", tc.req, err) } b, err = ReconfigureHostname(ctx, b) if err != nil { t.Fatalf("ReconfigureHostname(ctx, %v) = %v, want nil", b, err) } var resp ReconfigureHostnameResponse err = json.Unmarshal(b, &resp) if err != nil { t.Fatalf("json.Unmarshal(%v, %v) = %v, want nil", b, &resp, err) } if diff := cmp.Diff(tc.expectedResp, resp); diff != "" { t.Errorf("unexpected response from reconfigurehostname, diff (-want +got):\n%s", diff) } }) } } func TestCommandRoundTrip(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } hostname = "host1" fqdn = "host1.example.com" SetFQDNOrig := setFQDN setHostnameOrig := setHostname t.Cleanup(func() { setFQDN = SetFQDNOrig; setHostname = setHostnameOrig }) setFQDN = func(_ context.Context, hostname, fqdn string) error { if fqdn != "host1.example.com" { return fmt.Errorf("bad fqdn") } return nil } setHostname = func(_ context.Context, hostname string) error { if hostname != "host1" { return fmt.Errorf("bad hostname") } return nil } testpipe := filepath.Join(t.TempDir(), "commands.sock") if runtime.GOOS == "windows" { testpipe = `\\.\pipe\google-guest-agent-hostname-test-round-trip` } cfg.Retrieve().Unstable = &cfg.Unstable{ CommandMonitorEnabled: true, CommandPipePath: testpipe, FQDNAsHostname: false, SetHostname: true, SetFQDN: true, } req := []byte(fmt.Sprintf(`{"Command":"%s"}`, ReconfigureHostnameCommand)) desc, err := metadata.UnmarshalDescriptor(`{"instance":{"attributes":{"hostname":"host1.example.com"}}}`) if err != nil { t.Fatalf("metadata.UnmarshalJSON(%s) = %v, want nil", `{"instance":{"attributes":{"hostname":"host1.example.com"}}}`, err) } if err := command.Setup(ctx, command.ListenerCorePlugin); err != nil { t.Fatalf("command.Setup(ctx, command.ListenerCorePlugin) = %v, want nil", err) } t.Cleanup(func() { command.Close(ctx) }) if err := moduleSetup(ctx, desc); err != nil { t.Fatalf("moduleSetup(ctx, %+v) = %v, want nil", desc, err) } t.Cleanup(func() { moduleClose(ctx) }) var resp ReconfigureHostnameResponse b := command.SendCommand(ctx, req, command.ListenerCorePlugin) err = json.Unmarshal(b, &resp) if err != nil { t.Fatalf("json.Unmarshal(%s, %v) = %v, want nil", b, &resp, err) } expect := ReconfigureHostnameResponse{ Hostname: "host1", Fqdn: "host1.example.com", } if diff := cmp.Diff(expect, resp); diff != "" { t.Errorf("unexpected response from command.SendCommand(ctx, %s, command.ListenerCorePlugin), diff (-want +got):\n%s", req, diff) } } type testAddr struct{ s string } func (t testAddr) Network() string { return t.s } func (t testAddr) String() string { return t.s } func TestWriteHosts(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } testcases := []struct { name string cfg *cfg.Sections inputhosts string inputhostname string inputfqdn string inputaddrs []net.Addr expectOutput string }{ { name: "empty_hosts", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "", inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}}, expectOutput: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline, }, { name: "loopback_addresses", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "", inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}, testAddr{"127.0.0.1/8"}, testAddr{"::1/128"}}, expectOutput: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline, }, { name: "two_addresses", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "", inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}, testAddr{"10.0.0.20/16"}}, expectOutput: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline + "10.0.0.20 tc1.example.com tc1 # Added by Google" + newline, }, { name: "two_aliases", cfg: &cfg.Sections{Unstable: &cfg.Unstable{AdditionalAliases: "tc2,tc3"}}, inputhosts: "", inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}}, expectOutput: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 tc2 tc3 # Added by Google" + newline, }, { name: "existing_hosts_at_beginning", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "127.0.0.1 pre-existing.host.com" + newline + "12.12.12.12 tc1.example.com # Added by Google" + newline, inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}}, expectOutput: "127.0.0.1 pre-existing.host.com" + newline + "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline, }, { name: "existing_hosts_at_end", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "12.12.12.12 tc1.example.com # Added by Google" + newline + "127.0.0.1 pre-existing.host.com" + newline + "", inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}}, expectOutput: "127.0.0.1 pre-existing.host.com" + newline + "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline, }, { name: "two_gce_hosts_blocks", cfg: &cfg.Sections{Unstable: &cfg.Unstable{}}, inputhosts: "12.12.12.12 tc1.example.com # Added by Google" + newline + "127.0.0.1 pre-existing.host.com" + newline + "13.13.13.13 tc2.example.com # Added by Google" + newline, inputhostname: "tc1", inputfqdn: "tc1.example.com", inputaddrs: []net.Addr{testAddr{"10.0.0.10/16"}}, expectOutput: "127.0.0.1 pre-existing.host.com" + newline + "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.10 tc1.example.com tc1 # Added by Google" + newline, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { cfg.Retrieve().Unstable = tc.cfg.Unstable testfile, err := os.CreateTemp(t.TempDir(), "test-writehosts-"+strings.ReplaceAll(tc.name, " ", "-")) if err != nil { t.Fatalf("os.CreateTemp(t.TempDir(), %v) = %v, want nil", "test-writehosts-"+strings.ReplaceAll(tc.name, " ", "-"), err) } if _, err = testfile.Write([]byte(tc.inputhosts)); err != nil { t.Fatalf("testfile.Write(%s) = %v, want nil", tc.inputhosts, err) } hostsfile := testfile.Name() if err = testfile.Close(); err != nil { t.Fatalf("testfile.Close() = %v, want nil", err) } if err := writeHosts(context.Background(), tc.inputhostname, tc.inputfqdn, hostsfile, tc.inputaddrs); err != nil { t.Fatalf("writeHosts(context.Background(), %q, %q, %q, %q) = %v, want nil", tc.inputhostname, tc.inputfqdn, hostsfile, tc.inputaddrs, err) } output, err := os.ReadFile(hostsfile) if err != nil { t.Fatalf("os.ReadFile(%v) = %v, want nil", hostsfile, err) } if string(output) != tc.expectOutput { t.Errorf("unexpected output from writeHosts, want "+newline+"%q"+newline+"but got"+newline+"%q", tc.expectOutput, output) } }) } } func TestSetFQDN(t *testing.T) { testcases := []struct { name string hostname string fqdn string FQDNAddressInterfaceIndex int expectHostsContents string interfacesFunc func() ([]*ethernet.Interface, error) }{ { name: "success", hostname: "host1", fqdn: "host1.example.com", FQDNAddressInterfaceIndex: 0, expectHostsContents: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "192.168.0.1 host1.example.com host1 # Added by Google" + newline + "", interfacesFunc: func() ([]*ethernet.Interface, error) { return []*ethernet.Interface{ ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return []net.Addr{ testAddr{"192.168.0.1/24"}, }, nil }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, }, nil }, }, { name: "success_with_index_1", hostname: "host1", fqdn: "host1.example.com", FQDNAddressInterfaceIndex: 1, expectHostsContents: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "10.0.0.1 host1.example.com host1 # Added by Google" + newline + "", interfacesFunc: func() ([]*ethernet.Interface, error) { return []*ethernet.Interface{ ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return []net.Addr{ testAddr{"192.168.0.1/24"}, }, nil }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return []net.Addr{ testAddr{"10.0.0.1/24"}, }, nil }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, }, nil }, }, { name: "success_with_bad_address", hostname: "host1", fqdn: "host1.example.com", FQDNAddressInterfaceIndex: 0, expectHostsContents: "169.254.169.254 metadata.google.internal # Added by Google" + newline + "192.168.0.1 host1.example.com host1 # Added by Google" + newline + "", interfacesFunc: func() ([]*ethernet.Interface, error) { return []*ethernet.Interface{ ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return []net.Addr{ testAddr{"192.168.0.1/24"}, testAddr{"-1"}, }, nil }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, }, nil }, }, } ctx := context.Background() for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { if err := cfg.Load([]byte(fmt.Sprintf("[Unstable]\nfqdn_address_interface_index = %d\n", tc.FQDNAddressInterfaceIndex))); err != nil { t.Fatalf(`cfg.Load([]byte([Unstable]\nfqdn_address_interface_index = %d\n))) = %v, want nil`, tc.FQDNAddressInterfaceIndex, err) } oldOps := ethernet.DefaultInterfaceOps t.Cleanup(func() { ethernet.DefaultInterfaceOps = oldOps }) // Prepare fake operations. ethernet.DefaultInterfaceOps = ðernet.InterfaceOps{ Interfaces: tc.interfacesFunc, } platformHostsFileOld := platformHostsFile platformHostsFile = filepath.Join(t.TempDir(), "hosts") f, err := os.Create(platformHostsFile) if err != nil { t.Fatalf("os.Create(%q) = %v want nil", platformHostsFile, err) } if err := f.Close(); err != nil { t.Fatalf("file %q.Close() = %v want nil", platformHostsFile, err) } t.Cleanup(func() { platformHostsFile = platformHostsFileOld }) err = setFQDN(ctx, tc.hostname, tc.fqdn) if err != nil { t.Errorf("setFQDN(ctx, %q, %q) = %v want nil", tc.hostname, tc.fqdn, err) } platformHostsFileContents, err := os.ReadFile(platformHostsFile) if err != nil { t.Fatalf("os.ReadFile(%q) = err %v want nil", platformHostsFile, err) } if string(platformHostsFileContents) != tc.expectHostsContents { t.Errorf("os.ReadFile(platformHostsFile) = %q want %q", platformHostsFileContents, tc.expectHostsContents) } }) } } func TestSetFQDNError(t *testing.T) { testcases := []struct { name string FQDNAddressInterfaceIndex int interfacesFunc func() ([]*ethernet.Interface, error) }{ { name: "interfaces_error", FQDNAddressInterfaceIndex: 0, interfacesFunc: func() ([]*ethernet.Interface, error) { return nil, fmt.Errorf("no interfaces") }, }, { name: "addresses_error", FQDNAddressInterfaceIndex: 0, interfacesFunc: func() ([]*ethernet.Interface, error) { return []*ethernet.Interface{ ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return nil, fmt.Errorf("no addrs") }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, }, nil }, }, { name: "fqdn_interface_index_out_of_range", FQDNAddressInterfaceIndex: 1, interfacesFunc: func() ([]*ethernet.Interface, error) { return []*ethernet.Interface{ ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { return []net.Addr{ testAddr{"192.168.0.1/24"}, }, nil }, NameOp: func() string { return "ens4" }, HardwareAddr: func() net.HardwareAddr { return net.HardwareAddr{} }, MTU: func() int { return 1460 }, }, }, nil }, }, } ctx := context.Background() for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { if err := cfg.Load([]byte(fmt.Sprintf("[Unstable]\nfqdn_address_interface_index = %d\n", tc.FQDNAddressInterfaceIndex))); err != nil { t.Fatalf(`cfg.Load([]byte([Unstable]\nfqdn_address_interface_index = %d\n))) = %v, want nil`, tc.FQDNAddressInterfaceIndex, err) } oldOps := ethernet.DefaultInterfaceOps t.Cleanup(func() { ethernet.DefaultInterfaceOps = oldOps }) // Prepare fake operations. ethernet.DefaultInterfaceOps = ðernet.InterfaceOps{ Interfaces: tc.interfacesFunc, } platformHostsFileOld := platformHostsFile platformHostsFile = filepath.Join(t.TempDir(), "hosts") f, err := os.Create(platformHostsFile) if err != nil { t.Fatalf("os.Create(%q) = %v want nil", platformHostsFile, err) } if err := f.Close(); err != nil { t.Fatalf("file %q.Close() = %v want nil", platformHostsFile, err) } t.Cleanup(func() { platformHostsFile = platformHostsFileOld }) err = setFQDN(ctx, "test", "test.example.com") if err == nil { t.Errorf("setFQDN(ctx, %q, %q) = %v want non-nil", "test", "test.example.com", err) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname_windows.go000066400000000000000000000101161524141036400302000ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package hostname import ( "context" "encoding/json" "fmt" "os" "syscall" "unsafe" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "golang.org/x/sys/windows" ) var platformHostsFile = `C:\Windows\System32\Drivers\etc\hosts` const newline = "\r\n" var ( // Go GC needs to retain a reference to ipcallbackFunc as long as // ipcallbackHandle != nil. Store and modify them together. ipcallbackHandle uintptr ipcallbackFunc func() uintptr // Whether to immediately trigger the callback during setup. True only during // testing. triggerCallbackImmediately bool iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") procNotifyIPInterfaceChange = iphlpapi.NewProc("NotifyIpInterfaceChange") procCancelMibChangeNotify2 = iphlpapi.NewProc("CancelMibChangeNotify2") ) var setHostname = func(context.Context, string) error { return fmt.Errorf("setting hostnames in guest-agent is not supported on windows") } func notifyIPInterfaceChange(family uint32, callbackPtr uintptr, callerContext unsafe.Pointer, initialNotif bool, handle *uintptr) error { notify := 0 if initialNotif { notify = 1 } r, _, e := syscall.SyscallN(procNotifyIPInterfaceChange.Addr(), uintptr(family), // Address family callbackPtr, // callback ptr uintptr(callerContext), // caller context uintptr(notify), // call callback immediately after registration uintptr(unsafe.Pointer(handle)), // handle for deregistering callback ) if r != 0 { return e } return nil } func cancelMibChangeNotify2(handle uintptr) (err error) { r, _, e := syscall.SyscallN(procCancelMibChangeNotify2.Addr(), handle) if r != 0 { return e } return nil } func initPlatform(ctx context.Context) error { if ipcallbackHandle != 0 { galog.Infof("IP callback is already registered.") return nil } // Create callback here to use the context passed from caller. ipcallbackFunc = func() uintptr { galog.Infof("IP interface changed, reconfiguring FQDN.") req := []byte(fmt.Sprintf(`{"Command":"%s"}`, ReconfigureHostnameCommand)) b := command.SendCommand(ctx, req, command.ListenerCorePlugin) galog.Debugf("Got response: %s from reconfigure request", b) var resp ReconfigureHostnameResponse if err := json.Unmarshal(b, &resp); err != nil { galog.Errorf("Reponse %q is not a ReconfigureHostnameResponse: %v", b, err) } if resp.Status != 0 { galog.Errorf("Error reconfiguring hostname, got response %+v", resp) } return 0 // Report success } err := notifyIPInterfaceChange( windows.AF_UNSPEC, //ipv4+6 windows.NewCallback(ipcallbackFunc), nil, // Use go references to context rather than passing through win32 API. triggerCallbackImmediately, &ipcallbackHandle, ) if err != nil { return fmt.Errorf("unable to register callback for IP interface change: %v", err) } return nil } func closePlatform() { if ipcallbackHandle == 0 { galog.Infof("IP callback handle is not registered.") return } err := cancelMibChangeNotify2(ipcallbackHandle) if err != nil { galog.Errorf("Unable to unregister callback for IP interface change: %v", err) } ipcallbackHandle = 0 ipcallbackFunc = nil } func overwrite(ctx context.Context, dst string, contents []byte) error { stat, err := os.Stat(dst) if err != nil { return err } return file.SaferWriteFile(ctx, contents, dst, file.Options{Perm: stat.Mode()}) } google-guest-agent-20260819.00/cmd/core_plugin/network/hostname/hostname_windows_test.go000066400000000000000000000071441524141036400312460ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package hostname import ( "context" "sync/atomic" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "golang.org/x/sys/windows" ) func TestNotifyIpInterfaceChange(t *testing.T) { var handle uintptr var callbackExecuted bool callback := func() uintptr { callbackExecuted = true return 0 } if err := notifyIPInterfaceChange(windows.AF_UNSPEC, windows.NewCallback(callback), nil, true, &handle); err != nil { t.Errorf("failed to register callback: %v", err) } if handle == 0 { t.Error("notification handle is nil after registering callback") } if !callbackExecuted { t.Errorf("callback was not executed, callbackExecuted = %v", callbackExecuted) } if err := cancelMibChangeNotify2(handle); err != nil { t.Errorf("failed to unregister callback: %v", err) } } func TestCommandRoundTripWithCallback(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) = %v, want nil", err) } triggerCallbackImmediately = true t.Cleanup(func() { triggerCallbackImmediately = false }) hostname = "host1" fqdn = "host1.example.com" SetFQDNOrig := setFQDN setHostnameOrig := setHostname t.Cleanup(func() { setFQDN = SetFQDNOrig; setHostname = setHostnameOrig }) var setFQDNCalled, setHostnameCalled atomic.Int32 setFQDN = func(_ context.Context, hostname, fqdn string) error { setFQDNCalled.Add(1) return nil } setHostname = func(_ context.Context, hostname string) error { setHostnameCalled.Add(1) return nil } testpipe := `\\.\pipe\google-guest-agent-hostname-test-callback-round-trip` cfg.Retrieve().Unstable = &cfg.Unstable{ CommandMonitorEnabled: true, CommandPipePath: testpipe, FQDNAsHostname: false, SetHostname: true, SetFQDN: true, } desc, err := metadata.UnmarshalDescriptor(`{"instance":{"attributes":{"hostname":"host1.example.com"}}}`) if err != nil { t.Fatalf("metadata.UnmarshalJSON(%s) = %v, want nil", `{"instance":{"attributes":{"hostname":"host1.example.com"}}}`, err) } if err := command.Setup(ctx, command.ListenerCorePlugin); err != nil { t.Fatalf("command.Setup(ctx, command.ListenerCorePlugin) = %v, want nil", err) } t.Cleanup(func() { command.Close(ctx) }) if err := moduleSetup(ctx, desc); err != nil { t.Fatalf("moduleSetup(ctx, %+v) = %v, want nil", desc, err) } t.Cleanup(func() { moduleClose(ctx) }) // Each func should be called once during moduleSetup(), once from the // callback, and potentially more if the IP configuration changes. if setFQDNCalledCount := setFQDNCalled.Load(); setFQDNCalledCount < 2 { t.Errorf("setFQDN was called %d times, want at least 2", setFQDNCalledCount) } if setHostnameCalledCount := setHostnameCalled.Load(); setHostnameCalledCount < 2 { t.Errorf("setHostname was called %d times, want at least 2", setHostnameCalledCount) } } google-guest-agent-20260819.00/cmd/core_plugin/network/manager_linux.go000066400000000000000000000154301524141036400256270ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package network import ( "context" "fmt" "net" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/dhclient" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/netplan" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/networkd" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/nm" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/wicked" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) var ( // defaultLinuxManagers is the list of the linux network managers. defaultLinuxManagers = []*service.Handle{ netplan.NewService(), nm.NewService(), networkd.NewService(), wicked.NewService(), dhclient.NewService(), } // routeSetup is the function that sets up the routes. routeSetup = route.Setup ) // managerSetup sets up the network interfaces for linux. func managerSetup(ctx context.Context, nics []*nic.Configuration, networkChanged networkChanged) error { galog.Infof("Running linux network management module setup.") opts := service.NewOptions(defaultLinuxManagers, nics) if networkChanged.networkInterfaces && cfg.Retrieve().NetworkInterfaces.Setup { if err := runManagerSetup(ctx, opts); err != nil { return fmt.Errorf("failed to setup network configuration: %w", err) } } // Attempt to setup the routes. if err := routeSetup(ctx, opts); err != nil { return fmt.Errorf("failed to setup routes: %w", err) } galog.Infof("Finished linux network management module setup.") return nil } // runManagerSetup runs the actual linux network manager setup steps, it // controls the configuration flow. func runManagerSetup(ctx context.Context, opts *service.Options) error { managers, ok := opts.Data().([]*service.Handle) if !ok { return fmt.Errorf("failed get linux managers implementation list") } if len(opts.FilteredNICConfigs()) == 0 { galog.Infof("Skipping network setup - no NICs to configure.") return nil } active, err := activeManager(ctx, managers, opts) if err != nil { return fmt.Errorf("failed to get active manager: %w", err) } // Configure the active manager. if active.Configure != nil { galog.Debugf("Running pre-setup configuration for active manager %v", active.ID) if err := active.Configure(ctx); err != nil { return fmt.Errorf("failed to configure active manager(%q): %w", active.ID, err) } } else { galog.Debugf("Active manager(%q) has nothing to configure.", active.ID) } // Attempt to rollback the configuration of all the managers except the active // one. As it's a non-fatal error we log it and proceed with the setup. rolledBack, err := rollback(ctx, managers, active.ID, opts) if err != nil { galog.Warnf("Failed to rollback network configuration: %v.", err) } galog.Infof("Rolled back network configuration for %v.", rolledBack) // Attempt to setup the network configuration for the active manager. if err := active.Setup(ctx, opts); err != nil { return fmt.Errorf("failed to setup network configuration(%q): %w", active.ID, err) } go func() { // Setup might not have finished when we log and collect this information. Adding this // temporary sleep for debugging purposes to make sure we have up-to-date information. time.Sleep(2 * time.Second) logInterfaceState(ctx) }() return nil } // activeManager returns the active network manager service. If it the // implementation fail to check itself or if no manager is managing the network // interfaces, an error is returned. func activeManager(ctx context.Context, managers []*service.Handle, opts *service.Options) (*service.Handle, error) { galog.Debugf("Checking for active network manager service.") for _, manager := range managers { managing, err := manager.IsManaging(ctx, opts) if err != nil { galog.Warnf("Failed to check if manager is active(%q): %v.", manager.ID, err) continue } if managing { galog.Debugf("Found active network manager service: %q", manager.ID) return manager, nil } } return nil, fmt.Errorf("no linux network management service module found") } // rollback rolls back the changes created in Setup for all the network managers // except the one provided with skip argument. func rollback(ctx context.Context, managers []*service.Handle, skipID string, opts *service.Options) ([]string, error) { galog.Debugf("Rolling back network configuration for all the linux network management service modules") var rolledBack []string for _, manager := range managers { galog.V(1).Debugf("Rolling back network configuration for %q.", manager.ID) // Rollback network configurations for the manager. Avoid reloading the // active manager as we'll need to reload it anyway after the setup. if err := manager.Rollback(ctx, opts, manager.ID == skipID); err != nil { galog.Debugf("failed to rollback network configuration(%q): %v", manager.ID, err) } else { galog.V(1).Debugf("Successfully rolled back network configuration for %q.", manager.ID) } rolledBack = append(rolledBack, manager.ID) } return rolledBack, nil } // logInterfaceState logs the interface state and routes for all the network // interfaces. func logInterfaceState(ctx context.Context) { ifaces, err := net.Interfaces() if err != nil { galog.Warnf("Failed to get network interfaces: %v.", err) return } // Log the interface state. for _, iface := range ifaces { addrs, err := iface.Addrs() if err != nil { galog.Warnf("Failed to get addresses for interface %q: %v.", iface.Name, err) continue } galog.Infof("Interface(%s) - State: %+v, Addrs: %+v", iface.Name, iface, addrs) } // Log the routes. res, err := run.WithContext(ctx, run.Options{ OutputType: run.OutputStdout, Name: "ip", Args: []string{"route", "list", "table", "local"}, }) if err != nil { galog.Warnf("Failed to get routes: %v.", err) return } galog.Infof("Local Routes: %s", res.Output) } google-guest-agent-20260819.00/cmd/core_plugin/network/manager_linux_test.go000066400000000000000000000343671524141036400267000ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package network import ( "context" "errors" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/google/go-cmp/cmp" ) func TestActiveManager(t *testing.T) { tests := []struct { name string managers []*service.Handle wantID string wantError bool }{ { name: "fail-no-managers", wantError: true, }, { name: "fail-single-error-ismanaging", managers: []*service.Handle{ { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, errors.New("error") }, }, }, wantError: true, }, { name: "success-one-manager-failing", managers: []*service.Handle{ { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, errors.New("error") }, }, { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, }, }, wantError: false, }, { name: "fail-multiple-error-ismanaging", managers: []*service.Handle{ { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, nil }, }, { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, errors.New("error") }, }, }, wantError: true, }, { name: "success-single-manager", wantID: "manager-1", managers: []*service.Handle{ { ID: "manager-1", IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, }, }, wantError: false, }, { name: "success-multiple-managers", wantID: "manager-2", managers: []*service.Handle{ { IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, nil }, }, { ID: "manager-2", IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, }, }, wantError: false, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := activeManager(ctx, tc.managers, nil) if (err == nil) == tc.wantError { t.Errorf("activeManager(ctx, %+v) = %v, want %v", tc.managers, err, tc.wantError) } if !tc.wantError && got.ID != tc.wantID { t.Errorf("activeManager(ctx, %+v) = %v, want %v", tc.managers, got.ID, tc.wantID) } }) } } func TestRollback(t *testing.T) { tests := []struct { name string managers []*service.Handle skipID string wantRolledBack []string }{ { name: "success", wantRolledBack: []string{"manager-1"}, managers: []*service.Handle{ { ID: "manager-1", Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, }, { name: "failure", wantRolledBack: []string{"manager-1"}, managers: []*service.Handle{ { ID: "manager-1", Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return errors.New("error") }, }, }, }, { name: "success-skip", skipID: "manager-1", wantRolledBack: []string{"manager-1", "manager-2"}, managers: []*service.Handle{ { ID: "manager-1", Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, { ID: "manager-2", Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, }, } ctx := context.Background() opts := service.NewOptions(nil, []*nic.Configuration{}) for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rolledBack, err := rollback(ctx, tc.managers, tc.skipID, opts) if err != nil { t.Errorf("rollback(ctx, %+v, %q, nil) = %v, want nil", tc.managers, tc.skipID, err) } if diff := cmp.Diff(tc.wantRolledBack, rolledBack); diff != "" { t.Errorf("rollback(ctx, %+v, %q, nil) returned diff (-want +got):\n%s", tc.managers, tc.skipID, diff) } }) } } func TestRunManagerSetup(t *testing.T) { tests := []struct { name string opts *service.Options wantError bool }{ { name: "invalid-managers", opts: service.NewOptions("expected to be a options pointer", nil), wantError: true, }, { name: "no-active-managers", opts: service.NewOptions([]*service.Handle{}, []*nic.Configuration{{Index: 1}}), wantError: true, }, { name: "failing-setup-manager", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return errors.New("error") }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, []*nic.Configuration{ { Index: 1, Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, }, }, ), wantError: true, }, { name: "success-no-nics", opts: service.NewOptions([]*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, nil), wantError: false, }, { name: "error-ismanaging", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, errors.New("error") }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, Index: 1, }, }, ), wantError: true, }, { name: "error-rollback", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, { ID: "manager-2", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return errors.New("error") }, }, }, []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, }, }, ), wantError: false, }, { name: "error-setup", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return errors.New("error") }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, Index: 1, }, }, ), wantError: true, }, { name: "error-configure", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return errors.New("error") }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, Index: 1, }, }, ), wantError: true, }, { name: "success", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, }, []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, Index: 1, }, }, ), wantError: false, }, } ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := runManagerSetup(ctx, tc.opts) if (err == nil) == tc.wantError { t.Errorf("runManagerSetup(ctx, %+v) = %v, want error", tc.opts, err) } }) } } func TestNetworkInterfacesSetupDisabled(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } cfg.Retrieve().NetworkInterfaces.Setup = false defer func() { cfg.Retrieve().NetworkInterfaces.Setup = true }() var routeSetupCalled bool oldRouteSetup := routeSetup t.Cleanup(func() { routeSetup = oldRouteSetup }) routeSetup = func(ctx context.Context, opts *service.Options) error { routeSetupCalled = true return nil } testOpts := service.NewOptions(nil, []*nic.Configuration{ {Index: 1}, }) if err := managerSetup(ctx, testOpts.NICConfigs(), networkChanged{true, false}); err != nil { t.Errorf("managerSetup(ctx, %+v) = %v, want nil", testOpts, err) } // Routes setup should be called even if network interfaces setup is disabled. if !routeSetupCalled { t.Errorf("routeSetup() was not called") } } func TestManagerSetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } tests := []struct { name string opts *service.Options wantError bool }{ { name: "success", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", Configure: func(ctx context.Context) error { return nil }, IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, nil }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, { ID: "manager-2", IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return errors.New("error") }, }, }, []*nic.Configuration{ {Index: 1}, }, ), wantError: false, }, { name: "fail", opts: service.NewOptions( []*service.Handle{ { ID: "manager-1", IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return true, errors.New("error") }, Setup: func(ctx context.Context, opts *service.Options) error { return nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return nil }, }, { ID: "manager-2", IsManaging: func(ctx context.Context, opts *service.Options) (bool, error) { return false, nil }, Rollback: func(ctx context.Context, opts *service.Options, reload bool) error { return errors.New("error") }, }, }, []*nic.Configuration{ {Index: 1}, }, ), wantError: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { managers, ok := tc.opts.Data().([]*service.Handle) if !ok { t.Errorf("expected %+v to be a slice of service.Handle", tc.opts.Data()) } oldManagers := defaultLinuxManagers defaultLinuxManagers = managers t.Cleanup(func() { defaultLinuxManagers = oldManagers }) err := managerSetup(ctx, tc.opts.NICConfigs(), networkChanged{true, false}) if (err == nil) == tc.wantError { t.Errorf("runManagerSetup(ctx, %+v) = %v, want error", tc.opts, err) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/manager_windows.go000066400000000000000000000171431524141036400261650ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "context" "errors" "fmt" "maps" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "golang.org/x/sys/windows/registry" ) const ( // registryAddressKey is the registry key that contains the list of IP // addresses that are managed by the guest agent. registryAddressKey = reg.GCEKeyBase + `\ForwardedIps` ) // managerSetup is the windows entry point for the network management. func managerSetup(_ context.Context, nics []*nic.Configuration, _ networkChanged) error { for _, nicConfig := range nics { if err := nicSetup(nicConfig); err != nil { galog.Errorf("Failed to setup NIC %d: %v", nicConfig.Index, err) } } return nil } // nicSetup performs the setup of a single NIC. func nicSetup(nicConfig *nic.Configuration) error { if nicConfig.ExtraAddresses == nil { return nil } if nicConfig.Interface == nil { galog.Debugf("Skipping NIC setup for NIC %d: interface is nil", nicConfig.Index) return nil } extra := nicConfig.ExtraAddresses // These are the IP addresses that we want to be present on the NIC. We'll // filter out addresses that are already present and only add the delta ones. // // Note: Not considering IPaliases as it's not supported on Windows. wantedIPs := address.MergeIPAddressMap(extra.ForwardedIPs, extra.TargetInstanceIPs) iface := nicConfig.Interface macAddr := nicConfig.MacAddr // Read the underlying OS's current NIC's state. currentState, err := nicCurrentState(iface, macAddr) if err != nil { return fmt.Errorf("failed to get current state for NIC %s: %w", macAddr, err) } // Clone the registry map so that we can update it with the new state, // removing entries that are no longer present and adding new ones. registryMap := maps.Clone(currentState.registryRecordedAddrs) // Add new & unknown addresses to the NIC. addMe := currentState.newAddresses(wantedIPs) if len(addMe) > 0 { galog.Infof("Adding address(es) %q to NIC [%d] %s", addMe.FormatIPs(), nicConfig.Index, macAddr) } for k, v := range addMe { if err := addUnicastIPAddress(v, uint32(nicConfig.Interface.DeviceIndex())); err != nil { return fmt.Errorf("failed to add address %s to NIC %s: %w", k, macAddr, err) } registryMap[k] = v } // Remove addresses that are no longer of interest. removeMe := currentState.unwantedAddresses(wantedIPs) if len(removeMe) > 0 { galog.Infof("Removing address(es) %q from NIC [%d] %s", removeMe.FormatIPs(), nicConfig.Index, macAddr) } for k, v := range removeMe { if err := deleteUnicastIPAddress(v, uint32(nicConfig.Interface.DeviceIndex())); err != nil { return fmt.Errorf("failed to remove address %s from NIC %s: %w", k, macAddr, err) } delete(registryMap, k) } // Consolidate the addresses state in the registry. if err := writeRegistryIPAddress(registryAddressKey, macAddr, registryMap); err != nil { return fmt.Errorf("failed to write addresses to registry: %w", err) } galog.Infof("Successfully setup NIC: %q", nicConfig.Interface.Name()) return nil } // currentState contains the current state of the NIC. The addresses that are // present on the NIC and the addresses that are recorded in the registry. type currentState struct { // presentAddrs contains the addresses that are present on the NIC. presentAddrs address.IPAddressMap // registryRecordedAddrs contains the addresses that are recorded in the // registry. registryRecordedAddrs address.IPAddressMap } // newAddresses returns a map of addresses that are present in the addrs map but // not in the presentAddrs map. func (cs *currentState) newAddresses(addrs address.IPAddressMap) address.IPAddressMap { res := make(address.IPAddressMap) for k, v := range addrs { if _, ok := cs.presentAddrs[k]; !ok { res[k] = v } } return res } // unwantedAddresses returns a map of addresses that are present in the registry // but not in the addrs map. func (cs *currentState) unwantedAddresses(addrs address.IPAddressMap) address.IPAddressMap { res := make(address.IPAddressMap) for k, v := range cs.registryRecordedAddrs { if _, ok := addrs[k]; !ok { res[k] = v } } return res } // nicCurrentState returns the current state of the NIC, it contains the // currently configured addresses of the NIC and the addresses that are recorded // in the registry. func nicCurrentState(iface *ethernet.Interface, macAddr string) (*currentState, error) { addrs, err := iface.Addrs() if err != nil { return nil, fmt.Errorf("failed to get addresses for interface %s: %w", iface.Name(), err) } var ifaceAddrsSlice []string for _, addr := range addrs { ifaceAddrsSlice = append(ifaceAddrsSlice, addr.String()) } registryIPAddressMap, err := readRegistryIPAddress(registryAddressKey, macAddr) if err != nil { return nil, fmt.Errorf("failed to get addresses from registry: %w", err) } return ¤tState{ presentAddrs: address.NewIPAddressMap(ifaceAddrsSlice, nil), registryRecordedAddrs: registryIPAddressMap, }, nil } // writeRegistryIPAddress writes the addresses to the registry. It also removes // the legacy registry key if it exists. func writeRegistryIPAddress(key string, macAddr string, registryMap address.IPAddressMap) error { legacyFormat := strings.Replace(macAddr, ":", "", -1) legacyExists := true _, err := reg.ReadMultiString(key, legacyFormat) if err != nil { if errors.Is(err, registry.ErrNotExist) { galog.V(2).Debugf("Legacy registry key %q, name %q does not exist.", key, legacyFormat) legacyExists = false } else { return fmt.Errorf("searching for legacy registry key: %w", err) } } // Delete the legacy registry key if it exists. if legacyExists { if err := reg.Delete(legacyFormat); err != nil { return fmt.Errorf("deleting legacy registry key: %w", err) } } if err := reg.WriteMultiString(registryAddressKey, macAddr, registryMap.IPs()); err != nil { return err } return nil } // readRegistryIPAddress returns a map of IP addresses that are recorded in the // registry. The guest agent's old/deprecated format is supported for backward // compatibility. func readRegistryIPAddress(key string, mac string) (address.IPAddressMap, error) { var ( data []string err error ) // The old agent stored MAC addresses without the ':', use the deprecated // format as a fallback. macAddresses := []string{mac, strings.Replace(mac, ":", "", -1)} for _, macAddress := range macAddresses { data, err = reg.ReadMultiString(key, macAddress) if err != nil { if errors.Is(err, registry.ErrNotExist) { galog.V(2).Debugf("Failed to read registry key %q, name %q: %v", key, macAddress, err) continue } return nil, fmt.Errorf("failed to get addresses from registry: %w", err) } galog.V(2).Debugf("Successfully read registry key %q, name %q", key, macAddress) break } return address.NewIPAddressMap(data, nil), nil } google-guest-agent-20260819.00/cmd/core_plugin/network/manager_windows_test.go000066400000000000000000000112001524141036400272100ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "net" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/mocking" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "golang.org/x/exp/maps" ) func TestReadRegistryIPAddressSuccess(t *testing.T) { tests := []struct { name string macAddr string }{ { name: "valid-standard-1", macAddr: "00:00:5e:00:53:01", }, { name: "valid-standard-2", macAddr: "00-00-5e-00-53-01", }, { name: "valid-standard-3", macAddr: "0000.5e00.5301", }, { name: "valid-nosep-3", macAddr: "00005e005301", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { addressMap, err := readRegistryIPAddress(registryAddressKey, tc.macAddr) if err != nil { t.Fatalf("readRegistryIPAddress(%v) failed: %v", tc.macAddr, err) } if len(addressMap) != 0 { t.Fatalf("readRegistryIPAddress(%v) returned %v, want empty map", tc.macAddr, addressMap) } if err := reg.WriteMultiString(registryAddressKey, tc.macAddr, maps.Keys(addressMap)); err != nil { t.Fatalf("reg.WriteMultiString(%v, %v) failed: %v", registryAddressKey, tc.macAddr, err) } }) } } func TestNicCurrentState(t *testing.T) { tests := []struct { name string macAddr string network string addrs []mocking.TestAddr newAddrs address.IPAddressMap registryAddrs address.IPAddressMap }{ { name: "single-addr", macAddr: "00:00:5e:00:53:01", network: "eth0", addrs: []mocking.TestAddr{ mocking.TestAddr{Addr: "192.168.1.1", NetworkName: "eth0"}, }, newAddrs: address.NewIPAddressMap([]string{"192.168.1.10"}, nil), }, { name: "multi-addr", macAddr: "00:00:5e:00:53:01", network: "eth0", addrs: []mocking.TestAddr{ mocking.TestAddr{Addr: "192.168.1.1", NetworkName: "eth0"}, mocking.TestAddr{Addr: "192.168.1.2", NetworkName: "eth0"}, mocking.TestAddr{Addr: "192.168.1.3", NetworkName: "eth0"}, }, newAddrs: address.NewIPAddressMap([]string{"192.168.1.10", "192.168.1.11", "192.168.1.12"}, nil), registryAddrs: address.NewIPAddressMap([]string{"192.168.1.2"}, nil), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldOps := ethernet.DefaultInterfaceOps t.Cleanup(func() { ethernet.DefaultInterfaceOps = oldOps }) // Prepare fake operations. ethernet.DefaultInterfaceOps = ðernet.InterfaceOps{ Interfaces: func() ([]*ethernet.Interface, error) { return append([]*ethernet.Interface{}, ðernet.Interface{ AddrsOp: func() ([]net.Addr, error) { var res []net.Addr for _, addr := range tc.addrs { res = append(res, addr) } return res, nil }, NameOp: func() string { return tc.network }, HardwareAddr: func() net.HardwareAddr { res, _ := net.ParseMAC(tc.macAddr) return res }, }), nil }, } ifaces, err := ethernet.Interfaces() if err != nil { t.Fatalf("ethernet.Interfaces() failed: %v", err) } state, err := nicCurrentState(ifaces[0], tc.macAddr) if err != nil { t.Fatalf("nicCurrentState(%v, %v) failed: %v", ifaces[0], tc.macAddr, err) } if len(state.presentAddrs) != len(tc.addrs) { t.Fatalf("nicCurrentState(%v, %v) returned %v, want %v", ifaces[0], tc.macAddr, state.presentAddrs, tc.addrs) } if len(state.newAddresses(tc.newAddrs)) != len(tc.newAddrs) { t.Errorf("nicCurrentState(%v, %v) returned %v, want %v", ifaces[0], tc.macAddr, state.newAddresses(tc.newAddrs), tc.newAddrs) } // Fake registry read addresses. if tc.registryAddrs != nil { state.registryRecordedAddrs = tc.registryAddrs } if len(state.unwantedAddresses(tc.newAddrs)) != len(tc.registryAddrs) { t.Errorf("nicCurrentState(%v) returned %v, want %v", tc.macAddr, state.unwantedAddresses(tc.newAddrs), tc.registryAddrs) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/netplan/000077500000000000000000000000001524141036400241055ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/netplan/netplan.go000066400000000000000000000207361524141036400261050ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package netplan provides the service implementation for netplan. package netplan import ( "context" "os/exec" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" ) const ( // serviceID is the ID of the netplan service implementation. serviceID = "netplan" // debian12EthernetNamePrefix is the prefix for the debian 12 ethernet // interfaces. debian12EthernetNamePrefix = "a" // netplanDropinIdentifier is the default identifier to use for the netplan // drop-in file, i.e. this identifier will result in a drop-in file name like // "20-google-guest-agent-ethernet.yaml". netplanDropinIdentifier = "google-guest-agent" // netplanEthernetSuffix is the ethernet drop-in's file suffix. netplanEthernetSuffix = "-ethernet" // netplanVlanSuffix is the vlan drop-in's file suffix. netplanVlanSuffix = "-vlan" // netplanConfigVersion defines the version we are using for netplan's drop-in // files. netplanConfigVersion = 2 // netplanDropinFileMode is the file mode to use for the netplan drop-in file. netplanDropinFileMode = 0600 // backendDropinPrefix is the prefix to use for the networkd drop-in file. backendDropinPrefix = "10-netplan" // defaultPriority is the default priority to use for the netplan drop-in // file. defaultPriority = 20 // defaultNetplanConfigDir is the default netplan configuration directory. defaultNetplanConfigDir = "/run/netplan" // noOpBackendID is the ID of the no-op backend. noOpBackendID = "no-op" // defaultConfigPath is the path to the default netplan configuration file. defaultConfigPath = "/etc/netplan/90-default.yaml" // defaultConfig is the default netplan configuration for Debian 12. defaultConfig = ` network: version: 2 ethernets: all-en: match: name: en* dhcp4: true dhcp4-overrides: use-domains: true dhcp6: true dhcp6-overrides: use-domains: true all-eth: match: name: eth* dhcp4: true dhcp4-overrides: use-domains: true dhcp6: true dhcp6-overrides: use-domains: true ` ) var ( // execLookPath is the function to use to look up the path of an executable. // It is defined as a variable so it can be overridden in tests. execLookPath = exec.LookPath ) // netplanDropin maps the netplan dropin configuration yaml entries/data // structure. type netplanDropin struct { Network netplanNetwork `yaml:"network"` } // netplanNetwork is the netplan's drop-in network section. type netplanNetwork struct { // Version is the netplan's drop-in format version. Version int `yaml:"version"` // Ethernets are the ethernet configuration entries map. Ethernets map[string]netplanEthernet `yaml:"ethernets,omitempty"` // Vlans are the vlan interface's configuration entries map. Vlans map[string]netplanVlan `yaml:"vlans,omitempty"` } // netplanVlan describes the netplan's vlan interface configuration. type netplanVlan struct { // ID is the the VLAN ID. ID int `yaml:"id,omitempty"` // Link is the vlan's parent interface. Link string `yaml:"link"` // DHCPv4 determines if DHCPv4 support must be enabled to such an interface. DHCPv4 *bool `yaml:"dhcp4,omitempty"` // DHCPv6 determines if DHCPv6 support must be enabled to such an interface. DHCPv6 *bool `yaml:"dhcp6,omitempty"` // OverrideMacAddress sets the netplan macaddress configuration. This is used // to override the MAC address of the interface. OverrideMacAddress string `yaml:"macaddress,omitempty"` // MTU sets the MTU for interface. The default is 1500. MTU int `yaml:"mtu,omitempty"` // DHCP4Overrides sets DHCP4 overrides for the vlan. DHCP4Overrides *netplanDHCPOverrides `yaml:"dhcp4-overrides,omitempty"` // DHCP6Overrides sets DHCP6 overrides for the vlan. DHCP6Overrides *netplanDHCPOverrides `yaml:"dhcp6-overrides,omitempty"` } // netplanEthernet describes the actual ethernet configuration. type netplanEthernet struct { // Match is the interface's matching rule. Match netplanMatch `yaml:"match"` // DHCPv4 determines if DHCPv4 support must be enabled to such an interface. DHCPv4 *bool `yaml:"dhcp4,omitempty"` // DHCP4Overrides sets the netplan dhcp4-overrides configuration. DHCP4Overrides *netplanDHCPOverrides `yaml:"dhcp4-overrides,omitempty"` // DHCPv6 determines if DHCPv6 support must be enabled to such an interface. DHCPv6 *bool `yaml:"dhcp6,omitempty"` // DHCP6Overrides sets the netplan dhcp6-overrides configuration. DHCP6Overrides *netplanDHCPOverrides `yaml:"dhcp6-overrides,omitempty"` } // netplanDHCPOverrides sets the netplan dhcp-overrides configuration. type netplanDHCPOverrides struct { // When true, the domain name received from the DHCP server will be used as DNS // search domain over this link. UseDomains *bool `yaml:"use-domains,omitempty"` } // netplanMatch contains the keys uses to match an interface. type netplanMatch struct { // Name is the key used to match an interface by its name. Name string `yaml:"name"` } // netplanBackend is the interface for a netplan backend. It describes the // minimum set of operations required to inject and rollback drop-in files. type netplanBackend interface { // ID returns the backend's ID. ID() string // configuration. IsManaging(context.Context, *service.Options) (bool, error) // WriteDropins writes the backend's drop-in files based on the provided NICs. WriteDropins([]*nic.Configuration, string) (bool, error) // RollbackDropins rolls back the drop-in files previously created by us. RollbackDropins([]*nic.Configuration, string, bool) error // Reload reloads the backend's configuration. Reload(context.Context, int) error // WriteNetplanVlanDropins writes the backend's vlan drop-in files based on // the provided NICs. WriteNetplanVlanDropins(string, []*nic.Configuration) (bool, error) // RollbackNetplanVlanDropins rolls back the backend's vlan drop-in files // previously created by us. RollbackNetplanVlanDropins(map[string]bool, string) (bool, error) } // serviceNetplan implements the netplan service. type serviceNetplan struct { // backend is the active netplan backend. backend netplanBackend // forceNoOpBackend forces the use of the no-op backend. This is used for // testing purposes where we'd inject tests backend implementations. forceNoOpBackend bool // priority is the priority to use for the netplan drop-in file. priority int // netplanConfigDir is the directory where the netplan drop-in files are // located. netplanConfigDir string // backendReload indicates if the backend's configuration should be // reloaded after a change in the netplan configuration. backendReload bool // ethernetDropinIdentifier is the identifier to use for the ethernet drop-in // file, i.e. by default it's "google-guest-agent" resulting in a drop-in file // name like "20-google-guest-agent-ethernet.yaml". ethernetDropinIdentifier string // ethernetSuffix is the suffix to use for the ethernet drop-in file. ethernetSuffix string // ethernetNamePrefix is the prefix to use for the ethernet interfaces. ethernetNamePrefix string // configPath is the path to the netplan configuration file. This is used to // restore the default netplan configuration. configPath string // osInfoReader is the function to use to read the OS information. osInfoReader func() osinfo.OSInfo } // defaultModule returns the default module for netplan. func defaultModule() *serviceNetplan { mod := &serviceNetplan{} mod.defaultConfig() return mod } // defaultConfig sets the default configuration for the netplan service. func (sn *serviceNetplan) defaultConfig() { sn.backend = nil sn.backendReload = true sn.priority = defaultPriority sn.ethernetDropinIdentifier = netplanDropinIdentifier sn.netplanConfigDir = defaultNetplanConfigDir sn.ethernetSuffix = netplanEthernetSuffix sn.configPath = defaultConfigPath sn.osInfoReader = osinfo.Read } google-guest-agent-20260819.00/cmd/core_plugin/network/netplan/netplan_linux.go000066400000000000000000000433321524141036400273210ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package netplan import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "reflect" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/networkd" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "gopkg.in/yaml.v3" ) var ( // backends is the list of netplan backend currently supported. backends = []netplanBackend{ networkd.DefaultModule(), } ) // NewService returns a new netplan service handler. func NewService() *service.Handle { mod := defaultModule() return &service.Handle{ ID: serviceID, IsManaging: mod.IsManaging, Setup: mod.Setup, Rollback: mod.Rollback, } } // isUbuntu1804 checks if agent is running on Ubuntu 18.04. This is a helper // method to support some exceptions we have for 18.04. func isUbuntu1804() bool { info := osinfo.Read() if info.OS == "ubuntu" && info.VersionID == "18.04" { return true } return false } // IsManaging returns true if the netplan service is managing the network // configuration. func (sn *serviceNetplan) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { galog.Debugf("Checking if netplan is managing the network interfaces.") sn.defaultConfig() // Ubuntu 18.04, while having `netplan` installed, ships a outdated and // unsupported version of `networkctl`. This older version lacks essential // commands like `networkctl reload`, causing compatibility issues. Fallback // to dhclient on Ubuntu 18.04, even when netplan is present, to ensure // proper network configuration. if isUbuntu1804() { return false, nil } // Check if the netplan CLI exists. if _, err := execLookPath("netplan"); err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil } return false, fmt.Errorf("error looking up dhclient path: %w", err) } // Check if any of the backends is managing the network configuration. for _, backend := range backends { res, err := backend.IsManaging(ctx, opts) if err != nil { galog.Debugf("Backend(%s) failed to check if it is managing the network configuration: %v", backend.ID(), err) continue } if res { sn.backend = backend sn.setOSFlags(osinfo.Read()) return true, nil } } // No backend available. return false, nil } // setOSFlags sets the OS specific flags for the netplan service. func (sn *serviceNetplan) setOSFlags(osInfo osinfo.OSInfo) { // Debian 12 has a pretty generic matching netplan configuration for gce, // until we have that changed we are adjusting the configuration so we can // override it. if osInfo.OS == "debian" && osInfo.Version.Major == 12 { sn.ethernetNamePrefix = debian12EthernetNamePrefix } if osInfo.OS == "ubuntu" && osInfo.Version.Major == 18 && osInfo.Version.Minor == 04 { sn.backendReload = false } } // addPrefix adds the ethernet name prefix to the given name. If after is true, // the prefix will be added after the name, otherwise it will be added before // the name. If no ethernet name prefix is configured, the name is returned as is. // // This is used to ensure that the netplan backend drop-in files have a higher // priority than the netplan drop-in files, specifically on Debian 12, where the // default netplan configuration uses `all-en` as the configuration name. // With the prefix 'a' (example: `a-ens4`), the guest-agent-written configuration // will take priority due to lexicographical sorting. func (sn *serviceNetplan) addPrefix(name string, after bool) string { if sn.ethernetNamePrefix == "" { return name } if after { return fmt.Sprintf("%s-%s", name, sn.ethernetNamePrefix) } return fmt.Sprintf("%s-%s", sn.ethernetNamePrefix, name) } // Setup sets up the network configuration. func (sn *serviceNetplan) Setup(ctx context.Context, opts *service.Options) error { galog.Info("Setting up netplan interfaces.") nicConfigs := opts.FilteredNICConfigs() // Write the netplan drop-in file. netplanChanged, err := sn.writeDropin(nicConfigs) if err != nil { return fmt.Errorf("error writing netplan dropin: %w", err) } // Write the netplan vlan drop-in file. netplanVlanChanged, vlanDeleteMe, err := sn.writeVlanDropin(ctx, nicConfigs) if err != nil { return fmt.Errorf("error writing netplan vlan dropin: %w", err) } // Write the backend's vlan drop-in file. backendVlanChanged, err := sn.backend.WriteNetplanVlanDropins(backendDropinPrefix, nicConfigs) if err != nil { return fmt.Errorf("error writing backend vlan dropin: %w", err) } // Rollback the backend's vlan drop-in file if we are not the active network // manager. backendVlanCleanedup, err := sn.backend.RollbackNetplanVlanDropins(vlanDeleteMe, backendDropinPrefix) if err != nil { return fmt.Errorf("error rolling back backend vlan dropin: %w", err) } // Make sure to delete the actual vlan interfaces after we are done writing, // removing, cleaning up drop-in files, applied netplan configuration and // reloaded the backend's configuration. defer func() { galog.V(2).Debugf("Vlan nics to be deleted: %v", vlanDeleteMe) if len(vlanDeleteMe) > 0 { sn.removeOrphanedVlans(ctx, vlanDeleteMe) } }() // Write the backend's drop-in files. dropinPrefix := sn.addPrefix(backendDropinPrefix, true) backendChanged, err := sn.backend.WriteDropins(nicConfigs, dropinPrefix) if err != nil { return err } // Apply the netplan configuration. if netplanChanged || netplanVlanChanged || backendVlanChanged || backendVlanCleanedup { if err := sn.generateConfigs(ctx); err != nil { return fmt.Errorf("error applying netplan changes: %w", err) } } // Reload the backend if networkd's configuration has changed. if (netplanChanged || netplanVlanChanged || backendChanged || backendVlanChanged || backendVlanCleanedup) && sn.backendReload { if err := sn.backend.Reload(ctx, len(nicConfigs)); err != nil { return fmt.Errorf("error reloading backend(%q) configs: %v", sn.backend.ID(), err) } } galog.Info("Finished setting up netplan interfaces.") return nil } // generateConfigs regenerates the netplan configuration. This does not reload // the backend's configuration. func (sn *serviceNetplan) generateConfigs(ctx context.Context) error { opt := run.Options{OutputType: run.OutputNone, Name: "netplan", Args: []string{"generate"}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error reloading netplan changes: %w", err) } return nil } // writeVlanDropin writes the netplan drop-in file for the vlan interfaces. All // interfaces are consolidated into a single drop-in file, if no vlan interfaces // are configured, the drop-in file is removed - accounting for the removal // aspect of hot unplugging a dynamic vlan. func (sn *serviceNetplan) writeVlanDropin(ctx context.Context, nics []*nic.Configuration) (bool, map[string]bool, error) { galog.Debugf("Writing vlan drop-in configuration.") dropin := netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Vlans: make(map[string]netplanVlan), }, } var vlanConfigured bool deleteMe, err := sn.presentVlanNICs(sn.vlanDropinFile()) if err != nil { return false, nil, fmt.Errorf("failed to check if netplan vlan drop-in config is present: %w", err) } galog.V(2).Debugf("Present vlan nics before setup: %v", deleteMe) for _, nic := range nics { if !nic.ShouldManage() { continue } for _, vlan := range nic.VlanInterfaces { galog.Debugf("Adding vlan %s(parent %s) to drop-in configuration.", vlan.InterfaceName(), vlan.Parent.Name()) trueVal := true falseVal := false nv := netplanVlan{ ID: vlan.Vlan, Link: sn.addPrefix(nic.Interface.Name(), false), DHCPv4: &trueVal, OverrideMacAddress: vlan.MacAddr, MTU: vlan.MTU, DHCP4Overrides: &netplanDHCPOverrides{UseDomains: &falseVal}, DHCP6Overrides: &netplanDHCPOverrides{UseDomains: &falseVal}, } delete(deleteMe, vlan.InterfaceName()) if len(vlan.IPv6Addresses) > 0 { nv.DHCPv6 = &trueVal } dropin.Network.Vlans[vlan.InterfaceName()] = nv vlanConfigured = true } } // If we don't have any vlan interfaces, remove the drop-in file. if !vlanConfigured { galog.Debugf("No vlan interfaces configured, removing drop-in file: %s.", sn.vlanDropinFile()) fPath := sn.vlanDropinFile() if !file.Exists(fPath, file.TypeFile) { return false, nil, nil } if err := os.Remove(fPath); err != nil { return false, nil, fmt.Errorf("error removing netplan vlan dropin: %w", err) } return true, deleteMe, nil } wrote, err := sn.write(dropin, sn.vlanDropinFile()) if err != nil { return false, nil, fmt.Errorf("failed to write netplan vlan drop-in config: %+v", err) } return wrote, deleteMe, nil } // removeOrphanedVlans removes the orphaned vlan interfaces. This is required to // ensure that the vlan interfaces are removed from the system. // // This operation is best effort, if we fail to delete the orphaned vlans, it // will be a no-op. func (sn *serviceNetplan) removeOrphanedVlans(ctx context.Context, deleteMe map[string]bool) { var deleteNics []string for k := range deleteMe { deleteNics = append(deleteNics, k) } // Simply removing configs on disk and reloading netplan/networkctl doesn't remove // existing vlan nics, it requires instance reboot or systemd-networkd restart. Instead, // make sure its removed by [networkctl delete ] command. args := []string{"delete"} args = append(args, deleteNics...) galog.Debugf("Deleting orphaned vlan nics: %v", deleteNics) opt := run.Options{OutputType: run.OutputNone, Name: "networkctl", Args: args} if _, err := run.WithContext(ctx, opt); err != nil { galog.Debugf("Failed to delete orphaned vlan nics: %v", err) } } // writeDropin writes the netplan drop-in file. func (sn *serviceNetplan) writeDropin(nics []*nic.Configuration) (bool, error) { if len(nics) == 0 { return false, nil } dropin := netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Ethernets: make(map[string]netplanEthernet), }, } // Iterate over the NICs and add them to the drop-in configuration. for _, nic := range nics { if !nic.ShouldManage() { continue } galog.Debugf("Adding %s(%d) to drop-in configuration.", nic.Interface.Name(), nic.Index) trueVal := true useDomainsVal := nic.Index == 0 ne := netplanEthernet{ Match: netplanMatch{Name: nic.Interface.Name()}, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{UseDomains: &useDomainsVal}, DHCP6Overrides: &netplanDHCPOverrides{UseDomains: &useDomainsVal}, } if nic.SupportsIPv6 { ne.DHCPv6 = &trueVal } key := sn.addPrefix(nic.Interface.Name(), false) dropin.Network.Ethernets[key] = ne } update, err := sn.write(dropin, sn.ethernetDropinFile()) if err != nil { return false, fmt.Errorf("error writing netplan dropin: %w", err) } return update, nil } // write writes the netplan dropin file. func (sn *serviceNetplan) write(nd netplanDropin, dropinFile string) (bool, error) { dir := filepath.Dir(dropinFile) if err := os.MkdirAll(dir, 0755); err != nil { return false, fmt.Errorf("error creating netplan drop-in directory: %w", err) } // Check the existing file. Avoid writing if they're the same. equals, err := nd.equals(dropinFile) if err != nil { // Don't fail if we can't check if the file is equal. Assume we need to reload. galog.Debugf("Error checking if netplan drop-in file is equal: %v", err) } if equals { galog.Debugf("Netplan drop-in file is equal to the new configuration, skipping write.") return false, nil } // Marshal the configuration and write the file. galog.Debugf("Writing netplan drop-in file: %s", dropinFile) data, err := yaml.Marshal(&nd) if err != nil { return false, fmt.Errorf("error marshalling netplan drop-in yaml file: %w", err) } if err := os.WriteFile(dropinFile, data, netplanDropinFileMode); err != nil { return false, err } galog.Debugf("Successfully wrote netplan drop-in file: %s", dropinFile) return true, nil } // ethernetDropinFile returns the netplan ethernet drop-in file considering a // given suffix. // // Priority is lexicographically sorted in ascending order by file name. So a // configuration starting with '1-' takes priority over a configuration file // starting with '10-'. func (sn *serviceNetplan) ethernetDropinFile() string { fPath := fmt.Sprintf("%d-%s%s.yaml", sn.priority, sn.ethernetDropinIdentifier, sn.ethernetSuffix) return filepath.Join(sn.netplanConfigDir, fPath) } // ethernetDropinFile returns the vlan ethernet drop-in file considering a // given suffix. // // Priority is lexicographically sorted in ascending order by file name. So a // configuration starting with '1-' takes priority over a configuration file // starting with '10-'. func (sn *serviceNetplan) vlanDropinFile() string { fPath := fmt.Sprintf("%d-%s%s.yaml", sn.priority, sn.ethernetDropinIdentifier, netplanVlanSuffix) return filepath.Join(sn.netplanConfigDir, fPath) } // Rollback rolls back the network configuration. func (sn *serviceNetplan) Rollback(ctx context.Context, opts *service.Options, active bool) error { galog.Infof("Rolling back changes for netplan with reload [%t]", !active) // Rollback the backend's drop-in files. for _, backend := range backends { if err := backend.RollbackDropins(opts.FilteredNICConfigs(), backendDropinPrefix, active); err != nil { return err } } // Remove the netplan drop-in file. Don't remove it if we are the active network // manager. if !active && file.Exists(sn.ethernetDropinFile(), file.TypeFile) { if err := os.Remove(sn.ethernetDropinFile()); err != nil { return fmt.Errorf("error removing netplan dropin: %w", err) } } // Remove the netplan vlan drop-in file. vlanDropin := sn.vlanDropinFile() if !active && file.Exists(vlanDropin, file.TypeFile) { if err := os.Remove(vlanDropin); err != nil { return fmt.Errorf("error removing netplan vlan dropin: %w", err) } } // Attempt to restore the default netplan configuration. if err := sn.restoreDefaultConfig(ctx); err != nil { return fmt.Errorf("error restoring default netplan configuration: %w", err) } if !active { galog.Debugf("Reloading netplan configuration.") if _, err := execLookPath("netplan"); err != nil { if errors.Is(err, exec.ErrNotFound) { galog.Debugf("Netplan CLI not found, skipping reload.") return nil } return fmt.Errorf("error looking up netplan path: %w", err) } if sn.backend == nil { // This could happen if backend is not found during setup or its Ubuntu // 18.04 where we have exception. galog.Debugf("No backend found, skipping netplan rollback.") return nil } if err := sn.generateConfigs(ctx); err != nil { return fmt.Errorf("error reloading netplan changes: %w", err) } if err := sn.backend.Reload(ctx, 0); err != nil { return fmt.Errorf("error reloading backend(%q) configs: %v", sn.backend.ID(), err) } } return nil } // restoreDefaultConfig restores the default netplan configuration. func (sn *serviceNetplan) restoreDefaultConfig(ctx context.Context) error { if !cfg.Retrieve().NetworkInterfaces.RestoreDebian12NetplanConfig { galog.Debugf("Skipping restore of default netplan configuration.") return nil } osDesc := sn.osInfoReader() if osDesc.OS != "debian" || osDesc.Version.Major != 12 { galog.Debugf("Skipping restore of default netplan configuration for non-Debian 12.") return nil } if !file.Exists(sn.configPath, file.TypeFile) { if err := os.WriteFile(sn.configPath, []byte(defaultConfig), 0600); err != nil { return fmt.Errorf("error writing default netplan configuration: %w", err) } galog.Debugf("Restored default netplan configuration.") } return nil } // equals checks if the netplan drop-in file is equal to the provided drop-in // configuration. func (nd netplanDropin) equals(cfgPath string) (bool, error) { if !file.Exists(cfgPath, file.TypeFile) { return false, nil } data, err := os.ReadFile(cfgPath) if err != nil { return false, fmt.Errorf("error reading netplan drop-in file: %w", err) } cfg := new(netplanDropin) if err = yaml.Unmarshal(data, cfg); err != nil { return false, fmt.Errorf("error unmarshalling netplan drop-in yaml file: %w", err) } return reflect.DeepEqual(&nd, cfg), nil } // presentVlanNICs returns a map of vlan NICs present in the netplan drop-in // file. func (sn *serviceNetplan) presentVlanNICs(cfgPath string) (map[string]bool, error) { if !file.Exists(cfgPath, file.TypeFile) { galog.V(2).Debugf("Netplan vlan drop-in file(%s) does not exist, skipping read.", cfgPath) return nil, nil } data, err := os.ReadFile(cfgPath) if err != nil { return nil, fmt.Errorf("error reading netplan drop-in file: %w", err) } cfg := new(netplanDropin) if err = yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("error unmarshalling netplan drop-in yaml file: %w", err) } res := make(map[string]bool) for vlanName := range cfg.Network.Vlans { res[vlanName] = true } return res, nil } google-guest-agent-20260819.00/cmd/core_plugin/network/netplan/netplan_linux_test.go000066400000000000000000000666601524141036400303710ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package netplan import ( "context" "errors" "os" "os/exec" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/networkd" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/google/go-cmp/cmp" "gopkg.in/yaml.v3" ) type runMock struct { seenOpts []run.Options callback func(context.Context, run.Options) (*run.Result, error) } func (rm *runMock) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { rm.seenOpts = append(rm.seenOpts, opts) return rm.callback(ctx, opts) } type testBackend struct { IDCb func() string IsManagingCb func(context.Context, *service.Options) (bool, error) WriteDropinsCb func([]*nic.Configuration, string) (bool, error) RollbackDropinsCb func([]*nic.Configuration, string, bool) error ReloadCb func(context.Context, int) error } func (tb *testBackend) ID() string { return tb.IDCb() } func (tb *testBackend) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { return tb.IsManagingCb(ctx, opts) } func (tb *testBackend) WriteDropins(nics []*nic.Configuration, filePrefix string) (bool, error) { return tb.WriteDropinsCb(nics, filePrefix) } func (tb *testBackend) RollbackDropins(nics []*nic.Configuration, filePrefix string, active bool) error { return tb.RollbackDropinsCb(nics, filePrefix, active) } func (tb *testBackend) Reload(ctx context.Context, numInterfaces int) error { return tb.ReloadCb(ctx, numInterfaces) } func (tb *testBackend) WriteNetplanVlanDropins(prefix string, nics []*nic.Configuration) (bool, error) { return false, nil } func (tb *testBackend) RollbackNetplanVlanDropins(vlanDropins map[string]bool, prefix string) (bool, error) { return false, nil } func TestNewService(t *testing.T) { svc := NewService() if svc == nil { t.Fatalf("NewService() = nil, want non-nil") } if svc.ID != serviceID { t.Errorf("NewService().ID = %q, want %q", svc.ID, serviceID) } if svc.IsManaging == nil { t.Errorf("NewService().IsManaging = nil, want non-nil") } if svc.Setup == nil { t.Errorf("NewService().Setup = nil, want non-nil") } if svc.Rollback == nil { t.Errorf("NewService().Rollback = nil, want non-nil") } } type testNetplanBackend struct { IsManagingCb func(context.Context, *service.Options) (bool, error) } func (tb *testNetplanBackend) ID() string { return "test-netplan-backend" } func (tb *testNetplanBackend) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { return tb.IsManagingCb(ctx, opts) } func (tb *testNetplanBackend) WriteDropins([]*nic.Configuration, string) (bool, error) { return false, nil } func (tb *testNetplanBackend) RollbackDropins([]*nic.Configuration, string, bool) error { return nil } func (tb *testNetplanBackend) Reload(context.Context, int) error { return nil } func (tb *testNetplanBackend) WriteNetplanVlanDropins(string, []*nic.Configuration) (bool, error) { return false, nil } func (tb *testNetplanBackend) RollbackNetplanVlanDropins(map[string]bool, string) (bool, error) { return false, nil } func TestIsManaging(t *testing.T) { tests := []struct { name string execLookPath func(string) (string, error) backends []netplanBackend wantErr bool want bool }{ { name: "netplan-installed", execLookPath: func(string) (string, error) { return "netplan", nil }, want: false, wantErr: false, }, { name: "fail-backend-ismanaging", execLookPath: func(string) (string, error) { return "netplan", nil }, backends: []netplanBackend{ &testNetplanBackend{ IsManagingCb: func(context.Context, *service.Options) (bool, error) { return false, errors.New("fail-backend-ismanaging") }, }, }, want: false, wantErr: false, }, { name: "success-backend-ismanaging", execLookPath: func(string) (string, error) { return "netplan", nil }, backends: []netplanBackend{ &testNetplanBackend{ IsManagingCb: func(context.Context, *service.Options) (bool, error) { return true, nil }, }, }, want: true, wantErr: false, }, { name: "netplan-not-found", execLookPath: func(string) (string, error) { return "", exec.ErrNotFound }, want: false, wantErr: false, }, { name: "unknown-error", execLookPath: func(string) (string, error) { return "", errors.New("unknown error") }, want: false, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := NewService() execLookPath = tc.execLookPath var oldBackends []netplanBackend if tc.backends != nil { oldBackends = backends backends = tc.backends } t.Cleanup(func() { execLookPath = exec.LookPath if tc.backends != nil { backends = oldBackends } }) // Mocking a service options with nic configuration and a ethernet // interface. iface := ðernet.Interface{ NameOp: func() string { return "iface" }, } opts := service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: iface, }, }) got, err := svc.IsManaging(context.Background(), opts) if (err == nil) == tc.wantErr { t.Errorf("IsManaging() = %v, want error? %v", err, tc.wantErr) } if got != tc.want { t.Errorf("IsManaging() = %t, want %t", got, tc.want) } }) } } func TestSetup(t *testing.T) { networkdModule := networkd.DefaultModule() trueVal := true falseVal := false tests := []struct { name string opts *service.Options backend *testBackend runCallback func(context.Context, run.Options) (*run.Result, error) want *netplanDropin wantErr bool noReload bool writeFile bool }{ { name: "empty-options", opts: &service.Options{}, backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return false, nil }, }, wantErr: false, noReload: true, }, { name: "fail-write-backend-dropins", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, errors.New("write dropins failed") }, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, wantErr: true, noReload: true, }, { name: "fail-networkctl", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, IDCb: func() string { return "test-fail-networkctl" }, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "networkctl" { return &run.Result{}, errors.New("networkctl failed") } return &run.Result{}, nil }, wantErr: true, }, { name: "fail-netplan-apply", opts: service.NewOptions(nil, []*nic.Configuration{ { SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, { SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface2" }, }, Index: 1, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "netplan" && opts.Args[0] == "generate" { return &run.Result{}, errors.New("netplan generate failed") } return &run.Result{}, nil }, wantErr: true, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, want: &netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Ethernets: map[string]netplanEthernet{ "iface": netplanEthernet{ Match: netplanMatch{ Name: "iface", }, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, DHCPv6: &trueVal, DHCP6Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, }, }, }, }, wantErr: false, }, { name: "success-no-use-domains-on-secondary-nics", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface-1" }, }, }, &nic.Configuration{ SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 1, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, want: &netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Ethernets: map[string]netplanEthernet{ "iface-1": netplanEthernet{ Match: netplanMatch{ Name: "iface-1", }, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, DHCPv6: &trueVal, DHCP6Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, }, "iface-2": netplanEthernet{ Match: netplanMatch{ Name: "iface-2", }, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{ UseDomains: &falseVal, }, DHCPv6: &trueVal, DHCP6Overrides: &netplanDHCPOverrides{ UseDomains: &falseVal, }, }, }, }, }, wantErr: false, }, { name: "success-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ SupportsIPv6: true, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 12, IPv6Addresses: []*address.IPAddr{ &address.IPAddr{}, }, }, }, Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, want: &netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Ethernets: map[string]netplanEthernet{ "iface": netplanEthernet{ Match: netplanMatch{ Name: "iface", }, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, DHCPv6: &trueVal, DHCP6Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, }, }, }, }, wantErr: false, }, { name: "success-no-backend-reload", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return false, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, wantErr: false, writeFile: true, noReload: true, }, { name: "success-backend-reload", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ SupportsIPv6: true, Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ WriteDropinsCb: func([]*nic.Configuration, string) (bool, error) { return true, nil }, ReloadCb: networkdModule.Reload, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, wantErr: false, writeFile: true, noReload: false, }, } ctx := context.Background() if err := cfg.Load([]byte("[NetworkInterfaces]\nmanage_primary_nic = true\n")); err != nil { t.Fatalf("Failed to load config: %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldRunner := run.Client mockRunner := &runMock{ callback: tc.runCallback, } run.Client = mockRunner execLookPath = func(string) (string, error) { return "netplan", nil } t.Cleanup(func() { execLookPath = exec.LookPath run.Client = oldRunner }) svc := &serviceNetplan{ ethernetDropinIdentifier: netplanDropinIdentifier, ethernetSuffix: netplanEthernetSuffix, backend: tc.backend, backendReload: true, forceNoOpBackend: true, netplanConfigDir: filepath.Join(t.TempDir(), "netplan"), } // Write a pre-existing file. if tc.writeFile { dropinFPath := svc.ethernetDropinFile() if err := os.MkdirAll(filepath.Dir(dropinFPath), 0755); err != nil { t.Fatalf("Failed to create test directory: %v", err) } dropinFile := netplanDropin{ Network: netplanNetwork{ Version: netplanConfigVersion, Ethernets: map[string]netplanEthernet{ "iface": netplanEthernet{ Match: netplanMatch{ Name: "iface", }, DHCPv4: &trueVal, DHCP4Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, DHCP6Overrides: &netplanDHCPOverrides{ UseDomains: &trueVal, }, }, }, }, } if _, err := svc.write(dropinFile, dropinFPath); err != nil { t.Fatalf("Failed to write test data: %v", err) } } err := svc.Setup(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want error? %v", err, tc.wantErr) } // Check if the generated drop-in file matches the expected one. if !tc.wantErr && tc.want != nil { if file.Exists(svc.ethernetDropinFile(), file.TypeFile) { content, err := os.ReadFile(svc.ethernetDropinFile()) if err != nil { t.Errorf("Failed to read netplan dropin file: %v", err) } var got netplanDropin if err := yaml.Unmarshal(content, &got); err != nil { t.Errorf("Failed to unmarshal netplan dropin file: %v", err) } if diff := cmp.Diff(tc.want, &got); diff != "" { t.Errorf("Setup() returned diff (-want +got):\n%s", diff) } } else { t.Errorf("Setup() did not generate a drop-in file") } } // No commands should have been run if no files are written/changed. if tc.noReload != (len(mockRunner.seenOpts) == 0) { t.Errorf("Setup() called commands %d times, want %t\nCommands: %+v", len(mockRunner.seenOpts), tc.noReload, mockRunner.seenOpts) } // If vlan interfaces are present, the vlan drop-in file should not exist. if len(tc.opts.NICConfigs()) > 0 && len(tc.opts.NICConfigs()[0].VlanInterfaces) > 0 { tc.opts.NICConfigs()[0].VlanInterfaces = nil err := svc.Setup(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want error? %v", err, tc.wantErr) } fPath := svc.vlanDropinFile() if file.Exists(fPath, file.TypeFile) { t.Errorf("Vlan dropin file %q exists, want it to not exist", fPath) } } }) } } func TestRollback(t *testing.T) { tests := []struct { name string opts *service.Options backend *testBackend runner *runMock data string vlanData string wantErr bool }{ { name: "fail-rollback-backend-dropins", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ RollbackDropinsCb: func([]*nic.Configuration, string, bool) error { return errors.New("rollback dropins failed") }, ReloadCb: func(context.Context, int) error { return nil }, }, wantErr: true, }, { name: "fail-rollback-dropins", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ RollbackDropinsCb: func([]*nic.Configuration, string, bool) error { return nil }, ReloadCb: func(context.Context, int) error { return nil }, }, wantErr: false, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), backend: &testBackend{ RollbackDropinsCb: func([]*nic.Configuration, string, bool) error { return nil }, ReloadCb: func(context.Context, int) error { return nil }, }, data: "test-data", vlanData: "test-vlan-data", wantErr: false, }, } ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } execLookPath = func(string) (string, error) { return "netplan", nil } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldBackends := backends backends = []netplanBackend{tc.backend} oldRunner := run.Client run.Client = &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, } t.Cleanup(func() { backends = oldBackends run.Client = oldRunner }) svc := &serviceNetplan{ backend: tc.backend, ethernetDropinIdentifier: netplanDropinIdentifier, ethernetSuffix: netplanEthernetSuffix, forceNoOpBackend: true, backendReload: true, netplanConfigDir: filepath.Join(t.TempDir(), "netplan"), osInfoReader: func() osinfo.OSInfo { return osinfo.OSInfo{} }, } if tc.data != "" { filePath := svc.ethernetDropinFile() dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("Failed to create test directory: %v", err) } if err := os.WriteFile(filePath, []byte(tc.data), 0644); err != nil { t.Fatalf("Failed to write test data: %v", err) } } if tc.vlanData != "" { filePath := svc.vlanDropinFile() dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0755); err != nil { t.Fatalf("Failed to create test directory: %v", err) } if err := os.WriteFile(filePath, []byte(tc.vlanData), 0644); err != nil { t.Fatalf("Failed to write test data: %v", err) } } err := svc.Rollback(ctx, tc.opts, false) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want error? %v", err, tc.wantErr) } }) } } func TestIsManagingConfigReset(t *testing.T) { svc := &serviceNetplan{ backendReload: false, } if _, err := svc.IsManaging(context.Background(), &service.Options{}); err != nil { t.Errorf("IsManaging() = %v, want nil", err) } if !svc.backendReload { t.Errorf("backendReload = %v, want true", svc.backendReload) } } func TestSetOSFlags(t *testing.T) { tests := []struct { name string os osinfo.OSInfo wantEthernetSuffix string wantNetplanConfigDir string wantPriority int wantBackendReload bool wantEthernetNamePrefix string wantConfigPath string wantOSInfoReader func() osinfo.OSInfo }{ { name: "ubuntu-16.04", os: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 16, Minor: 04}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: true, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, { name: "ubuntu-18.04", os: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 18, Minor: 04}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: false, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, { name: "ubuntu-20.04", os: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 20, Minor: 04}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: true, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, { name: "ubuntu-22.10", os: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 22, Minor: 10}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: true, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, { name: "ubuntu-22.04", os: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 22, Minor: 04}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: true, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, { name: "debian-12", os: osinfo.OSInfo{ OS: "debian", Version: osinfo.Ver{Major: 12}, }, wantPriority: defaultPriority, wantNetplanConfigDir: defaultNetplanConfigDir, wantEthernetSuffix: netplanEthernetSuffix, wantBackendReload: true, wantEthernetNamePrefix: debian12EthernetNamePrefix, wantConfigPath: defaultConfigPath, wantOSInfoReader: osinfo.Read, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &serviceNetplan{} svc.defaultConfig() svc.setOSFlags(tc.os) if svc.backendReload != tc.wantBackendReload { t.Errorf("backendReload = %v, want %v", svc.backendReload, tc.wantBackendReload) } if svc.ethernetSuffix != tc.wantEthernetSuffix { t.Errorf("ethernetSuffix = %v, want %v", svc.ethernetSuffix, tc.wantEthernetSuffix) } if svc.netplanConfigDir != tc.wantNetplanConfigDir { t.Errorf("netplanConfigDir = %v, want %v", svc.netplanConfigDir, tc.wantNetplanConfigDir) } if svc.priority != tc.wantPriority { t.Errorf("priority = %v, want %v", svc.priority, tc.wantPriority) } if svc.ethernetNamePrefix != tc.wantEthernetNamePrefix { t.Errorf("ethernetNamePrefix = %v, want %v", svc.ethernetNamePrefix, tc.wantEthernetNamePrefix) } if svc.configPath != tc.wantConfigPath { t.Errorf("configPath = %v, want %v", svc.configPath, tc.wantConfigPath) } if svc.osInfoReader() != tc.wantOSInfoReader() { t.Errorf("osInfoReader = %v, want %v", svc.osInfoReader(), tc.wantOSInfoReader()) } }) } } func TestRestoreDefaultConfig(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } var v any // This is to ensure that the default config is a valid yaml. if err := yaml.Unmarshal([]byte(defaultConfig), &v); err != nil { t.Fatalf("Failed to unmarshal default config: %v", err) } tests := []struct { name string restoreCfg bool osInfo osinfo.OSInfo createFile bool existingContent string wantFileContent string wantFileCreated bool }{ { name: "restore-disabled", restoreCfg: false, osInfo: osinfo.OSInfo{ OS: "debian", Version: osinfo.Ver{Major: 12}, }, }, { name: "not-debian12", restoreCfg: true, osInfo: osinfo.OSInfo{ OS: "ubuntu", Version: osinfo.Ver{Major: 22}, }, }, { name: "file-exists", restoreCfg: true, osInfo: osinfo.OSInfo{ OS: "debian", Version: osinfo.Ver{Major: 12}, }, createFile: true, existingContent: "existing-content", wantFileContent: "existing-content", }, { name: "file-does-not-exist-expect-restore", restoreCfg: true, osInfo: osinfo.OSInfo{ OS: "debian", Version: osinfo.Ver{Major: 12}, }, createFile: false, wantFileCreated: true, wantFileContent: defaultConfig, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() cfgPath := filepath.Join(tmpDir, "90-default.yaml") if tc.createFile { if err := os.WriteFile(cfgPath, []byte(tc.existingContent), 0644); err != nil { t.Fatalf("Failed to write test file: %v", err) } } sn := &serviceNetplan{ configPath: cfgPath, osInfoReader: func() osinfo.OSInfo { return tc.osInfo }, } cfg.Retrieve().NetworkInterfaces.RestoreDebian12NetplanConfig = tc.restoreCfg if err := sn.restoreDefaultConfig(ctx); err != nil { t.Errorf("restoreDefaultConfig() returned unexpected error: %v", err) } fExists := file.Exists(cfgPath, file.TypeFile) if !tc.createFile && !tc.wantFileCreated && fExists { t.Errorf("restoreDefaultConfig() created file %s, expected it to be missing", cfgPath) } if tc.wantFileCreated && !fExists { t.Fatalf("restoreDefaultConfig() did not create file %s", cfgPath) } if fExists { gotContent, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("Failed to read config file: %v", err) } if string(gotContent) != tc.wantFileContent { t.Errorf("restoreDefaultConfig() wrote unexpected content to %s, got: %s, want: %s", cfgPath, string(gotContent), tc.wantFileContent) } } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/netplan/netplan_test.go000066400000000000000000000054221524141036400271370ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package netplan import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" ) func TestDefaultModule(t *testing.T) { mod := defaultModule() if mod.priority != defaultPriority { t.Errorf("defaultModule().priority = %v, want %v", mod.priority, defaultPriority) } if mod.netplanConfigDir != defaultNetplanConfigDir { t.Errorf("defaultModule().netplanConfigDir = %v, want %v", mod.netplanConfigDir, defaultNetplanConfigDir) } } type noopBackend struct{} func (tb *noopBackend) ID() string { return "test" } func (tb *noopBackend) IsManaging(context.Context, *service.Options) (bool, error) { return true, nil } func (tb *noopBackend) WriteDropins([]*nic.Configuration, string) (bool, error) { return true, nil } func (tb *noopBackend) RollbackDropins([]*nic.Configuration, string, bool) error { return nil } func (tb *noopBackend) Reload(context.Context, int) error { return nil } func (tb *noopBackend) WriteNetplanVlanDropins(string, []*nic.Configuration) (bool, error) { return false, nil } func (tb *noopBackend) RollbackNetplanVlanDropins(map[string]bool, string) (bool, error) { return false, nil } func TestDefaultConfig(t *testing.T) { mod := &serviceNetplan{ backend: &noopBackend{}, } mod.defaultConfig() if mod.backend != nil { t.Errorf("defaultConfig() set backend to %v, want nil", mod.backend) } if mod.backendReload != true { t.Errorf("defaultConfig() set backendReload to %v, want true", mod.backendReload) } if mod.priority != defaultPriority { t.Errorf("defaultConfig() set priority to %v, want %v", mod.priority, defaultPriority) } if mod.netplanConfigDir != defaultNetplanConfigDir { t.Errorf("defaultConfig() set netplanConfigDir to %v, want %v", mod.netplanConfigDir, defaultNetplanConfigDir) } if mod.ethernetDropinIdentifier != netplanDropinIdentifier { t.Errorf("defaultConfig() set ethernetDropinIdentifier to %v, want %v", mod.ethernetDropinIdentifier, netplanDropinIdentifier) } if mod.ethernetSuffix != netplanEthernetSuffix { t.Errorf("defaultConfig() set ethernetSuffix to %v, want %v", mod.ethernetSuffix, netplanEthernetSuffix) } } google-guest-agent-20260819.00/cmd/core_plugin/network/network.go000066400000000000000000000233141524141036400244670ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package network is the network management subsystem. package network import ( "context" "fmt" "reflect" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/wsfc" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" ) const ( // networkModuleID is the ID of the network late initialization module. networkModuleID = "network" ) // module is the network late initialization module. type module struct { // prevMetadata is the previous metadata descriptor. prevMetadata *metadata.Descriptor // wsfcEnabled is true if WSFC is enabled. wsfcEnabled bool // failedConfiguration indicates if the last setup has failed. failedConfiguration bool // skipMDS skips the metadata fetch if set to true. This is used for testing // purposes only. skipMDS bool } // NewModule returns the network early initialization module. func NewModule(_ context.Context) *manager.Module { module := &module{} return &manager.Module{ ID: networkModuleID, Enabled: &cfg.Retrieve().Daemons.NetworkDaemon, BlockSetup: module.setup, Description: "Manages the initialization and configuration of the network subsystem", } } // setup is the setup function for the late network module. func (mod *module) setup(ctx context.Context, data any) error { // If the network interface setup is disabled, we skip the rest of the // initialization - first setup is not done and no metadata longpoll event // handler is registered. config := cfg.Retrieve() if !config.Daemons.NetworkDaemon { galog.Infof("Network daemon is disabled, skipping network module setup.") return nil } // If the address manager is disabled, we skip the network module setup and // the metadata longpoll event handler is not registered. This is because // changing the configuration flag will require a restart of the guest agent // anyway, so there's no need to dynamically react to changes to the flag. if config.AddressManager != nil && config.AddressManager.Disable { galog.Infof("Address manager is disabled, skipping network module setup.") return nil } // In normal use cases, the data is not a metadata descriptor. This is just // used for testing so we can avoid doing an actual metadata fetch. var err error desc, ok := data.(*metadata.Descriptor) if !ok { // This error case should only ever be hit in tests. if mod.skipMDS { return fmt.Errorf("failed to get a metadata descriptor") } desc, err = metadata.New().Get(ctx) if err != nil { return fmt.Errorf("failed to get metadata descriptor: %v", err) } } galog.Debugf("Initializing %s module", networkModuleID) // Avoid setting up network interfaces if address manager is disabled. if desc.AddressManagerDisabled() { galog.Infof("Instance metadata attribute disable-address-manager is set to true, skipping network module setup.") } else { // Perform early network platform-specific initialization. if err := platformEarlyInit(ctx); err != nil { return fmt.Errorf("failed to perform early network initialization: %v", err) } // Do the initial setup of the network interfaces. It will be handled by the // metadata longpoll event handler/subscriber after the first setup. if _, err := mod.networkSetup(ctx, config, desc); err != nil { galog.Errorf("Failed to handle first network setup: %v", err) } } eManager := events.FetchManager() sub := events.EventSubscriber{Name: networkModuleID, Callback: mod.metadataSubscriber, MetricName: acmpb.GuestAgentModuleMetric_NETWORK_INITIALIZATION} eManager.Subscribe(metadata.LongpollEvent, sub) galog.Debugf("Finished initializing %s module", networkModuleID) return nil } // metadataSubscriber is the callback function to be called by the event manager // when a metadata longpoll event is received. func (mod *module) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type we log it and // don't renew the handler. if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } // If the event manager is passing/reporting an error we log it and keep // renewing the handler. if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, will retry setup", evData.Error) } // If the address manager is disabled, we skip the network module setup. if desc.AddressManagerDisabled() { var prevDisable bool if mod.prevMetadata != nil { prevDisable = mod.prevMetadata.AddressManagerDisabled() } // Only log the first time the address manager is disabled. if !prevDisable { galog.Infof("Instance metadata attribute disable-address-manager is set to true, skipping network module setup.") } return true, true, nil } noop, err := mod.networkSetup(ctx, cfg.Retrieve(), desc) return true, noop, err } // networkSetup sets up all the network interfaces on the system. func (mod *module) networkSetup(ctx context.Context, config *cfg.Sections, mds *metadata.Descriptor) (bool, error) { failedSetup := false defer func() { mod.prevMetadata = mds mod.failedConfiguration = failedSetup }() // If WSFC is enabled, map the configured IP addresses to WSFC configurations // and use the mapping to ignore the matching addresses on the IPForwarding, // IPAliases and other network configurations. var ignoreAddressMap address.IPAddressMap if wsfc.Enabled(mds, config) { ignoreAddressMap = wsfc.AddressMap(mds, config) mod.wsfcEnabled = true } nicConfigs, err := nic.NewConfigs(mds, config, ignoreAddressMap) if err != nil { return false, fmt.Errorf("failed to create nic configs: %v", err) } // If the metadata has not changed then we return early to avoid unnecessary // work. metadataChanged := mod.networkMetadataChanged(mds, config) routeChanged := mod.routeChanged(ctx, nicConfigs) if !metadataChanged && !routeChanged && !mod.failedConfiguration { return true, nil } galog.V(1).Debugf("Network metadata has changed or failed configuration, setting up network interfaces.") // Forward the network configuration to the platform's network manager. if err := managerSetup(ctx, nicConfigs, networkChanged{networkInterfaces: metadataChanged, routes: routeChanged}); err != nil { failedSetup = true return false, fmt.Errorf("failed to setup network interfaces: %v", err) } galog.V(1).Debugf("Network interfaces setup completed successfully.") return false, nil } // networkChanged indicates if the network interfaces or routes have changed. type networkChanged struct { // networkInterfaces indicates if the network interfaces have changed. networkInterfaces bool // routes indicates if the routes have changed. routes bool } // networkMetadataChanged returns true if the metadata has changed or if it's being // called on behalf of the first handler's execution. func (mod *module) networkMetadataChanged(mds *metadata.Descriptor, config *cfg.Sections) bool { // If the module has not been initialized yet then we return true to force // the first execution of the setup. if mod.prevMetadata == nil { return true } // If the WSFC enabled state has changed then we return true to force the // reconfiguration of the network interfaces. if mod.wsfcEnabled != wsfc.Enabled(mds, config) { return true } // Has the network interfaces metadata changed? if !reflect.DeepEqual(mod.prevMetadata.Instance().NetworkInterfaces(), mds.Instance().NetworkInterfaces()) { return true } // Has the vlan interfaces metadata changed? if !reflect.DeepEqual(mod.prevMetadata.Instance().VlanInterfaces(), mds.Instance().VlanInterfaces()) { return true } return false } // routeChanged returns true if the route metadata has changed, or if the routes // present on the system have changed from what is expected based on the network // interfaces configuration. func (mod *module) routeChanged(ctx context.Context, nicConfigs []*nic.Configuration) bool { for _, nic := range nicConfigs { if nic.Invalid || nic.Interface == nil { continue } wantedRoutes := nic.ExtraAddresses.MergedMap() if missing, err := route.MissingRoutes(ctx, nic.Interface.Name(), wantedRoutes); err != nil { galog.V(2).Debugf("Failed to get missing routes for interface %q: %v", nic.Interface.Name(), err) continue } else if len(missing) > 0 { return true } if extra, err := route.ExtraRoutes(ctx, nic.Interface.Name(), wantedRoutes); err != nil { galog.V(2).Debugf("Failed to get extra routes for interface %q: %v", nic.Interface.Name(), err) continue } else if len(extra) > 0 { return true } } return false } google-guest-agent-20260819.00/cmd/core_plugin/network/network_early_linux.go000066400000000000000000000016361524141036400271050ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package network import ( "context" "github.com/GoogleCloudPlatform/galog" ) // platformEarlyInit is a hook for platform-specific early initialization. func platformEarlyInit(ctx context.Context) error { galog.V(2).Debugf("Early network initialization not implemented for linux, skipping.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/network/network_early_windows.go000066400000000000000000000066611524141036400274430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "context" "fmt" "slices" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" ) // platformEarlyInit is a hook for platform-specific early initialization. func platformEarlyInit(ctx context.Context) error { galog.Debugf("Initializing %s module", networkModuleID) table, err := route.Table() if err != nil { return fmt.Errorf("failed to get route table: %w", err) } // On Windows, we want to allow users to see the route table in the logs in // case there's some configuration conflict etc. galog.Infof("Route table: %+v", table) if len(table) == 0 { return fmt.Errorf("no routes found in the route table") } defRoute, err := defaultRouteFromTable(table) if err != nil { return fmt.Errorf("failed to get default route: %w", err) } dest, err := address.ParseIP(route.MetadataRouteDestination) if err != nil { return fmt.Errorf("failed to parse metadata route destination: %w", err) } gateway, err := address.ParseIP(route.MetadataRouteGateway) if err != nil { return fmt.Errorf("failed to parse metadata route gateway: %w", err) } mdsRoute := route.Handle{ Destination: dest, Gateway: gateway, InterfaceIndex: defRoute.InterfaceIndex, Metric: defRoute.Metric, // Persistent for windows translates as "immortal route" and is persistent // across reboots. Persistent: true, } contains := slices.ContainsFunc(table, func(r route.Handle) bool { return r.Destination.String() == mdsRoute.Destination.String() && r.Gateway.String() == mdsRoute.Gateway.String() }) if !contains { if err := route.Add(ctx, mdsRoute); err != nil { return fmt.Errorf("failed to add route for metadata server: %w", err) } } galog.Debugf("Finished initializing %s module", networkModuleID) return nil } // defaultRouteFromTable returns the default route from the given route table. func defaultRouteFromTable(table []route.Handle) (*route.Handle, error) { // primaryRoute is one route with interface index 0. If we can't find a // default route, we will use this route as the default route (we are only // interested in the route metric and it should be consistent with the // default route). var primaryRoute *route.Handle defaultRouteDestination, err := address.ParseIP("0.0.0.0") if err != nil { return nil, fmt.Errorf("failed to parse default route destination: %w", err) } for _, route := range table { if route.InterfaceIndex == 0 { primaryRoute = &route } if route.Destination.String() == defaultRouteDestination.String() { return &route, nil } } if primaryRoute != nil { return primaryRoute, nil } return nil, fmt.Errorf("no default route to %s found in route table %+v", defaultRouteDestination.String(), table) } google-guest-agent-20260819.00/cmd/core_plugin/network/network_early_windows_test.go000066400000000000000000000051451524141036400304760ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" ) func TestEarlyWindows(t *testing.T) { if err := platformEarlyInit(context.Background()); err != nil { t.Errorf("platformEarlyInit() = %v, want nil", err) } } func TestDefaultRouteTableSuccess(t *testing.T) { ipAddr, err := address.ParseIP("0.0.0.0") if err != nil { t.Errorf("address.ParseIP(%v) = %v, want nil", "0.0.0.0", err) } data := []route.Handle{ {Destination: ipAddr, InterfaceIndex: 1}, } route, err := defaultRouteFromTable(data) if err != nil { t.Errorf("defaultRouteFromTable(%v) = %v, want nil", data, err) } if route.Destination.String() != "0.0.0.0" { t.Errorf("defaultRouteFromTable(%v) = %v, want 0.0.0.0", data, route.Destination) } if route.InterfaceIndex != 1 { t.Errorf("defaultRouteFromTable(%v) = %v, want 1", data, route.InterfaceIndex) } } func TestDefaultRouteTableBasedOnIndexSuccess(t *testing.T) { ipAddr, err := address.ParseIP("10.0.0.1") if err != nil { t.Errorf("address.ParseIP(%v) = %v, want nil", "10.0.0.1", err) } data := []route.Handle{ {Destination: ipAddr, InterfaceIndex: 0}, } route, err := defaultRouteFromTable(data) if err != nil { t.Errorf("defaultRouteFromTable(%v) = %v, want nil", data, err) } if route.Destination.String() != "10.0.0.1" { t.Errorf("defaultRouteFromTable(%v) = %v, want 0.0.0.0", data, route.Destination) } if route.InterfaceIndex != 0 { t.Errorf("defaultRouteFromTable(%v) = %v, want 0", data, route.InterfaceIndex) } } func TestDefaultRouteTableFailure(t *testing.T) { ipAddr, err := address.ParseIP("10.0.0.1") if err != nil { t.Errorf("address.ParseIP(%v) = %v, want nil", "10.0.0.1", err) } data := []route.Handle{ {Destination: ipAddr, InterfaceIndex: 1}, } route, err := defaultRouteFromTable(data) if err == nil { t.Errorf("defaultRouteFromTable(%v) = %v, want error", data, route) } } google-guest-agent-20260819.00/cmd/core_plugin/network/network_linux_test.go000066400000000000000000000100241524141036400267370ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package network import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) type testRunner struct { hasExtraRoutes bool } func (tr testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { var out string if tr.hasExtraRoutes { out = "local 10.128.0.23 dev eth0 proto 66 scope host\nlocal 10.0.0.1/23 dev eth0 proto 66 scope host\n" } return &run.Result{Output: out}, nil } func TestRouteChanged(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } desc, err := metadata.UnmarshalDescriptor(`{ "instance": { "networkInterfaces": [ { "mac": "00:00:00:00:00:01", "forwardedIps": [ "10.128.0.23/32", "10.0.0.1/23" ] } ] } }`) if err != nil { t.Fatalf("UnmarshalDescriptor() returned an unexpected error: %v", err) } tests := []struct { name string nicConfigs []*nic.Configuration hasExtraRoutes bool want bool }{ { name: "no-change-empty-extra-addresses", nicConfigs: []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: &address.ExtraAddresses{}, }, }, want: false, }, { name: "no-change-extra-addresses", nicConfigs: []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: address.NewExtraAddresses(desc.Instance().NetworkInterfaces()[0], cfg.Retrieve(), nil), }, }, hasExtraRoutes: true, want: false, }, { name: "no-interface", nicConfigs: []*nic.Configuration{ { ExtraAddresses: &address.ExtraAddresses{}, }, }, want: false, }, { name: "invalid-interface", nicConfigs: []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: &address.ExtraAddresses{}, Invalid: true, }, }, want: false, }, { name: "change-missing-route", nicConfigs: []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: address.NewExtraAddresses(desc.Instance().NetworkInterfaces()[0], cfg.Retrieve(), nil), }, }, want: true, }, { name: "change-extra-route", nicConfigs: []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: &address.ExtraAddresses{}, }, }, hasExtraRoutes: true, want: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { oldClient := run.Client run.Client = testRunner{hasExtraRoutes: test.hasExtraRoutes} t.Cleanup(func() { run.Client = oldClient }) mod := &module{} got := mod.routeChanged(context.Background(), test.nicConfigs) if got != test.want { t.Errorf("routeChanged(%v) = %t, want %t", test.nicConfigs, got, test.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/network_syscall_windows.go000066400000000000000000000175001524141036400277730ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "bytes" "encoding/binary" "fmt" "net" "syscall" "unsafe" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" "golang.org/x/sys/windows" ) var ( // modiphlpapi is the module handle for iphlpapi.dll. modiphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") // https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-addipaddress procAddIPAddress = modiphlpapi.NewProc("AddIPAddress") // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-createunicastipaddressentry procCreateUnicastIPAddressEntry = modiphlpapi.NewProc("CreateUnicastIpAddressEntry") // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-deleteipaddress procDeleteIPAddress = modiphlpapi.NewProc("DeleteIPAddress") // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-deleteunicastipaddressentry procDeleteUnicastIPAddressEntry = modiphlpapi.NewProc("DeleteUnicastIpAddressEntry") // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-getunicastipaddressentry procGetUnicastIPAddressEntry = modiphlpapi.NewProc("GetUnicastIpAddressEntry") // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-initializeunicastipaddressentry procInitializeUnicastIPAddressEntry = modiphlpapi.NewProc("InitializeUnicastIpAddressEntry") ) // mibUnicastIPAddressRow structure stores information about a unicast IP address. // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ns-netioapi-mib_unicastipaddress_row type mibUnicastIPAddressRow struct { Address route.RawSockaddrInet InterfaceLuid netLUID InterfaceIndex uint32 PrefixOrigin uint32 SuffixOrigin uint32 ValidLifetime uint32 PreferredLifetime uint32 OnLinkPrefixLength uint8 SkipAsSource bool } // netLUID represents the locally unique identifier (LUID) for a network interface. // https://learn.microsoft.com/en-us/windows/win32/api/ifdef/ns-ifdef-net_luid_lh type netLUID uint64 // addUnicastIPAddress adds a unicast IP address of the NIC with the given // index. func addUnicastIPAddress(ip *address.IPAddr, index uint32) error { galog.Infof("Adding address %+v on NIC index %d", ip, index) mask, err := ip.Mask() if err != nil { return fmt.Errorf("failed to get IP mask for address %s: %w", ip, err) } subnet, _ := mask.Size() // AddIPAddress supports only IPv4 addresses, in case of IPv6 address we use // CreateUnicastIPAddressEntry directly. if ip.IsIPv6() { return createUnicastIPAddressEntry(ip, uint8(subnet), index) } // CreateUnicastIpAddressEntry only available Vista onwards. if err := procCreateUnicastIPAddressEntry.Find(); err != nil { return addIPAddress(ip, mask, index) } return createUnicastIPAddressEntry(ip, uint8(subnet), index) } // addIPAddress adds an IP address of the NIC with the given index. func addIPAddress(ip *address.IPAddr, mask net.IPMask, index uint32) error { var ( nteC int nteI int ) ret, _, _ := procAddIPAddress.Call( uintptr(binary.LittleEndian.Uint32(ip.IP.To4())), uintptr(binary.LittleEndian.Uint32(mask)), uintptr(index), uintptr(unsafe.Pointer(&nteC)), uintptr(unsafe.Pointer(&nteI))) if ret != 0 { return fmt.Errorf("nonzero return code from AddIPAddress: %s", syscall.Errno(ret)) } return nil } // createUnicastIPAddressEntry creates a unicast IP address of the NIC with the // given index. func createUnicastIPAddressEntry(ip *address.IPAddr, prefix uint8, index uint32) error { ipRow := new(mibUnicastIPAddressRow) // No return value. procInitializeUnicastIPAddressEntry.Call(uintptr(unsafe.Pointer(ipRow))) ipRow.InterfaceIndex = index ipRow.OnLinkPrefixLength = prefix // https://blogs.technet.microsoft.com/rmilne/2012/02/08/fine-grained-control-when-registering-multiple-ip-addresses-on-a-network-card/ ipRow.SkipAsSource = true addr := route.RawSockaddrInet{} addr.SetAddr(ip.NetAddr()) ipRow.Address = addr galog.V(2).Debugf("Creating unicast IP address entry: %+v", ipRow) if ret, _, _ := procCreateUnicastIPAddressEntry.Call(uintptr(unsafe.Pointer(ipRow))); ret != 0 { return fmt.Errorf("nonzero return code from CreateUnicastIpAddressEntry: %s", syscall.Errno(ret)) } return nil } // deleteUnicastIpAddress deletes a unicast IP address of the NIC with the given // index. func deleteUnicastIPAddress(ip *address.IPAddr, index uint32) error { galog.Infof("Deleting address %+v on NIC index %d", ip, index) mask, err := ip.Mask() if err != nil { return fmt.Errorf("failed to get IP mask for address %s: %w", ip, err) } subnet, _ := mask.Size() if ip.IsIPv6() { // Unlike ipv4 that can be added either by addIPAddress or // createUnicastIpAddressEntry ipv6 addresses can only be added by // createUnicastIpAddressEntry. Try removing them directly by // deleteUnicastIpAddressEntry as deleteIPAddress deletes IP address // previously added using AddIPAddress only. return deleteUnicastIPAddressEntry(ip, uint8(subnet), index) } // DeleteUnicastIPAddressEntry only available Vista onwards. if err := procDeleteUnicastIPAddressEntry.Find(); err != nil { return deleteIPAddress(ip) } return deleteUnicastIPAddressEntry(ip, uint8(subnet), index) } // deleteUnicastIPAddressEntry deletes a unicast IP address of the NIC with the // given index. func deleteUnicastIPAddressEntry(ip *address.IPAddr, prefix uint8, index uint32) error { ipRow := new(mibUnicastIPAddressRow) ipRow.InterfaceIndex = index ipRow.OnLinkPrefixLength = prefix addr := route.RawSockaddrInet{} addr.SetAddr(ip.NetAddr()) ipRow.Address = addr galog.V(2).Debugf("Deleting unicast IP address entry: %+v", ipRow) ret, _, _ := procGetUnicastIPAddressEntry.Call(uintptr(unsafe.Pointer(ipRow))) // ERROR_NOT_FOUND if ret == 1168 && !ip.IsIPv6() { // This address was added by addIPAddress(), need to remove with deleteIPAddress() return deleteIPAddress(ip) } if ret != 0 { return fmt.Errorf("nonzero return code from GetUnicastIpAddressEntry: %s", syscall.Errno(ret)) } if ret, _, _ := procDeleteUnicastIPAddressEntry.Call(uintptr(unsafe.Pointer(ipRow))); ret != 0 { return fmt.Errorf("nonzero return code from DeleteUnicastIpAddressEntry: %s", syscall.Errno(ret)) } return nil } // deleteIPAddress deletes an IP address of the NIC with the given index. func deleteIPAddress(ipAddr *address.IPAddr) error { ip := ipAddr.IP.To4() b := make([]byte, 1) ai := (*syscall.IpAdapterInfo)(unsafe.Pointer(&b[0])) l := uint32(0) syscall.GetAdaptersInfo(ai, &l) b = make([]byte, int32(l)) ai = (*syscall.IpAdapterInfo)(unsafe.Pointer(&b[0])) if err := syscall.GetAdaptersInfo(ai, &l); err != nil { return err } galog.V(2).Debugf("Deleting IP address %+v", ipAddr) for ; ai != nil; ai = ai.Next { for ipl := &ai.IpAddressList; ipl != nil; ipl = ipl.Next { ipb := bytes.Trim(ipl.IpAddress.String[:], "\x00") if string(ipb) != ip.String() { continue } nteC := ipl.Context ret, _, _ := procDeleteIPAddress.Call(uintptr(nteC)) if ret != 0 { return fmt.Errorf("nonzero return code from DeleteIPAddress: %s", syscall.Errno(ret)) } return nil } } return fmt.Errorf("did not find address %s on system", ip) } google-guest-agent-20260819.00/cmd/core_plugin/network/network_test.go000066400000000000000000000301561524141036400255300ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package network import ( "context" "errors" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "google.golang.org/protobuf/proto" ) const mdsJSON = ` { "instance": { "networkInterfaces": [ { } ] } }` const emptyJSON = ` { "instance": { } }` func TestModule(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } mod := NewModule(context.Background()) if mod.ID == "" { t.Errorf("NewEarlyModule() returned module with empty ID") } if mod.BlockSetup == nil { t.Errorf("NewEarlyModule() returned module with nil BlockSetup") } } func TestNetworkDaemonDisabled(t *testing.T) { events.FetchManager().Unsubscribe(metadata.LongpollEvent, networkModuleID) mds, err := metadata.UnmarshalDescriptor(`{}`) if err != nil { t.Fatalf("UnmarshalDescriptor() returned unexpected error: %v", err) } if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned unexpected error: %v", err) } cfg.Retrieve().Daemons.NetworkDaemon = false mod := &module{} if err := mod.setup(context.Background(), mds); err != nil { t.Errorf("module.setup() returned unexpected error: %v", err) } if events.FetchManager().IsSubscribed(metadata.LongpollEvent, networkModuleID) { t.Errorf("%s subscribed to metadata.LongpollEvent, want not subscribed", networkModuleID) } t.Cleanup(func() { events.FetchManager().Unsubscribe(metadata.LongpollEvent, networkModuleID) }) } func TestAddressManagerDisabled(t *testing.T) { events.FetchManager().Unsubscribe(metadata.LongpollEvent, networkModuleID) emptyMDS := `{}` disableInstanceMDS := `{ "instance": { "attributes": { "disable-address-manager": "true" } } }` enableInstanceMDS := `{ "instance": { "attributes": { "disable-address-manager": "false" } } }` tests := []struct { name string mdsJSON string cfgDisableAddressManager *bool networkSetupCalled bool wantSubscribe bool }{ { // The network module should subscribe in case the address manager is // re-enabled in MDS. name: "disabled-in-instance-mds", mdsJSON: disableInstanceMDS, wantSubscribe: true, }, { // Config file always disables. name: "disabled-in-config", cfgDisableAddressManager: proto.Bool(true), mdsJSON: emptyMDS, wantSubscribe: false, }, { // Config file disables, but MDS enables. Config file takes precedence // here, so the network module should not subscribe. name: "disabled-in-config-enabled-in-mds", mdsJSON: enableInstanceMDS, cfgDisableAddressManager: proto.Bool(true), wantSubscribe: false, }, { // Config file enables, but MDS disables. Because the config file enables // the address manager, the network module should subscribe. name: "enabled-in-config-disabled-in-mds", mdsJSON: disableInstanceMDS, cfgDisableAddressManager: proto.Bool(false), wantSubscribe: true, }, { // Instance MDS should take precedence over project MDS. name: "disabled-in-project-mds-enabled-in-instance-mds", mdsJSON: `{ "project": { "attributes": { "disable-address-manager": "true" } }, "instance": { "attributes": { "disable-address-manager": "false" } } }`, networkSetupCalled: true, wantSubscribe: true, }, { // Instance MDS and project MDS both disable the address manager. name: "disabled-in-project-mds-and-instance-mds", mdsJSON: `{ "project": { "attributes": { "disable-address-manager": "true" } }, "instance": { "attributes": { "disable-address-manager": "true" } } }`, wantSubscribe: true, }, { // Project MDS enables, but instance MDS disables. Instance MDS takes // precedence. name: "enabled-in-project-mds-disabled-in-instance-mds", mdsJSON: `{ "project": { "attributes": { "disable-address-manager": "false" } }, "instance": { "attributes": { "disable-address-manager": "true" } } }`, wantSubscribe: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() returned unexpected error: %v", err) } if tc.cfgDisableAddressManager != nil { cfg.Retrieve().AddressManager = &cfg.AddressManager{ Disable: *tc.cfgDisableAddressManager, } } mds, err := metadata.UnmarshalDescriptor(tc.mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor() returned unexpected error: %v", err) } t.Cleanup(func() { events.FetchManager().Unsubscribe(metadata.LongpollEvent, networkModuleID) }) mod := &module{} if err := mod.setup(context.Background(), mds); err != nil { t.Errorf("module.setup() returned unexpected error: %v", err) } // prevMetadata is only set when network setup runs. This should serve as // confirmation that network setup was skipped. if (mod.prevMetadata != nil) != tc.networkSetupCalled { t.Errorf("module.prevMetadata = %v, want nil", mod.prevMetadata) } if events.FetchManager().IsSubscribed(metadata.LongpollEvent, networkModuleID) != tc.wantSubscribe { t.Errorf("%s subscribed to metadata.LongpollEvent, want subscribed = %t", networkModuleID, tc.wantSubscribe) } }) } } func TestInitFailure(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", mdsJSON, err) } tests := []struct { name string mds any wantError bool disabledConfig bool }{ { name: "invalid-mds", wantError: true, mds: context.Background(), }, { name: "valid-mds", mds: mds, disabledConfig: true, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.disabledConfig { cfg.Retrieve().NetworkInterfaces.Setup = false t.Cleanup(func() { cfg.Retrieve().NetworkInterfaces.Setup = true }) } mod := &module{skipMDS: true} if err := mod.setup(context.Background(), tc.mds); (err == nil) == tc.wantError { t.Errorf("setup() returned error %v, want error %t", err, tc.wantError) } }) } } func TestInitSuccess(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", mdsJSON, err) } if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } mod := &module{} if err := mod.setup(context.Background(), mds); err != nil { t.Errorf("setup() returned an unexpected error: %v", err) } } func TestMetadataSubscriberFailure(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", mdsJSON, err) } // This is used to skip actual network setup. emptyMDS, err := metadata.UnmarshalDescriptor(emptyJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", emptyJSON, err) } tests := []struct { name string mds any sameMDS bool withError bool want bool wantError bool wantNoop bool }{ { name: "invalid-mds", mds: context.Background(), want: false, wantError: true, wantNoop: true, }, { name: "valid-mds-with-error", mds: mds, withError: true, want: true, wantError: true, wantNoop: true, }, { name: "valid-mds-changed", mds: emptyMDS, withError: false, want: true, wantError: false, wantNoop: false, }, { name: "valid-no-mds-changed", mds: mds, sameMDS: true, withError: false, want: true, wantError: false, wantNoop: true, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } // Force consistent behavior for both linux and windows. cfg.Retrieve().WSFC = &cfg.WSFC{ Enable: false, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { evdata := &events.EventData{Data: tc.mds} if tc.withError { evdata.Error = errors.New("test error") } mod := &module{} if tc.sameMDS { mds, ok := tc.mds.(*metadata.Descriptor) if ok { mod.prevMetadata = mds } } got, noop, err := mod.metadataSubscriber(context.Background(), metadata.LongpollEvent, nil, evdata) if (err != nil) != tc.wantError { t.Errorf("metadataSubscriber() returned error: %v, want error: %t", err, tc.wantError) } if noop != tc.wantNoop { t.Errorf("metadataSubscriber() returned noop = %t, want %t", noop, tc.wantNoop) } if got != tc.want { t.Errorf("metadataSubscriber() = %v, want %v", got, tc.want) } }) } } func TestMetadataSubscriberSuccess(t *testing.T) { mds, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("UnmarshalDescriptor(%q) returned an unexpected error: %v", mdsJSON, err) } if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } evdata := &events.EventData{Data: mds} mod := &module{prevMetadata: mds} got, noop, err := mod.metadataSubscriber(context.Background(), metadata.LongpollEvent, nil, evdata) if err != nil { t.Errorf("metadataSubscriber() returned an unexpected error: %v, want nil", err) } if !noop { t.Errorf("metadataSubscriber() returned noop = %t, want true", noop) } if !got { t.Errorf("metadataSubscriber() = false, want true") } } func TestNetworkMetadataChanged(t *testing.T) { tests := []struct { name string prevMDSJSON string newMDSJSON string prevWSFCEnabled bool want bool }{ { name: "no-change-basic-mds", prevMDSJSON: ` { "instance": { } }`, newMDSJSON: ` { "instance": { } }`, want: false, }, { name: "wsfc-from-disabled-to-enabled", prevMDSJSON: ` { "instance": { } }`, newMDSJSON: ` { "instance": { } }`, prevWSFCEnabled: true, want: true, }, { name: "network-interfaces-changes", prevMDSJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "AAAAA" } ] } }`, newMDSJSON: ` { "instance": { "networkInterfaces": [ { "MAC": "BBBBB" } ] } }`, want: true, }, } // This makes sure we have consistent behavior both for linux and windows. config := &cfg.Sections{ WSFC: &cfg.WSFC{ Enable: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { prevDesc, err := metadata.UnmarshalDescriptor(tc.prevMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", tc.prevMDSJSON, err) } newDesc, err := metadata.UnmarshalDescriptor(tc.newMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", tc.newMDSJSON, err) } mod := &module{prevMetadata: prevDesc, wsfcEnabled: tc.prevWSFCEnabled} got := mod.networkMetadataChanged(newDesc, config) if got != tc.want { t.Errorf("metadataChanged(%v) = %t, want %t", newDesc, got, tc.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/network_windows_test.go000066400000000000000000000027751524141036400273100ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package network import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" ) // We don't test for a change because MissingRoutes is not implemented on windows. func TestRouteChanged(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Load(nil) returned an unexpected error: %v", err) } nicConfigs := []*nic.Configuration{ { Interface: ðernet.Interface{ NameOp: func() string { return "eth0" }, }, ExtraAddresses: &address.ExtraAddresses{}, }, } mod := &module{} got := mod.routeChanged(context.Background(), nicConfigs) if got { t.Errorf("routeChanged(%v) = %t, want false", nicConfigs, got) } } google-guest-agent-20260819.00/cmd/core_plugin/network/networkd/000077500000000000000000000000001524141036400243015ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/networkd/networkd.go000066400000000000000000000132561524141036400264740ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package networkd import ( "os/exec" "time" ) const ( // defaultSystemdNetworkdPriority is a value adjusted to be above netplan // (usually set to 10) and low enough to be under the generic configurations. defaultSystemdNetworkdPriority = 20 // deprecatedPriority is the priority previously supported by us and // requires us to roll it back. deprecatedPriority = 1 // minSupportedVersion is the minimum supported version of systemd-networkd. // If the version is too old, some vital operations like `networkctl reload` // are not yet supported/available. minSupportedVersion = 252 // defaultDHCPTimeout is the time to wait for DHCP to acquire leases. defaultDHCPTimeout = 5 * time.Second // ServiceID is the service ID for systemd-networkd. ServiceID = "systemd-networkd" // DefaultDropinDir is the directory where systemd-networkd's drop-in files // are located. DefaultDropinDir = "/run/systemd/network/" // DefaultConfigDir is the directory where systemd-networkd's configuration // files are located. DefaultConfigDir = "/usr/lib/systemd/network" // DefaultDHCPLeasesDir is the directory where systemd-networkd stores the // DHCP leases. DefaultDHCPLeasesDir = "/run/systemd/netif/leases" ) var ( // execLookPath is the function to use to look up the path of an executable. // It's overridden in tests. execLookPath = exec.LookPath // DefaultNetworkCtlKeys is the default networkctl keys used to check if // systemd-networkd is managing the network interfaces. DefaultNetworkCtlKeys = []string{"AdministrativeState", "SetupState", "State"} ) // networkdMatchConfig contains the systemd-networkd's interface matching // criteria. type networkdMatchConfig struct { // Name is the matching criteria based on the interface name. Name string // Type is the matching type i.e. vlan. Type string `ini:",omitempty"` } // networkdLinkConfig contains the systemd-networkd's link configuration // section. type networkdLinkConfig struct { // MACAddress is the address to be set to the link. MACAddress string // MTUBytes is the systemd-networkd's Link's MTU configuration in bytes. MTUBytes int } // networkdNetworkConfig contains the actual interface rule's configuration. type networkdNetworkConfig struct { // DHCP determines the ipv4/ipv6 protocol version for use with dhcp. DHCP string `ini:"DHCP,omitempty"` // DNSDefaultRoute is used to determine if the link's configured DNS servers // are used for resolving domain names that do not match any link's domain. DNSDefaultRoute bool // VLAN specifies the VLANs this network should be member of. VLANS []string `ini:"VLAN,omitempty,allowshadow"` } // networkdRoute contains the systemd-networkd's route configuration. type networkdRoute struct { // Destination is the destination of the route. Destination string // Scope is the scope of the route (i.e. link, site, global, host). Scope string `ini:",omitempty"` // Type is the type of the route (i.e. local). Type string `ini:",omitempty"` } // networkdDHCPConfig contains the dhcp specific configurations for a // systemd network configuration. RouteToDNS and RouteToNTP are present // only in context of [DHCPv4]. // https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#RoutesToDNS= // https://www.freedesktop.org/software/systemd/man/latest/systemd.network.html#RoutesToNTP= type networkdDHCPConfig struct { // RoutesToDNS defines if routes to the DNS servers received from the DHCP // should be configured/installed. RoutesToDNS bool // RoutesToNTP defines if routes to the NTP servers received from the DHCP // should be configured/installed. RoutesToNTP bool } // Module implements systemd-networkd configuration handler for Linux. type Module struct { // configDir determines where the agent writes its configuration files. configDir string // dropinDir determines where the agent writes its drop-in files. dropinDir string // dhcpLeasesDir determines where the agent looks for the DHCP leases. dhcpLeasesDir string // networkCtlKeys helps with compatibility with different versions of // systemd, where the desired status key can be different. networkCtlKeys []string // priority dictates the priority with which guest-agent should write // the configuration files. priority int // deprecatedPriority is the priority previously supported by us and // requires us to roll it back. deprecatedPriority int // dhcpTimeout is the time to wait for DHCP to acquire leases. dhcpTimeout time.Duration // dhcpWaitErr is the error gotten when waiting for DHCP to acquire leases. // This is used in testing to determine if a timeout has occurred. Outside of // tests, the timeout shouldn't block the rest of the network setup process. dhcpWaitErr error } // DefaultModule returns the default module for systemd-networkd. func DefaultModule() *Module { return &Module{ configDir: DefaultConfigDir, dropinDir: DefaultDropinDir, dhcpLeasesDir: DefaultDHCPLeasesDir, networkCtlKeys: DefaultNetworkCtlKeys, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, dhcpTimeout: defaultDHCPTimeout, } } google-guest-agent-20260819.00/cmd/core_plugin/network/networkd/networkd_linux.go000066400000000000000000000646771524141036400277300ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package networkd provides is the service implementation for systemd-networkd. package networkd import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "reflect" "regexp" "slices" "strconv" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/ini" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/regex" "github.com/fsnotify/fsnotify" ) // NewService returns a new networkd service handler. func NewService() *service.Handle { mod := DefaultModule() return &service.Handle{ ID: ServiceID, Configure: mod.Configure, IsManaging: mod.IsManaging, Setup: mod.Setup, Rollback: mod.Rollback, } } // ID returns the service ID. func (sn *Module) ID() string { return ServiceID } // Configure configures the systemd-networkd service. This updates the config // directory based on the guest agent configuration. func (sn *Module) Configure(ctx context.Context) error { configDir := cfg.Retrieve().Unstable.SystemdConfigDir // Ignore empty config directory. if configDir != "" { galog.Debugf("Configuring systemd-networkd with config directory: %q", configDir) sn.configDir = configDir } return nil } // IsManaging is the module's implementation of service.IsManaging and checks // whether systemd-networkd is managing the network interfaces. func (sn *Module) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { galog.Debugf("Checking if systemd-networkd is managing the network interfaces.") ifaceNIC, err := opts.GetPrimaryNIC() if err != nil { return false, fmt.Errorf("failed to get primary NIC: %w", err) } iface := ifaceNIC.Interface.Name() // Check if networkctl is installed. if _, err := execLookPath("networkctl"); err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil } return false, fmt.Errorf("error looking up networkctl path: %w", err) } // Check the systemd-networkd version. res, err := run.WithContext(ctx, run.Options{ Name: "networkctl", Args: []string{"--version"}, OutputType: run.OutputStdout, }) if err != nil { return false, fmt.Errorf("failed to get networkctl version: %w", err) } // The version is the second field of the first line. versionString := strings.Split(strings.Split(res.Output, "\n")[0], " ")[1] version, err := strconv.Atoi(versionString) if err != nil { return false, fmt.Errorf("error parsing systemd version: %v", err) } if version < minSupportedVersion { galog.Debugf("systemd-networkd version %v not supported: minimum %v required", version, minSupportedVersion) return false, nil } // First check if the service is running. status, err := daemon.UnitStatus(ctx, "systemd-networkd.service") if err != nil { return false, fmt.Errorf("error checking systemd-networkd service status: %w", err) } // If the service is not running, we don't need to check the interface. if status != daemon.Active { return false, nil } // First attempt to check the interface using the json output - it may not be // supported by the version of networkctl installed. configured, err := sn.interfaceConfiguredJSON(ctx, iface) if err == nil { return configured, nil } galog.Debugf("Failed to check interface state using json output, falling back to plain text; err: %v", err) // If the json output is not supported, we fallback to the plain text output. configured, err = sn.interfaceConfiguredText(ctx, iface) if err == nil { return configured, nil } return false, fmt.Errorf("failed to check interface state using plain text output: %w", err) } // interfaceConfiguredText checks if the interface is configured by // systemd-networkd using its plain text output. func (sn *Module) interfaceConfiguredText(ctx context.Context, iface string) (bool, error) { opt := run.Options{ OutputType: run.OutputStdout, Name: "networkctl", Args: []string{"status", iface}, } res, err := run.WithContext(ctx, opt) if err != nil { return false, err } for _, line := range strings.Split(res.Output, "\n") { for _, key := range sn.networkCtlKeys { if strings.Contains(line, key+":") { return strings.Contains(line, "configured"), nil } } } return false, fmt.Errorf("could not determine interface state(plain text output), none of %v keys are present", sn.networkCtlKeys) } // interfaceConfiguredJson checks if the interface is configured by // systemd-networkd using its json output if supported. func (sn *Module) interfaceConfiguredJSON(ctx context.Context, iface string) (bool, error) { // Check systemd network configuration. opt := run.Options{ OutputType: run.OutputStdout, Name: "networkctl", Args: []string{"status", iface, "--json=short"}, } res, err := run.WithContext(ctx, opt) if err != nil { return false, fmt.Errorf("error checking systemd-networkd network status(json output): %w", err) } // Parse networkctl's output and check if the interface is managed by // systemd-networkd. interfaceStatus := make(map[string]any) if err = json.Unmarshal([]byte(res.Output), &interfaceStatus); err != nil { return false, fmt.Errorf("failed to unmarshal interface status: %w", err) } for _, statusKey := range sn.networkCtlKeys { state, found := interfaceStatus[statusKey] if !found { continue } return state == "configured", nil } return false, fmt.Errorf("could not determine interface state(json output), none of %v keys are present", sn.networkCtlKeys) } // WriteDropins writes the networkd drop-in files based on the provided NICs. func (sn *Module) WriteDropins(nics []*nic.Configuration, filePrefix string) (bool, error) { galog.Debugf("Writing systemd-networkd drop-in files.") changed := false for _, nic := range nics { if !nic.ShouldManage() { continue } filePath := sn.dropinFile(filePrefix, nic.Interface.Name()) dir := filepath.Dir(filePath) if err := os.MkdirAll(dir, 0755); err != nil { return changed, fmt.Errorf("error creating drop-in directory %s: %v", dir, err) } // Only write the drop-in files for the primary NIC if the primary NIC is // managed by guest-agent. galog.Debugf("Writing systemd-networkd drop-in file: %s", filePath) wrote, err := sn.writeEthernetConfig(nic, filePath, nic.Index == 0) if err != nil { return changed, fmt.Errorf("error writing systemd-networkd drop-in configs: %v", err) } changed = changed || wrote } galog.Debugf("Finished writing systemd-networkd drop-in files.") return changed, nil } // RollbackDropins rolls back the drop-in files previously created by us. func (sn *Module) RollbackDropins(nics []*nic.Configuration, filePrefix string, active bool) error { galog.Debugf("Rolling back systemd-networkd drop-in files.") for _, nic := range nics { // If this is the active network manager, we only want to rollback the // primary NIC if we are not managing it. if active && (nic.Index != 0 || nic.ShouldManage()) { continue } filePath := sn.dropinFile(filePrefix, nic.Interface.Name()) galog.Debugf("Rolling back systemd-networkd drop-in file: %s", filePath) if _, err := rollbackConfiguration(filePath); err != nil { return fmt.Errorf("error rolling back systemd-networkd drop-in config: %w", err) } dir := filepath.Dir(filePath) if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("error removing systemd-networkd drop-in directory: %w", err) } } galog.Debugf("Finished rolling back systemd-networkd drop-in files.") return nil } // Setup sets up the network interfaces using systemd-networkd. func (sn *Module) Setup(ctx context.Context, opts *service.Options) error { galog.Info("Setting up systemd-networkd interfaces.") nicConfigs := opts.FilteredNICConfigs() var keepVlanConfigs []string var changed bool // Write the config files. for _, nic := range nicConfigs { if !nic.ShouldManage() { continue } filePath := sn.networkFile(nic.Interface.Name()) wroteEthernet, err := sn.writeEthernetConfig(nic, filePath, nic.Index == 0) if err != nil { return fmt.Errorf("error writing network configs: %v", err) } // Make sure to rollback previously supported and now deprecated .network // and .netdev config files. galog.Debugf("Attempting to rollback deprecated .network file for: %s.", nic.Interface.Name()) if _, err := rollbackConfiguration(sn.deprecatedNetworkFile(nic.Interface.Name())); err != nil { galog.Infof("Failed to rollback .network file: %v.", err) } // Setup the interface's VLANs. wroteVlan := false for _, vic := range nic.VlanInterfaces { wrote, err := sn.writeVlanConfig(vic) if err != nil { return fmt.Errorf("error writing vlan configs: %w", err) } keepVlanConfigs = append(keepVlanConfigs, vic.InterfaceName()) wroteVlan = wroteVlan || wrote } changed = changed || wroteVlan || wroteEthernet } // Cleanup any vlan interfaces that are no longer present. vlanCleanedup, err := sn.cleanupVlanConfigs(keepVlanConfigs) if err != nil { return fmt.Errorf("error cleaning up vlan configs: %w", err) } // If we've not changed any configuration we shouldn't have to reload // systemd-networkd. if !changed && !vlanCleanedup { galog.Debugf("No configuration changes made, skipping reload.") galog.Infof("Finished setting up systemd-networkd interfaces.") return nil } // Attempt to reload systemd-networkd configurations. if err := sn.Reload(ctx, len(nicConfigs)); err != nil { return fmt.Errorf("error reloading systemd-networkd daemon: %w", err) } galog.Infof("Finished setting up systemd-networkd interfaces.") return nil } // Reload reloads the systemd-networkd daemon. func (sn *Module) Reload(ctx context.Context, numInterfaces int) error { // Reset the error gotten when waiting for DHCP to acquire leases. sn.dhcpWaitErr = nil // We do actually a reload so we avoid restarting systemd-networkd service so // we don run into cyclical dependencies with the guest-agent. opt := run.Options{OutputType: run.OutputNone, Name: "networkctl", Args: []string{"reload"}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error reloading systemd-networkd network configs: %w", err) } // If the leases directory is not present, we can't wait for DHCP leases. if !file.Exists(sn.dhcpLeasesDir, file.TypeDir) { galog.Debugf("No DHCP leases directory found: %s.", sn.dhcpLeasesDir) return nil } if numInterfaces == 0 { galog.Debugf("No interfaces to wait for DHCP leases, skipping wait.") return nil } // We need to wait for systemd-networkd to acquire DHCP leases to avoid // race conditions where we try to configure routes before they are acquired. // This is only needed for `systemd-networkd` because other network managers // block until the leases are acquired before completing the configuration. watcher, err := fsnotify.NewWatcher() if err != nil { galog.Warnf("Failed to create fsnotify watcher: %v", err) return nil } watcher.Add(sn.dhcpLeasesDir) defer watcher.Close() // Wait for DHCP leases to be acquired. timeout, cancel := context.WithTimeout(ctx, sn.dhcpTimeout) defer cancel() galog.Debugf("Waiting for DHCP leases for all interfaces.") var numWrites int for { select { case fsnotifyEvent := <-watcher.Events: galog.V(3).Debugf("fsnotifyEvent: %v", fsnotifyEvent.String()) if fsnotifyEvent.Has(fsnotify.Write) { numWrites++ } if numWrites >= numInterfaces { galog.Debugf("Found DHCP leases for all interfaces.") return nil } case <-timeout.Done(): galog.Warnf("Timed out after %v waiting for DHCP leases.", sn.dhcpTimeout) sn.dhcpWaitErr = timeout.Err() return nil } } } // cleanupVlanConfigs removes vlan interfaces that are no longer present. The // process involves iterating over all configuration files present in the known // configuration directory and removing the files which names match the known // naming pattern for vlan interfaces and that are not present in the keepMe // list. func (sn *Module) cleanupVlanConfigs(keepMe []string) (bool, error) { galog.Debugf("Cleaning up systemd-networkd vlan interfaces.") if !file.Exists(sn.configDir, file.TypeDir) { galog.V(2).Debugf("No systemd-networkd configuration directory found: %s.", sn.configDir) return false, nil } files, err := os.ReadDir(sn.configDir) if err != nil { return false, fmt.Errorf("failed to read content from %s: %w", sn.configDir, err) } configExp := `(?P[0-9]+)-(?P.*\.[0-9]+)-(?P.*)\.(?Pnetwork|netdev)` configRegex := regexp.MustCompile(configExp) requiresRestart := false for _, file := range files { // Skip directories. if file.IsDir() { continue } fileName := file.Name() groups := regex.GroupsMap(configRegex, fileName) galog.V(2).Debugf("Vlan file(%q) name extracted groups: %v.", fileName, groups) // If we don't have a matching interface skip it. currIface, ok := groups["interface"] if !ok { continue } // If suffix is not google-guest-agent that means it's not a vlan interface // we created. if suffix, ok := groups["suffix"]; !ok || suffix != "google-guest-agent" { continue } // If this is an interface still present skip it. if slices.Contains(keepMe, currIface) { continue } galog.Debugf("Removing systemd-networkd vlan interface config(%s).", fileName) if err := os.Remove(filepath.Join(sn.configDir, fileName)); err != nil { return requiresRestart, fmt.Errorf("failed to remove vlan interface config(%s): %w", fileName, err) } requiresRestart = true } return requiresRestart, nil } // networkdNetdev is the networkd's netdev [NetDev] section. type networkdNetdev struct { // Name is the vlan interface name. Name string // Kind is the vlan interface's Kind: "vlan". Kind string } // networkdVlan is the networkd's netdev [VLAN] section. type networkdVlan struct { // Id is the vlan's id. ID int `ini:"Id,omitempty"` // ReorderHeader determines if the vlan reorder header must be used. ReorderHeader bool } // networkdNetdevConfig is the networkd's netdev configuration file. type networkdNetdevConfig struct { // NetDev is the systemd-networkd netdev file's [NetDev] section. NetDev networkdNetdev // NetDev is the systemd-networkd netdev file's [VLAN] section. VLAN networkdVlan } // write writes networkd's .netdev config file. func (nd *networkdNetdevConfig) write(sn *Module, iface string) (bool, error) { galog.V(2).Debugf("Writing systemd-networkd's .netdev configuration file: %s.", sn.netdevFile(iface)) equals, err := nd.equals(sn.netdevFile(iface)) if err != nil { // Don't fail if we can't check if the file is equal. Assume we need to reload. galog.Debugf("Error checking if networkd .netdev configuration file is equal: %v", err) } if equals { galog.Debugf("Networkd .netdev configuration file is equal to the new configuration, skipping write.") return false, nil } if err := ini.WriteIniFile(sn.netdevFile(iface), &nd); err != nil { return false, fmt.Errorf("error saving .netdev config for %s: %w", iface, err) } return true, nil } // equals checks if the networkd .netdev configuration file is equal to the // provided configuration. func (nd networkdNetdevConfig) equals(fPath string) (bool, error) { if !file.Exists(fPath, file.TypeFile) { return false, nil } oldCfg := new(networkdNetdevConfig) if err := ini.ReadIniFile(fPath, oldCfg); err != nil { return false, fmt.Errorf("error reading existing networkd's .netdev config: %w", err) } return reflect.DeepEqual(&nd, oldCfg), nil } // writeVlanConfig writes the systemd config for the provided vlan interface. func (sn *Module) writeVlanConfig(vic *ethernet.VlanInterface) (bool, error) { galog.Debugf("Write vlan's systemd-networkd network config for %s.", vic.InterfaceName()) iface := vic.InterfaceName() // Create and setup .network file. network := networkdConfig{ Match: networkdMatchConfig{Name: iface, Type: "vlan"}, Network: networkdNetworkConfig{DHCP: "yes" /* enables ipv4 and ipv6 */}, Link: &networkdLinkConfig{MACAddress: vic.MacAddr, MTUBytes: vic.MTU}, } wroteNetwork, err := network.write(sn.networkFile(iface)) if err != nil { return false, fmt.Errorf("failed to write networkd's vlan .network config: %w", err) } // Create and setup .netdev file. netdev := networkdNetdevConfig{ NetDev: networkdNetdev{Name: iface, Kind: "vlan"}, VLAN: networkdVlan{ID: vic.Vlan, ReorderHeader: false}, } wroteNetdev, err := netdev.write(sn, iface) if err != nil { return false, fmt.Errorf("failed to write networkd's vlan .netdev config: %w", err) } return (wroteNetwork || wroteNetdev), nil } // writeEthernetConfig writes the systemd config for all the provided interfaces // in the provided directory using the given priority. func (sn *Module) writeEthernetConfig(nic *nic.Configuration, filePath string, primary bool) (bool, error) { galog.Debugf("Writeing systemd-networkd network config for %s.", nic.Interface.Name()) dhcpIpv6 := map[bool]string{true: "yes", false: "ipv4"} dhcp := dhcpIpv6[nic.SupportsIPv6] // Create and setup ini file. data := &networkdConfig{ Match: networkdMatchConfig{Name: nic.Interface.Name()}, Network: networkdNetworkConfig{DHCP: dhcp, DNSDefaultRoute: true, VLANS: nic.VlanNames()}, } // We are only interested on DHCP offered routes on the primary nic, ignore it // for the secondary ones. if !primary { data.Network.DNSDefaultRoute = false data.DHCPv4 = &networkdDHCPConfig{RoutesToDNS: false, RoutesToNTP: false} } wrote, err := data.write(filePath) if err != nil { return false, fmt.Errorf("failed to write networkd's ethernet interface config: %w", err) } return wrote, nil } // netdevFile returns the networkd's .netdev file path. // // Priority is lexicographically sorted in ascending order by file name. So a // configuration starting with '1-' takes priority over a configuration file // starting with '10-'. // // Setting a priority of 1 allows the guest-agent to override any existing // default configurations while also allowing users the freedom of using // priorities of '0...' to override the agent's own configurations. func (sn Module) netdevFile(iface string) string { fName := fmt.Sprintf("%d-%s-google-guest-agent.netdev", sn.priority, iface) return filepath.Join(sn.configDir, fName) } // networkFile returns the networkd's .network file path. // // The priority is lexicographically sorted in ascending order by file name. So // a configuration starting with '1-' takes priority over a configuration file // starting with '10-'. func (sn *Module) networkFile(iface string) string { fName := fmt.Sprintf("%d-%s-google-guest-agent.network", sn.priority, iface) return filepath.Join(sn.configDir, fName) } // dropinFile returns the networkd's drop-in file path. func (sn *Module) dropinFile(prefix string, iface string) string { fName := fmt.Sprintf("%s-%s.network.d", prefix, iface) return filepath.Join(sn.dropinDir, fName, "override.conf") } // deprecatedNetworkFile returns the older and deprecated networkd's network // file. It's present mainly to allow us to roll it back. func (sn *Module) deprecatedNetworkFile(iface string) string { fName := fmt.Sprintf("%d-%s-google-guest-agent.network", sn.deprecatedPriority, iface) return filepath.Join(sn.configDir, fName) } // Rollback rolls back the changes created in Setup. func (sn *Module) Rollback(ctx context.Context, opts *service.Options, active bool) error { galog.Infof("Rolling back changes for systemd-networkd with reload [%t].", !active) ethernetRequiresReload := false // Rollback ethernet interfaces. for _, nic := range opts.FilteredNICConfigs() { // If this is the active network manager, we only want to rollback the // primary NIC if we are not managing it. if active && (nic.Index != 0 || nic.ShouldManage()) { continue } iface := nic.Interface.Name() reqRestart1, err := rollbackConfiguration(sn.networkFile(iface)) if err != nil { galog.Warnf("Failed to rollback .network file: %v.", err) } reqRestart2, err := rollbackConfiguration(sn.deprecatedNetworkFile(iface)) if err != nil { galog.Warnf("Failed to rollback deprecated .network file: %v.", err) } ethernetRequiresReload = reqRestart1 || reqRestart2 } // Cleanup vlan interfaces. vlanCleanedUp, err := sn.cleanupVlanConfigs(nil) if err != nil { return fmt.Errorf("error cleaning up vlan configs: %w", err) } if !ethernetRequiresReload && !vlanCleanedUp { galog.Debugf("No systemd-networkd configuration rolled back, skipping restart.") return nil } // Attempt to reload systemd-networkd configurations. if !active { galog.Debugf("Reloading systemd-networkd daemon.") if err := sn.Reload(ctx, 0); err != nil { return fmt.Errorf("error reloading systemd-networkd daemon: %w", err) } } return nil } // networkdConfig wraps the interface configuration for systemd-networkd. // Ultimately the structure will be unmarshalled into a .ini file. type networkdConfig struct { // Match is the systemd-networkd ini file's [Match] section. Match networkdMatchConfig // Network is the systemd-networkd ini file's [Network] section. Network networkdNetworkConfig // DHCPv4 is the systemd-networkd ini file's [DHCPv4] section. DHCPv4 *networkdDHCPConfig `ini:",omitempty"` // Link is the systemd-networkd init file's [Link] section. Link *networkdLinkConfig `ini:",omitempty"` } // write writes the networkd configuration file to its destination. func (sc *networkdConfig) write(fPath string) (bool, error) { galog.V(2).Debugf("Writing systemd-networkd's configuration file: %s.", fPath) // Check if the file exists, and if it does, check if the contents are the // same. If they are the same, we don't need to write the file. equals, err := sc.equals(fPath) if err != nil { // Don't fail if we can't check if the file is equal. Assume we need to reload. galog.Debugf("Error checking if systemd-networkd configuration file is equal: %v", err) } if equals { galog.Debugf("Systemd-networkd configuration file is equal to the new configuration, skipping write.") return false, nil } dir := filepath.Dir(fPath) if err := os.MkdirAll(dir, 0755); err != nil { return false, fmt.Errorf("error creating configuration directory %s: %v", dir, err) } if err := ini.WriteIniFile(fPath, &sc); err != nil { return false, fmt.Errorf("error saving .network config: %s: %w", fPath, err) } return true, nil } // equals checks if the networkd configuration file is equal to the provided // configuration. func (sc networkdConfig) equals(fPath string) (bool, error) { if !file.Exists(fPath, file.TypeFile) { return false, nil } cfg := new(networkdConfig) if err := ini.ReadIniFile(fPath, cfg); err != nil { return false, fmt.Errorf("error reading systemd-networkd configuration file: %w", err) } return reflect.DeepEqual(&sc, cfg), nil } // rollbackConfiguration rolls back the .network files created previously // created by us. func rollbackConfiguration(configFile string) (bool, error) { galog.Debugf("Rolling back systemd-networkd configuration(%s).", configFile) // Check if the file exists. if !file.Exists(configFile, file.TypeFile) { galog.Debugf("No systemd-networkd configuration found: %s.", configFile) return false, nil } galog.V(2).Debugf("removing file %s.", configFile) if err := os.Remove(configFile); err != nil { return false, fmt.Errorf("failed to remove systemd-networkd config(%s): %w", configFile, err) } return true, nil } // WriteNetplanVlanDropins writes the systemd-networkd override drop-in file for // the vlan interfaces. func (sn *Module) WriteNetplanVlanDropins(prefix string, nics []*nic.Configuration) (bool, error) { galog.Debugf("Writing systemd-networkd override drop-in configuration for netplan interfaces.") configChanged := false for _, nic := range nics { if !nic.ShouldManage() { continue } for _, vlan := range nic.VlanInterfaces { dhcp := "ipv4" if len(vlan.IPv6Addresses) > 0 { dhcp = "yes" } cfg := &networkdConfig{ Match: networkdMatchConfig{Name: vlan.InterfaceName()}, Network: networkdNetworkConfig{ DNSDefaultRoute: false, DHCP: dhcp, }, DHCPv4: &networkdDHCPConfig{ RoutesToDNS: false, RoutesToNTP: false, }, } var readCfg networkdConfig err := ini.ReadIniFile(sn.dropinFile(prefix, vlan.InterfaceName()), &cfg) if err != nil { return configChanged, fmt.Errorf("failed to read networkd's vlan dropin: %w", err) } if reflect.DeepEqual(cfg, readCfg) { galog.Debugf("Networkd vlan dropin configuration is equal to the new configuration, skipping write for %s.", vlan.InterfaceName()) continue } _, err = cfg.write(sn.dropinFile(prefix, vlan.InterfaceName())) if err != nil { return configChanged, fmt.Errorf("failed to write networkd's vlan .network config: %w", err) } configChanged = true } } return configChanged, nil } // RollbackNetplanVlanDropins rolls back the systemd-networkd override drop-in // file for the vlan interfaces. func (sn *Module) RollbackNetplanVlanDropins(vlanDropins map[string]bool, prefix string) (bool, error) { galog.Debugf("Rolling back systemd-networkd vlan drop-in configuration for netplan interfaces.") configChanged := false for vlan := range vlanDropins { if !file.Exists(sn.dropinFile(prefix, vlan), file.TypeFile) { galog.Debugf("No systemd-networkd vlan dropin found: %s. Skipping rollback.", sn.dropinFile(prefix, vlan)) continue } if err := os.RemoveAll(filepath.Dir(sn.dropinFile(prefix, vlan))); err != nil { return configChanged, fmt.Errorf("failed to remove systemd-networkd vlan dropin directory: %w", err) } configChanged = true } return configChanged, nil } google-guest-agent-20260819.00/cmd/core_plugin/network/networkd/networkd_linux_test.go000066400000000000000000001015301524141036400307430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package networkd import ( "context" "fmt" "os" "os/exec" "path" "path/filepath" "slices" "strings" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "gopkg.in/ini.v1" ) // mockSystemd is the test systemd-networkd implementation to use for testing. var ( mockSystemd = Module{ networkCtlKeys: []string{"AdministrativeState", "SetupState"}, priority: 1, } ) // systemdTestOpts is a wrapper for all options to set for test setup. type systemdTestOpts struct { // lookPathOpts contains options for lookPath mocking. lookPathOpts systemdLookPathOpts // runnerOpts contains options for run mocking. runnerOpts systemdRunnerOpts } // testNetworkdConfig is a wrapper for the systemd-networkd config file. // This is used to parse the config file and compare it to the expected values. type testNetworkdConfig struct { // Match is the systemd-networkd ini file's [Match] section. Match networkdMatchConfig // Network is the systemd-networkd ini file's [Network] section. Network networkdNetworkConfig // DHCPv4 is the systemd-networkd ini file's [DHCPv4] section. DHCPv4 *networkdDHCPConfig `ini:",omitempty"` // DHCPv6 is the systemd-networkd ini file's [DHCPv4] section. DHCPv6 *networkdDHCPConfig `ini:",omitempty"` // Link is the systemd-networkd init file's [Link] section. Link *networkdLinkConfig `ini:",omitempty"` // Route specifies the routes to be installed for this network. Route *[]*networkdRoute `ini:",omitempty,nonunique"` } // systemdLookPathOpts contains options for lookPath mocking. type systemdLookPathOpts struct { // returnErr indicates whether to return error. returnErr bool // returnValue indicates the return value for mocking. returnValue bool } // systemdVersionOpts are options for running `networkctl --version`. type systemdVersionOpts struct { // returnErr indicates whether the command should return an error. returnErr bool // version indicates the version to return when running the command. version int } // systemdStatusOpts are options for running `networkctl status iface --json=short` type systemdStatusOpts struct { // returnValue indicates whether to return a configured or non-configured interface. returnValue bool // returnErr indicates whether to return an error. returnErr bool // hasKey determines whether the configuredKey should be included or not. hasKey bool // configuredKey is used only when returnValue is not err. This indicates what key to // use for determining the configured state. configuredKey string } // systemdRunnerOpts are options to set for initializing the MockRunner. type systemdRunnerOpts struct { // versionOpts are options for when running `networkctl --version` versionOpts systemdVersionOpts // isActiveErr is an option for running `systemctl is-active systemd-networkd.service` // isActiveErr indicates whether to return an error when running the command. isActiveErr bool // statusOpts are options for running `networkctl status iface --json=short` statusOpts systemdStatusOpts } // systemdMockRunner is the Mock Runner to use for testing. type systemdMockRunner struct { // versionOpts are options for when running `networkctl --version` versionOpts systemdVersionOpts // isActiveErr is an option for running `systemctl is-active systemd-networkd.service` // isActiveErr indicates whether to return an error when running the command. isActiveErr bool // statusOpts are options for running `networkctl status iface --json=short` statusOpts systemdStatusOpts } func (s systemdMockRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.OutputType == run.OutputCombined || opts.OutputType == run.OutputNone { return nil, nil } argsStr := strings.Join(opts.Args, " ") if opts.Name == "networkctl" && argsStr == "--version" { verOpts := s.versionOpts if verOpts.returnErr { return nil, &exec.ExitError{} } return &run.Result{ Output: fmt.Sprintf("systemd %v (%v-1.0)\n+TEST +ESTT +STTE +TTES", verOpts.version, verOpts.version), }, nil } if opts.Name == "systemctl" && argsStr == "is-active systemd-networkd.service" { if s.isActiveErr { return nil, &exec.ExitError{} } return &run.Result{Output: "active"}, nil } if opts.Name == "networkctl" && argsStr == "status iface --json=short" { statusOpts := s.statusOpts if statusOpts.returnErr { return nil, &exec.ExitError{} } if statusOpts.returnValue { mockOut := fmt.Sprintf(`{"Name": "iface", "%s": "%s"}`, statusOpts.configuredKey, "configured") return &run.Result{ Output: mockOut, }, nil } if statusOpts.hasKey { mockOut := fmt.Sprintf(`{"Name": "iface", "%s": "%s"}`, statusOpts.configuredKey, "unmanaged") return &run.Result{ Output: mockOut, }, nil } mockOut := `{"Name": "iface"}` return &run.Result{ Output: mockOut, }, nil } return nil, &exec.ExitError{} } // runMock is the Mock Runner to use for testing. type runMock struct { seenOpts []run.Options callback func(ctx context.Context, opts run.Options) (*run.Result, error) } func (r *runMock) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { r.seenOpts = append(r.seenOpts, opts) return r.callback(ctx, opts) } // systemdTestSetup sets up the environment before each test. func systemdTestSetup(t *testing.T, opts systemdTestOpts) { t.Helper() mockDir := path.Join(t.TempDir(), "systemd", "network") mockSystemd.configDir = mockDir runnerOpts := opts.runnerOpts lookPathOpts := opts.lookPathOpts // Create the temporary directory. if err := os.MkdirAll(mockDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if lookPathOpts.returnErr { execLookPath = func(name string) (string, error) { return "", fmt.Errorf("mock error finding path") } } else if lookPathOpts.returnValue { execLookPath = func(name string) (string, error) { return name, nil } } else { execLookPath = func(name string) (string, error) { return "", exec.ErrNotFound } } run.Client = &systemdMockRunner{ versionOpts: runnerOpts.versionOpts, isActiveErr: runnerOpts.isActiveErr, statusOpts: runnerOpts.statusOpts, } } // systemdTestTearDown cleans up after each test. func systemdTestTearDown(t *testing.T) { t.Helper() execLookPath = exec.LookPath run.Client = &run.Runner{} } func TestNewService(t *testing.T) { service := NewService() if service == nil { t.Fatalf("NewService() returned nil") } if service.ID != ServiceID { t.Fatalf("NewService() returned service with ID %v, want %v", service.ID, ServiceID) } } func TestConfigure(t *testing.T) { tests := []struct { name string dir string expectedDir string }{ { name: "default", expectedDir: DefaultConfigDir, }, { name: "custom", dir: "/test/dir", expectedDir: "/test/dir", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cfg.Load(nil) if tc.dir != "" { cfg.Retrieve().Unstable.SystemdConfigDir = tc.dir } mod := DefaultModule() mod.Configure(context.Background()) if mod.configDir != tc.expectedDir { t.Errorf("defaultModule() configDir = %s, want %s", mod.configDir, tc.expectedDir) } }) } } // TestSystemdNetworkdIsManaging tests whether IsManaging behaves correctly given some // mock environment setup. func TestSystemdNetworkdIsManaging(t *testing.T) { tests := []struct { // name is the name of the test. name string // opts are the options to set for test environment setup. opts systemdTestOpts // expectedRes is the expected return value of IsManaging() expectedRes bool // expectErr determines whether an error is expected. expectErr bool }{ // networkctl does not exist. { name: "no-networkctl", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: false, }, }, expectedRes: false, expectErr: false, }, // LookPath error. { name: "lookpath-error", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnErr: true, }, }, expectedRes: false, expectErr: true, }, // networkctl version error { name: "systemd-version-error", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ returnErr: true, }, }, }, expectedRes: false, expectErr: true, }, // networkctl version not supported. { name: "systemd-version-not-supported", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ version: 237, }, }, }, expectedRes: false, expectErr: false, }, // networkctl is-active error. { name: "networkctl-is-active-error", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ version: 300, }, isActiveErr: true, }, }, expectedRes: false, expectErr: true, }, // networkctl status error. { name: "networkctl-status-error", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ isActiveErr: true, versionOpts: systemdVersionOpts{ version: 300, }, statusOpts: systemdStatusOpts{ returnErr: true, }, }, }, expectedRes: false, expectErr: true, }, // networkctl status no networkctl key. { name: "networkctl-status-no-key", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ returnErr: true, version: 300, }, statusOpts: systemdStatusOpts{ returnValue: false, hasKey: false, }, }, }, expectedRes: false, expectErr: true, }, // networkctl status interface is unmanaged. { name: "networkctl-status-unmanaged", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ version: 300, }, statusOpts: systemdStatusOpts{ returnValue: false, hasKey: true, configuredKey: "AdministrativeState", }, }, }, expectedRes: false, expectErr: false, }, // networkctl status interface is managed. Whole method passes. { name: "pass", opts: systemdTestOpts{ lookPathOpts: systemdLookPathOpts{ returnValue: true, }, runnerOpts: systemdRunnerOpts{ versionOpts: systemdVersionOpts{ version: 300, }, statusOpts: systemdStatusOpts{ returnValue: true, hasKey: true, configuredKey: "SetupState", }, }, }, expectedRes: true, expectErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() systemdTestSetup(t, tc.opts) // Mocking a service options with nic configuration and a ethernet // interface. iface := ðernet.Interface{ NameOp: func() string { return "iface" }, } opts := service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: iface, }, }) res, err := mockSystemd.IsManaging(ctx, opts) // Check expected errors. if err != nil && !tc.expectErr { t.Fatalf("err returned when none expected: %v", err) } if tc.expectErr { if err == nil { t.Fatalf("no err returned when err expected") } } // Check expected output. if res != tc.expectedRes { t.Fatalf("incorrect return value. Expected: %v, Actual: %v", tc.expectedRes, res) } systemdTestTearDown(t) }) } } // TestSystemdNetworkdConfig tests whether config file writing works correctly. func TestSystemdNetworkdConfig(t *testing.T) { tests := []struct { // name is the name of the test. name string // testInterfaces is the list of mock interfaces. testInterfaces []string // testIpv6Interfaces is the list of mock IPv6 interfaces. testIpv6Interfaces []string // expectedFiles is the list of expected file names. expectedFiles []string // expectedDHCP is the list of expected DHCP values. expectedDHCP []string }{ { name: "ipv4", testInterfaces: []string{"iface0"}, expectedFiles: []string{ "1-iface0-google-guest-agent.network", }, expectedDHCP: []string{ "ipv4", }, }, { name: "ipv6", testInterfaces: []string{"iface0"}, testIpv6Interfaces: []string{"iface0"}, expectedFiles: []string{ "1-iface0-google-guest-agent.network", }, expectedDHCP: []string{ "yes", }, }, { name: "multinic", testInterfaces: []string{"iface0", "iface1"}, testIpv6Interfaces: []string{"iface1"}, expectedFiles: []string{ "1-iface0-google-guest-agent.network", "1-iface1-google-guest-agent.network", }, expectedDHCP: []string{ "ipv4", "yes", }, }, } if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } cfg.Retrieve().NetworkInterfaces.ManagePrimaryNIC = true for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { systemdTestSetup(t, systemdTestOpts{}) var nicConfigs []*nic.Configuration for ii, ifaces := range tc.testInterfaces { iface := ðernet.Interface{ NameOp: func() string { return ifaces }, } nicConfig := &nic.Configuration{ Interface: iface, SupportsIPv6: tc.expectedDHCP[ii] == "yes", Index: uint32(ii), } nicConfigs = append(nicConfigs, nicConfig) } for _, nic := range nicConfigs { filePath := mockSystemd.networkFile(nic.Interface.Name()) if _, err := mockSystemd.writeEthernetConfig(nic, filePath, nic.Index == 0 /* primary interface? */); err != nil { t.Fatalf("unexpected error: %v", err) } } // Check the files. files, err := os.ReadDir(mockSystemd.configDir) if err != nil { t.Fatalf("error reading configuration directory: %v", err) } for i, file := range files { // Ensure the only files are those written by guest agent. if !slices.Contains(tc.expectedFiles, file.Name()) { t.Fatalf("unexpected file in configuration directory: %v", file.Name()) } // Check contents. filePath := path.Join(mockSystemd.configDir, file.Name()) opts := ini.LoadOptions{ Loose: true, Insensitive: true, AllowNonUniqueSections: true, } config, err := ini.LoadSources(opts, filePath) if err != nil { t.Fatalf("error loading config file: %v", err) } t.Logf("Config sections: %v", config.SectionStrings()) sections := new(testNetworkdConfig) if err := config.MapTo(sections); err != nil { t.Fatalf("error parsing config ini: %v", err) } // Check that the file matches the interface. if sections.Match.Name != tc.testInterfaces[i] { t.Errorf(`%s does not have correct match. Expected: %s Actual: %s`, file.Name(), tc.testInterfaces[i], sections.Match.Name) } // Make sure the DHCP section is set correctly. if sections.Network.DHCP != tc.expectedDHCP[i] { t.Errorf(`%s has incorrect DHCP value. Expected: %s Actual: %s`, file.Name(), tc.expectedDHCP[i], sections.Network.DHCP) } // For non-primary interfaces, check DNSDefaultRoute field. if i != 0 { if sections.Network.DNSDefaultRoute { t.Errorf("%s, a secondary interface, has DNSDefaultRoute set", file.Name()) } } } // Cleanup. systemdTestTearDown(t) }) } } func TestSetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } var mod *Module type testOptions struct { createConfigDir bool } iface := ðernet.Interface{ NameOp: func() string { return "iface" }, } vlanOptions := service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: iface, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: iface, MTU: 1500, Vlan: 1, }, }, Index: 1, }, }) tests := []struct { name string opts *service.Options testOptions testOptions runCallback func(context.Context, run.Options) (*run.Result, error) wantErr bool wantWaitErr bool // If we expect an error when waiting for DHCP leases. writeFile bool noReload bool }{ { name: "empty-success", opts: &service.Options{}, wantErr: false, }, { name: "fail-with-vlan", opts: vlanOptions, testOptions: testOptions{ createConfigDir: true, }, wantErr: true, }, { name: "no-config-dir", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), wantErr: true, }, { name: "fail-to-reload-networkctl", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), testOptions: testOptions{ createConfigDir: true, }, wantErr: true, }, { name: "success-no-reload", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), testOptions: testOptions{ createConfigDir: true, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, wantErr: false, writeFile: true, noReload: true, }, { name: "success-reload", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface2" }, }, Index: 2, }, }), testOptions: testOptions{ createConfigDir: true, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "networkctl" && opts.Args[0] == "reload" { go func() { time.Sleep(500 * time.Millisecond) if err := os.WriteFile(filepath.Join(mod.dhcpLeasesDir, "2"), []byte("test"), 0644); err != nil { t.Errorf("failed to write file: %v", err) } if err := os.WriteFile(filepath.Join(mod.dhcpLeasesDir, "3"), []byte("test"), 0644); err != nil { t.Errorf("failed to write file: %v", err) } }() } return &run.Result{}, nil }, wantErr: false, writeFile: false, noReload: false, }, { name: "success-reload-timeout", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), testOptions: testOptions{ createConfigDir: true, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, wantErr: false, wantWaitErr: true, writeFile: false, noReload: false, }, { name: "success-reload-secondary-no-lease", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface2" }, }, Index: 2, }, }), testOptions: testOptions{ createConfigDir: true, }, runCallback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "networkctl" && opts.Args[0] == "reload" { go func() { time.Sleep(500 * time.Millisecond) file := filepath.Join(mod.dhcpLeasesDir, "2") if err := os.WriteFile(file, []byte("test"), 0644); err != nil { t.Errorf("failed to write file: %v", err) } }() } return &run.Result{}, nil }, wantErr: false, wantWaitErr: true, writeFile: false, noReload: false, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { configDir := filepath.Join(t.TempDir(), "systemd", "network") mod = &Module{ configDir: configDir, dhcpLeasesDir: filepath.Join(configDir, "dhcp", "leases"), networkCtlKeys: []string{"AdministrativeState", "SetupState"}, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, dhcpTimeout: time.Second, } if tc.testOptions.createConfigDir { if err := os.MkdirAll(mod.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } } if err := os.MkdirAll(mod.dhcpLeasesDir, 0755); err != nil { t.Fatalf("failed to create mock DHCP leases directory: %v", err) } // Setup mock runner if a callback is provided. var mockRunner *runMock if tc.runCallback != nil { oldRunner := run.Client mockRunner = &runMock{ callback: tc.runCallback, } run.Client = mockRunner t.Cleanup(func() { run.Client = oldRunner }) } if tc.writeFile { configPath := mod.networkFile("iface") configData := networkdConfig{ Match: networkdMatchConfig{ Name: "iface", }, Network: networkdNetworkConfig{ DHCP: "ipv4", DNSDefaultRoute: false, }, DHCPv4: &networkdDHCPConfig{ RoutesToDNS: false, RoutesToNTP: false, }, } if _, err := configData.write(configPath); err != nil { t.Fatalf("failed to write file: %v", err) } } err := mod.Setup(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want %v", err, tc.wantErr) } if (mod.dhcpWaitErr == nil) == tc.wantWaitErr { t.Errorf("Setup() = dhcp wait error: %v, want error? %v", mod.dhcpWaitErr, tc.wantWaitErr) } // Only time commands are run are for reloads. if mockRunner != nil && tc.noReload != (len(mockRunner.seenOpts) == 0) { t.Errorf("Setup() called commands %d times, want %t\nCommands: %+v", len(mockRunner.seenOpts), tc.noReload, mockRunner.seenOpts) } }) } } func TestRollbackNetwork(t *testing.T) { tests := []struct { name string data string wantErr bool want bool }{ { name: "no-such-file", want: false, wantErr: false, }, { name: "invalid-data", data: "invalid data", want: true, wantErr: false, }, { name: "success", data: "key = value", want: true, wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { file := filepath.Join(t.TempDir(), "systemd", "network", tc.name+".network") if tc.data != "" { if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if err := os.WriteFile(file, []byte(tc.data), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } } want, err := rollbackConfiguration(file) if (err == nil) == tc.wantErr { t.Errorf("rollbackNetwork() = %v, want error? %v", err, tc.wantErr) } if want != tc.want { t.Errorf("rollbackNetwork() = %v, want %v", want, tc.want) } }) } } func TestRollback(t *testing.T) { tests := []struct { name string reload bool opts *service.Options data string wantErr bool }{ { name: "success-empty", opts: &service.Options{}, wantErr: false, }, { name: "success-no-files-removed", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, }, { name: "success-remove-file", reload: true, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { configDir := filepath.Join(t.TempDir(), "systemd", "network") mod := &Module{ configDir: configDir, dhcpLeasesDir: filepath.Join(t.TempDir(), "dhcp", "leases"), networkCtlKeys: []string{"AdministrativeState", "SetupState"}, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, } // Create a mock DHCP leases directory with a file to prevent unnecessary waits. if err := os.MkdirAll(mod.dhcpLeasesDir, 0755); err != nil { t.Fatalf("failed to create mock DHCP leases directory: %v", err) } if err := os.WriteFile(filepath.Join(mod.dhcpLeasesDir, "2"), []byte("test"), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } if tc.data != "" { nic := tc.opts.NICConfigs()[0] networkFile := mod.networkFile(nic.Interface.NameOp()) deprecatedNetworkFile := mod.deprecatedNetworkFile(nic.Interface.NameOp()) for _, file := range []string{networkFile, deprecatedNetworkFile} { if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if err := os.WriteFile(file, []byte(tc.data), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } } } err := mod.Rollback(context.Background(), tc.opts, !tc.reload) if (err == nil) == tc.wantErr { t.Errorf("Rollback() = %v, want error? %v", err, tc.wantErr) } }) } } func TestVlanSetup(t *testing.T) { iface := ðernet.Interface{ NameOp: func() string { return "iface" }, } vic := ðernet.VlanInterface{ Parent: iface, MTU: 1500, Vlan: 1, } configDir := filepath.Join(t.TempDir(), "systemd", "network") mod := &Module{ configDir: configDir, networkCtlKeys: []string{"AdministrativeState", "SetupState"}, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, } if err := os.MkdirAll(filepath.Dir(configDir), 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } // Write a file as the configuration directory so we can fail to os.ReadDir(). if err := os.WriteFile(configDir, []byte("test"), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } // Cleanup should fail due to os.ReadDir() failing. cleanedUp, err := mod.cleanupVlanConfigs(nil) if err != nil { t.Errorf("cleanupVlanConfigs() = nil, want error") } if cleanedUp { t.Errorf("cleanupVlanConfigs() = true, want false") } if err := os.Remove(configDir); err != nil { t.Fatalf("failed to remove file: %v", err) } if err := os.MkdirAll(configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } // Second run with os.ReadDir() succeeding. if _, err := mod.writeVlanConfig(vic); err != nil { t.Fatalf("failed to write vlan config: %v", err) } // A pre-existing directory should not be deleted. existingDir := filepath.Join(configDir, "pre-existing-dir") if err := os.MkdirAll(existingDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } invalidNetworkFile := strings.Replace(mod.networkFile(vic.InterfaceName()), "google-guest-agent", "xxx", 1) if err := os.MkdirAll(filepath.Dir(invalidNetworkFile), 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if err := os.WriteFile(invalidNetworkFile, []byte("test"), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } invalidNetdevFile := strings.Replace(mod.netdevFile(vic.InterfaceName()), "google-guest-agent", "xxxx", 1) if err := os.WriteFile(invalidNetdevFile, []byte("test"), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } if !file.Exists(mod.netdevFile(vic.InterfaceName()), file.TypeFile) { t.Errorf("vlan .netdev config file %s does not exist", mod.networkFile(vic.InterfaceName())) } cleanedUp, err = mod.cleanupVlanConfigs(nil) if err != nil { t.Fatalf("failed to cleanup vlan configs: %v", err) } if !cleanedUp { t.Errorf("vlan configs were not cleaned up") } if file.Exists(mod.networkFile(vic.InterfaceName()), file.TypeFile) { t.Errorf("vlan .network config file %s was not cleaned up", mod.networkFile(vic.InterfaceName())) } if file.Exists(mod.netdevFile(vic.InterfaceName()), file.TypeFile) { t.Errorf("vlan .netdev config file %s was not cleaned up", mod.netdevFile(vic.InterfaceName())) } if !file.Exists(invalidNetworkFile, file.TypeFile) { t.Errorf("invalid .network config file %s was deleted", invalidNetworkFile) } if !file.Exists(invalidNetdevFile, file.TypeFile) { t.Errorf("invalid .netdev config file %s was deleted", invalidNetdevFile) } if !file.Exists(existingDir, file.TypeDir) { t.Errorf("existing directory %s was deleted", existingDir) } } func TestWriteDropins(t *testing.T) { tests := []struct { name string opts *service.Options wantErr bool }{ { name: "empty-success", opts: &service.Options{}, wantErr: false, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { dropinDir := filepath.Join(t.TempDir(), "systemd", "network", "dropins") if err := os.MkdirAll(dropinDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } configDir := filepath.Join(t.TempDir(), "systemd", "network") if err := os.MkdirAll(configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } mod := &Module{ dropinDir: dropinDir, configDir: configDir, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, } _, err := mod.WriteDropins(tc.opts.NICConfigs(), "default-prefix") if (err == nil) == tc.wantErr { t.Errorf("WriteDropins() = %v, want %v", err, tc.wantErr) } }) } } func TestRollbackDropins(t *testing.T) { tests := []struct { name string opts *service.Options data string wantErr bool }{ { name: "empty-success", opts: &service.Options{}, wantErr: false, }, { name: "fail-no-file", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), data: "key = value", wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { dropinDir := filepath.Join(t.TempDir(), "systemd", "network", "dropins") mod := &Module{ dropinDir: dropinDir, priority: defaultSystemdNetworkdPriority, deprecatedPriority: deprecatedPriority, } filePrefix := "default-prefix" if tc.data != "" { filePath := mod.dropinFile(filePrefix, fmt.Sprintf("a-%s", tc.opts.NICConfigs()[0].Interface.Name())) if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if err := os.WriteFile(filePath, []byte(tc.data), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } } err := mod.RollbackDropins(tc.opts.NICConfigs(), "default-prefix", false) if (err == nil) == tc.wantErr { t.Errorf("WriteDropins() = %v, want %v", err, tc.wantErr) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/networkd/networkd_test.go000066400000000000000000000021171524141036400275250ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package networkd import ( "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" ) func TestDefaultModule(t *testing.T) { cfg.Load(nil) mod := DefaultModule() if mod.priority != defaultSystemdNetworkdPriority { t.Errorf("defaultModule() priority = %d, want %d", mod.priority, defaultSystemdNetworkdPriority) } if mod.deprecatedPriority != deprecatedPriority { t.Errorf("defaultModule() deprecatedPriority = %d, want %d", mod.deprecatedPriority, deprecatedPriority) } } google-guest-agent-20260819.00/cmd/core_plugin/network/nm/000077500000000000000000000000001524141036400230565ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/nm/nm.go000066400000000000000000000105361524141036400240240ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package nm provides is the service implementation for NetworkManager. package nm import ( "os/exec" ) const ( // serviceID is the ID of the NetworkManager service. serviceID = "NetworkManager" // defaultNetworkManagerConfigDir is the directory where the network manager // nmconnection files are stored. defaultNetworkManagerConfigDir = "/etc/NetworkManager/system-connections" // defaultNetworkScriptsDir is the directory where the old (no longer managed) // ifcfg files are stored. defaultNetworkScriptsDir = "/etc/sysconfig/network-scripts" // nmConfigFileMode is the file mode for the NetworkManager config files. // The permissions need to be 600 in order for nmcli to load and use the file // correctly. nmConfigFileMode = 0600 // defaultAutoconnectPriority is the default autoconnect priority for // NetworkManager connections. The priority ranges from -999 to 999, having it // set to 100 gives room for users to override our configuration if they need // (where it's either not too high or too low). defaultAutoconnectPriority = 100 ) var ( // execLookPath is a mockable version of exec.LookPath. This is used for // testing. execLookPath = exec.LookPath ) // nmConnectionSection is the connection section of NetworkManager's keyfile. type nmConnectionSection struct { // InterfaceName is the name of the interface to configure. InterfaceName string `ini:"interface-name"` // ID is the unique ID for this connection. ID string `ini:"id"` // ConnType is the type of connection (i.e. ethernet). ConnType string `ini:"type"` // Autoconnect is the autoconnect setting for this connection, it means this // connection will be automatically connected when the NetworkManager is // activating the connection, if more than one connection is available for the // interface the one with higher AutoconnectPriority will be chosen. Autoconnect bool `ini:"autoconnect"` // AutoconnectPriority is the priority of this connection, it means this // connection (if autoconnect is true) will be automatically connected when // the NetworkManager is activating the connection, if more than one // connection is available for the interface the one with higher // AutoconnectPriority will be chosen. AutoconnectPriority int `ini:"autoconnect-priority"` } // nmIPSection is the ipv4/ipv6 section of NetworkManager's keyfile. type nmIPSection struct { // Method is the IP configuration method. Supports "auto", "manual", and // "link-local". Method string `ini:"method"` } // nmVlan is the vlan section of NetworkManager's keyfile. type nmVlan struct { // Flags are the flags for the vlan. See the following link for more details: // https://networkmanager.dev/docs/api/latest/nm-settings-nmcli.html Flags int `ini:"flags"` // ID is the actual Vlan ID. ID int `ini:"id"` // Parent is the name of the parent interface. Parent string `ini:"parent"` } // nmConfig is a wrapper containing all the sections for the NetworkManager // keyfile. type nmConfig struct { // Connection is the connection section. Connection nmConnectionSection `ini:"connection"` // Ipv4 is the ipv4 section. Ipv4 nmIPSection `ini:"ipv4"` // Ipv6 is the ipv6 section. Ipv6 nmIPSection `ini:"ipv6"` // Vlan is the vlan section. Vlan *nmVlan `ini:"vlan,omitempty"` } // serviceNetworkManager is the service implementation for NetworkManager. type serviceNetworkManager struct { // networkScriptsDir is the directory containing the ifcfg files. networkScriptsDir string // configDir is the directory containing the NetworkManager config files. configDir string } // defaultModule returns the default NetworkManager service implementation. func defaultModule() *serviceNetworkManager { return &serviceNetworkManager{ networkScriptsDir: defaultNetworkScriptsDir, configDir: defaultNetworkManagerConfigDir, } } google-guest-agent-20260819.00/cmd/core_plugin/network/nm/nm_linux.go000066400000000000000000000326311524141036400252430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package nm import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "slices" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/ini" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/regex" ) // NewService returns a new NetworkManager service handler. func NewService() *service.Handle { mod := defaultModule() return &service.Handle{ ID: serviceID, IsManaging: mod.IsManaging, Setup: mod.Setup, Rollback: mod.Rollback, } } // IsManaging returns true if the service is managing the network interface. func (sn *serviceNetworkManager) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { galog.Debugf("Checking if NetworkManager is managing the network interfaces.") // Check for existence of nmcli. Without nmcli, the agent cannot tell // NetworkManager to reload the configs for its connections. if _, err := execLookPath("nmcli"); err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil } return false, fmt.Errorf("error checking for nmcli: %w", err) } // Check whether NetworkManager.service is active. status, err := daemon.UnitStatus(ctx, "NetworkManager.service") if err != nil { return false, fmt.Errorf("error checking status of NetworkManager.service: %w", err) } if status != daemon.Active { return false, nil } // Use nmcli to check status of provided interface. opt := run.Options{OutputType: run.OutputStdout, Name: "nmcli", Args: []string{"-t", "-f", "DEVICE,STATE", "dev", "status"}} res, err := run.WithContext(ctx, opt) if err != nil { return false, fmt.Errorf("error checking status of devices on NetworkManager: %w", err) } lines := strings.Split(res.Output, "\n") primaryNIC, err := opts.GetPrimaryNIC() if err != nil { return false, fmt.Errorf("failed to get primary NIC: %v", err) } iface := primaryNIC.Interface.Name() for _, line := range lines { if strings.HasPrefix(line, iface) { fields := strings.Split(line, ":") return fields[1] == "connected", nil } } return false, nil } // Setup sets up the network interface. func (sn *serviceNetworkManager) Setup(ctx context.Context, opts *service.Options) error { galog.Info("Setting up NetworkManager interfaces.") nicConfigs := opts.FilteredNICConfigs() var keepVlanConfigs []string // Write the config files. for _, nic := range nicConfigs { if !nic.ShouldManage() { continue } fPath := sn.configFilePath(nic.Interface.Name()) // Write the config file for the current NIC. if err := sn.writeEthernetConfig(nic, fPath); err != nil { return err } // Write the VLAN config files for the current NIC. for _, vic := range nic.VlanInterfaces { if err := sn.writeVlanConfig(vic, sn.configFilePath(vic.InterfaceName())); err != nil { return err } keepVlanConfigs = append(keepVlanConfigs, vic.InterfaceName()) } } if err := sn.cleanupVlanConfigs(keepVlanConfigs); err != nil { return fmt.Errorf("failed to cleanup vlan configs: %w", err) } // This is primarily for RHEL-7 compatibility. Without reloading, attempting // to enable the connections in the next step returns a "mismatched interface" // error. if err := sn.reloadInterfaces(ctx); err != nil { return fmt.Errorf("failed to reload NetworkManager interfaces: %w", err) } // Enable the new connections. Ignore the primary interface as it will already // be up. for _, nic := range nicConfigs { if !nic.ShouldManage() { continue } connID := sn.connectionID(nic.Interface.Name()) galog.Debugf("Enabling NetworkManager connection(%q).", connID) opt := run.Options{OutputType: run.OutputNone, Name: "nmcli", Args: []string{"conn", "up", "id", connID}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error enabling NetworkManager connection(%q): %w", connID, err) } } galog.Info("Finished setting up NetworkManager interfaces.") return nil } // cleanupVlanConfigs removes vlan interfaces that are no longer present. The // process involves iterating over all configuration files present in the known // configuration directory and removing the files which names match the known // naming pattern for vlan interfaces and that are not present in the keepMe // list. func (sn *serviceNetworkManager) cleanupVlanConfigs(keepMe []string) error { galog.Debugf("Cleaning up NetworkManager vlan interfaces.") if !file.Exists(sn.configDir, file.TypeDir) { galog.V(2).Debugf("No NetworkManager configuration directory found: %s.", sn.configDir) return nil } files, err := os.ReadDir(sn.configDir) if err != nil { return fmt.Errorf("failed to read content from %s: %w", sn.configDir, err) } configExp := `(?Pgoogle-guest-agent)-(?P.*\.[0-9]+)\.(?Pnmconnection)` configRegex := regexp.MustCompile(configExp) for _, file := range files { // Skip directories. if file.IsDir() { continue } fileName := file.Name() groups := regex.GroupsMap(configRegex, fileName) galog.V(2).Debugf("Vlan file(%q) name extracted groups: %v.", fileName, groups) // If we don't have a matching interface skip it. currIface, ok := groups["interface"] if !ok { continue } // If prefix is not google-guest-agent that means it's not a vlan interface // we created. if prefix, ok := groups["prefix"]; !ok || prefix != "google-guest-agent" { continue } // If this is an interface still present skip it. if slices.Contains(keepMe, currIface) { continue } galog.Debugf("Removing NetworkManager vlan interface config(%s).", fileName) if err := os.Remove(filepath.Join(sn.configDir, fileName)); err != nil { return fmt.Errorf("failed to remove vlan interface config(%s): %w", fileName, err) } } galog.Debugf("Finished cleaning up NetworkManager vlan interfaces.") return nil } // writeVlanConfig writes the NetworkManager config file for the provided VLAN. func (sn *serviceNetworkManager) writeVlanConfig(vic *ethernet.VlanInterface, filePath string) error { galog.Debugf("Writing NetworkManager VLAN config file for %s.", vic.InterfaceName()) // Create the ini file. iface := vic.InterfaceName() connID := fmt.Sprintf("google-guest-agent-%s", iface) config := nmConfig{ Connection: nmConnectionSection{ InterfaceName: iface, ID: connID, ConnType: "vlan", }, Vlan: &nmVlan{ // for now hardcoded with NM_VLAN_FLAG_REORDER_HEADERS we don't support // other flags. Flags: 1, ID: vic.Vlan, Parent: vic.Parent.Name(), }, Ipv4: nmIPSection{ Method: "auto", }, Ipv6: nmIPSection{ Method: "auto", }, } // Save the config file. if err := ini.WriteIniFile(filePath, &config); err != nil { return fmt.Errorf("error writing NetworkManager VLAN config file: %v", err) } // If the permission is not properly set nmcli will fail to load the file // correctly. if err := os.Chmod(filePath, nmConfigFileMode); err != nil { return fmt.Errorf("error updating permissions for %s VLAN connection config: %w", iface, err) } galog.Debugf("Successfully wrote NetworkManager VLAN config file for %s.", vic.InterfaceName()) return nil } // writeEthernetConfig writes the NetworkManager config file for the provided // NIC. func (sn *serviceNetworkManager) writeEthernetConfig(nic *nic.Configuration, filePath string) error { galog.Debugf("Writing NetworkManager ethernet config file: %s", filePath) // Create the ini file. iface := nic.Interface.Name() config := nmConfig{ Connection: nmConnectionSection{ InterfaceName: iface, ID: sn.connectionID(iface), ConnType: "ethernet", Autoconnect: true, AutoconnectPriority: defaultAutoconnectPriority, }, Ipv4: nmIPSection{ Method: "auto", }, Ipv6: nmIPSection{ Method: "auto", }, } inicfg, err := ini.ReflectFrom(&config) if err != nil { return fmt.Errorf("error marshalling ini file: %w", err) } // Save the config file. if err := inicfg.SaveTo(filePath); err != nil { return fmt.Errorf("error writing NetworkManager config file: %v", err) } // If the permission is not properly set nmcli will fail to load the file // correctly. if err := os.Chmod(filePath, nmConfigFileMode); err != nil { return fmt.Errorf("error updating permissions for %s connection config: %w", iface, err) } // Remove the previously managed ifcfg file if it exists. if err := os.RemoveAll(sn.ifcfgFilePath(iface)); err != nil { return fmt.Errorf("failed to remove previously managed ifcfg file(%s): %w", sn.ifcfgFilePath(iface), err) } galog.Debugf("Successfully wrote NetworkManager ethernet config file: %s", filePath) return nil } // reloadInterfaces reloads the provided interfaces. func (sn *serviceNetworkManager) reloadInterfaces(ctx context.Context) error { opt := run.Options{OutputType: run.OutputNone, Name: "nmcli", Args: []string{"conn", "reload"}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error reloading NetworkManager config cache: %w", err) } return nil } // connectionID returns the connection ID for the given interface. func (sn *serviceNetworkManager) connectionID(iface string) string { return fmt.Sprintf("google-guest-agent-%s", iface) } // configFilePath gets the config file path for the provided interface. func (sn *serviceNetworkManager) configFilePath(iface string) string { fName := fmt.Sprintf("google-guest-agent-%s.nmconnection", iface) return filepath.Join(sn.configDir, fName) } // ifcfgFilePath returns the path to the ifcfg file for the given interface. func (sn *serviceNetworkManager) ifcfgFilePath(iface string) string { return filepath.Join(sn.networkScriptsDir, fmt.Sprintf("ifcfg-%s", iface)) } // Rollback rolls back the network interface. func (sn *serviceNetworkManager) Rollback(ctx context.Context, opts *service.Options, active bool) error { galog.Infof("Rolling back changes for NetworkManager with reload [%t].", !active) // removeOp is a helper struct to keep track of which config files to remove. // More than just keeping track of the file path it also keeps track of the // type of config file (ethernet or VLAN). type removeOp struct { primary bool name string configType string configFile string } // Iterate over all NICs and remove their respective config file. var deleteMe []removeOp for _, nic := range opts.FilteredNICConfigs() { // If this is the active network manager, we only want to rollback the // primary NIC if we are not managing it. if active && (nic.Index != 0 || nic.ShouldManage()) { continue } deleteMe = append(deleteMe, removeOp{ primary: nic.Index == 0, name: nic.Interface.Name(), configType: "ethernet", configFile: sn.configFilePath(nic.Interface.Name()), }) for _, vic := range nic.VlanInterfaces { deleteMe = append(deleteMe, removeOp{ configType: "VLAN", configFile: sn.configFilePath(vic.InterfaceName()), }) } } var reconnectPrimaryNic bool var primaryOp removeOp for _, op := range deleteMe { galog.Debugf("Removing NetworkManager configuration: %q", op.configFile) err := os.Remove(op.configFile) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("error deleting NetworkManager %s config file(%q): %v", op.configType, op.configFile, err) } // If the primary NIC config file was removed successfully, we need to // reconnect it to NetworkManager. if op.primary && err == nil { reconnectPrimaryNic = true primaryOp = op } } if _, err := execLookPath("nmcli"); err != nil { galog.Debugf("Cannot find nmcli, skipping reload: %v", err) return nil } if !active { if err := sn.reloadInterfaces(ctx); err != nil { return fmt.Errorf("failed to reload NetworkManager interfaces: %w", err) } return nil } // NetworkManager will not create a default connection if we are removing the // one we manage, in that case we need to force it to connect and then with // that create a default connection. // // We only do this if we are not managing the primary NIC to avoid unnecessary // reloads. Otherwise the primary NIC will be reloaded twice. if reconnectPrimaryNic && !cfg.Retrieve().NetworkInterfaces.ManagePrimaryNIC { opt := run.Options{OutputType: run.OutputNone, Name: "nmcli", Args: []string{"device", "connect", primaryOp.name}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error reconnecting device(%q): %w", primaryOp.name, err) } } return nil } google-guest-agent-20260819.00/cmd/core_plugin/network/nm/nm_linux_test.go000066400000000000000000000571061524141036400263060ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nm import ( "context" "errors" "fmt" "os" "os/exec" "path" "path/filepath" "slices" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/google/go-cmp/cmp" ) type runMock struct { callback func(context.Context, run.Options) (*run.Result, error) called []string } func (rm *runMock) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { called := fmt.Sprintf("%s %s", opts.Name, strings.Join(opts.Args, " ")) rm.called = append(rm.called, called) return rm.callback(ctx, opts) } func TestNewService(t *testing.T) { svc := NewService() if svc == nil { t.Fatalf("NewService() = nil, want non-nil") } if svc.ID != serviceID { t.Errorf("NewService().ID = %q, want %q", svc.ID, serviceID) } if svc.IsManaging == nil { t.Errorf("NewService().IsManaging = nil, want non-nil") } if svc.Setup == nil { t.Errorf("NewService().Setup = nil, want non-nil") } if svc.Rollback == nil { t.Errorf("NewService().Rollback = nil, want non-nil") } } func TestIsManaging(t *testing.T) { successExecLookPath := func(string) (string, error) { return "nmcli", nil } tests := []struct { name string execLookPath func(string) (string, error) opts *service.Options runMock *runMock wantErr bool want bool }{ { name: "no-nmcli-installed", execLookPath: func(string) (string, error) { return "", exec.ErrNotFound }, wantErr: false, want: false, }, { name: "fail-to-lookup-nmcli", execLookPath: func(string) (string, error) { return "", errors.New("unknown error") }, wantErr: true, want: false, }, { name: "fail-check-nm-active", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "systemctl" { return nil, errors.New("unknown error") } return &run.Result{}, nil }, }, wantErr: true, want: false, }, { name: "fail-query-interfaces", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { if opts.Name == "nmcli" { return nil, errors.New("unknown error") } return &run.Result{Output: "active"}, nil }, }, wantErr: true, want: false, }, { name: "no-result", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{Output: ""}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: false, }, { name: "non-connected-interface", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{Output: "iface:unmanaged"}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: false, }, { name: "managing", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active NetworkManager.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "nmcli" && args == "-t -f DEVICE,STATE dev status" { return &run.Result{Output: "iface:connected"}, nil } return nil, errors.New("unknown error") }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: true, }, { name: "not-managing", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active NetworkManager.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "nmcli" && args == "-t -f DEVICE,STATE dev status" { return &run.Result{Output: "invalid-interface:unknown"}, nil } return nil, errors.New("unknown error") }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: false, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &serviceNetworkManager{} execLookPath = tc.execLookPath oldRunClient := run.Client run.Client = tc.runMock t.Cleanup(func() { run.Client = oldRunClient execLookPath = exec.LookPath }) got, err := svc.IsManaging(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("IsManaging() = %v, want error? %v", err, tc.wantErr) } if got != tc.want { t.Errorf("IsManaging() = %v, want %v", got, tc.want) } }) } } func TestSetup(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } tests := []struct { name string runMock *runMock opts *service.Options wantConfig []string createIfcfgFiles bool createConfigDirs bool wantErr bool }{ { name: "fail-writing-config", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, fmt.Errorf("unknown error") }, }, createConfigDirs: false, wantErr: true, }, { name: "fail-reloading", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "nmcli" && args == "conn reload" { return nil, errors.New("unknown error") } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), createConfigDirs: true, createIfcfgFiles: true, wantErr: true, }, { name: "fail-reloading-with-vlan", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "nmcli" && args == "conn reload" { return nil, errors.New("unknown error") } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 1, }, }, Index: 1, }, }), createConfigDirs: true, createIfcfgFiles: true, wantErr: true, }, { name: "fail-secondary-interface-bringup", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "nmcli" && strings.HasPrefix(args, "conn up") { return nil, errors.New("unknown error") } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), createConfigDirs: true, createIfcfgFiles: true, wantErr: true, }, { name: "fail-secondary-interface-bringup-with-vlan", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "nmcli" && strings.HasPrefix(args, "conn up") { return nil, errors.New("unknown error") } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 1, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Vlan: 1, }, }, Index: 2, }, }), createConfigDirs: true, createIfcfgFiles: true, wantErr: true, }, { name: "success", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, ExtraAddresses: &address.ExtraAddresses{ IPAliases: address.NewIPAddressMap([]string{"192.168.1.1", "10.10.10.10", "10.10.10.10/24"}, nil), }, Index: 2, }, }), wantConfig: []string{ `[connection] interface-name = iface id = google-guest-agent-iface type = ethernet autoconnect = true autoconnect-priority = 100 [ipv4] method = auto [ipv6] method = auto `, `[connection] interface-name = iface-2 id = google-guest-agent-iface-2 type = ethernet autoconnect = true autoconnect-priority = 100 [ipv4] method = auto [ipv6] method = auto `}, createConfigDirs: true, createIfcfgFiles: true, wantErr: false, }, { name: "success-with-vlan", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 1, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Vlan: 1, }, }, Index: 2, }, }), wantConfig: []string{ `[connection] interface-name = iface id = google-guest-agent-iface type = ethernet autoconnect = true autoconnect-priority = 100 [ipv4] method = auto [ipv6] method = auto `, `[connection] interface-name = iface-2 id = google-guest-agent-iface-2 type = ethernet autoconnect = true autoconnect-priority = 100 [ipv4] method = auto [ipv6] method = auto `, }, createConfigDirs: true, createIfcfgFiles: true, wantErr: false, }, } ctx := context.Background() mapContent := func(data string) map[string]bool { res := make(map[string]bool) unwantedTokens := []string{" ", "\n", "\t"} for _, line := range strings.Split(data, "\n") { for _, token := range unwantedTokens { line = strings.ReplaceAll(line, token, "") } if line == "" { continue } res[line] = true } return res } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &serviceNetworkManager{ configDir: path.Join(t.TempDir(), "NetworkManager", "config"), networkScriptsDir: path.Join(t.TempDir(), "NetworkManager", "ifcfg"), } if err := os.MkdirAll(svc.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if tc.createConfigDirs { if err := os.MkdirAll(svc.networkScriptsDir, 0755); err != nil { t.Fatalf("failed to create mock network scripts directory: %v", err) } } if tc.createIfcfgFiles { if err := os.MkdirAll(svc.networkScriptsDir, 0755); err != nil { t.Fatalf("failed to create mock network scripts directory: %v", err) } if err := os.WriteFile(svc.ifcfgFilePath("iface"), []byte("iface"), 0644); err != nil { t.Fatalf("failed to create mock ifcfg file: %v", err) } } oldRunClient := run.Client run.Client = tc.runMock t.Cleanup(func() { run.Client = oldRunClient }) err := svc.Setup(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want error? %v", err, tc.wantErr) } for ii, wantConfig := range tc.wantConfig { nic := tc.opts.NICConfigs()[ii] iface := nic.Interface.Name() content, err := os.ReadFile(svc.configFilePath(iface)) if err != nil { t.Fatalf("failed to read config file: %v", err) } // The marshalling process is not stable when it comes to indentation // and the order the fields are written. contentMap := mapContent(string(content)) wantConfigMap := mapContent(wantConfig) if diff := cmp.Diff(wantConfigMap, contentMap); diff != "" { t.Errorf("config file(%d) content diff (-want +got):\n%s", ii, diff) } } }) } } func TestRollback(t *testing.T) { tests := []struct { // Name of the test. name string // List of test service options. opts *service.Options // Indicates whether the config file should be created. createConfigFile bool // Indicates whether the NetworkManager service is the active manager. active bool // Indicates whether nmcli exists on the system. nmcliExists bool // Indicates whether the primary NIC should be managed. primaryNic bool // Indicates whether the config file should be kept. keepConfig bool // Indicates whether nmcli conn reload should be called. wantReload bool // Indicates whether nmcli device connect iface should be called. wantReconnect bool // Indicates whether an error should be returned when Rollback is called. wantErr bool }{ { name: "no-config-file", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), createConfigFile: false, wantErr: false, }, { name: "no-config-file-with-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 1, }, }, Index: 1, }, }), createConfigFile: false, wantErr: false, }, { name: "with-config-file", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), createConfigFile: true, wantErr: false, }, { name: "with-config-file-with-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Vlan: 1, }, }, Index: 1, }, }), createConfigFile: true, wantErr: false, }, { name: "with-config-file-reload", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, }), createConfigFile: true, nmcliExists: true, active: false, wantReload: true, wantReconnect: false, wantErr: false, }, { // Primary NIC config file shouldn't be removed if this is the active // manager. As a result, neither reload nor reconnect should be called. name: "with-config-file-primary-nic-no-reconnect", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 0, }, }), createConfigFile: true, nmcliExists: true, active: true, keepConfig: true, primaryNic: true, wantReload: false, wantReconnect: false, wantErr: false, }, { // In case we are active, but the primary NIC is no longer being managed, // we need to remove the primary NIC config file and force NM to reconnect. name: "with-config-file-no-primary-nic-yes-reconnect", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 0, }, }), createConfigFile: true, nmcliExists: true, active: true, primaryNic: false, wantReload: false, wantReconnect: true, wantErr: false, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } cfg.Retrieve().NetworkInterfaces.ManagePrimaryNIC = tc.primaryNic runMock := &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, } oldRunClient := run.Client run.Client = runMock t.Cleanup(func() { run.Client = oldRunClient }) svc := &serviceNetworkManager{ configDir: path.Join(t.TempDir(), "NetworkManager", "config"), } if tc.nmcliExists { execLookPath = func(path string) (string, error) { return "nmcli", nil } } if tc.createConfigFile { if err := os.MkdirAll(svc.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } if err := os.WriteFile(svc.configFilePath("iface"), []byte("iface"), 0644); err != nil { t.Fatalf("failed to create mock network config file: %v", err) } } // Run the rollback. err := svc.Rollback(ctx, tc.opts, tc.active) if (err == nil) == tc.wantErr { t.Errorf("Rollback() = %v, want error? %v", err, tc.wantErr) } // Check if the config file was removed or kept. fileExists := file.Exists(svc.configFilePath("iface"), file.TypeFile) if !tc.wantErr && fileExists && !tc.keepConfig { t.Errorf("config file %s was not removed", svc.configFilePath("iface")) } if tc.keepConfig && !fileExists { t.Errorf("config file %s was removed", svc.configFilePath("iface")) } // Check if nmcli conn reload was called. reloadCalled := slices.Contains(runMock.called, "nmcli conn reload") if tc.wantReload != reloadCalled { t.Errorf("nmcli conn reload called: %v, want: %v", reloadCalled, tc.wantReload) } // Check if nmcli device connect iface was called. reconnectCalled := slices.Contains(runMock.called, "nmcli device connect iface") if tc.wantReconnect != reconnectCalled { t.Errorf("nmcli device connect iface called: %v, want: %v", reconnectCalled, tc.wantReconnect) } }) } } func TestCleanupVlanConfigs(t *testing.T) { tests := []struct { name string configFiles []string survivingFiles []string }{ { name: "empty-config-dir", configFiles: []string{}, }, { name: "all-valid-files", configFiles: []string{ "google-guest-agent-test1-iface.1.nmconnection", "google-guest-agent-test1-iface2.1.nmconnection", "google-guest-agent-test1-iface3.2.nmconnection", }, }, { name: "valid-unkown-prefix-leftover", configFiles: []string{ "google-guest-agent-test2-iface.1.nmconnection", "google-guest-agent-test2-iface2.2.nmconnection", "google-guest-agent-test2-iface3.3.nmconnection", "unkown-prefix-iface4.nmconnection", }, survivingFiles: []string{ "unkown-prefix-iface4.nmconnection", }, }, { name: "keep-matching-surviving-files", configFiles: []string{ "google-guest-agent-test3-iface.1.nmconnection", "google-guest-agent-test3-iface2.2.nmconnection", "google-guest-agent-test3-iface3.3.nmconnection", }, survivingFiles: []string{ "google-guest-agent-test3-iface2.2.nmconnection", "google-guest-agent-test3-iface3.3.nmconnection", }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc := &serviceNetworkManager{ configDir: path.Join(t.TempDir(), "NetworkManager", "config"), } for _, file := range tc.configFiles { if err := os.MkdirAll(svc.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } fpath := filepath.Join(svc.configDir, file) if err := os.WriteFile(fpath, []byte("config data"), 0644); err != nil { t.Fatalf("failed to create mock network config file: %v", err) } } if err := svc.cleanupVlanConfigs(tc.survivingFiles); err != nil { t.Errorf("cleanupVlanConfigs() = %v, want nil", err) } if len(tc.configFiles) > 0 { dir, err := os.ReadDir(svc.configDir) if err != nil { t.Fatalf("failed to open config directory: %v", err) } for _, file := range dir { if !slices.Contains(tc.survivingFiles, file.Name()) { t.Errorf("file %s was not removed", file.Name()) } } } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/nm/nm_test.go000066400000000000000000000020331524141036400250540ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nm import ( "testing" ) func TestDefaultModule(t *testing.T) { mod := defaultModule() if mod.networkScriptsDir != defaultNetworkScriptsDir { t.Errorf("defaultModule() returned diff networkScriptsDir: got %q, want %q.", mod.networkScriptsDir, defaultNetworkScriptsDir) } if mod.configDir != defaultNetworkManagerConfigDir { t.Errorf("defaultModule() returned diff configDir: got %q, want %q.", mod.configDir, defaultNetworkManagerConfigDir) } } google-guest-agent-20260819.00/cmd/core_plugin/network/wicked/000077500000000000000000000000001524141036400237125ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/network/wicked/wicked.go000066400000000000000000000034541524141036400255150ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package wicked provides network management service implementation for wicked. package wicked import ( "os/exec" ) const ( // serviceID is the ID of the wicked service. serviceID = "wicked" // dhclientEthernetRoutePriority is the priority for the dhclient route used // for the ethernet interface. dhclientEthernetRoutePriority = 10100 // dhclientVlanRoutePriority is the priority for the dhclient route used // for the vlan interface. dhclientVlanRoutePriority = 20200 // googleComment is the comment to add as the head of the wicked config file. googleComment = "# Added by Google Compute Engine Guest Agent." // defaultWickedConfigDir is the default location for wicked configuration files. defaultWickedConfigDir = "/etc/sysconfig/network" ) var ( // execLookPath is the function to use to find the wicked binary. This is // used for testing. execLookPath = exec.LookPath ) // serviceWicked is the service implementation for wicked. type serviceWicked struct { // configDir is the directory containing the wicked config files. configDir string } // defaultModule returns the default wicked service implementation. func defaultModule() *serviceWicked { return &serviceWicked{ configDir: defaultWickedConfigDir, } } google-guest-agent-20260819.00/cmd/core_plugin/network/wicked/wicked_linux.go000066400000000000000000000324421524141036400267330ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package wicked import ( "context" "errors" "fmt" "os" "os/exec" "path" "regexp" "slices" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/regex" ) // NewService returns a new wicked service handler. func NewService() *service.Handle { mod := defaultModule() return &service.Handle{ ID: serviceID, IsManaging: mod.IsManaging, Setup: mod.Setup, Rollback: mod.Rollback, } } // IsManaging returns true if wicked is managing the primary network interface. func (sn *serviceWicked) IsManaging(ctx context.Context, opts *service.Options) (bool, error) { galog.Debugf("Checking if wicked is managing the network interfaces.") if _, err := execLookPath("wicked"); err != nil { if errors.Is(err, exec.ErrNotFound) { return false, nil } return false, fmt.Errorf("error checking for wicked binary: %w", err) } // Check if the wicked service is running. status, err := daemon.UnitStatus(ctx, "wicked.service") if err != nil { return false, fmt.Errorf("failed to check status of wicked.service: %w", err) } if status != daemon.Active { return false, nil } primaryNIC, err := opts.GetPrimaryNIC() if err != nil { return false, fmt.Errorf("failed to get primary nic: %w", err) } iface := primaryNIC.Interface.Name() // Check the status of configured interfaces. opt := run.Options{OutputType: run.OutputStdout, Name: "wicked", Args: []string{"ifstatus", "--brief", iface}} res, err := run.WithContext(ctx, opt) if err != nil { return false, fmt.Errorf("failed to check status of wicked configuration: %s", res.Output) } fields := strings.Fields(res.Output) if len(fields) != 2 { return false, nil } if fields[1] == "up" || fields[1] == "setup-in-progress" { return true, nil } return false, nil } // Setup sets up the network interface. func (sn *serviceWicked) Setup(ctx context.Context, opts *service.Options) error { galog.Info("Setting up wicked interfaces.") var ifupInterfaces []string var vlanInterfaces []string // Write the config files. for _, nic := range opts.FilteredNICConfigs() { if !nic.ShouldManage() { continue } fPath := sn.ifcfgFilePath(nic.Interface.Name()) // Don't write a new config file if one already exists. if file.Exists(fPath, file.TypeFile) { galog.Debugf("Wicked config file for %s already exists (%s), skipping.", nic.Interface.Name(), fPath) } else { // Write the config file for the current NIC. if err := sn.writeEthernetConfig(nic, fPath); err != nil { return err } // Add the interface to the list of interfaces to bring up. ifupInterfaces = append(ifupInterfaces, nic.Interface.Name()) } priority := dhclientVlanRoutePriority // Write the VLAN config files for the current NIC. for _, vic := range nic.VlanInterfaces { fPath := sn.ifcfgFilePath(vic.InterfaceName()) if err := sn.writeVlanConfig(vic, priority, fPath); err != nil { return err } // Add the vlan interface to the list of interfaces to bring up. ifupInterfaces = append(ifupInterfaces, vic.InterfaceName()) // Add the vlan interface to the list of interfaces to avoid cleaning up. vlanInterfaces = append(vlanInterfaces, vic.InterfaceName()) priority += 100 } } // Early return if there are no interfaces to bring up. if len(ifupInterfaces) == 0 { galog.Infof("No wicked interfaces to bring up.") return nil } // Remove the config files for the interfaces we no longer manage, it will // make sure to turn them down before removing the config file. if err := sn.cleanupVlanInterfaces(ctx, vlanInterfaces); err != nil { return fmt.Errorf("failed to cleanup vlan interfaces: %w", err) } if err := sn.reloadInterfaces(ctx, ifupInterfaces); err != nil { return fmt.Errorf("failed to reload interfaces: %w", err) } galog.Infof("Finished setting up wicked interfaces.") return nil } // reloadInterfaces reloads the provided interfaces. func (sn *serviceWicked) reloadInterfaces(ctx context.Context, interfaces []string) error { opt := run.Options{OutputType: run.OutputNone, Name: "wicked", Args: append([]string{"ifreload"}, interfaces...)} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error reloading interfaces: %w", err) } return nil } // ifcfgFilePath gets the file path for the configuration file for the given // interface. func (sn *serviceWicked) ifcfgFilePath(iface string) string { return path.Join(sn.configDir, fmt.Sprintf("ifcfg-%s", iface)) } // writeVlanConfig writes the wicked config file for the provided VLAN. func (sn *serviceWicked) writeVlanConfig(vic *ethernet.VlanInterface, priority int, filePath string) error { galog.Debugf("Writing wicked VLAN config file for %s.", vic.InterfaceName()) configLines := []string{ googleComment, "BOOTPROTO=dhcp", // NOTE: 'dhcp' is the DHCPv4 + DHCPv6 option. "VLAN=yes", "ETHTOOL_OPTIONS=reorder_hdr off", fmt.Sprintf("DEVICE=%s", vic.InterfaceName()), fmt.Sprintf("MTU=%d", vic.MTU), fmt.Sprintf("LLADDR=%s", vic.MacAddr), fmt.Sprintf("ETHERDEVICE=%s", vic.Parent.Name()), fmt.Sprintf("VLAN_ID=%d", vic.Vlan), fmt.Sprintf("DHCLIENT_ROUTE_PRIORITY=%d", priority), } ifcfg, err := os.Create(filePath) if err != nil { return fmt.Errorf("failed to create vlan's ifcfg file: %s; %w", filePath, err) } defer ifcfg.Close() content := strings.Join(configLines, "\n") writeLen, err := ifcfg.WriteString(content) if err != nil { return fmt.Errorf("error writing vlan's icfg file: %s; %w", filePath, err) } if writeLen != len(content) { return fmt.Errorf("error writing vlan's ifcfg, wrote %d bytes, expected %d bytes", writeLen, len(content)) } galog.Debugf("Successfully wrote wicked VLAN config file for %s.", vic.InterfaceName()) return nil } // cleanupVlanInterfaces removes the config files for the provided interfaces. func (sn *serviceWicked) cleanupVlanInterfaces(ctx context.Context, keepMe []string) error { galog.Debugf("Cleaning up old wicked interfaces.") files, err := os.ReadDir(sn.configDir) if err != nil { return fmt.Errorf("failed to read content from: %s; %+v", sn.configDir, err) } configExp := `(?Pifcfg)-(?P.*)\.(?P.*)` configRegex := regexp.MustCompile(configExp) for _, file := range files { if file.IsDir() { continue } fileName := file.Name() filePath := path.Join(sn.configDir, fileName) groups := regex.GroupsMap(configRegex, fileName) // If we don't have a matching interface skip it. parent, found := groups["parent"] if !found { continue } // If it's not a vlan interface skip it. vlan, foundVlan := groups["vlan"] if !foundVlan { galog.Debugf("Skipping non-vlan interface ifcfg file: %s", filePath) continue } galog.V(2).Debugf("Vlan interface's ID: %s", vlan) iface := fmt.Sprintf("%s.%s", parent, vlan) // Don't remove the interface if it's in the list of interfaces to keep. if slices.Contains(keepMe, iface) { continue } removed, err := sn.removeInterface(ctx, filePath, false) if err != nil { return fmt.Errorf("failed to remove vlan interface: %+v", err) } if !removed { continue } opt := run.Options{OutputType: run.OutputNone, Name: "wicked", Args: []string{"ifdown", iface}} if _, err := run.WithContext(ctx, opt); err != nil { return fmt.Errorf("error disabling interfaces: %w", err) } } galog.Debugf("Finished cleaning up old wicked interfaces.") return nil } // writeEthernetConfig writes the wicked config file for the provided NIC. func (sn *serviceWicked) writeEthernetConfig(nic *nic.Configuration, filePath string) error { galog.Debugf("Writing wicked config file: %s", filePath) ifcfg, err := os.Create(filePath) if err != nil { return fmt.Errorf("failed to create wicked config file: %w", err) } defer ifcfg.Close() contents := []string{ googleComment, "STARTMODE=hotplug", // NOTE: 'dhcp' is the DHCPv4 and DHCPv6 option. "BOOTPROTO=dhcp", fmt.Sprintf("DHCLIENT_ROUTE_PRIORITY=%d", dhclientEthernetRoutePriority), } if cfg.Retrieve().Unstable.SetFQDN { // If google_up.sh does not exist (i.e. when guest-configs is too old), // guest-agent can still set the FQDN initially and things will *probably* // continue to work without problems, but if the VM gets assigned a new IP // the FQDN will stop working since guest-agent won't be notified. contents = append(contents, `POST_UP_SCRIPT="compat:suse:google_up.sh"`) } if _, err := ifcfg.WriteString(strings.Join(contents, "\n")); err != nil { return fmt.Errorf("error wicked ifcfg file: %s: %w", nic.Interface.Name(), err) } galog.Debugf("Successfully wrote wicked config file: %s", filePath) return nil } // Rollback rolls back the network interface. func (sn *serviceWicked) Rollback(ctx context.Context, opts *service.Options, active bool) error { galog.Infof("Rolling back changes for wicked with reload [%t].", !active) // If the config directory does not exist we got nothing to rollback, skip it. if !file.Exists(sn.configDir, file.TypeDir) { galog.Debugf("Wicked config directory does not exist, skipping rollback.") return nil } // Remove the config files. var reloadInterfaces []string for _, nic := range opts.FilteredNICConfigs() { // If this is the active network manager, we only want to rollback the // primary NIC if we are not managing it. if active && (nic.Index != 0 || nic.ShouldManage()) { continue } // Remove the config file for the current NIC. removed, err := sn.removeInterface(ctx, sn.ifcfgFilePath(nic.Interface.Name()), nic.Index == 0) if err != nil { return fmt.Errorf("failed to remove wicked config file: %w", err) } if removed { reloadInterfaces = append(reloadInterfaces, nic.Interface.Name()) } } // Remove all the vlan config files. if err := sn.cleanupVlanInterfaces(ctx, nil); err != nil { return fmt.Errorf("failed to cleanup vlan interfaces: %w", err) } if len(reloadInterfaces) == 0 { return nil } // Check if wicked is installed. if _, err := exec.LookPath("wicked"); err != nil { galog.Debugf("Cannot find wicked binary, skipping reload: %v", err) return nil } // Reload the wicked configuration. if !active { if err := sn.reloadInterfaces(ctx, reloadInterfaces); err != nil { return fmt.Errorf("failed to reload interfaces: %w", err) } } return nil } // removeInterface removes the wicked config file for the given interface. func (sn *serviceWicked) removeInterface(ctx context.Context, filePath string, isPrimary bool) (bool, error) { galog.Debugf("Attempting to remove wicked config file: %q", filePath) shouldRemove, err := managedByGuestAgent(filePath) if err != nil { return false, fmt.Errorf("failed to check if file is managed by guest agent: %w", err) } // File is not managed by us, skip it. if !shouldRemove { galog.Debugf("Wicked config file is not managed by guest agent: %q", filePath) return false, nil } // Delete the ifcfg file if it's not primary. We don't want to remove the // primary NIC's config file because doing so will cause the VM to lose // network connectivity once reloaded. if !isPrimary { if err = os.Remove(filePath); err != nil { return false, fmt.Errorf("error deleting config file: %s, %v", filePath, err) } galog.Debugf("Successfully removed wicked config file: %q", filePath) } return true, nil } // managedByGuestAgent checks if the provided file is managed by the guest // agent. func managedByGuestAgent(filePath string) (bool, error) { // Check if the file exists. info, err := os.Stat(filePath) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("failed to stat wicked ifcfg file: %+v", err) } commentLen := len(googleComment) // We definitely don't manage this file, skip it. if info.Size() < int64(commentLen) { return false, nil } configFile, err := os.Open(filePath) if err != nil { return false, fmt.Errorf("failed to open wicked ifcfg file: %+v", err) } defer configFile.Close() // Read only enough to fit - the comment and check if the comment is present. buffer := make([]byte, commentLen) _, err = configFile.Read(buffer) if err != nil { return false, fmt.Errorf("failed to read google comment from wicked ifcfg file: %+v", err) } // This file is clearly not managed by us. if string(buffer) != googleComment { return false, nil } return true, nil } google-guest-agent-20260819.00/cmd/core_plugin/network/wicked/wicked_linux_test.go000066400000000000000000000610411524141036400277670ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wicked import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/ethernet" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) type runMock struct { callback func(context.Context, run.Options) (*run.Result, error) } func (rm *runMock) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { return rm.callback(ctx, opts) } func TestNewService(t *testing.T) { svc := NewService() if svc == nil { t.Fatalf("NewService() = nil, want non-nil") } if svc.ID != serviceID { t.Errorf("NewService().ID = %q, want %q", svc.ID, serviceID) } if svc.IsManaging == nil { t.Errorf("NewService().IsManaging = nil, want non-nil") } if svc.Setup == nil { t.Errorf("NewService().Setup = nil, want non-nil") } if svc.Rollback == nil { t.Errorf("NewService().Rollback = nil, want non-nil") } } func TestIsManaging(t *testing.T) { successExecLookPath := func(string) (string, error) { return "wicked", nil } tests := []struct { name string execLookPath func(string) (string, error) opts *service.Options runMock *runMock wantErr bool want bool }{ { name: "no-wicked", execLookPath: func(string) (string, error) { return "", errors.New("no wicked") }, wantErr: true, want: false, }, { name: "fail-service-is-active", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{}, errors.New("unknown error") } return &run.Result{}, nil }, }, wantErr: true, want: false, }, { name: "no-wicked-service-inactive", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "inactive"}, nil } return &run.Result{}, nil }, }, wantErr: false, want: false, }, { name: "fail-ifstatus", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "wicked" && args == "ifstatus --brief iface" { return &run.Result{}, errors.New("unknown error") } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: true, want: false, }, { name: "not-managing-iface-invalid-output", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "wicked" && args == "ifstatus --brief iface" { return &run.Result{}, nil } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: false, }, { name: "not-managing-iface-valid-output", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "wicked" && args == "ifstatus --brief iface" { return &run.Result{Output: "foo bar"}, nil } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: false, }, { name: "success-with-up-field", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "wicked" && args == "ifstatus --brief iface" { return &run.Result{Output: "iface up"}, nil } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: true, }, { name: "success-with-setup-in-progress", execLookPath: successExecLookPath, runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { args := strings.Join(opts.Args, " ") if opts.Name == "systemctl" && args == "is-active wicked.service" { return &run.Result{Output: "active"}, nil } if opts.Name == "wicked" && args == "ifstatus --brief iface" { return &run.Result{Output: "iface setup-in-progress"}, nil } return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }), wantErr: false, want: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { execLookPath = tc.execLookPath oldRunClient := run.Client run.Client = tc.runMock t.Cleanup(func() { execLookPath = exec.LookPath run.Client = oldRunClient }) svc := &serviceWicked{} got, err := svc.IsManaging(ctx, tc.opts) if (err == nil) == tc.wantErr { t.Errorf("IsManaging() = %v, want error: %v", err, tc.wantErr) } if got != tc.want { t.Errorf("IsManaging() = %v, want %v", got, tc.want) } }) } } func TestSetup(t *testing.T) { tests := []struct { name string opts *service.Options configs string runMock *runMock createConfigDir bool wantErr bool }{ { name: "success-no-interfaces", opts: &service.Options{}, wantErr: false, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: true, wantErr: false, }, { name: "success-manage-primary-nic", configs: "[NetworkInterfaces]\nmanage_primary_nic = true", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 0, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: true, wantErr: false, }, { name: "success-with-hostname", configs: "[Unstable]\nset_fqdn = true", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: true, wantErr: false, }, { name: "success-with-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: true, wantErr: false, }, { name: "fail-create-config-file", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: false, wantErr: true, }, { name: "fail-create-config-file-with-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, createConfigDir: false, wantErr: true, }, { name: "fail-ifup", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, errors.New("unknown error") }, }, createConfigDir: true, wantErr: true, }, { name: "fail-ifup-with-vlan", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, errors.New("unknown error") }, }, createConfigDir: true, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if err := cfg.Load([]byte(tc.configs)); err != nil { t.Fatalf("cfg.Load(%q) = %v, want nil", tc.configs, err) } t.Cleanup(func() { if err := cfg.Load(nil); err != nil { t.Errorf("during cleanup, cfg.Load(nil) = %v, want nil", err) } }) oldRunClient := run.Client run.Client = tc.runMock t.Cleanup(func() { run.Client = oldRunClient }) svc := &serviceWicked{ configDir: filepath.Join(t.TempDir(), "wicked", "config"), } if tc.createConfigDir { if err := os.MkdirAll(svc.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } } err := svc.Setup(context.Background(), tc.opts) if (err == nil) == tc.wantErr { t.Errorf("Setup() = %v, want error: %v", err, tc.wantErr) } }) } } func TestRollback(t *testing.T) { tests := []struct { name string opts *service.Options runMock *runMock data string wantErr bool wantFileRemoved bool }{ { name: "success-no-interfaces", opts: service.NewOptions(nil, []*nic.Configuration{}), wantErr: false, }, { name: "success-no-config-file", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), wantFileRemoved: true, wantErr: false, }, { name: "success-no-config-file-with-vlan", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), wantFileRemoved: true, wantErr: false, }, { name: "success-no-config-file-with-vlan", runMock: &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), wantFileRemoved: true, wantErr: false, }, { name: "success-invalid-header-size", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), data: "# shorter header", // shorter than the google header/comment. wantErr: false, }, { name: "success-invalid-header", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), data: "# long header, longer than the google header/comment.", wantErr: false, }, { name: "success-invalid-header-size-with-vlan", runMock: &runMock{ callback: func(context.Context, run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), data: "# shorter header", // shorter than the google header/comment. wantErr: false, }, { name: "success", opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, Index: 2, }, }), data: googleComment, wantFileRemoved: true, wantErr: false, }, { name: "success-with-vlan", runMock: &runMock{ callback: func(context.Context, run.Options) (*run.Result, error) { return &run.Result{}, nil }, }, opts: service.NewOptions(nil, []*nic.Configuration{ &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 1, Parent: ðernet.Interface{ NameOp: func() string { return "iface" }, }, }, }, Index: 1, }, &nic.Configuration{ Interface: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, VlanInterfaces: []*ethernet.VlanInterface{ ðernet.VlanInterface{ Vlan: 2, Parent: ðernet.Interface{ NameOp: func() string { return "iface-2" }, }, }, }, Index: 2, }, }), data: googleComment, wantFileRemoved: true, wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { oldRunClient := run.Client if tc.runMock != nil { run.Client = tc.runMock } t.Cleanup(func() { run.Client = oldRunClient }) svc := &serviceWicked{ configDir: filepath.Join(t.TempDir(), "wicked", "config"), } if err := os.MkdirAll(svc.configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } for _, nic := range tc.opts.NICConfigs() { for _, vic := range nic.VlanInterfaces { fPath := svc.ifcfgFilePath(vic.InterfaceName()) if err := os.WriteFile(fPath, []byte(googleComment), 0644); err != nil { t.Fatalf("failed to create mock network config file: %v", err) } } } if tc.data != "" { for _, iface := range tc.opts.NICConfigs() { fPath := svc.ifcfgFilePath(iface.Interface.Name()) if err := os.WriteFile(fPath, []byte(tc.data), 0644); err != nil { t.Fatalf("failed to create mock network config file: %v", err) } } } err := svc.Rollback(context.Background(), tc.opts, false) if (err == nil) == tc.wantErr { t.Errorf("Rollback() = %v, want error: %v", err, tc.wantErr) } for _, iface := range tc.opts.NICConfigs() { fPath := svc.ifcfgFilePath(iface.Interface.Name()) exists := file.Exists(fPath, file.TypeFile) if exists && tc.wantFileRemoved { t.Errorf("Rollback() did not remove config file: %q", fPath) } if !exists && !tc.wantFileRemoved { t.Errorf("Rollback() wrongfuly removed config file: %q", fPath) } } }) } } func TestCleanupVlan(t *testing.T) { tests := []struct { name string fileName string contents string wantRemove bool }{ { name: "not-vlan-managed-by-us", fileName: "ifcfg-iface", contents: googleComment, wantRemove: false, }, { name: "not-vlan-managed-by-us-2", fileName: "ifcfg", contents: googleComment, wantRemove: false, }, { name: "not-vlan-managed-by-us-3", fileName: "12345", contents: googleComment, wantRemove: false, }, { name: "not-vlan-not-managed-by-us", fileName: "ifcfg-iface", contents: "", wantRemove: false, }, { name: "not-vlan-not-managed-by-us-2", fileName: "ifcfg", contents: "", wantRemove: false, }, { name: "not-vlan-not-managed-by-us-3", fileName: "12345", contents: "", wantRemove: false, }, { name: "not-vlan-not-managed-by-us-4", fileName: "ifcfg-eth0", contents: googleComment, wantRemove: false, }, { name: "vlan-not-managed-by-us", fileName: "ifcfg-eth0.1", contents: "", wantRemove: false, }, { name: "vlan-managed-by-us", fileName: "ifcfg-eth0.1", contents: googleComment, wantRemove: true, }, } for _, tc := range tests { t.Run(tc.fileName, func(t *testing.T) { oldRunner := run.Client run.Client = &runMock{ callback: func(ctx context.Context, opts run.Options) (*run.Result, error) { return &run.Result{}, nil }, } t.Cleanup(func() { run.Client = oldRunner }) configDir := filepath.Join(t.TempDir(), "wicked", "config") if err := os.MkdirAll(configDir, 0755); err != nil { t.Fatalf("failed to create mock network config directory: %v", err) } fileName := filepath.Join(configDir, tc.fileName) if err := os.WriteFile(fileName, []byte(tc.contents), 0644); err != nil { t.Fatalf("failed to create mock network config file: %v", err) } svc := &serviceWicked{ configDir: configDir, } err := svc.cleanupVlanInterfaces(context.Background(), nil) if err != nil { t.Errorf("cleanupVlanInterfaces() = %v, want nil", err) } fileExists := file.Exists(fileName, file.TypeFile) if fileExists && tc.wantRemove { t.Errorf("cleanupVlanInterfaces() did not remove config file: %q", fileName) } if !fileExists && !tc.wantRemove { t.Errorf("cleanupVlanInterfaces() wrongfuly removed config file: %q", fileName) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/network/wicked/wicked_test.go000066400000000000000000000015221524141036400265460ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wicked import ( "testing" ) func TestDefaultModule(t *testing.T) { mod := defaultModule() if mod.configDir != defaultWickedConfigDir { t.Errorf("defaultModule() returned module with configDir %q, want %q", mod.configDir, defaultWickedConfigDir) } } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/000077500000000000000000000000001524141036400224255ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_linux.go000066400000000000000000000742461524141036400256620ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package oslogin contains the Linux implementation of the OS Login module. package oslogin import ( "context" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/pipewatcher" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/textconfig" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" ) const ( // osloginModuleID is the ID of the OS Login module. osloginModuleID = "oslogin" // defaultPipePath is the default path to the ssh trusted ca pipe. defaultPipePath = "/etc/ssh/oslogin_trustedca.pub" // defaultPipeMode is the default mode for the ssh trusted ca pipe, it aligns // with the distribution's default mode for /etc/ssh/. defaultPipeMode = 0755 // sshcaEventWatcherID is the ID of the ssh trusted ca pipe event watcher. sshcaEventWatcherID = "oslogin-sshca-pipe-event-watcher" // sshcaPipeWatcherReadEventID is the ID of the ssh trusted ca pipe event // watcher read event. sshcaPipeWatcherReadEventID = "oslogin-sshca-pipe-event-watcher,read" // defaultSSHDConfigPath is the default path to the openssh daemon // configuration file. defaultSSHDConfigPath = "/etc/ssh/sshd_config" // defaultNSSwitchConfigPath is the default path to the NSSwitch configuration // file. defaultNSSwitchConfigPath = "/etc/nsswitch.conf" // defaultPAMConfigPath is the default path to the PAM configuration file. defaultPAMConfigPath = "/etc/pam.d/sshd" // defaultGroupConfigPath is the default path to the group configuration file. defaultGroupConfigPath = "/etc/security/group.conf" // defaultSudoersPath is the default path to the sudoers file. defaultSudoersPath = "/etc/sudoers.d/google-sudoers" // defaultMaxRetryCount is the default maximum number of times to retry // configuration in case of retryable errors. defaultMaxRetryCount = 5 ) var ( // sshcaPipeWatcherOpts are the ssh trusted ca pipe event watcher options. sshcaPipeWatcherOpts = pipewatcher.Options{ PipePath: defaultPipePath, Mode: defaultPipeMode, ReadEventID: sshcaPipeWatcherReadEventID, } // osloginConfigMode is the mode for all OSLogin configuration files. osloginConfigMode = fs.FileMode(0644) // defaultAuthorizedKeysCommandPaths are the possible paths to the authorized // keyscommand binaries. defaultAuthorizedKeysCommandPaths = []string{ "/usr/bin/google_authorized_keys", "/usr/local/bin/google_authorized_keys", } // defaultAuthorizedKeysCommandSKPaths are the possible paths to the // authorized keys command binaries (in the security key case/variation). defaultAuthorizedKeysCommandSKPaths = []string{ "/usr/bin/google_authorized_keys_sk", "/usr/local/bin/google_authorized_keys_sk", } // defaultServices are the services to restart after configuration changes. // Each sub-array of the map indicates that only one of those services need to // be successfully restarted. The services are grouped by services that are // mutually exclusive, or exist to provide the same functionality. defaultServices = map[daemon.RestartMethod][]serviceRestartConfig{ daemon.ReloadOrRestart: []serviceRestartConfig{ { protocol: serviceRestartAtLeastOne, services: []string{"ssh", "sshd"}, }, }, daemon.TryRestart: []serviceRestartConfig{ { // nscd and unscd are optional because they don't come by default on // some platforms (like Debian or RHEL). Restarting this refreshes the // caches for `password`, `group`, and `hosts` databases. protocol: serviceRestartOptional, services: []string{"nscd", "unscd"}, }, { // systemd-logind is on all Linux distributions. Restarting this force // stops existing user sessions so users can start a new session with // the updated configuration. protocol: serviceRestartOptional, services: []string{"systemd-logind"}, }, { // One of cron or crond exists on all Linux distributions. Restarting // this forces cron to reload user information from NSS. This forces // cron to avoid running jobs for a user that may no longer exist. protocol: serviceRestartOptional, services: []string{"cron", "crond"}, }, }, } // defaultOSLoginDirs are the directories to create for OSLogin, if necessary. defaultOSLoginDirs = []string{ "/var/google-sudoers.d", "/var/google-users.d", } // defaultDeprecatedEntries are the deprecated files to clean up. This is a map // of the file path to the key-value pairs to remove. defaultDeprecatedEntries = map[string][]*textconfig.Entry{ "/etc/pam.d/su": []*textconfig.Entry{ textconfig.NewEntry("account", "[success=bad ignore=ignore] pam_oslogin_login.so"), }, } // osloginConfigOpts are the oslogin configuration file options. This is used // in all the configuration files related to OSLogin. osloginConfigOpts = textconfig.Options{ Delimiters: &textconfig.Delimiter{ Start: "#### Google OS Login control. Do not edit this section. ####", End: "#### End Google OS Login control section. ####", }, } // execLookPath is stubbed out for testing. execLookPath = exec.LookPath // osinfoRead is stubbed out for testing. osinfoRead = osinfo.Read // sles16Map contains mapping of files to be copied from /usr to /etc for SLES 16. sles16Map = map[string]string{ "/usr/etc/ssh/sshd_config": defaultSSHDConfigPath, "/usr/etc/nsswitch.conf": defaultNSSwitchConfigPath, "/usr/lib/pam.d/sshd": defaultPAMConfigPath, "/usr/etc/security/group.conf": defaultGroupConfigPath, } ) // osloginModule is the OS Login module. type osloginModule struct { // prevMetadata is the previous metadata descriptor. prevMetadata *metadata.Descriptor // pipeEventHandler is the ssh trusted ca pipe event handler. pipeEventHandler *PipeEventHandler // pipeEventWatcher is the ssh trusted ca pipe event watcher. pipeEventWatcher *pipewatcher.Handle // enabled is true if the module is enabled. enabled atomic.Bool // failedConfiguration indicates if configuration setup has failed. failedConfiguration atomic.Bool // retryCount is the number of times the module has failed to configure. retryCount atomic.Int32 // permanentFailure indicates if the module has failed to configure due to a // permanent error and should not be retried. permanentFailure atomic.Bool // sshdConfigPath is the path to the openssh daemon configuration file. sshdConfigPath string // nsswitchConfigPath is the path to the NSSwitch configuration file. nsswitchConfigPath string // pamConfigPath is the path to the PAM configuration file. pamConfigPath string // groupConfPath is the path to the group configuration file. groupConfigPath string // authorizedKeysCommandPaths are the possible paths to the authorized keys // command binaries. authorizedKeysCommandPaths []string // authorizedKeysCommandSKPaths are the possible paths to the authorized keys // command binaries (in the security key case/variation). authorizedKeysCommandSKPaths []string // services is a map of restart methods to the services to restart. services map[daemon.RestartMethod][]serviceRestartConfig // osloginDirs are the directories to create for OSLogin, if necessary. osloginDirs []string // sudoers is the path to the sudoers file. sudoers string // deprecatedEntries are the deprecated files to clean up. deprecatedEntries map[string][]*textconfig.Entry // sles16Setup is true if the SLES 16 setup has been done, this is to avoid // doing the setup multiple times if the module is enabled/disabled multiple // times or reties. sles16Setup atomic.Bool } // serviceRestartProtocol is the protocol to use when restarting a service. type serviceRestartProtocol int const ( // serviceRestartAtLeastOne indicates that at least one service must be // successfully restarted. This is the default protocol. serviceRestartAtLeastOne serviceRestartProtocol = iota // serviceRestartOptional indicates that if all services fail to restart, // the configuration will still be considered successful. This is primarily // used for services that don't necessarily exist on all platforms. serviceRestartOptional ) // serviceRestartConfig is the configuration for restarting a service. // This is primarily a wrapper around the serviceRestartProtocol enum to make // it easier to pass around. type serviceRestartConfig struct { protocol serviceRestartProtocol services []string } // NewModule returns a new oslogin module for late registration. func NewModule(context.Context) *manager.Module { module := &osloginModule{ sshdConfigPath: defaultSSHDConfigPath, nsswitchConfigPath: defaultNSSwitchConfigPath, pamConfigPath: defaultPAMConfigPath, groupConfigPath: defaultGroupConfigPath, authorizedKeysCommandPaths: defaultAuthorizedKeysCommandPaths, authorizedKeysCommandSKPaths: defaultAuthorizedKeysCommandSKPaths, services: defaultServices, osloginDirs: defaultOSLoginDirs, sudoers: defaultSudoersPath, deprecatedEntries: defaultDeprecatedEntries, } return &manager.Module{ ID: osloginModuleID, Setup: module.moduleSetup, } } // moduleSetup is the setup function for the oslogin module. func (mod *osloginModule) moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing OS Login module.") desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("oslogin module expects a metadata descriptor in the data pointer") } if err := mod.removeDeprecatedEntries(); err != nil { galog.Errorf("Failed to remove deprecated entries: %v", err) } // Do the initial first setup execution in the module initialization, it will // be handled by the metadata longpoll event handler/subscriber after the // first setup. _, _, err := mod.osloginSetup(ctx, desc) if err != nil { galog.Errorf("Failed to handle first oslogin setup: %v", err) } // Subscribe to the metadata longpoll event. sub := events.EventSubscriber{Name: osloginModuleID, Callback: mod.metadataSubscriber, MetricName: acmpb.GuestAgentModuleMetric_OS_LOGIN_INITIALIZATION} events.FetchManager().Subscribe(metadata.LongpollEvent, sub) galog.Debugf("Finished initializing OS Login module.") return nil } // metadataSubscriber is the callback for the metadata event and handles the // platform oslogin configuration changes. func (mod *osloginModule) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type we log it and // don't renew the handler. if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } // If the event manager is passing/reporting an error we log it and keep // renewing the handler. if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, will retry setup", evData.Error) } return mod.osloginSetup(ctx, desc) } // setupSles16OSLoginDirs copies files from /usr to /etc for SLES 16. This is // only needed for OSLogin required files that are installed in /usr but not // /etc on SLES 16. // https://documentation.suse.com/releasenotes/sles/html/releasenotes_sles_16.0/index.html#jsc-PED-4963 func (mod *osloginModule) setupSles16OSLoginDirs(ctx context.Context) error { if mod.sles16Setup.Load() { galog.V(2).Debugf("Skipping OSLogin SLES 16 specific setup") return nil } info := osinfoRead() isSles16 := strings.Contains(info.OS, "sles") || strings.Contains(info.OS, "opensuse") if !isSles16 || info.Version.Major != 16 { galog.Infof("Skipping OSLogin SLES 16 specific setup on %s %d", info.OS, info.Version.Major) mod.sles16Setup.Store(true) return nil } for k, v := range sles16Map { if file.Exists(v, file.TypeFile) { galog.Infof("File %q already exists, skipping copy from %q", v, k) continue } galog.Infof("Copying file %q -> %q", k, v) if err := file.CopyFile(ctx, k, v, file.Options{Perm: 0644}); err != nil { return fmt.Errorf("failed to copy %q -> %q: %w", k, v, err) } } mod.sles16Setup.Store(true) return nil } // osloginSetup is the actual oslogin's configuration entry point. func (mod *osloginModule) osloginSetup(ctx context.Context, desc *metadata.Descriptor) (bool, bool, error) { defer func() { mod.prevMetadata = desc mod.decrementRetryCount() }() // If the module has failed due to a permanent error, we return early and // don't retry. if mod.permanentFailure.Load() { return true, true, nil } // If the we are not forcing a retry and the metadata has not changed, we // return early. // // The only case where we are forcefully retrying is if a systemd unit/service // is required to be restarted and has previously failed. // // We don't need to clean up the files here because the textconfig library // rolls back its previous changes before applying new ones. if !mod.forcedRetrying() && !mod.metadataChanged(desc) && !mod.failedConfiguration.Load() { return true, true, nil } evManager := events.FetchManager() // If the module is disabled make sure the configuration is disabled and // return early. if !desc.OSLoginEnabled() { defer func() { mod.enabled.Store(false) }() if !mod.enabled.Load() { return true, true, nil } // If the module is disabled now but was previously enabled do the // run the disabling path. if err := mod.disableOSLogin(ctx, evManager); err != nil { // Failed to restart the necessary services. mod.failedConfiguration.Store(true) return true, false, fmt.Errorf("failed to disable OS Login: %w", err) } mod.failedConfiguration.Store(false) return true, false, nil } galog.Info("Enabling OS Login") // Enable/start the ssh trusted ca pipe event handler. if mod.pipeEventHandler == nil { mod.pipeEventHandler = newPipeEventHandler(pipeWatcherSubscriberID, metadata.New()) } // Enable/start the ssh trusted ca pipe event watcher. if mod.pipeEventWatcher == nil { mod.pipeEventWatcher = pipewatcher.New(sshcaEventWatcherID, sshcaPipeWatcherOpts) evManager.AddWatcher(ctx, mod.pipeEventWatcher) } var failed bool var errs error // Attempt to copy files from /usr to /etc for SLES 16 first time we enable // OSLogin. if err := mod.setupSles16OSLoginDirs(ctx); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup OSLogin directories for SLES 16: %w", err)) mod.enabled.Store(false) mod.failedConfiguration.Store(true) // Don't attempt to continue the setup if this fails, return early. Expected // files don't exist, half setup could leave the system in a bad state. return true, false, errs } // Write SSH config. if err := mod.setupOpenSSH(desc); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup openssh: %w", err)) failed = true } // Write NSSwitch config. if err := mod.setupNSSwitch(false); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup nsswitch: %w", err)) failed = true } // Write PAM config. if err := mod.setupPAM(desc); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup pam: %w", err)) failed = true } // Write Group config. if err := mod.setupGroup(); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup group: %w", err)) failed = true } // Restart services. This is not a blocker. if err := mod.restartServices(ctx); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to restart services: %w", err)) failed = true } // Create the necessary OSLogin directories and other files. if err := mod.setupOSLoginDirs(ctx); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to setup OSLogin directories: %w", err)) failed = true } if err := mod.setupOSLoginSudoers(); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to create OSLogin sudoers file: %w", err)) failed = true } // Tickle the systemd service to fill the NSS cache. galog.Infof("Triggering NSS cache refresh via systemd.") if _, err := run.WithContext(ctx, run.Options{ Name: "systemctl", Args: []string{"start", "--no-block", "google-oslogin-cache.service"}, OutputType: run.OutputNone, ExecMode: run.ExecModeSync, }); err != nil { errs = errors.Join(errs, fmt.Errorf("failed to trigger NSS cache refresh via systemd: %w", err)) failed = true } mod.enabled.Store(!failed) mod.failedConfiguration.Store(failed) return true, false, errs } // setupOpenSSH configures the openssh daemon. func (mod *osloginModule) setupOpenSSH(desc *metadata.Descriptor) error { galog.Debug("Configuring OpenSSH daemon for OS Login.") sshdCfg := textconfig.New(mod.sshdConfigPath, osloginConfigMode, osloginConfigOpts) block := textconfig.NewBlock(textconfig.Top) sshdCfg.AddBlock(block) // Determine the authorized keys command binary. authorizedKeysCommand, err := availableBinary(mod.authorizedKeysCommandPaths) if err != nil { return fmt.Errorf("failed to find authorized keys command binary: %w", err) } if desc.SecurityKeyEnabled() { authorizedKeysCommand, err = availableBinary(mod.authorizedKeysCommandSKPaths) if err != nil { return fmt.Errorf("failed to find authorized keys command binary: %w", err) } } cfg := cfg.Retrieve() certReq := desc.CertRequiredEnabled() skEnabled := desc.SecurityKeyEnabled() // Certificates can be used to bypass OS Login security key authentication. // Certificate based authentication will only be enabled if security keys are // not enabled. if (certReq || cfg.OSLogin.CertAuthentication) && !skEnabled { // Add the relevant certificate authority keys. block.Append("TrustedUserCAKeys", defaultPipePath) block.Append("AuthorizedPrincipalsCommand", "/usr/bin/google_authorized_principals %u %k") block.Append("AuthorizedPrincipalsCommandUser", "root") } if !certReq || skEnabled { block.Append("AuthorizedKeysCommand", authorizedKeysCommand) block.Append("AuthorizedKeysCommandUser", "root") } bottomBlock := textconfig.NewBlock(textconfig.Bottom) // Source per-user config from /var/google-users.d. bottomBlock.Append("Include", "/var/google-users.d/*") sshdCfg.AddBlock(bottomBlock) // Add two-factor authentication configuration if enabled. if desc.TwoFactorEnabled() { block.Append("AuthenticationMethods", "publickey,keyboard-interactive") block.Append("ChallengeResponseAuthentication", "yes") bottomBlock.Append("Match", "User sa_*") bottomBlock.Append("AuthenticationMethods", "publickey") } if err := sshdCfg.Apply(); err != nil { return fmt.Errorf("failed to apply openssh config: %w", err) } galog.Debug("Successfully configured OpenSSH daemon for OS Login.") return nil } // setupNSSwitch configures the NSSwitch configuration file. If cleanup is true // then the configuration is rolled back, otherwise it is set up. func (mod *osloginModule) setupNSSwitch(cleanup bool) error { logMessage := "Setting up" if cleanup { logMessage = "Rolling back" } galog.Debugf("%s NSSwitch configurations for OS Login", logMessage) nsswitch, err := os.ReadFile(mod.nsswitchConfigPath) if err != nil { return fmt.Errorf("failed to read nsswitch.conf: %w", err) } var lines []string for _, line := range strings.Split(string(nsswitch), "\n") { if strings.HasPrefix(line, "passwd:") || strings.HasPrefix(line, "group:") { if cleanup { line = strings.Replace(line, "cache_oslogin oslogin", "", 1) line = strings.TrimSpace(line) } else { if !strings.Contains(line, "oslogin") { line += " cache_oslogin oslogin" } } } lines = append(lines, line) } if err := os.WriteFile(mod.nsswitchConfigPath, []byte(strings.Join(lines, "\n")), osloginConfigMode); err != nil { return fmt.Errorf("failed to write nsswitch.conf: %w", err) } galog.Debugf("Finished %s OS Login nsswitch configuration.", strings.ToLower(logMessage)) return nil } // setupPAM configures the PAM module. func (mod *osloginModule) setupPAM(desc *metadata.Descriptor) error { galog.Debug("Configuring PAM module for OS Login.") // Make sure the PAM config directory exists, we have seen custom images not // having this directory, since it's a mandatory configuration we must ensure // it exists and the configuration can be written. pamConfigDir := filepath.Dir(mod.pamConfigPath) if !file.Exists(pamConfigDir, file.TypeDir) { if err := os.Mkdir(pamConfigDir, 0755); err != nil { return fmt.Errorf("failed to create PAM config directory: %w", err) } } pamConfig := textconfig.New(mod.pamConfigPath, osloginConfigMode, osloginConfigOpts) topBlock := textconfig.NewBlock(textconfig.Top) bottomBlock := textconfig.NewBlock(textconfig.Bottom) pamConfig.AddBlock(topBlock) pamConfig.AddBlock(bottomBlock) pamOSLogin := "[success=done perm_denied=die default=ignore]" pamGroup := "[default=ignore]" session := "[success=ok default=ignore]" if desc.TwoFactorEnabled() { topBlock.Append("auth", fmt.Sprintf("%s pam_oslogin_login.so", pamOSLogin)) } topBlock.Append("auth", fmt.Sprintf("%s pam_group.so", pamGroup)) bottomBlock.Append("session", fmt.Sprintf("%s pam_mkhomedir.so", session)) if err := pamConfig.Apply(); err != nil { return fmt.Errorf("failed to apply pam config: %w", err) } galog.Debug("Successfully configured PAM module for OS Login.") return nil } // setupGroup configures the group config. func (mod *osloginModule) setupGroup() error { galog.Debug("Setting up OS Login group configuration.") groupConf := textconfig.New(mod.groupConfigPath, osloginConfigMode, osloginConfigOpts) block := textconfig.NewBlock(textconfig.Bottom) groupConf.AddBlock(block) config := "sshd;*;*;Al0000-2400;video" block.Append(config, "") if err := groupConf.Apply(); err != nil { return fmt.Errorf("failed to apply group config: %w", err) } galog.Debug("Successfully set up OS Login group configuration.") return nil } // availableBinary returns the first binary in the fpath that exists. func availableBinary(fpath []string) (string, error) { for _, f := range fpath { if file.Exists(f, file.TypeFile) { return f, nil } } return "", fmt.Errorf("no binary found in %v", fpath) } // removeDeprecatedEntries removes the deprecated entries from the files. func (mod *osloginModule) removeDeprecatedEntries() error { // Clean up the deprecated files. for f, entries := range mod.deprecatedEntries { deprecatedEntryOpts := textconfig.Options{ Delimiters: &textconfig.Delimiter{ Start: "#### Google OS Login control. Do not edit this section. ####", End: "#### End Google OS Login control section. ####", }, DeprecatedEntries: entries, } handle := textconfig.New(f, osloginConfigMode, deprecatedEntryOpts) if err := handle.Cleanup(); err != nil { return fmt.Errorf("failed to cleanup deprecated file %s: %w", f, err) } } galog.Debug("Removed deprecated entries from OS Login configuration.") return nil } // disableOSLogin stop internal "services" and rollback user's configuration. func (mod *osloginModule) disableOSLogin(ctx context.Context, evManager *events.Manager) error { galog.Infof("Disabling OS Login.") // Make sure we only stop responding to requests to the ssh trusted ca pipe // when all user's configuration is rolled back. defer func() { mod.pipeEventHandler.Close() mod.pipeEventHandler = nil evManager.RemoveWatcher(ctx, mod.pipeEventWatcher) mod.pipeEventWatcher = nil }() // Rollback all the configuration. galog.Debugf("Rolling back OpenSSH config %s", mod.sshdConfigPath) sshdCfg := textconfig.New(mod.sshdConfigPath, osloginConfigMode, osloginConfigOpts) if err := sshdCfg.Cleanup(); err != nil { return fmt.Errorf("failed to rollback openssh config: %w", err) } galog.Debugf("Successfully rolled back OpenSSH config %s", mod.sshdConfigPath) if err := mod.setupNSSwitch(true); err != nil { return fmt.Errorf("failed to rollback nsswitch config: %w", err) } galog.Debugf("Rolling back PAM config %s", mod.pamConfigPath) pamCfg := textconfig.New(mod.pamConfigPath, osloginConfigMode, osloginConfigOpts) if err := pamCfg.Cleanup(); err != nil { return fmt.Errorf("failed to rollback pam config: %w", err) } galog.Debugf("Successfully rolled back PAM config %s", mod.pamConfigPath) galog.Debugf("Rolling back group config %s", mod.groupConfigPath) groupCfg := textconfig.New(mod.groupConfigPath, osloginConfigMode, osloginConfigOpts) if err := groupCfg.Cleanup(); err != nil { return fmt.Errorf("failed to rollback group config: %w", err) } galog.Debugf("Successfully rolled back group config %s", mod.groupConfigPath) // Restart the services to reflect the rollback. if err := mod.restartServices(ctx); err != nil { return fmt.Errorf("failed to restart services: %w", err) } return nil } // setupOSLoginDirs creates the necessary directories for OSLogin, if necessary. // This also runs restorecon on the new directories. func (mod *osloginModule) setupOSLoginDirs(ctx context.Context) error { restorecon, restoreconerr := execLookPath("restorecon") for _, dir := range mod.osloginDirs { if err := os.MkdirAll(dir, 0750); err != nil { return fmt.Errorf("failed to create oslogin directory: %w", err) } if restoreconerr != nil { continue } if _, err := run.WithContext(ctx, run.Options{ OutputType: run.OutputNone, Name: restorecon, Args: []string{dir}, }); err != nil { return fmt.Errorf("failed to run restorecon on %s: %w", dir, err) } } return nil } // setupOSLoginSudoers creates the necessary sudoers file for OSLogin, // if necessary. func (mod *osloginModule) setupOSLoginSudoers() error { if file.Exists(mod.sudoers, file.TypeFile) { galog.Debugf("Sudoers file %s already exists, skipping.", mod.sudoers) return nil } if err := os.WriteFile(mod.sudoers, []byte("#includedir /var/google-sudoers.d\n"), 0440); err != nil { return fmt.Errorf("failed to write sudoers file: %w", err) } galog.Debugf("Wrote OS Login sudoers file %s", mod.sudoers) return nil } // metadataChanged returns true if the metadata has changed or if it's being // called on behalf of the first handler's execution. func (mod *osloginModule) metadataChanged(desc *metadata.Descriptor) bool { // If the module has not been initialized yet then we return true to force // the first execution of the setup. if mod.prevMetadata == nil { return true } // Have the metadata's oslogin knobs changed? if desc.OSLoginEnabled() != mod.prevMetadata.OSLoginEnabled() { return true } // Have the metadata's two factor authentication knobs changed? if desc.TwoFactorEnabled() != mod.prevMetadata.TwoFactorEnabled() { return true } // Has the metadata's security key knobs changed? if desc.SecurityKeyEnabled() != mod.prevMetadata.SecurityKeyEnabled() { return true } // Have the metadata's cert required knobs changed? if desc.CertRequiredEnabled() != mod.prevMetadata.CertRequiredEnabled() { return true } // No changes detected. return false } // forcedRetrying returns true if the module should retry setup even if the // metadata has not changed. Cases where this is true are when the module is // failing to restart dependent systemd units/services. func (mod *osloginModule) forcedRetrying() bool { if mod.retryCount.Load() > 0 { return true } return false } // decrementRetryCount decrements the retry count by 1. func (mod *osloginModule) decrementRetryCount() { if mod.retryCount.Load() == 0 { return } mod.retryCount.Store(mod.retryCount.Load() - 1) } // restartServices restarts the provided services with the provided methods. func (mod *osloginModule) restartServices(ctx context.Context) error { for method, serviceConfigs := range mod.services { // One of the services in each service list must be successfully restarted. for _, serviceConfig := range serviceConfigs { // Indicates if one of the services in the list was successfully // restarted. var passed bool var errs error for _, service := range serviceConfig.services { galog.V(2).Debugf("Checking if service %s exists", service) if found, err := daemon.CheckUnitExists(ctx, service); !found { if err != nil { galog.V(2).Debugf("Failed to check if service %s exists: %v", service, err) errs = errors.Join(errs, fmt.Errorf("failed to check if service %s exists: %w", service, err)) } continue } galog.V(2).Debugf("Service %s exists, restarting...", service) if err := daemon.RestartService(ctx, service, method); err != nil { galog.V(2).Debugf("Failed to restart service %s: %v", service, err) errs = errors.Join(errs, fmt.Errorf("failed to restart service %s: %w", service, err)) continue } galog.V(2).Debugf("Successfully restarted service %s", service) passed = true break } if !passed { if serviceConfig.protocol == serviceRestartAtLeastOne { if mod.retryCount.Load() > 1 { galog.V(2).Warnf("Failed to restart one of %v, it will be retried", serviceConfig.services) return nil } // We are on last attempt and failed - mark it as permanent failure. if mod.retryCount.Load() == 1 { mod.permanentFailure.Store(true) } return errors.Join(fmt.Errorf("failed to restart one of: %v", serviceConfig.services), errs) } // Only log a debug message if the restart is optional. galog.Debugf("Failed to restart optional services: %v", serviceConfig.services) } } } return nil } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_linux_test.go000066400000000000000000001172521524141036400267140ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package oslogin import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/pipewatcher" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/textconfig" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestNewModule(t *testing.T) { mod := NewModule(context.Background()) if mod == nil { t.Fatalf("NewModule() = nil, want non-nil") } if mod.ID != osloginModuleID { t.Errorf("NewModule().ID = %q, want %q", mod.ID, osloginModuleID) } if mod.Setup == nil { t.Errorf("NewModule().Setup = nil, want non-nil") } } func TestModuleSetupInputValidity(t *testing.T) { mdsJSON := ` { "instance": { } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", mdsJSON, err) } tests := []struct { name string desc any shouldFail bool }{ { name: "wrong-data", desc: "wrong data", shouldFail: true, }, { name: "empty-mds", desc: desc, shouldFail: false, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := &osloginModule{} err := mod.moduleSetup(ctx, tc.desc) if tc.shouldFail != (err != nil) { t.Errorf("moduleSetup() = %v, want %v", err, tc.shouldFail) } // Double call should not fail. err = mod.moduleSetup(ctx, tc.desc) if tc.shouldFail != (err != nil) { t.Errorf("moduleSetup() = %v, want %v", err, tc.shouldFail) } }) } } func TestMetadataSubscriberInputValidity(t *testing.T) { mdsJSON := ` { "instance": { } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", mdsJSON, err) } tests := []struct { name string desc any err error want bool wantErr bool }{ { name: "wrong-data", desc: "wrong data", want: false, wantErr: true, }, { name: "empty-mds", desc: desc, want: true, wantErr: false, }, { name: "error-evdata", desc: desc, want: true, err: errors.New("error"), wantErr: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mod := &osloginModule{} evData := &events.EventData{Data: tc.desc, Error: tc.err} got, noop, err := mod.metadataSubscriber(ctx, "evType", nil, evData) if (err != nil) != tc.wantErr { t.Fatalf("metadataSubscriber() = %v, want error: %t", err, tc.wantErr) } if !noop { t.Errorf("metadataSubscriber() = %v, want: %t", noop, true) } if got != tc.want { t.Errorf("metadataSubscriber() = %v, want %v", got, tc.want) } }) } } func TestMetadataChanged(t *testing.T) { tests := []struct { name string prevMDSJSON string newMDSJSON string want bool }{ { name: "no-change-basic-mds", prevMDSJSON: ` { "instance": { } }`, newMDSJSON: ` { "instance": { } }`, want: false, }, { name: "same-instance-enabled", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin": "true" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin": "true" } } }`, want: false, }, { name: "transition-instance-enabled", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin": "false" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin": "true" } } }`, want: true, }, { name: "same-project-enabled", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin": "true" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin": "true" } } }`, want: false, }, { name: "transition-project-enabled", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin": "false" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin": "true" } } }`, want: true, }, { name: "same-instance-2fa", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-2fa": "true" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-2fa": "true" } } }`, want: false, }, { name: "transition-instance-2fa", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-2fa": "false" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-2fa": "true" } } }`, want: true, }, { name: "same-project-2fa", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-2fa": "true" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-2fa": "true" } } }`, want: false, }, { name: "transition-project-2fa", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-2fa": "false" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-2fa": "true" } } }`, want: true, }, { name: "same-instance-sk", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-sk": "true" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-sk": "true" } } }`, want: false, }, { name: "transition-instance-sk", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-sk": "false" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-sk": "true" } } }`, want: true, }, { name: "same-project-sk", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-sk": "true" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-sk": "true" } } }`, want: false, }, { name: "transition-project-sk", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-sk": "false" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-sk": "true" } } }`, want: true, }, { name: "same-instance-certificates", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-certificates": "true" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-certificates": "true" } } }`, want: false, }, { name: "transition-instance-certificates", prevMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-certificates": "false" } } }`, newMDSJSON: ` { "instance": { "attributes": { "enable-oslogin-certificates": "true" } } }`, want: true, }, { name: "same-project-certificates", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-certificates": "true" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-certificates": "true" } } }`, want: false, }, { name: "transition-project-certificates", prevMDSJSON: ` { "project": { "attributes": { "enable-oslogin-certificates": "false" } } }`, newMDSJSON: ` { "project": { "attributes": { "enable-oslogin-certificates": "true" } } }`, want: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { prevDesc, err := metadata.UnmarshalDescriptor(tc.prevMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", tc.prevMDSJSON, err) } newDesc, err := metadata.UnmarshalDescriptor(tc.newMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", tc.newMDSJSON, err) } mod := &osloginModule{prevMetadata: prevDesc} got := mod.metadataChanged(newDesc) if got != tc.want { t.Errorf("metadataChanged(%v) = %t, want %t", newDesc, got, tc.want) } }) } } func TestOSLoginSetupError(t *testing.T) { mod := &osloginModule{} mod.enabled.Store(true) mod.pipeEventHandler = &PipeEventHandler{} mod.pipeEventWatcher = &pipewatcher.Handle{} disableMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "false" } } }` disableDesc, err := metadata.UnmarshalDescriptor(disableMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", disableMDSJSON, err) } got, noop, err := mod.osloginSetup(context.Background(), disableDesc) if err == nil { t.Errorf("osloginSetup(ctx, %v) = %v, want error", disableDesc, got) } if noop { t.Errorf("osloginSetup(ctx, %v) = %t, want noop to be false", disableDesc, noop) } if !got { t.Errorf("osloginSetup(ctx, %v) = %t, want %t", disableDesc, got, true) } if !mod.failedConfiguration.Load() { t.Errorf("mod.failedConfiguration.Load() = %t, want %t", mod.failedConfiguration.Load(), true) } } func TestEnableDisable(t *testing.T) { // Initialize cfg. if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } // Create test files and setup test runner. module := createTestModule(t) createTestFiles(t, module, osloginTestFileOpts{ testSSHD: true, testNSSwitch: true, testPAM: true, testGroup: true, }) _ = setupTestRunner(t, false) enabledMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "true" } } }` enabledDesc, err := metadata.UnmarshalDescriptor(enabledMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", enabledMDSJSON, err) } ctx := context.Background() got, noop, err := module.osloginSetup(ctx, enabledDesc) if err != nil { t.Fatalf("osloginSetup(ctx, %v) = %v, want nil", enabledDesc, err) } if noop { t.Errorf("osloginSetup(ctx, %v) = %t, want noop to be false", enabledDesc, noop) } if got != true { t.Errorf("osloginSetup(ctx, %v) = %t, want %t", enabledDesc, got, true) } evManager := events.FetchManager() if !evManager.IsSubscribed(sshcaPipeWatcherOpts.ReadEventID, pipeWatcherSubscriberID) { t.Errorf("pipewatcher.ReadEvent is not subscribed to pipeWatcherSubscriberID, it should be") } if module.enabled.Load() != true { t.Errorf("mod.enabled.Load() = %v, want %v", module.enabled.Load(), true) } disabledMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "false" } } }` disabledDesc, err := metadata.UnmarshalDescriptor(disabledMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", disabledMDSJSON, err) } got, noop, err = module.osloginSetup(ctx, disabledDesc) if err != nil { t.Fatalf("osloginSetup(ctx, %v) = %v, want nil", disabledDesc, err) } if noop { t.Errorf("osloginSetup(ctx, %v) = %t, want noop to be false", disabledDesc, noop) } if !got { t.Errorf("osloginSetup(ctx, %v) = %t, want %t", disabledDesc, got, true) } if evManager.IsSubscribed(sshcaPipeWatcherOpts.ReadEventID, pipeWatcherSubscriberID) { t.Errorf("pipewatcher.ReadEvent is still subscribed to pipeWatcherSubscriberID, it should not be") } if module.enabled.Load() { t.Errorf("mod.enabled.Load() = %v, want %v", module.enabled.Load(), false) } } func TestDisableOSLoginErrors(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } tests := []struct { name string fileOpts osloginTestFileOpts runnerErr bool wantErr bool }{ { name: "fail-sshd-cleanup", wantErr: true, }, // Non existent nss file should not cause an error. { name: "fail-nss-cleanup", fileOpts: osloginTestFileOpts{testSSHD: true}, wantErr: true, }, // Non existent pam file should not cause an error. { name: "fail-pam-cleanup", fileOpts: osloginTestFileOpts{testSSHD: true, testNSSwitch: true}, }, // Non existent group file should not cause an error. { name: "fail-group-cleanup", fileOpts: osloginTestFileOpts{testSSHD: true, testNSSwitch: true, testPAM: true}, }, { name: "fail-restart-services", fileOpts: osloginTestFileOpts{testSSHD: true, testNSSwitch: true, testPAM: true, testGroup: true}, runnerErr: true, wantErr: true, }, } // Mock oslogin enabled metadata. enabledMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "true" } } }` enabledDesc, err := metadata.UnmarshalDescriptor(enabledMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", enabledMDSJSON, err) } // Run tests. for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := createTestModule(t) module.services = map[daemon.RestartMethod][]serviceRestartConfig{ daemon.TryRestart: { { services: []string{"service1"}, }, }, } createTestFiles(t, module, test.fileOpts) _ = setupTestRunner(t, test.runnerErr) module.osloginSetup(context.Background(), enabledDesc) // Now test for errors. if err := module.disableOSLogin(context.Background(), events.FetchManager()); test.wantErr != (err != nil) { t.Errorf("disableOSLogin(ctx, evManager) = %v, want err: %t", err, test.wantErr) } }) } } func TestSetupOpenSSH(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } module := createTestModule(t) createTestFiles(t, module, osloginTestFileOpts{testSSHD: true}) tests := []struct { name string desc string certAuthEnabled bool expectedLines []string }{ { name: "no_cert_no_2fa", desc: ` { "instance": { "attributes": { "enable-oslogin": "true" } } } `, certAuthEnabled: true, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }, }, { name: "cert", certAuthEnabled: true, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-certificates": "true" } } } `, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", "Include /var/google-users.d/*", }, }, { name: "2fa", certAuthEnabled: true, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-2fa": "true" } } } `, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "AuthenticationMethods publickey,keyboard-interactive", "ChallengeResponseAuthentication yes", "Include /var/google-users.d/*", "Match User sa_*", "AuthenticationMethods publickey", }, }, { name: "cert_and_2fa", certAuthEnabled: true, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-2fa": "true", "enable-oslogin-certificates": "true" } } } `, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", "AuthenticationMethods publickey,keyboard-interactive", "ChallengeResponseAuthentication yes", "Include /var/google-users.d/*", "Match User sa_*", "AuthenticationMethods publickey", }, }, { name: "sk", certAuthEnabled: true, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-sk": "true" } } } `, expectedLines: []string{ fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandSKPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }, }, { name: "cert_disabled", certAuthEnabled: false, desc: ` { "instance": { "attributes": { "enable-oslogin": "true" } } } `, expectedLines: []string{ fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }, }, { name: "cert_metadata_disabled_cert_auth_enabled", certAuthEnabled: true, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-certificates": "false" } } } `, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }, }, { name: "cert_metadata_enabled_cert_auth_disabled", certAuthEnabled: false, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-certificates": "true" } } } `, expectedLines: []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", "Include /var/google-users.d/*", }, }, { name: "2fa_cert_disabled", certAuthEnabled: false, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-2fa": "true" } } } `, expectedLines: []string{ fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "AuthenticationMethods publickey,keyboard-interactive", "ChallengeResponseAuthentication yes", "Include /var/google-users.d/*", "Match User sa_*", "AuthenticationMethods publickey", }, }, { name: "sk_cert_disabled", certAuthEnabled: false, desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-sk": "true" } } } `, expectedLines: []string{ fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandSKPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { desc, err := metadata.UnmarshalDescriptor(test.desc) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", test.desc, err) } if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } cfg.Retrieve().OSLogin.CertAuthentication = test.certAuthEnabled err = module.setupOpenSSH(desc) if err != nil { t.Fatalf("setupOpenSSH(desc) = %v, want nil", err) } // Check that the file has expected contents. checkTestFile(t, module.sshdConfigPath, test.expectedLines) }) } } func TestSetupNSSwitch(t *testing.T) { module := createTestModule(t) createTestFiles(t, module, osloginTestFileOpts{testNSSwitch: true}) if err := module.setupNSSwitch(false); err != nil { t.Fatalf("setupNSSwitch(false) = %v, want nil", err) } checkTestFile(t, module.nsswitchConfigPath, []string{ "passwd: files cache_oslogin oslogin", "group: files cache_oslogin oslogin", }) } func TestSetupPAM(t *testing.T) { tests := []struct { name string desc string expectedLines []string }{ { name: "default", desc: ` { "instance": { "attributes": { "enable-oslogin": "true" } } } `, expectedLines: []string{ "auth [default=ignore] pam_group.so", "session [success=ok default=ignore] pam_mkhomedir.so", }, }, { name: "two-factor", desc: ` { "instance": { "attributes": { "enable-oslogin": "true", "enable-oslogin-2fa": "true" } } } `, expectedLines: []string{ "auth [success=done perm_denied=die default=ignore] pam_oslogin_login.so", "auth [default=ignore] pam_group.so", "session [success=ok default=ignore] pam_mkhomedir.so", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := createTestModule(t) desc, err := metadata.UnmarshalDescriptor(test.desc) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", test.desc, err) } createTestFiles(t, module, osloginTestFileOpts{testPAM: true}) if err := module.setupPAM(desc); err != nil { t.Fatalf("setupPAM() = %v, want nil", err) } checkTestFile(t, module.pamConfigPath, test.expectedLines) }) } } func TestSetupGroup(t *testing.T) { module := createTestModule(t) createTestFiles(t, module, osloginTestFileOpts{testGroup: true}) if err := module.setupGroup(); err != nil { t.Fatalf("setupGroup() = %v, want nil", err) } checkTestFile(t, module.groupConfigPath, []string{ "sshd;*;*;Al0000-2400;video", }) } func TestRestartServices(t *testing.T) { unknownMethod := daemon.RestartMethod(50) tests := []struct { name string returnErr bool services map[daemon.RestartMethod][]serviceRestartConfig expectedCommand string expectedArgs []string }{ { name: "restart", services: map[daemon.RestartMethod][]serviceRestartConfig{ daemon.TryRestart: { { services: []string{"service1"}, }, }, }, expectedCommand: "systemctl", expectedArgs: []string{"try-restart", "service1"}, }, { name: "reload_restart", services: map[daemon.RestartMethod][]serviceRestartConfig{ daemon.ReloadOrRestart: { { services: []string{"service1"}, }, }, }, expectedCommand: "systemctl", expectedArgs: []string{"reload-or-restart", "service1"}, }, { name: "error", returnErr: true, services: map[daemon.RestartMethod][]serviceRestartConfig{ daemon.TryRestart: { { services: []string{"service1"}, }, }, }, }, { name: "unknown_method", returnErr: true, services: map[daemon.RestartMethod][]serviceRestartConfig{ unknownMethod: { { services: []string{"service1"}, }, }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { runner := setupTestRunner(t, test.returnErr) module := &osloginModule{ services: test.services, } if err := module.restartServices(context.Background()); (err == nil) == test.returnErr { t.Fatalf("restartServices(ctx) = %v, want %v", err, test.returnErr) } if test.returnErr { return } args, found := runner.seenCommand[test.expectedCommand] if !found { t.Fatalf("restartServices(ctx) did not call %q", test.expectedCommand) } if len(args) != 2 { t.Fatalf("restartServices(ctx) called %q with %d args, want 2", test.expectedCommand, len(args)) } if diff := cmp.Diff(test.expectedArgs, args[1]); diff != "" { t.Fatalf("restartServices(ctx) called %q with args diff (-want +got): %v", test.expectedCommand, diff) } }) } } func TestSetupOSLoginDirs(t *testing.T) { temp := t.TempDir() module := &osloginModule{ osloginDirs: []string{ filepath.Join(temp, "google-users.d"), filepath.Join(temp, "/tmp/google-sudoers.d"), }, } execLookPath = func(string) (string, error) { return "restoreconn", nil } t.Cleanup(func() { execLookPath = exec.LookPath }) testRunner := setupTestRunner(t, false) if err := module.setupOSLoginDirs(context.Background()); err != nil { t.Fatalf("setupOSLoginDirs(ctx) = %v, want nil", err) } for _, dir := range module.osloginDirs { if !file.Exists(dir, file.TypeDir) { t.Fatalf("setupOSLoginDirs(ctx) did not create dir %q", dir) } } expectedCommandArgs := [][]string{ {module.osloginDirs[0]}, {module.osloginDirs[1]}, } seenCommandArgs, found := testRunner.seenCommand["restoreconn"] if !found { t.Fatalf("setupOSLoginDirs(ctx) did not call restoreconn") } if diff := cmp.Diff(expectedCommandArgs, seenCommandArgs); diff != "" { t.Fatalf("setupOSLoginDirs(ctx) called restoreconn with diff (-want +got): %v", diff) } } func TestSetupOSLoginSudoers(t *testing.T) { temp := t.TempDir() module := &osloginModule{ sudoers: filepath.Join(temp, "google-oslogin-sudoers"), } if err := module.setupOSLoginSudoers(); err != nil { t.Fatalf("createOSLoginSudoers(ctx) = %v, want nil", err) } checkTestFile(t, module.sudoers, []string{ "#include /var/google-sudoers.d", }) } func TestRemoveDeprecatedEntries(t *testing.T) { // Create a test file with a deprecated entry. temp := t.TempDir() testFile := filepath.Join(temp, "test_file") testContents := "depKey depEntry\nnotDepKey notDepEntry\n" if err := os.WriteFile(testFile, []byte(testContents), osloginConfigMode); err != nil { t.Fatalf("failed to setup test file: %v", err) } module := &osloginModule{ deprecatedEntries: map[string][]*textconfig.Entry{ testFile: []*textconfig.Entry{ textconfig.NewEntry("depKey", "depEntry"), }, }, } if err := module.removeDeprecatedEntries(); err != nil { t.Fatalf("removeDeprecatedEntries() = %v, want nil", err) } // Double check that the file no longer has the deprecated entry. checkTestFile(t, testFile, []string{"notDepKey notDepEntry"}) } func TestSetupUsrEtcOSLoginDirs(t *testing.T) { oldSles16Map := sles16Map oldOsinfoRead := osinfoRead t.Cleanup(func() { sles16Map = oldSles16Map osinfoRead = oldOsinfoRead }) wantMap := map[string]string{ "/usr/etc/ssh/sshd_config": "/etc/ssh/sshd_config", "/usr/etc/nsswitch.conf": "/etc/nsswitch.conf", "/usr/lib/pam.d/sshd": "/etc/pam.d/sshd", "/usr/etc/security/group.conf": "/etc/security/group.conf", } if diff := cmp.Diff(wantMap, sles16Map); diff != "" { t.Fatalf("sles16Map unexpected diff (-want +got):\n%s", diff) } ctx := context.Background() tests := []struct { name string info osinfo.OSInfo createSrc bool createDst bool dstContent string dstShouldExist bool prevSetup bool }{ { name: "debian12-no-copy", info: osinfo.OSInfo{OS: "debian", Version: osinfo.Ver{Major: 12}}, createSrc: true, createDst: false, dstShouldExist: false, }, { name: "opensuse15-no-copy", info: osinfo.OSInfo{OS: "opensuse-leap", Version: osinfo.Ver{Major: 15}}, createSrc: true, createDst: false, dstShouldExist: false, }, { name: "sles15-no-copy", info: osinfo.OSInfo{OS: "sles", Version: osinfo.Ver{Major: 15}}, createSrc: true, createDst: false, dstShouldExist: false, }, { name: "sles16-copy", info: osinfo.OSInfo{OS: "sles", Version: osinfo.Ver{Major: 16}}, createSrc: true, createDst: false, dstShouldExist: true, dstContent: "test", }, { name: "opensuse16-copy", info: osinfo.OSInfo{OS: "opensuse-leap", Version: osinfo.Ver{Major: 16}}, createSrc: true, createDst: false, dstShouldExist: true, dstContent: "test", }, { name: "sles16-no-copy-if-exists", info: osinfo.OSInfo{OS: "sles", Version: osinfo.Ver{Major: 16}}, createSrc: false, createDst: true, dstContent: "exists", dstShouldExist: true, }, { name: "sles16-no-copy-if-already-setup", info: osinfo.OSInfo{OS: "sles", Version: osinfo.Ver{Major: 16}}, createSrc: false, createDst: true, dstContent: "exists", dstShouldExist: true, prevSetup: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mod := &osloginModule{} mod.sles16Setup.Store(tt.prevSetup) usrDir := t.TempDir() etcDir := t.TempDir() src := filepath.Join(usrDir, "nsswitch.conf") dst := filepath.Join(etcDir, "nsswitch.conf") sles16Map = map[string]string{ src: dst, } if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { t.Fatalf("Failed to create dir for %s: %v", src, err) } if tt.createSrc { if err := os.WriteFile(src, []byte("test"), 0644); err != nil { t.Fatalf("Failed to write to %s: %v", src, err) } } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { t.Fatalf("Failed to create dir for %s: %v", dst, err) } if tt.createDst { if err := os.WriteFile(dst, []byte(tt.dstContent), 0644); err != nil { t.Fatalf("Failed to write to %s: %v", dst, err) } } osinfoRead = func() osinfo.OSInfo { return tt.info } if err := mod.setupSles16OSLoginDirs(ctx); err != nil { t.Fatalf("setupUsrEtcOSLoginDirs() returned err: %v, want nil", err) } if got := file.Exists(dst, file.TypeFile); got != tt.dstShouldExist { t.Errorf("Destination file %s exists: %t, want: %t", dst, got, tt.dstShouldExist) } if tt.dstShouldExist { got, err := os.ReadFile(dst) if err != nil { t.Fatalf("Failed to read destination file %s: %v", dst, err) } if string(got) != tt.dstContent { t.Errorf("Destination file %s content changed to %s, want %s", dst, string(got), tt.dstContent) } } if got := mod.sles16Setup.Load(); !got { t.Errorf("sles16Setup is %t, want true", got) } }) } } func TestOSLoginSetupSLES16Failure(t *testing.T) { oldSles16Map := sles16Map oldOsinfoRead := osinfoRead t.Cleanup(func() { sles16Map = oldSles16Map osinfoRead = oldOsinfoRead }) if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } mod := createTestModule(t) mod.enabled.Store(true) usrDir := t.TempDir() // sles16Map source files are not created, so copy will fail. sles16Map = map[string]string{ filepath.Join(usrDir, "sshd_config"): mod.sshdConfigPath, } osinfoRead = func() osinfo.OSInfo { return osinfo.OSInfo{OS: "sles", Version: osinfo.Ver{Major: 16}} } enabledMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "true" } } }` enabledDesc, err := metadata.UnmarshalDescriptor(enabledMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", enabledMDSJSON, err) } ctx := context.Background() shouldContinue, noop, err := mod.osloginSetup(ctx, enabledDesc) if err == nil { t.Fatalf("osloginSetup(ctx, %+v) succeeded, want error", enabledDesc) } wantErr := "failed to setup OSLogin directories" if !strings.Contains(err.Error(), wantErr) { t.Fatalf("osloginSetup(ctx, %+v) error is %q, want error to contain %q", enabledDesc, err.Error(), wantErr) } if !mod.failedConfiguration.Load() { t.Errorf("osloginSetup(ctx, %+v) failedConfiguration is false, want true", enabledDesc) } if mod.enabled.Load() { t.Errorf("osloginSetup(ctx, %+v) enabled is true, want false", enabledDesc) } if !shouldContinue { t.Errorf("osloginSetup(ctx, %+v) shouldContinue is %t, want true", enabledDesc, shouldContinue) } if noop { t.Errorf("osloginSetup(ctx, %+v) noop is %t, want false", enabledDesc, noop) } if mod.sles16Setup.Load() { t.Errorf("osloginSetup(ctx, %+v) sles16Setup is true, want false", enabledDesc) } } func TestRetryFailConfiguration(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("failed to load config: %v", err) } tests := []struct { name string services map[daemon.RestartMethod][]serviceRestartConfig fileOpts osloginTestFileOpts runnerErr bool }{ { name: "retry-sshd", }, { name: "retry-nss", fileOpts: osloginTestFileOpts{ testSSHD: true, }, }, { name: "retry-restart-services", services: map[daemon.RestartMethod][]serviceRestartConfig{ daemon.TryRestart: { { protocol: serviceRestartOptional, services: []string{"service1"}, }, }, }, fileOpts: osloginTestFileOpts{ testSSHD: true, testNSSwitch: true, testPAM: true, testGroup: true, }, runnerErr: true, }, { name: "retry-nss-cache-fill", fileOpts: osloginTestFileOpts{ testSSHD: true, testNSSwitch: true, testPAM: true, testGroup: true, }, runnerErr: true, }, } // Mock oslogin enabled metadata. enabledMDSJSON := ` { "instance": { "attributes": { "enable-oslogin": "true" } } }` enabledDesc, err := metadata.UnmarshalDescriptor(enabledMDSJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", enabledMDSJSON, err) } // Run tests. for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := createTestModule(t) module.services = test.services createTestFiles(t, module, test.fileOpts) _ = setupTestRunner(t, test.runnerErr) ok, noop, err := module.osloginSetup(context.Background(), enabledDesc) if err == nil { t.Fatalf("osloginSetup(ctx, %v) = nil, want error", enabledDesc) } if noop { t.Fatalf("osloginSetup(ctx, %v) = %t, want false", enabledDesc, noop) } if !ok { t.Fatalf("osloginSetup(ctx, %v) = %t, want %t", enabledDesc, ok, true) } // Initial setup somehow succeeded. if !module.failedConfiguration.Load() { t.Fatalf("osloginSetup(ctx, %v) did not set failedConfiguration", enabledDesc) } // Now make sure things can pass. _ = setupTestRunner(t, false) createTestFiles(t, module, osloginTestFileOpts{ testSSHD: true, testNSSwitch: true, testPAM: true, testGroup: true, }) // Retry should succeed. ok, noop, err = module.osloginSetup(context.Background(), enabledDesc) if err != nil { t.Fatalf("osloginSetup(ctx, %v) = %v, want nil", enabledDesc, err) } if noop { t.Fatalf("osloginSetup(ctx, %v) = %t, want false", enabledDesc, noop) } if !ok { t.Fatalf("osloginSetup(ctx, %v) = %t, want %t", enabledDesc, ok, true) } if module.failedConfiguration.Load() { t.Fatalf("osloginSetup(ctx, %v) did not clear failedConfiguration", enabledDesc) } // Check that the files do not have duplicate blocks. checkTestFile(t, module.sshdConfigPath, []string{ "TrustedUserCAKeys /etc/ssh/oslogin_trustedca.pub", "AuthorizedPrincipalsCommand /usr/bin/google_authorized_principals %u %k", "AuthorizedPrincipalsCommandUser root", fmt.Sprintf("AuthorizedKeysCommand %s", module.authorizedKeysCommandPaths[0]), "AuthorizedKeysCommandUser root", "Include /var/google-users.d/*", }) checkTestFile(t, module.nsswitchConfigPath, []string{ "passwd: files cache_oslogin oslogin", "group: files cache_oslogin oslogin", }) checkTestFile(t, module.pamConfigPath, []string{ "auth [default=ignore] pam_group.so", "session [success=ok default=ignore] pam_mkhomedir.so", }) checkTestFile(t, module.groupConfigPath, []string{ "sshd;*;*;Al0000-2400;video", }) }) } } type testRunner struct { returnErr bool seenCommand map[string][][]string } func (t *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { t.seenCommand[opts.Name] = append(t.seenCommand[opts.Name], opts.Args) if t.returnErr { return nil, errors.New("error") } return &run.Result{Output: "1 loaded units listed."}, nil } func setupTestRunner(t *testing.T, returnErr bool) *testRunner { testRunner := &testRunner{ returnErr: returnErr, seenCommand: make(map[string][][]string), } oldClient := run.Client run.Client = testRunner t.Cleanup(func() { run.Client = oldClient }) return testRunner } type osloginTestFileOpts struct { testSSHD bool testNSSwitch bool testPAM bool testGroup bool } func createTestModule(t *testing.T) *osloginModule { temp := t.TempDir() return &osloginModule{ sshdConfigPath: filepath.Join(temp, "sshd_config"), nsswitchConfigPath: filepath.Join(temp, "nss_switch.conf"), pamConfigPath: filepath.Join(temp, "sshd"), groupConfigPath: filepath.Join(temp, "group.conf"), authorizedKeysCommandPaths: []string{filepath.Join(temp, "google_authorized_keys")}, authorizedKeysCommandSKPaths: []string{filepath.Join(temp, "google_authorized_keys_sk")}, osloginDirs: []string{filepath.Join(temp, "google-users.d"), filepath.Join(temp, "google-sudoers.d")}, sudoers: filepath.Join(temp, "google-oslogin-sudoers"), deprecatedEntries: make(map[string][]*textconfig.Entry), } } func createTestFiles(t *testing.T, module *osloginModule, opts osloginTestFileOpts) { if opts.testSSHD { if err := os.WriteFile(module.sshdConfigPath, nil, osloginConfigMode); err != nil { t.Fatalf("failed to setup test sshd_config file: %v", err) } // Create test files for AuthorizedKeysCommand and AuthorizedKeysCommandSK. if err := os.WriteFile(module.authorizedKeysCommandPaths[0], nil, osloginConfigMode); err != nil { t.Fatalf("failed to setup test authorized_keys_command file: %v", err) } if err := os.WriteFile(module.authorizedKeysCommandSKPaths[0], nil, osloginConfigMode); err != nil { t.Fatalf("failed to setup test authorized_keys_command_sk file: %v", err) } } if opts.testNSSwitch { if err := os.WriteFile(module.nsswitchConfigPath, []byte("passwd: files\ngroup: files"), osloginConfigMode); err != nil { t.Fatalf("failed to setup test nsswitch.conf file: %v", err) } } if opts.testPAM { if err := os.WriteFile(module.pamConfigPath, nil, osloginConfigMode); err != nil { t.Fatalf("failed to setup test pam.conf file: %v", err) } } if opts.testGroup { if err := os.WriteFile(module.groupConfigPath, nil, osloginConfigMode); err != nil { t.Fatalf("failed to setup test group.conf file: %v", err) } } } // checkTestFile checks that the provided file has the expected lines. // It ignores any lines that are commented out or empty. func checkTestFile(t *testing.T, path string, expectedLines []string) { t.Helper() contents, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read test file: %v", err) } lines := strings.Split(string(contents), "\n") if diff := cmp.Diff(expectedLines, lines, cmpopts.IgnoreSliceElements(func(e string) bool { return e == "" || strings.HasPrefix(e, "#") })); diff != "" { t.Errorf("test file %q written with diff (-want +got): %v", filepath.Base(path), diff) } } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_sshca_linux.go000066400000000000000000000103661524141036400270340ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package oslogin import ( "context" "encoding/json" "fmt" "strings" "github.com/GoogleCloudPlatform/galog" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/pipewatcher" ) const ( // pipeWatcherSubscriberID is the subscriber id for the pipe watcher event // handler. pipeWatcherSubscriberID = "sshca-pipe-handler" ) // Certificates wrapps a list of certificate authorities. type Certificates struct { Certs []TrustedCert `json:"trustedCertificateAuthorities"` } // TrustedCert defines the object containing a public key. type TrustedCert struct { PublicKey string `json:"publicKey"` } // PipeEventHandler is the specialized ssh ca pipe event handler. type PipeEventHandler struct { // subscriberID is the event subscriber id for the pipe watcher event handler. subscriberID string // mdsClient is the metadata client to use. mdsClient metadata.MDSClientInterface } // newPipeEventHandler creates a new pipe event handler. func newPipeEventHandler(subscriberID string, mdsClient metadata.MDSClientInterface) *PipeEventHandler { res := &PipeEventHandler{ subscriberID: subscriberID, mdsClient: mdsClient, } subscriber := events.EventSubscriber{Name: subscriberID, Callback: res.writeFile, MetricName: acmpb.GuestAgentModuleMetric_OS_LOGIN_INITIALIZATION} events.FetchManager().Subscribe(sshcaPipeWatcherOpts.ReadEventID, subscriber) return res } // Close finishes the sshca module. func (pe *PipeEventHandler) Close() { galog.Debugf("Closing SSH CA pipe event handler.") events.FetchManager().Unsubscribe(sshcaPipeWatcherOpts.ReadEventID, pe.subscriberID) } // writeFile is an event handler callback and writes the actual sshca content to the pipe // used by openssh to grant access based on ssh ca. func (pe *PipeEventHandler) writeFile(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { // There was some error on the pipe watcher, just ignore it. if evData.Error != nil { return false, true, fmt.Errorf("ssh trusted ca cert event watcher reported error: %v", evData.Error) } // Make sure we close the pipe after we've done writing to it. pipeData, ok := evData.Data.(*pipewatcher.PipeData) if !ok { return false, true, fmt.Errorf("ssh ca event data is not a pipe data") } defer func() { if err := pipeData.Close(); err != nil { galog.Errorf("Failed to close pipe: %s", err) } pipeData.Finished() }() certs, err := osloginMDSCertificates(ctx, pe.mdsClient) if err != nil { return true, false, fmt.Errorf("unable to get certificates from MDS: %w", err) } var outData []string for _, curr := range certs.Certs { outData = append(outData, curr.PublicKey) } outStr := strings.Join(outData, "\n") _, err = pipeData.WriteString(outStr) if err != nil { return true, false, fmt.Errorf("failed to write certificate to the write end of the pipe: %w", err) } return true, false, nil } // osloginMDSCertificates returns the list of certificates from the metadata // server. func osloginMDSCertificates(ctx context.Context, mdsClient metadata.MDSClientInterface) (*Certificates, error) { certificate, err := mdsClient.GetKey(ctx, "oslogin/certificates", nil) if err != nil { return nil, fmt.Errorf("failed to get certificate from metadata server: %w", err) } certs := new(Certificates) if err := json.Unmarshal([]byte(certificate), certs); err != nil { return nil, fmt.Errorf("failed to unmarshal certificate json: %w", err) } return certs, nil } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_sshca_linux_test.go000066400000000000000000000105311524141036400300650ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package oslogin import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/pipewatcher" ) func TestInputData(t *testing.T) { tmpDir := t.TempDir() pipeFilePath := filepath.Join(tmpDir, "pipe") pipeFile, err := os.Create(pipeFilePath) if err != nil { t.Fatalf("os.Create(%q) failed: %v", pipeFilePath, err) } defer pipeFile.Close() pipeFilePath2 := filepath.Join(tmpDir, "pipe2") pipeFile2, err := os.Create(pipeFilePath2) if err != nil { t.Fatalf("os.Create(%q) failed: %v", pipeFilePath2, err) } defer pipeFile2.Close() tests := []struct { name string evData *events.EventData certificates *Certificates want bool wantError bool wantNoop bool }{ { name: "with-error", evData: &events.EventData{ Error: errors.New("error"), }, want: false, wantError: true, wantNoop: true, }, { name: "with-invalid-data", evData: &events.EventData{ Data: []byte("invalid-data"), }, want: false, wantError: true, wantNoop: true, }, { name: "valid-data", evData: &events.EventData{ Data: pipewatcher.NewPipeData(pipeFile, func() {}), }, want: true, wantError: true, wantNoop: false, }, { name: "valid-data-with-cert", evData: &events.EventData{ Data: pipewatcher.NewPipeData(pipeFile2, func() {}), }, want: true, wantError: false, wantNoop: false, certificates: &Certificates{ Certs: []TrustedCert{ TrustedCert{ PublicKey: "foobar", }, }, }, }, { name: "valid-data-with-cert-nopipe", evData: &events.EventData{ Data: pipewatcher.NewPipeData(nil, func() {}), }, want: true, wantError: true, wantNoop: false, certificates: &Certificates{ Certs: []TrustedCert{ TrustedCert{ PublicKey: "foobar", }, }, }, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { client := &mdsTestClient{ certs: tc.certificates, } sub := newPipeEventHandler("subscriber-id,"+tc.name, client) defer sub.Close() got, noop, err := sub.writeFile(ctx, "evType", nil, tc.evData) if (err != nil) != tc.wantError { t.Errorf("writeFile(ctx, 'evType', nil, %v) returned error: %v, want error: %t", tc.evData, err, tc.wantError) } if noop != tc.wantNoop { t.Errorf("writeFile(ctx, 'evType', nil, %v) returned noop: %t, want noop: %t", tc.evData, noop, tc.wantNoop) } if got != tc.want { t.Errorf("writeFile(ctx, 'evType', nil, %v) = %v, want %v", tc.evData, got, tc.want) } }) } } type mdsTestClient struct { certs *Certificates } func (m *mdsTestClient) Get(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not implemented") } func (m *mdsTestClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { if key != "oslogin/certificates" { return "", fmt.Errorf("non supported key: %q", key) } if m.certs == nil { return "", fmt.Errorf("no certs defined") } data, err := json.Marshal(m.certs) if err != nil { return "", fmt.Errorf("json.Marshal(%v) failed: %v", m.certs, err) } return string(data), nil } func (m *mdsTestClient) GetKeyRecursive(context.Context, string) (string, error) { return "", fmt.Errorf("not implemented") } func (m *mdsTestClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not implemented") } func (m *mdsTestClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not implemented") } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_windows.go000066400000000000000000000016731524141036400262070ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package oslogin import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule returns a no-op module for windows. func NewModule(context.Context) *manager.Module { galog.V(2).Debug("OS Login module is not supported on windows.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/oslogin/oslogin_windows_test.go000066400000000000000000000014541524141036400272430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package oslogin import ( "context" "testing" ) func TestNewModule(t *testing.T) { if mod := NewModule(context.Background()); mod != nil { t.Errorf("NewModule() = %v, want nil", mod) } } google-guest-agent-20260819.00/cmd/core_plugin/platscript/000077500000000000000000000000001524141036400231405ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/platscript/platscript_linux.go000066400000000000000000000115161524141036400270770ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package platscript is responsible for running platform specific setup scripts. package platscript import ( "context" "fmt" "path/filepath" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) // platformScript is a single platform script to run. type platformScript struct { // enabled is true if the script should be run/is enabled. enabled bool // script is the name of the script to run. script string } var ( // scriptsConfig is the list of platform scripts to run - and their "enabled" // flag. Scripts are enabled via guest agent configuration. scriptsConfig []*platformScript // scriptsPathPrefix is the path to the platform scripts directory. If set // (not empty) the scripts will be run from this directory(useful for // testing). scriptsPathPrefix = "" // overcommitCommand is the command to run to set the overcommit memory // setting to 1 for e2 instances. overcommitCommand = []string{"sysctl", "vm.overcommit_memory=1"} ) const ( // platscriptModuleID is the module ID for the platform script manager. platscriptModuleID = "platform-scripts" ) // NewModule returns the first boot module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: platscriptModuleID, Setup: moduleSetup, Description: "Executes platform configuration scripts available in the guest environment", } } // initScriptsMapping initializes the scripts list, sets its "enabled" flag // and the script path (if set). func initScriptsMapping() { config := cfg.Retrieve() // Map the scripts to their "enabled" flag. scriptsConfig = []*platformScript{ &platformScript{enabled: config.InstanceSetup.OptimizeLocalSSD, script: "google_optimize_local_ssd"}, &platformScript{enabled: config.InstanceSetup.SetMultiqueue, script: "google_set_multiqueue"}, } // Prepend scriptsPathPrefix to the script name. for _, curr := range scriptsConfig { curr.script = filepath.Join(scriptsPathPrefix, curr.script) } } // moduleSetup runs the platform scripts on Linux. func moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing linux platform scripts module.") initScriptsMapping() desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("platform script module expects a metadata descriptor in the data pointer") } // Iterate over the scripts and run the enabled ones. for _, curr := range scriptsConfig { galog.V(2).Debugf("Platform script(%q) enabled: (%t)", curr.script, curr.enabled) if !curr.enabled { continue } galog.Debugf("Running platform script: %q", curr.script) opts := run.Options{Name: curr.script, OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to run platform script %q: %w", curr.script, err) } galog.Debugf("Finished running platform script: %q", curr.script) } if err := overCommitMemory(ctx, desc); err != nil { return fmt.Errorf("failed to run overcommit memory setup: %w", err) } galog.Debugf("Finished initializing linux platform scripts module.") return nil } // overCommitMemory sets the overcommit memory setting to 1 for e2 instances. func overCommitMemory(ctx context.Context, desc *metadata.Descriptor) error { // Ignore overcommit accounting if not e2 instances. if !validMachineType(desc.Instance().MachineType()) { galog.V(2).Debug("Not an e2 instance, skipping overcommit memory.") return nil } // Run the overcommit command. opts := run.Options{Name: overcommitCommand[0], Args: overcommitCommand[1:], OutputType: run.OutputNone} if _, err := run.WithContext(ctx, opts); err != nil { return fmt.Errorf("failed to run 'sysctl vm.overcommit_memory=1': %w", err) } galog.Debug("Enabled memory overcommit.") return nil } // validMachineType returns true if the machine type is an e2 instance. func validMachineType(machineType string) bool { parts := strings.Split(machineType, "/") if !strings.HasPrefix(parts[len(parts)-1], "e2-") { return false } return true } google-guest-agent-20260819.00/cmd/core_plugin/platscript/platscript_linux_test.go000066400000000000000000000141421524141036400301340ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package platscript import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) func loadConfig(t *testing.T) { t.Helper() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed with error: %v", err) } } func createScriptFile(t *testing.T, scriptPath string, dataPath string) { t.Helper() scriptTemplate := fmt.Sprintf(`#!/bin/bash echo ran > %s `, dataPath) if err := os.WriteFile(scriptPath, []byte(scriptTemplate), 0755); err != nil { t.Fatalf("Failed to write script file: %v", err) } } func TestSuccess(t *testing.T) { tmpDir := t.TempDir() scriptsPathPrefix = filepath.Join(tmpDir, "scripts") loadConfig(t) t.Cleanup(func() { scriptsPathPrefix = "" }) if err := os.Mkdir(scriptsPathPrefix, 0755); err != nil { t.Fatalf("Failed to create scripts directory: %v", err) } scripts := []struct { scriptPath string dataPath string }{ { scriptPath: filepath.Join(scriptsPathPrefix, "google_optimize_local_ssd"), dataPath: filepath.Join(tmpDir, "google_optimize_local_ssd.txt"), }, { scriptPath: filepath.Join(scriptsPathPrefix, "google_set_multiqueue"), dataPath: filepath.Join(tmpDir, "google_set_multiqueue.txt"), }, } for _, s := range scripts { createScriptFile(t, s.scriptPath, s.dataPath) } mdsJSON := ` { "instance": { "ID": 111111 } } ` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) failed: %v", mdsJSON, err) } if err := moduleSetup(context.Background(), desc); err != nil { t.Fatalf("Failed to run patform scripts: %v", err) } for _, s := range scripts { if _, err := os.Stat(s.dataPath); os.IsNotExist(err) { t.Errorf("Script %q did not run", s.scriptPath) } data, err := os.ReadFile(s.dataPath) if err != nil { t.Errorf("Failed to read data file %q: %v", s.dataPath, err) } want := "ran\n" if string(data) != want { t.Errorf("Inconsistent data in %q: got %q, want %q", s.scriptPath, string(data), want) } } } func TestFailure(t *testing.T) { loadConfig(t) // No scripts exist in PATH, it should fail. if err := moduleSetup(context.Background(), nil); err == nil { t.Errorf("moduleSetup() succeeded, want error") } } func TestDisabledSuccess(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } config := cfg.Retrieve() config.InstanceSetup.OptimizeLocalSSD = false config.InstanceSetup.SetMultiqueue = false mdsJSON := ` { "instance": { "ID": 111111 } } ` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) failed: %v", mdsJSON, err) } // Scripts don't exist in PATH, but they are disabled, so it should succeed. if err := moduleSetup(context.Background(), desc); err != nil { t.Errorf("moduleSetup() failed: %v", err) } } func TestInvalidMetadata(t *testing.T) { loadConfig(t) if err := moduleSetup(context.Background(), nil); err == nil { t.Error("moduleSetup() succeeded, want error") } } func TestInvalidMachineType(t *testing.T) { invalidTypes := []string{ "n4-standard-2", "n4-standard-16", "c3d-standard-4", "c3d-standard-16", "c3d-standard-4", "c3d-standard-16", "c3-standard-4", "c3-standard-22", "c3-standard-4", "c3-standard-22", } for _, tc := range invalidTypes { t.Run(tc, func(t *testing.T) { if ok := validMachineType(tc); ok { t.Errorf("validMachineType(%q) = true, want false", tc) } }) } } func TestNoopInvalidMachineType(t *testing.T) { mdsJSON := ` { "instance": { "machineType": "projects/111111111111/machineTypes/c3-standard-4" } } ` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) failed: %v", mdsJSON, err) } if err := overCommitMemory(context.Background(), desc); err != nil { t.Fatalf("overCommitMemory() failed: %v", err) } } func TestOvercommitMemoryFailure(t *testing.T) { mdsJSON := ` { "instance": { "machineType": "projects/111111111111/machineTypes/e2-medium" } } ` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) failed: %v", mdsJSON, err) } if err := overCommitMemory(context.Background(), desc); err == nil { t.Fatalf("overCommitMemory() succeeded, want error") } } func TestOvercommitMemorySuccess(t *testing.T) { loadConfig(t) oldOvercommitCommand := overcommitCommand overcommitCommand = []string{"echo", "foobar"} t.Cleanup(func() { overcommitCommand = oldOvercommitCommand }) mdsJSON := ` { "instance": { "machineType": "projects/111111111111/machineTypes/e2-medium" } } ` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) failed: %v", mdsJSON, err) } if err := overCommitMemory(context.Background(), desc); err != nil { t.Errorf("overCommitMemory(ctx, %s) failed unexpectedly: %v", mdsJSON, err) } } func TestNewModule(t *testing.T) { mod := NewModule(context.Background()) if mod.ID != platscriptModuleID { t.Errorf("NewModule() returned module with ID %q, want %q", mod.ID, platscriptModuleID) } if mod.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if mod.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } if mod.Description == "" { t.Errorf("NewModule() returned module with empty Description") } } google-guest-agent-20260819.00/cmd/core_plugin/platscript/platscript_windows.go000066400000000000000000000016671524141036400274400ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package platscript import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule is no-op on windows. func NewModule(context.Context) *manager.Module { galog.V(2).Debug("Platform scripts module is not supported on windows.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/platscript/platscript_windows_test.go000066400000000000000000000014611524141036400304670ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package platscript import ( "context" "testing" ) func TestModuleRegister(t *testing.T) { if NewModule(context.Background()) != nil { t.Errorf("NewModule() should return nil on Windows") } } google-guest-agent-20260819.00/cmd/core_plugin/service.go000066400000000000000000000223251524141036400227460ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "encoding/json" "fmt" "net" "os" "os/signal" "syscall" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages/early" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages/late" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "google.golang.org/grpc" "google.golang.org/grpc/status" pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" ) const ( // initStatusRequest type is the request type to check if early initialization // is completed. initStatusRequest = "early-initialization" ) // pluginServer is the core plugin server that implements the plugin interface. var pluginServer *PluginServer // initPluginServer initializes the core plugin server and starts serving // requests from Guest Agent. func initPluginServer() error { galog.Infof("Initializing core plugin server...") listener, err := net.Listen(protocol, address) if err != nil { return fmt.Errorf("start listening on %q using %q: %v", address, protocol, err) } defer listener.Close() galog.Infof("Core plugin server listening on %q using %q", address, protocol) // This is the grpc server in communication with the Guest Agent. server := grpc.NewServer() pluginServer = &PluginServer{server: server} // Successfully registering the server and starting to listen on the address // offered mean Guest Agent was successful in installing/launching the plugin // & will manage the lifecycle (start, stop, or revision change) here onwards. pb.RegisterGuestAgentPluginServer(server, pluginServer) galog.Infof("Core plugin server registered. Starting to serve requests...") if err := server.Serve(listener); err != nil { return fmt.Errorf("cannot continue serving on %q: %v", address, err) } return nil } // runAgent runs the agent early initialization steps and starts event manager. func (ps *PluginServer) runAgent(ctx context.Context) { galog.Infof("Running core plugin...") // Register signal handler and implements its callback. sigHandler(ctx, func(_ os.Signal) { // We're handling some external signal here, set cleanup to [false]. // If this was Guest Agent trying to stop it would call [Stop] RPC directly // or do a [SIGKILL] which anyways cannot be intercepted. ps.Stop(ctx, &pb.StopRequest{Cleanup: false}) }) // Run early platform initialization path. All the steps executed in this // phase assumes metadata server is not accessible yet. // It is ok to run this is separate go routine and not within [Start] RPC as // we have [GetStatus: early-initialization] check way to report Guest Agent // that core plugin has successfully initialized. if err := early.Retrieve().Run(ctx); err != nil { logAndExit(fmt.Sprintf("Failed to run early initialization: %v", err)) } galog.Infof("Initialized (version: %q)", version) // Run late modules initialization path. The code path executed from this // point on assumes metadata server is accessible and the platform is fully // initialized. Any reported error is in the context of the pre or post module // initialization, the (per) module initialization errors are logged and the // module is marked as disabled. if err := late.Retrieve().Run(ctx); err != nil { logAndExit(fmt.Sprintf("Failed to run late initialization: %v", err)) } defer func() { galog.Infof("Stopping core plugin...") ps.server.GracefulStop() }() // This kind of runs for a life-time of process. It returns when all watchers // are done or context is closed. MDS watcher is never removed and keeps the // the runner alive for the process's life-time. if err := events.FetchManager().Run(ctx); err != nil { logAndExit(fmt.Sprintf("Failed to run event manager: %v", err)) } } // sigHandler handles SIGTERM, SIGINT etc signals. The function provided in the // cancel argument handles internal framework termination and the plugin // interface notification of the "exiting" state. func sigHandler(ctx context.Context, cancel func(sig os.Signal)) { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGHUP) go func() { select { case sig := <-sigChan: galog.Infof("Got signal: %d, leaving...", sig) close(sigChan) cancel(sig) case <-ctx.Done(): break } }() } // handleVMEvent spins up the metadata script runner that runs scripts based on // the VM event. func handleVMEvent(ctx context.Context, req []byte) error { galog.Debugf("Handling VM event") evReq := &manager.Request{} if err := json.Unmarshal(req, evReq); err != nil { return fmt.Errorf("unmarshal VM event request: %w", err) } // Handling shutdown event would allow for a graceful shutdown of the core // plugin. if evReq.Event != "shutdown" { galog.Debugf("Ignoring VM event %q", evReq.Event) return nil } galog.Infof("Shutdown event received, shutting down core plugin...") // Stop response is always empty and error is nil so ignore it. pluginServer.Stop(ctx, &pb.StopRequest{Cleanup: false}) return nil } // PluginServer implements the core-plugin RPC server interface. type PluginServer struct { // server is the grpc server that serves RPC requests for core plugin. server *grpc.Server // cancel is the cancel function to be called when core plugin is stopped. cancel context.CancelFunc // This is for compatibility with `protoc`, which requires this be embedded. pb.UnimplementedGuestAgentPluginServer } // Apply applies the config sent or performs the work defined in the message. // There's no use-case defined for this yet and is un-implemented. func (ps *PluginServer) Apply(ctx context.Context, msg *pb.ApplyRequest) (*pb.ApplyResponse, error) { galog.Debugf("Handling apply request %+v", msg) reqBytes := []byte(msg.GetStringConfig()) req := &command.Request{} resp := &pb.ApplyResponse{} if err := json.Unmarshal(reqBytes, req); err != nil { return resp, status.Errorf(1, "failed to unmarshal apply request (%s): %v", msg.GetStringConfig(), err) } switch req.Command { case manager.VMEventCmd: if err := handleVMEvent(ctx, reqBytes); err != nil { galog.Errorf("Failed to handle VM event: %v", err) } return resp, nil default: return resp, status.Errorf(1, "unsupported command: %q", req.Command) } } // Start starts the plugin and initiates the plugin functionality. func (ps *PluginServer) Start(ctx context.Context, msg *pb.StartRequest) (*pb.StartResponse, error) { // This is core plugin context. Context received in the request cannot be used // here as it can have request timeouts or deadlines set by the Guest Agent. // Context's lifetimes are scoped to that of the request when the request is // finished, the context is cancelled. // Treat this as the entry point for a plugin to be functional. pCtx, cancel := context.WithCancel(context.Background()) ps.cancel = cancel // Log the config read by the core plugin to debug. cfg.Log() galog.Debugf("Handling start request %+v", msg) if err := events.FetchManager().AddWatcher(pCtx, metadata.NewWatcher()); err != nil { return nil, status.Errorf(1, "failed to add metadata watcher: %v", err) } // Time it takes for early initialization steps may vary and event manager runs // for a life-time of process. All that should not impact [Start] RPC. Run it // in another go routine and return, otherwise [Start] RPC can timeout and // fail for agent. // This go routine exits when event manager's [Run] method returns. go ps.runAgent(pCtx) return &pb.StartResponse{}, nil } // Stop is the stop hook and implements core plugin stop workflow. func (ps *PluginServer) Stop(ctx context.Context, msg *pb.StopRequest) (*pb.StopResponse, error) { galog.Infof("Handling stop request %+v, stopping core plugin...", msg) galog.Shutdown(galogShutdownTimeout) ps.cancel() return &pb.StopResponse{}, nil } // GetStatus is the health check agent would perform to make sure plugin process // is alive. func (ps *PluginServer) GetStatus(ctx context.Context, msg *pb.GetStatusRequest) (*pb.Status, error) { galog.Debugf("Handling get status request %+v", msg) switch msg.GetData() { case initStatusRequest: if early.Retrieve().Initialized() { return &pb.Status{Code: 0, Results: []string{"successfully completed early initialization"}}, nil } return &pb.Status{Code: 1, Results: []string{"still working..."}}, nil default: return &pb.Status{Code: 0, Results: []string{"core-plugin-alive, running ok"}}, nil } } google-guest-agent-20260819.00/cmd/core_plugin/service_test.go000066400000000000000000000046241524141036400240070ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" ) func TestHandleVMEventError(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly: %v", err) } req := `{"Command":2}` if err := handleVMEvent(ctx, []byte(req)); err == nil { t.Errorf("handleVMEvent(ctx, %s) = nil, want error", req) } } func TestApply(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) pluginServer = &PluginServer{cancel: cancel} t.Cleanup(func() { pluginServer = nil cancel() }) if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly: %v", err) } tests := []struct { desc string req string wantErr bool cancelContext bool }{ { desc: "invalid_request", req: `{"Command":2}`, wantErr: true, }, { desc: "invalid_command", req: `{"Command":"unknown"}`, wantErr: true, }, { desc: "valid_request_nothandled", req: `{"Command":"VmEvent", "Event":"startup"}`, }, { desc: "valid_request", req: `{"Command":"VmEvent", "Event":"shutdown"}`, cancelContext: true, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { req := &pb.ApplyRequest{ ServiceConfig: &pb.ApplyRequest_StringConfig{StringConfig: tc.req}, } _, err := pluginServer.Apply(ctx, req) if (err != nil) != tc.wantErr { t.Errorf("Apply(ctx, %s) = error %v, want error %t", tc.req, err, tc.wantErr) } ctxClosed := (ctx.Err() != nil) if tc.cancelContext != ctxClosed { t.Errorf("Apply(ctx, %s) = context closed %t, want %t", tc.req, ctxClosed, tc.cancelContext) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/000077500000000000000000000000001524141036400226125ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/snapshot/proto/000077500000000000000000000000001524141036400237555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/snapshot/proto/cloud_vmm/000077500000000000000000000000001524141036400257425ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/snapshot/proto/cloud_vmm/snapshot_service.pb.go000066400000000000000000000475641524141036400322700ustar00rootroot00000000000000// Copyright 2019 Google // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.12 // protoc v7.35.1 // source: snapshot_service.proto package cloud_vmm import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type OperationType int32 const ( OperationType_NOT_SET OperationType = 0 OperationType_PRE_SNAPSHOT OperationType = 1 OperationType_POST_SNAPSHOT OperationType = 2 ) // Enum value maps for OperationType. var ( OperationType_name = map[int32]string{ 0: "NOT_SET", 1: "PRE_SNAPSHOT", 2: "POST_SNAPSHOT", } OperationType_value = map[string]int32{ "NOT_SET": 0, "PRE_SNAPSHOT": 1, "POST_SNAPSHOT": 2, } ) func (x OperationType) Enum() *OperationType { p := new(OperationType) *p = x return p } func (x OperationType) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (OperationType) Descriptor() protoreflect.EnumDescriptor { return file_snapshot_service_proto_enumTypes[0].Descriptor() } func (OperationType) Type() protoreflect.EnumType { return &file_snapshot_service_proto_enumTypes[0] } func (x OperationType) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use OperationType.Descriptor instead. func (OperationType) EnumDescriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{0} } type SupportedFeatures int32 const ( SupportedFeatures_NONE SupportedFeatures = 0 SupportedFeatures_SNAPSHOTS SupportedFeatures = 1 ) // Enum value maps for SupportedFeatures. var ( SupportedFeatures_name = map[int32]string{ 0: "NONE", 1: "SNAPSHOTS", } SupportedFeatures_value = map[string]int32{ "NONE": 0, "SNAPSHOTS": 1, } ) func (x SupportedFeatures) Enum() *SupportedFeatures { p := new(SupportedFeatures) *p = x return p } func (x SupportedFeatures) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SupportedFeatures) Descriptor() protoreflect.EnumDescriptor { return file_snapshot_service_proto_enumTypes[1].Descriptor() } func (SupportedFeatures) Type() protoreflect.EnumType { return &file_snapshot_service_proto_enumTypes[1] } func (x SupportedFeatures) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SupportedFeatures.Descriptor instead. func (SupportedFeatures) EnumDescriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{1} } type AgentErrorCode int32 const ( AgentErrorCode_NO_ERROR AgentErrorCode = 0 // The snapshot config was improper in some way. AgentErrorCode_INVALID_CONFIG AgentErrorCode = 1 // The pre or post snapshot script was not found on disk. AgentErrorCode_SCRIPT_NOT_FOUND AgentErrorCode = 2 // The pre or post snapshot script timed out. AgentErrorCode_SCRIPT_TIMED_OUT AgentErrorCode = 3 // The pre or post snapshot script returned an error, but the "continue on // error" flag was not set. AgentErrorCode_UNHANDLED_SCRIPT_ERROR AgentErrorCode = 4 ) // Enum value maps for AgentErrorCode. var ( AgentErrorCode_name = map[int32]string{ 0: "NO_ERROR", 1: "INVALID_CONFIG", 2: "SCRIPT_NOT_FOUND", 3: "SCRIPT_TIMED_OUT", 4: "UNHANDLED_SCRIPT_ERROR", } AgentErrorCode_value = map[string]int32{ "NO_ERROR": 0, "INVALID_CONFIG": 1, "SCRIPT_NOT_FOUND": 2, "SCRIPT_TIMED_OUT": 3, "UNHANDLED_SCRIPT_ERROR": 4, } ) func (x AgentErrorCode) Enum() *AgentErrorCode { p := new(AgentErrorCode) *p = x return p } func (x AgentErrorCode) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (AgentErrorCode) Descriptor() protoreflect.EnumDescriptor { return file_snapshot_service_proto_enumTypes[2].Descriptor() } func (AgentErrorCode) Type() protoreflect.EnumType { return &file_snapshot_service_proto_enumTypes[2] } func (x AgentErrorCode) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use AgentErrorCode.Descriptor instead. func (AgentErrorCode) EnumDescriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{2} } type SnapshotRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The operation id of the snapshot. OperationId int32 `protobuf:"varint,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // A list of comma separated target/lun values, e.g. "1/2,3/4". DiskList string `protobuf:"bytes,2,opt,name=disk_list,json=diskList,proto3" json:"disk_list,omitempty"` // The operation type. Type OperationType `protobuf:"varint,3,opt,name=type,proto3,enum=cloud.vmm.OperationType" json:"type,omitempty"` // A list of comma separated NGUIDs for NVMe disks. If a NVMe disk does not // have an NGUID, an empty string will be used. NguidList string `protobuf:"bytes,4,opt,name=nguid_list,json=nguidList,proto3" json:"nguid_list,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotRequest) Reset() { *x = SnapshotRequest{} mi := &file_snapshot_service_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotRequest) ProtoMessage() {} func (x *SnapshotRequest) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotRequest.ProtoReflect.Descriptor instead. func (*SnapshotRequest) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{0} } func (x *SnapshotRequest) GetOperationId() int32 { if x != nil { return x.OperationId } return 0 } func (x *SnapshotRequest) GetDiskList() string { if x != nil { return x.DiskList } return "" } func (x *SnapshotRequest) GetType() OperationType { if x != nil { return x.Type } return OperationType_NOT_SET } func (x *SnapshotRequest) GetNguidList() string { if x != nil { return x.NguidList } return "" } type ServerInfo struct { state protoimpl.MessageState `protogen:"open.v1"` SupportedFeatures []SupportedFeatures `protobuf:"varint,1,rep,packed,name=supported_features,json=supportedFeatures,proto3,enum=cloud.vmm.SupportedFeatures" json:"supported_features,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerInfo) Reset() { *x = ServerInfo{} mi := &file_snapshot_service_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ServerInfo) String() string { return protoimpl.X.MessageStringOf(x) } func (*ServerInfo) ProtoMessage() {} func (x *ServerInfo) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ServerInfo.ProtoReflect.Descriptor instead. func (*ServerInfo) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{1} } func (x *ServerInfo) GetSupportedFeatures() []SupportedFeatures { if x != nil { return x.SupportedFeatures } return nil } type SnapshotResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The operation id of the snapshot. OperationId int32 `protobuf:"varint,1,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // The return code of the scripts run by the guest. If this is non-zero, then // agent_return_code should be UNHANDLED_SCRIPT_ERROR. ScriptsReturnCode int32 `protobuf:"varint,2,opt,name=scripts_return_code,json=scriptsReturnCode,proto3" json:"scripts_return_code,omitempty"` // The agent return code. AgentReturnCode AgentErrorCode `protobuf:"varint,3,opt,name=agent_return_code,json=agentReturnCode,proto3,enum=cloud.vmm.AgentErrorCode" json:"agent_return_code,omitempty"` // The operation type. Type OperationType `protobuf:"varint,4,opt,name=type,proto3,enum=cloud.vmm.OperationType" json:"type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SnapshotResponse) Reset() { *x = SnapshotResponse{} mi := &file_snapshot_service_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *SnapshotResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*SnapshotResponse) ProtoMessage() {} func (x *SnapshotResponse) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use SnapshotResponse.ProtoReflect.Descriptor instead. func (*SnapshotResponse) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{2} } func (x *SnapshotResponse) GetOperationId() int32 { if x != nil { return x.OperationId } return 0 } func (x *SnapshotResponse) GetScriptsReturnCode() int32 { if x != nil { return x.ScriptsReturnCode } return 0 } func (x *SnapshotResponse) GetAgentReturnCode() AgentErrorCode { if x != nil { return x.AgentReturnCode } return AgentErrorCode_NO_ERROR } func (x *SnapshotResponse) GetType() OperationType { if x != nil { return x.Type } return OperationType_NOT_SET } type GuestReady struct { state protoimpl.MessageState `protogen:"open.v1"` RequestServerInfo bool `protobuf:"varint,1,opt,name=request_server_info,json=requestServerInfo,proto3" json:"request_server_info,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GuestReady) Reset() { *x = GuestReady{} mi := &file_snapshot_service_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GuestReady) String() string { return protoimpl.X.MessageStringOf(x) } func (*GuestReady) ProtoMessage() {} func (x *GuestReady) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GuestReady.ProtoReflect.Descriptor instead. func (*GuestReady) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{3} } func (x *GuestReady) GetRequestServerInfo() bool { if x != nil { return x.RequestServerInfo } return false } type GuestMessage struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Msg: // // *GuestMessage_SnapshotRequest // *GuestMessage_ServerInfo Msg isGuestMessage_Msg `protobuf_oneof:"msg"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GuestMessage) Reset() { *x = GuestMessage{} mi := &file_snapshot_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GuestMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*GuestMessage) ProtoMessage() {} func (x *GuestMessage) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GuestMessage.ProtoReflect.Descriptor instead. func (*GuestMessage) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{4} } func (x *GuestMessage) GetMsg() isGuestMessage_Msg { if x != nil { return x.Msg } return nil } func (x *GuestMessage) GetSnapshotRequest() *SnapshotRequest { if x != nil { if x, ok := x.Msg.(*GuestMessage_SnapshotRequest); ok { return x.SnapshotRequest } } return nil } func (x *GuestMessage) GetServerInfo() *ServerInfo { if x != nil { if x, ok := x.Msg.(*GuestMessage_ServerInfo); ok { return x.ServerInfo } } return nil } type isGuestMessage_Msg interface { isGuestMessage_Msg() } type GuestMessage_SnapshotRequest struct { SnapshotRequest *SnapshotRequest `protobuf:"bytes,1,opt,name=snapshot_request,json=snapshotRequest,proto3,oneof"` } type GuestMessage_ServerInfo struct { ServerInfo *ServerInfo `protobuf:"bytes,2,opt,name=server_info,json=serverInfo,proto3,oneof"` } func (*GuestMessage_SnapshotRequest) isGuestMessage_Msg() {} func (*GuestMessage_ServerInfo) isGuestMessage_Msg() {} type ServerAck struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerAck) Reset() { *x = ServerAck{} mi := &file_snapshot_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ServerAck) String() string { return protoimpl.X.MessageStringOf(x) } func (*ServerAck) ProtoMessage() {} func (x *ServerAck) ProtoReflect() protoreflect.Message { mi := &file_snapshot_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ServerAck.ProtoReflect.Descriptor instead. func (*ServerAck) Descriptor() ([]byte, []int) { return file_snapshot_service_proto_rawDescGZIP(), []int{5} } var File_snapshot_service_proto protoreflect.FileDescriptor const file_snapshot_service_proto_rawDesc = "" + "\n" + "\x16snapshot_service.proto\x12\tcloud.vmm\"\x9e\x01\n" + "\x0fSnapshotRequest\x12!\n" + "\foperation_id\x18\x01 \x01(\x05R\voperationId\x12\x1b\n" + "\tdisk_list\x18\x02 \x01(\tR\bdiskList\x12,\n" + "\x04type\x18\x03 \x01(\x0e2\x18.cloud.vmm.OperationTypeR\x04type\x12\x1d\n" + "\n" + "nguid_list\x18\x04 \x01(\tR\tnguidList\"Y\n" + "\n" + "ServerInfo\x12K\n" + "\x12supported_features\x18\x01 \x03(\x0e2\x1c.cloud.vmm.SupportedFeaturesR\x11supportedFeatures\"\xda\x01\n" + "\x10SnapshotResponse\x12!\n" + "\foperation_id\x18\x01 \x01(\x05R\voperationId\x12.\n" + "\x13scripts_return_code\x18\x02 \x01(\x05R\x11scriptsReturnCode\x12E\n" + "\x11agent_return_code\x18\x03 \x01(\x0e2\x19.cloud.vmm.AgentErrorCodeR\x0fagentReturnCode\x12,\n" + "\x04type\x18\x04 \x01(\x0e2\x18.cloud.vmm.OperationTypeR\x04type\"<\n" + "\n" + "GuestReady\x12.\n" + "\x13request_server_info\x18\x01 \x01(\bR\x11requestServerInfo\"\x98\x01\n" + "\fGuestMessage\x12G\n" + "\x10snapshot_request\x18\x01 \x01(\v2\x1a.cloud.vmm.SnapshotRequestH\x00R\x0fsnapshotRequest\x128\n" + "\vserver_info\x18\x02 \x01(\v2\x15.cloud.vmm.ServerInfoH\x00R\n" + "serverInfoB\x05\n" + "\x03msg\"\v\n" + "\tServerAck*A\n" + "\rOperationType\x12\v\n" + "\aNOT_SET\x10\x00\x12\x10\n" + "\fPRE_SNAPSHOT\x10\x01\x12\x11\n" + "\rPOST_SNAPSHOT\x10\x02*,\n" + "\x11SupportedFeatures\x12\b\n" + "\x04NONE\x10\x00\x12\r\n" + "\tSNAPSHOTS\x10\x01*z\n" + "\x0eAgentErrorCode\x12\f\n" + "\bNO_ERROR\x10\x00\x12\x12\n" + "\x0eINVALID_CONFIG\x10\x01\x12\x14\n" + "\x10SCRIPT_NOT_FOUND\x10\x02\x12\x14\n" + "\x10SCRIPT_TIMED_OUT\x10\x03\x12\x1a\n" + "\x16UNHANDLED_SCRIPT_ERROR\x10\x042\xaa\x01\n" + "\x0fSnapshotService\x12F\n" + "\x10CreateConnection\x12\x15.cloud.vmm.GuestReady\x1a\x17.cloud.vmm.GuestMessage\"\x000\x01\x12O\n" + "\x18HandleResponsesFromGuest\x12\x1b.cloud.vmm.SnapshotResponse\x1a\x14.cloud.vmm.ServerAck\"\x00B\fZ\n" + "/cloud_vmmb\x06proto3" var ( file_snapshot_service_proto_rawDescOnce sync.Once file_snapshot_service_proto_rawDescData []byte ) func file_snapshot_service_proto_rawDescGZIP() []byte { file_snapshot_service_proto_rawDescOnce.Do(func() { file_snapshot_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_snapshot_service_proto_rawDesc), len(file_snapshot_service_proto_rawDesc))) }) return file_snapshot_service_proto_rawDescData } var file_snapshot_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_snapshot_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_snapshot_service_proto_goTypes = []any{ (OperationType)(0), // 0: cloud.vmm.OperationType (SupportedFeatures)(0), // 1: cloud.vmm.SupportedFeatures (AgentErrorCode)(0), // 2: cloud.vmm.AgentErrorCode (*SnapshotRequest)(nil), // 3: cloud.vmm.SnapshotRequest (*ServerInfo)(nil), // 4: cloud.vmm.ServerInfo (*SnapshotResponse)(nil), // 5: cloud.vmm.SnapshotResponse (*GuestReady)(nil), // 6: cloud.vmm.GuestReady (*GuestMessage)(nil), // 7: cloud.vmm.GuestMessage (*ServerAck)(nil), // 8: cloud.vmm.ServerAck } var file_snapshot_service_proto_depIdxs = []int32{ 0, // 0: cloud.vmm.SnapshotRequest.type:type_name -> cloud.vmm.OperationType 1, // 1: cloud.vmm.ServerInfo.supported_features:type_name -> cloud.vmm.SupportedFeatures 2, // 2: cloud.vmm.SnapshotResponse.agent_return_code:type_name -> cloud.vmm.AgentErrorCode 0, // 3: cloud.vmm.SnapshotResponse.type:type_name -> cloud.vmm.OperationType 3, // 4: cloud.vmm.GuestMessage.snapshot_request:type_name -> cloud.vmm.SnapshotRequest 4, // 5: cloud.vmm.GuestMessage.server_info:type_name -> cloud.vmm.ServerInfo 6, // 6: cloud.vmm.SnapshotService.CreateConnection:input_type -> cloud.vmm.GuestReady 5, // 7: cloud.vmm.SnapshotService.HandleResponsesFromGuest:input_type -> cloud.vmm.SnapshotResponse 7, // 8: cloud.vmm.SnapshotService.CreateConnection:output_type -> cloud.vmm.GuestMessage 8, // 9: cloud.vmm.SnapshotService.HandleResponsesFromGuest:output_type -> cloud.vmm.ServerAck 8, // [8:10] is the sub-list for method output_type 6, // [6:8] is the sub-list for method input_type 6, // [6:6] is the sub-list for extension type_name 6, // [6:6] is the sub-list for extension extendee 0, // [0:6] is the sub-list for field type_name } func init() { file_snapshot_service_proto_init() } func file_snapshot_service_proto_init() { if File_snapshot_service_proto != nil { return } file_snapshot_service_proto_msgTypes[4].OneofWrappers = []any{ (*GuestMessage_SnapshotRequest)(nil), (*GuestMessage_ServerInfo)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_snapshot_service_proto_rawDesc), len(file_snapshot_service_proto_rawDesc)), NumEnums: 3, NumMessages: 6, NumExtensions: 0, NumServices: 1, }, GoTypes: file_snapshot_service_proto_goTypes, DependencyIndexes: file_snapshot_service_proto_depIdxs, EnumInfos: file_snapshot_service_proto_enumTypes, MessageInfos: file_snapshot_service_proto_msgTypes, }.Build() File_snapshot_service_proto = out.File file_snapshot_service_proto_goTypes = nil file_snapshot_service_proto_depIdxs = nil } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/proto/cloud_vmm/snapshot_service_grpc.pb.go000066400000000000000000000176161524141036400332760ustar00rootroot00000000000000// Copyright 2019 Google // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc v7.35.1 // source: snapshot_service.proto package cloud_vmm import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( SnapshotService_CreateConnection_FullMethodName = "/cloud.vmm.SnapshotService/CreateConnection" SnapshotService_HandleResponsesFromGuest_FullMethodName = "/cloud.vmm.SnapshotService/HandleResponsesFromGuest" ) // SnapshotServiceClient is the client API for SnapshotService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Service to handle pre and post snapshot requests from vanadium. type SnapshotServiceClient interface { // The client is expected to first send a "GuestReady" message, indicating // they are available and establishing the connection that vanadium writes // requests to the agent on. CreateConnection(ctx context.Context, in *GuestReady, opts ...grpc.CallOption) (grpc.ServerStreamingClient[GuestMessage], error) // Handles the agent's responses to the above requests. HandleResponsesFromGuest(ctx context.Context, in *SnapshotResponse, opts ...grpc.CallOption) (*ServerAck, error) } type snapshotServiceClient struct { cc grpc.ClientConnInterface } func NewSnapshotServiceClient(cc grpc.ClientConnInterface) SnapshotServiceClient { return &snapshotServiceClient{cc} } func (c *snapshotServiceClient) CreateConnection(ctx context.Context, in *GuestReady, opts ...grpc.CallOption) (grpc.ServerStreamingClient[GuestMessage], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &SnapshotService_ServiceDesc.Streams[0], SnapshotService_CreateConnection_FullMethodName, cOpts...) if err != nil { return nil, err } x := &grpc.GenericClientStream[GuestReady, GuestMessage]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } if err := x.ClientStream.CloseSend(); err != nil { return nil, err } return x, nil } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type SnapshotService_CreateConnectionClient = grpc.ServerStreamingClient[GuestMessage] func (c *snapshotServiceClient) HandleResponsesFromGuest(ctx context.Context, in *SnapshotResponse, opts ...grpc.CallOption) (*ServerAck, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ServerAck) err := c.cc.Invoke(ctx, SnapshotService_HandleResponsesFromGuest_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // SnapshotServiceServer is the server API for SnapshotService service. // All implementations must embed UnimplementedSnapshotServiceServer // for forward compatibility. // // Service to handle pre and post snapshot requests from vanadium. type SnapshotServiceServer interface { // The client is expected to first send a "GuestReady" message, indicating // they are available and establishing the connection that vanadium writes // requests to the agent on. CreateConnection(*GuestReady, grpc.ServerStreamingServer[GuestMessage]) error // Handles the agent's responses to the above requests. HandleResponsesFromGuest(context.Context, *SnapshotResponse) (*ServerAck, error) mustEmbedUnimplementedSnapshotServiceServer() } // UnimplementedSnapshotServiceServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedSnapshotServiceServer struct{} func (UnimplementedSnapshotServiceServer) CreateConnection(*GuestReady, grpc.ServerStreamingServer[GuestMessage]) error { return status.Error(codes.Unimplemented, "method CreateConnection not implemented") } func (UnimplementedSnapshotServiceServer) HandleResponsesFromGuest(context.Context, *SnapshotResponse) (*ServerAck, error) { return nil, status.Error(codes.Unimplemented, "method HandleResponsesFromGuest not implemented") } func (UnimplementedSnapshotServiceServer) mustEmbedUnimplementedSnapshotServiceServer() {} func (UnimplementedSnapshotServiceServer) testEmbeddedByValue() {} // UnsafeSnapshotServiceServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to SnapshotServiceServer will // result in compilation errors. type UnsafeSnapshotServiceServer interface { mustEmbedUnimplementedSnapshotServiceServer() } func RegisterSnapshotServiceServer(s grpc.ServiceRegistrar, srv SnapshotServiceServer) { // If the following call panics, it indicates UnimplementedSnapshotServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&SnapshotService_ServiceDesc, srv) } func _SnapshotService_CreateConnection_Handler(srv interface{}, stream grpc.ServerStream) error { m := new(GuestReady) if err := stream.RecvMsg(m); err != nil { return err } return srv.(SnapshotServiceServer).CreateConnection(m, &grpc.GenericServerStream[GuestReady, GuestMessage]{ServerStream: stream}) } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type SnapshotService_CreateConnectionServer = grpc.ServerStreamingServer[GuestMessage] func _SnapshotService_HandleResponsesFromGuest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SnapshotResponse) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(SnapshotServiceServer).HandleResponsesFromGuest(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: SnapshotService_HandleResponsesFromGuest_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(SnapshotServiceServer).HandleResponsesFromGuest(ctx, req.(*SnapshotResponse)) } return interceptor(ctx, in, info, handler) } // SnapshotService_ServiceDesc is the grpc.ServiceDesc for SnapshotService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var SnapshotService_ServiceDesc = grpc.ServiceDesc{ ServiceName: "cloud.vmm.SnapshotService", HandlerType: (*SnapshotServiceServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "HandleResponsesFromGuest", Handler: _SnapshotService_HandleResponsesFromGuest_Handler, }, }, Streams: []grpc.StreamDesc{ { StreamName: "CreateConnection", Handler: _SnapshotService_CreateConnection_Handler, ServerStreams: true, }, }, Metadata: "snapshot_service.proto", } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/proto/snapshot_service.proto000066400000000000000000000052731524141036400304300ustar00rootroot00000000000000// Copyright 2019 Google // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; package cloud.vmm; option go_package = "/cloud_vmm"; enum OperationType { NOT_SET = 0; PRE_SNAPSHOT = 1; POST_SNAPSHOT = 2; } enum SupportedFeatures { NONE = 0; SNAPSHOTS = 1; } message SnapshotRequest { // The operation id of the snapshot. int32 operation_id = 1; // A list of comma separated target/lun values, e.g. "1/2,3/4". string disk_list = 2; // The operation type. OperationType type = 3; // A list of comma separated NGUIDs for NVMe disks. If a NVMe disk does not // have an NGUID, an empty string will be used. string nguid_list = 4; } message ServerInfo { repeated SupportedFeatures supported_features = 1; } enum AgentErrorCode { NO_ERROR = 0; // The snapshot config was improper in some way. INVALID_CONFIG = 1; // The pre or post snapshot script was not found on disk. SCRIPT_NOT_FOUND = 2; // The pre or post snapshot script timed out. SCRIPT_TIMED_OUT = 3; // The pre or post snapshot script returned an error, but the "continue on // error" flag was not set. UNHANDLED_SCRIPT_ERROR = 4; } message SnapshotResponse { // The operation id of the snapshot. int32 operation_id = 1; // The return code of the scripts run by the guest. If this is non-zero, then // agent_return_code should be UNHANDLED_SCRIPT_ERROR. int32 scripts_return_code = 2; // The agent return code. AgentErrorCode agent_return_code = 3; // The operation type. OperationType type = 4; } message GuestReady { bool request_server_info = 1; } message GuestMessage { oneof msg { SnapshotRequest snapshot_request = 1; ServerInfo server_info = 2; } } message ServerAck {} // Service to handle pre and post snapshot requests from vanadium. service SnapshotService { // The client is expected to first send a "GuestReady" message, indicating // they are available and establishing the connection that vanadium writes // requests to the agent on. rpc CreateConnection(GuestReady) returns (stream GuestMessage) {} // Handles the agent's responses to the above requests. rpc HandleResponsesFromGuest(SnapshotResponse) returns (ServerAck) {} } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/snapshot_linux.go000066400000000000000000000346151524141036400262300ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package snapshot is responsible for running scripts for guest flush snapshots. package snapshot import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/GoogleCloudPlatform/galog" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" sspb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/snapshot/proto/cloud_vmm" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/lru" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // defaultScriptsDir is the directory with snapshot pre/post scripts to be // executed on request. defaultScriptsDir = "/etc/google/snapshots/" // maxIDCacheSize is the maximum size of the operation ID cache. maxIDCacheSize = 128 // responseMaxAttempts is the maximum number of attempts to send the response // to the snapshot service - we are considering re trying for 10 seconds. responseMaxAttempts = 10 // snapshotModuleID is the ID of the snapshot module. snapshotModuleID = "snapshot" // defaultNguidGlobPattern is the default glob pattern to find all nguid files for NVMe devices. defaultNguidGlobPattern = "/sys/class/block/nvme*n*/nguid" ) // clientOptions contains the options for the snapshot handler. type clientOptions struct { // protocol is the protocol of the snapshot service. protocol string // address is the address of the snapshot service. address string // timeoutInSeconds is the timeout for the snapshot service. timeoutInSeconds time.Duration // scriptDir is the directory with snapshot pre/post scripts to be executed // on request. scriptDir string } // snapshotClient is the snapshot handler implementation for linux. type snapshotClient struct { // seenPreOperationIDS is the cache of operation IDs that have been seen // for pre snapshot operations. seenPreOperationIDS *lru.Handle[int32] // seenPostOperationIDS is the cache of operation IDs that have been seen // for post snapshot operations. seenPostOperationIDS *lru.Handle[int32] // options are the options for the snapshot handler. options clientOptions // findDeviceByNguidFunc is the function to find a device by Nguid. findDeviceByNguidFunc func(targetNguid string) (string, error) } // NewModule returns the snapshot module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: snapshotModuleID, Enabled: &cfg.Retrieve().Snapshots.Enabled, Setup: moduleSetup, Description: "Handles snapshot service requests and triggers pre/post snapshot scripts", } } // moduleSetup runs the actual snapshot handler for linux. func moduleSetup(ctx context.Context, _ any) error { galog.Debugf("Initializing linux snapshot module.") config := cfg.Retrieve().Snapshots opts := clientOptions{ protocol: "tcp", address: fmt.Sprintf("%s:%d", config.SnapshotServiceIP, config.SnapshotServicePort), timeoutInSeconds: time.Duration(config.TimeoutInSeconds) * time.Second, scriptDir: defaultScriptsDir, } handler, err := newClient(opts) if err != nil { return fmt.Errorf("failed to create snapshot handler: %w", err) } // If we don't trigger a new go routine here the snapshot service will block // the module manager as it Wait()s for all modules to finish their work - and // this module will keep running forever. go func() { handler.run(ctx) }() galog.Debugf("Finished initializing linux snapshot module.") return nil } // newClient creates a new snapshot handler. func newClient(options clientOptions) (*snapshotClient, error) { return &snapshotClient{ seenPreOperationIDS: lru.New[int32](maxIDCacheSize), seenPostOperationIDS: lru.New[int32](maxIDCacheSize), options: options, findDeviceByNguidFunc: findDeviceByNguid, }, nil } // fullAddress returns the full address of the snapshot service. func (op clientOptions) fullAddress() string { // In case of unit tests force the unix domain socket scheme. Let the grpc // library decide the other cases. if op.protocol == "unix" { return fmt.Sprintf("%s:///%s", op.protocol, op.address) } return op.address } // run runs the snapshot handler. func (s *snapshotClient) run(ctx context.Context) error { if !file.Exists(s.options.scriptDir, file.TypeDir) { galog.V(1).Debugf("Creating scripts directory %q.", s.options.scriptDir) if err := os.MkdirAll(s.options.scriptDir, 0700); err != nil { return fmt.Errorf("failed to create scripts directory %q: %w", s.options.scriptDir, err) } } if err := s.listen(ctx); err != nil { return fmt.Errorf("failed to listen for snapshot requests: %w", err) } return nil } // closeConnection closes the given connection, and logs any errors. func closeConnection(conn *grpc.ClientConn) { if err := conn.Close(); err != nil { galog.Errorf("Failed to close connection to snapshot service: %v.", err) } } // listen listens for snapshot requests from the snapshot service. func (s *snapshotClient) listen(ctx context.Context) error { galog.Infof("Starting to listen for snapshot requests.") for context.Cause(ctx) == nil { galog.Debugf("Attempting to connect to snapshot service at %q via %q.", s.options.address, s.options.protocol) creds := grpc.WithTransportCredentials(insecure.NewCredentials()) keepAlive := grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 1 * time.Minute, Timeout: 20 * time.Second, PermitWithoutStream: true, }) conn, err := grpc.NewClient(s.options.fullAddress(), creds, keepAlive) if err != nil { return fmt.Errorf("failed to connect to snapshot service: %w", err) } c := sspb.NewSnapshotServiceClient(conn) guestReady := sspb.GuestReady{ RequestServerInfo: false, } r, err := c.CreateConnection(ctx, &guestReady) if err != nil { if !errors.Is(err, context.Canceled) { galog.Errorf("Error creating connection with snapshot service: %v.", err) } closeConnection(conn) continue } for { request, err := r.Recv() if err != nil { galog.Errorf("Error reading snapshot request: %v.", err) break } go func() { if err := s.handleRequest(ctx, request.GetSnapshotRequest()); err != nil { galog.Errorf("Failed to handle snapshot request: %v.", err) } }() } closeConnection(conn) } return nil } // findDeviceByNguid searches for an NVMe device matching the given Nguid using the default pattern. func findDeviceByNguid(targetNguid string) (string, error) { return findDeviceByNguidWithPattern(targetNguid, defaultNguidGlobPattern) } // findDeviceByNguidWithPattern searches for an NVMe device matching the given Nguid using a specified glob pattern. func findDeviceByNguidWithPattern(targetNguid string, pattern string) (string, error) { // Find files matching the pattern matches, err := filepath.Glob(pattern) if err != nil { return "", fmt.Errorf("error globbing pattern %s: %w", pattern, err) } // No devices found if len(matches) == 0 { galog.Warnf("No files found matching pattern: %s", pattern) return "", fmt.Errorf("no nvme devices found") } for _, match := range matches { galog.Debugf("Checking file matching pattern: %s", match) content, err := os.ReadFile(match) if err != nil { galog.Warnf("Could not read file %s: %v", match, err) continue } nguidFromFile := strings.TrimSpace(string(content)) // Remove hyphens from the Nguid read from the file currentNguid := strings.ReplaceAll(nguidFromFile, "-", "") // Compare with the target Nguid if currentNguid == targetNguid { deviceName := "/dev/" + filepath.Base(filepath.Dir(match)) return deviceName, nil } } // No match found after checking all files return "", fmt.Errorf("no device found with Nguid: %s", targetNguid) } // handleRequest handles a single snapshot request. It runs the appropriate // script and sends the response back to the snapshot service. func (s *snapshotClient) handleRequest(ctx context.Context, request *sspb.SnapshotRequest) error { type snapshotOperation struct { cache *lru.Handle[int32] scriptFileName string name string } operationConfigs := map[sspb.OperationType]*snapshotOperation{ sspb.OperationType_PRE_SNAPSHOT: &snapshotOperation{ cache: s.seenPreOperationIDS, scriptFileName: "pre.sh", name: "pre", }, sspb.OperationType_POST_SNAPSHOT: &snapshotOperation{ cache: s.seenPostOperationIDS, scriptFileName: "post.sh", name: "post", }, } // Determine if we know how to handle the operation type. config, found := operationConfigs[request.GetType()] if !found { return fmt.Errorf("unhandled operation type %q", request.GetType()) } // Have we seen this operation ID before? if _, found := config.cache.Get(request.GetOperationId()); found { return fmt.Errorf("duplicate %s snapshot request operation id %d", config.name, request.GetOperationId()) } galog.Infof("Handling snapshot request type: %q, operation id: %d.", config.name, request.GetOperationId()) // Mark the operation ID as seen and avoid repeated execution. config.cache.Put(request.GetOperationId(), true) scriptPath := filepath.Join(s.options.scriptDir, config.scriptFileName) // Trigger the execution of the script. var exitCode int var errCode sspb.AgentErrorCode if request.GetNguidList() != "" { exitCode, errCode = s.runScriptWithNguid(ctx, scriptPath, request.GetNguidList()) } else { exitCode, errCode = s.runScript(ctx, scriptPath, request.GetDiskList()) } response := &sspb.SnapshotResponse{ OperationId: request.GetOperationId(), Type: request.GetType(), ScriptsReturnCode: int32(exitCode), AgentReturnCode: errCode, } // Send the response back to the snapshot service. if err := s.sendResponse(ctx, response); err != nil { return fmt.Errorf("failed to send snapshot response: %w", err) } galog.Debugf("Successfully handled snapshot request type: %q, operation id: %d.", config.name, request.GetOperationId()) return nil } // sendResponse sends the given response to the snapshot service. func (s *snapshotClient) sendResponse(ctx context.Context, response *sspb.SnapshotResponse) error { creds := grpc.WithTransportCredentials(insecure.NewCredentials()) conn, err := grpc.NewClient(s.options.fullAddress(), creds) if err != nil { return fmt.Errorf("failed to connect to snapshot service to send response: %w", err) } defer func() { if err := conn.Close(); err != nil { galog.Errorf("Failed to close snapshot response connection: %v.", err) } }() c := sspb.NewSnapshotServiceClient(conn) // retryCb is the the actual response sending function. retryCb := func() error { _, err = c.HandleResponsesFromGuest(ctx, response) return err } // Retry sending the response to the snapshot service. policy := retry.Policy{MaxAttempts: responseMaxAttempts, BackoffFactor: 1, Jitter: time.Second} if err := retry.Run(ctx, policy, retryCb); err != nil { return fmt.Errorf("failed to send snapshot response: %w", err) } galog.Debugf("Successfully sent snapshot response for operation id %d.", response.GetOperationId()) return nil } // runScript runs the script at the given path with the given disks as // arguments and returns the process' exit code and the snapshot service error // code. func (s *snapshotClient) runScript(ctx context.Context, scriptPath string, disks string) (int, sspb.AgentErrorCode) { galog.Infof("Running guest consistent snapshot script: %s, disks: %s.", scriptPath, disks) if !file.Exists(scriptPath, file.TypeFile) { return -1, sspb.AgentErrorCode_SCRIPT_NOT_FOUND } cmd := []string{scriptPath, disks} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone, Timeout: s.options.timeoutInSeconds} _, err := run.WithContext(ctx, opts) // Handle timeout error. if _, ok := run.AsTimeoutError(err); ok { return -1, sspb.AgentErrorCode_SCRIPT_TIMED_OUT } // Handle "unknown" exit error. if xerr, ok := run.AsExitError(err); ok { return xerr.ExitCode(), sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR } galog.Infof("Snapshot script %q succeeded.", scriptPath) return 0, sspb.AgentErrorCode_NO_ERROR } // runScriptWithNguid runs the script at the given path with the given Nguids as // arguments and returns the process' exit code and the snapshot service error // code. func (s *snapshotClient) runScriptWithNguid(ctx context.Context, scriptPath string, nguids string) (int, sspb.AgentErrorCode) { galog.Infof("Running guest consistent snapshot script: %s, nguids: %s.", scriptPath, nguids) if !file.Exists(scriptPath, file.TypeFile) { return -1, sspb.AgentErrorCode_SCRIPT_NOT_FOUND } // Translate nguids to disk names // e.g., "deadbeef" -> '/dev/nvme0n1' var diskNames []string for _, nguid := range strings.Split(nguids, ",") { diskName, err := s.findDeviceByNguidFunc(nguid) if err != nil { galog.Debugf("Failed to find device by Nguid: %v", err) continue } diskNames = append(diskNames, diskName) } if len(diskNames) == 0 { return -1, sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR } diskNamesString := strings.Join(diskNames, ",") cmd := []string{scriptPath, diskNamesString} opts := run.Options{Name: cmd[0], Args: cmd[1:], OutputType: run.OutputNone, Timeout: s.options.timeoutInSeconds} _, err := run.WithContext(ctx, opts) // Handle timeout error. if _, ok := run.AsTimeoutError(err); ok { return -1, sspb.AgentErrorCode_SCRIPT_TIMED_OUT } // Handle "unknown" exit error. if xerr, ok := run.AsExitError(err); ok { return xerr.ExitCode(), sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR } galog.Infof("Snapshot script %q succeeded.", scriptPath) return 0, sspb.AgentErrorCode_NO_ERROR } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/snapshot_linux_test.go000066400000000000000000000657251524141036400272750ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package snapshot import ( "context" "fmt" "net" "os" "path/filepath" "sync" "sync/atomic" "testing" "time" "google.golang.org/grpc" sspb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/snapshot/proto/cloud_vmm" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) const ( // We are using unix domain socket for testing so we don't need to rely on // any level of network connectivity during test execution. testProtocol = "unix" // testGetResponseTimeoutInSeconds is the timeout to use when getting a // response from the server. defaultTestGetResponseTimeoutInSeconds = 2 // testGetResponseJitter is the jitter to use when getting a response from the // server. testGetResponseJitter = time.Millisecond * time.Duration(200) ) // snapshotServer is a test server that implements the SnapshotServiceServer // interface. type snapshotServer struct { // messageBus is a channel that is used to send messages from the test to the // server - and consequently to the client. messageBus chan *sspb.GuestMessage // abort is a channel that is used to signal the server to stop. abort chan bool // initialized is a flag that is set to true when the server is initialized. initialized atomic.Bool // mu is a mutex that is used to protect the responses map. mu sync.Mutex // responses is a map of responses from the client. responses map[int32]*sspb.SnapshotResponse // lastRequestID is the last request ID received by the server. lastRequestID int32 // idMu is a mutex that is used to protect the lastRequestID. idMu sync.Mutex // embed unimplemented server. sspb.UnimplementedSnapshotServiceServer } // Close closes the server - sending the abort signal if the server is // initialized. func (s *snapshotServer) Close() { if s.initialized.Load() { s.abort <- true } } // addResponse adds a response to the responses map. func (s *snapshotServer) addResponse(resp *sspb.SnapshotResponse) { s.mu.Lock() defer s.mu.Unlock() s.responses[resp.GetOperationId()] = resp } // delResponse deletes a response from the responses map. func (s *snapshotServer) delResponse(id int32) { s.mu.Lock() defer s.mu.Unlock() delete(s.responses, id) } // getResponse gets a response from the responses map. func (s *snapshotServer) getResponse(id int32) (*sspb.SnapshotResponse, bool) { s.mu.Lock() defer s.mu.Unlock() resp, found := s.responses[id] return resp, found } func (s *snapshotServer) waitForResponse(ctx context.Context, timeoutInSeconds int, id int32) (*sspb.SnapshotResponse, error) { var res *sspb.SnapshotResponse // retryCb is the the actual getResponse call. retryCb := func() error { resp, found := s.getResponse(id) if !found { return fmt.Errorf("response not found") } res = resp return nil } attempts := int((time.Second * time.Duration(timeoutInSeconds)) / testGetResponseJitter) // Retry getting the response registered with the given id. policy := retry.Policy{MaxAttempts: attempts, BackoffFactor: 1, Jitter: testGetResponseJitter} if err := retry.Run(ctx, policy, retryCb); err != nil { return nil, fmt.Errorf("failed to get snapshot response: %w", err) } return res, nil } // requestID returns the next request ID. func (s *snapshotServer) requestID() int32 { s.idMu.Lock() defer s.idMu.Unlock() s.lastRequestID++ return s.lastRequestID } // CreateConnection implements the SnapshotServiceServer interface. It sends // messages from the message bus to the client. func (s *snapshotServer) CreateConnection(ready *sspb.GuestReady, stream sspb.SnapshotService_CreateConnectionServer) error { s.initialized.Store(true) for { select { case msg := <-s.messageBus: if err := stream.Send(msg); err != nil { return err } case <-s.abort: return nil } } } // HandleResponsesFromGuest implements the SnapshotServiceServer interface. And // handles responses from the client. func (s *snapshotServer) HandleResponsesFromGuest(ctx context.Context, resp *sspb.SnapshotResponse) (ack *sspb.ServerAck, err error) { s.addResponse(resp) return nil, nil } // runServer runs the test server. func runServer(ss *snapshotServer, socket string) error { listener, err := net.Listen(testProtocol, socket) if err != nil { return fmt.Errorf("start listening on %q using unix domain socket: %v", socket, err) } defer listener.Close() server := grpc.NewServer() sspb.RegisterSnapshotServiceServer(server, ss) if err := server.Serve(listener); err != nil { return fmt.Errorf("cannot continue serving on %q: %v", socket, err) } return nil } // newSnapshotServer creates/allocates a new snapshot server. func newSnapshotServer() *snapshotServer { return &snapshotServer{ messageBus: make(chan *sspb.GuestMessage), abort: make(chan bool), responses: make(map[int32]*sspb.SnapshotResponse), } } // testOptions is a struct that contains the options for running the client and // server. type testOptions struct { // client is the client that is used for testing. client *snapshotClient // server is the server that is used for testing. server *snapshotServer } // runClientAndServer runs the client and server in separate goroutines. func runClientAndServer(ctx context.Context, t *testing.T, testOpts testOptions) { t.Helper() // Run the server in a separate goroutine and keep it running until the test // finishes and sends the abort signal. go func() { if err := runServer(testOpts.server, testOpts.client.options.address); err != nil { t.Errorf("Failed to run server: %v", err) } }() retryCb := func() error { if !testOpts.server.initialized.Load() { return nil } return fmt.Errorf("server not initialized") } // Wait up to 10 seconds for the server to initialize, if it doesn't we fail // the test. policy := retry.Policy{MaxAttempts: 10, BackoffFactor: 1, Jitter: time.Second} if err := retry.Run(ctx, policy, retryCb); err != nil { t.Fatalf("Server never initialized") } // After attesting the server is running we can start the client. // Keep the client running until the context is cancelled. go func() { if err := testOpts.client.run(ctx); err != nil { t.Errorf("Failed to run handler: %v", err) } }() } // newRequest creates a new snapshot request message. func newRequest(operationID int32, diskList string, opType sspb.OperationType) *sspb.GuestMessage { return &sspb.GuestMessage{ Msg: &sspb.GuestMessage_SnapshotRequest{ SnapshotRequest: &sspb.SnapshotRequest{ OperationId: operationID, DiskList: diskList, Type: opType, }, }, } } // newRequestWithNguid creates a new snapshot request message with nguid list. func newRequestWithNguid(operationID int32, diskList string, opType sspb.OperationType, nguidList string) *sspb.GuestMessage { return &sspb.GuestMessage{ Msg: &sspb.GuestMessage_SnapshotRequest{ SnapshotRequest: &sspb.SnapshotRequest{ OperationId: operationID, DiskList: diskList, NguidList: nguidList, Type: opType, }, }, } } // Fake findDeviceByNguid that successfully finds devices func fakeFindDeviceByNguid(targetNguid string) (string, error) { nguidMap := map[string]string{ "deadbeef": "/dev/nvme0n1", "1badf00d": "/dev/nvme1n1", } if devName, ok := nguidMap[targetNguid]; ok { return devName, nil } return "", fmt.Errorf("fakeFindDeviceSuccess: nguid %s not found", targetNguid) } func writeScript(t *testing.T, op sspb.OperationType, scriptDir string, content string) { t.Helper() scriptName := "" if op == sspb.OperationType_PRE_SNAPSHOT { scriptName = "pre.sh" } else if op == sspb.OperationType_POST_SNAPSHOT { scriptName = "post.sh" } else { t.Fatalf("Unsupported operation type: %v", op) } scriptContent := fmt.Sprintf(`#!/bin/bash %s `, content) scriptPath := filepath.Join(scriptDir, scriptName) if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil { t.Fatalf("Failed to write script %q: %v", scriptPath, err) } } func TestSuccess(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string output string command string script string op sspb.OperationType want string }{ { name: "pre", output: "pre.out", command: "echo 'pre script' > %s", script: "pre.sh", op: sspb.OperationType_PRE_SNAPSHOT, want: "pre script\n", }, { name: "post", output: "post.out", command: "echo 'post script' > %s", script: "post.sh", op: sspb.OperationType_POST_SNAPSHOT, want: "post script\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { outputFile := filepath.Join(scriptDir, tc.output) writeScript(t, tc.op, scriptDir, fmt.Sprintf(tc.command, outputFile)) // NOLINT reqID := server.requestID() msg := newRequest(reqID, "disklist", tc.op) server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Fatalf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_NO_ERROR { t.Fatalf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_NO_ERROR) } if !file.Exists(outputFile, file.TypeFile) { t.Fatalf("Script output file (%q) not written, expected to be written", outputFile) } data, err := os.ReadFile(outputFile) if err != nil { t.Fatalf("Failed to read %s script output file: %v", tc.name, err) } if string(data) != tc.want { t.Fatalf("Script output (%q) file content = %q, want %q", outputFile, string(data), tc.want) } }) } } func TestSuccessWithNguid(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } client.findDeviceByNguidFunc = fakeFindDeviceByNguid testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string output string command string script string op sspb.OperationType want string }{ { name: "pre", output: "pre.out", command: "echo \"pre script $1\" > %s", script: "pre.sh", op: sspb.OperationType_PRE_SNAPSHOT, want: "pre script /dev/nvme0n1,/dev/nvme1n1\n", }, { name: "post", output: "post.out", command: "echo \"post script $1\" > %s", script: "post.sh", op: sspb.OperationType_POST_SNAPSHOT, want: "post script /dev/nvme0n1,/dev/nvme1n1\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { outputFile := filepath.Join(scriptDir, tc.output) writeScript(t, tc.op, scriptDir, fmt.Sprintf(tc.command, outputFile)) // NOLINT reqID := server.requestID() msg := newRequestWithNguid(reqID, "disklist", tc.op, "deadbeef,1badf00d") server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Fatalf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_NO_ERROR { t.Fatalf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_NO_ERROR) } if !file.Exists(outputFile, file.TypeFile) { t.Fatalf("Script output file (%q) not written, expected to be written", outputFile) } data, err := os.ReadFile(outputFile) if err != nil { t.Fatalf("Failed to read %s script output file: %v", tc.name, err) } if string(data) != tc.want { t.Fatalf("Script output (%q) file content = %q, want %q", outputFile, string(data), tc.want) } }) } } func TestPartialSuccessWithNguid(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } client.findDeviceByNguidFunc = fakeFindDeviceByNguid testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string output string command string script string op sspb.OperationType want string }{ { name: "pre", output: "pre.out", command: "echo \"pre script $1\" > %s", script: "pre.sh", op: sspb.OperationType_PRE_SNAPSHOT, want: "pre script /dev/nvme0n1\n", }, { name: "post", output: "post.out", command: "echo \"post script $1\" > %s", script: "post.sh", op: sspb.OperationType_POST_SNAPSHOT, want: "post script /dev/nvme0n1\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { outputFile := filepath.Join(scriptDir, tc.output) writeScript(t, tc.op, scriptDir, fmt.Sprintf(tc.command, outputFile)) // NOLINT reqID := server.requestID() msg := newRequestWithNguid(reqID, "disklist", tc.op, "deadbeef,01abcdef") server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Fatalf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_NO_ERROR { t.Fatalf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_NO_ERROR) } if !file.Exists(outputFile, file.TypeFile) { t.Fatalf("Script output file (%q) not written, expected to be written", outputFile) } data, err := os.ReadFile(outputFile) if err != nil { t.Fatalf("Failed to read %s script output file: %v", tc.name, err) } if string(data) != tc.want { t.Fatalf("Script output (%q) file content = %q, want %q", outputFile, string(data), tc.want) } }) } } func TestNoMatchedDeviceWithNguid(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } client.findDeviceByNguidFunc = fakeFindDeviceByNguid testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string op sspb.OperationType exitCode int32 }{ { name: "pre", op: sspb.OperationType_PRE_SNAPSHOT, exitCode: -1, }, { name: "post", op: sspb.OperationType_POST_SNAPSHOT, exitCode: -1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { scriptContent := fmt.Sprintf("exit %d", 0) // NOLINT writeScript(t, tc.op, scriptDir, scriptContent) reqID := server.requestID() msg := newRequestWithNguid(reqID, "disklist", tc.op, "01abcdef") server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Errorf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR { t.Errorf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR) } if resp.GetScriptsReturnCode() != tc.exitCode { t.Errorf("Response.ExitCode = %v, want %v", resp.GetScriptsReturnCode(), tc.exitCode) } }) } } func TestFindNguidSuccess(t *testing.T) { tmp := t.TempDir() // Example device structure: /tmpDir/nvme0n1/nguid devDir := filepath.Join(tmp, "nvme0n1") if err := os.MkdirAll(devDir, 0755); err != nil { t.Fatalf("Failed to create test dir: %v", err) } nguidFile := filepath.Join(devDir, "nguid") nguidContent := "deadbeef-1234-5678-90ab-cdef12345678" if err := os.WriteFile(nguidFile, []byte(nguidContent), 0644); err != nil { t.Fatalf("Failed to write test nguid file: %v", err) } testPattern := filepath.Join(tmp, "nvme*n*/nguid") targetNguid := "deadbeef1234567890abcdef12345678" // Call the refactored function with the test pattern deviceName, err := findDeviceByNguidWithPattern(targetNguid, testPattern) if err != nil { t.Errorf("findDeviceByNguidWithPattern(...) failed: %v", err) } expectedDeviceName := "/dev/nvme0n1" if deviceName != expectedDeviceName { t.Errorf("findDeviceByNguidWithPattern(...) = %q, want %q", deviceName, expectedDeviceName) } } func TestFindNguidNoDevice(t *testing.T) { tmp := t.TempDir() testPattern := filepath.Join(tmp, "nvme*n*/nguid") targetNguid := "deadbeef1234567890abcdef12345678" // Call the refactored function with the test pattern _, err := findDeviceByNguidWithPattern(targetNguid, testPattern) if err == nil { t.Errorf("Expected error from findDeviceByNguidWithPattern with no devices") } } func TestFindNguidNoMatch(t *testing.T) { tmp := t.TempDir() // Example device structure: /tmpDir/nvme0n1/nguid devDir := filepath.Join(tmp, "nvme0n1") if err := os.MkdirAll(devDir, 0755); err != nil { t.Fatalf("Failed to create test dir: %v", err) } nguidFile := filepath.Join(devDir, "nguid") nguidContent := "deadbeef-deadbeef-deadbeef-deadbeef" if err := os.WriteFile(nguidFile, []byte(nguidContent), 0644); err != nil { t.Fatalf("Failed to write test nguid file: %v", err) } testPattern := filepath.Join(tmp, "nvme*n*/nguid") targetNguid := "1badf00d" _, err := findDeviceByNguidWithPattern(targetNguid, testPattern) if err == nil { t.Errorf("findDeviceByNguidWithPattern(...) succeeded unexpectedly, want error") } } func TestNoSuchScript(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) tests := []struct { name string op sspb.OperationType }{ { name: "pre", op: sspb.OperationType_PRE_SNAPSHOT, }, { name: "post", op: sspb.OperationType_POST_SNAPSHOT, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reqID := server.requestID() msg := newRequest(reqID, "disklist", tc.op) server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Errorf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_SCRIPT_NOT_FOUND { t.Errorf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_SCRIPT_NOT_FOUND) } }) } } func TestDuplicateRequest(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) tests := []struct { name string op sspb.OperationType }{ { name: "pre", op: sspb.OperationType_PRE_SNAPSHOT, }, { name: "post", op: sspb.OperationType_POST_SNAPSHOT, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reqID := server.requestID() msg := newRequest(reqID, "disklist", tc.op) server.messageBus <- msg _, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Errorf("Failed to get response: %v, expected success", err) } server.delResponse(reqID) // Send the same request again. We should never get a response. server.messageBus <- msg _, err = server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err == nil { t.Errorf("Duplicate request succeeded, expected to fail") } }) } } func TestTimedoutScript(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() scriptTimeout := 1 clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(scriptTimeout) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string op sspb.OperationType }{ { name: "pre", op: sspb.OperationType_PRE_SNAPSHOT, }, { name: "post", op: sspb.OperationType_POST_SNAPSHOT, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { scriptContent := fmt.Sprintf("sleep %d", scriptTimeout+1) // NOLINT writeScript(t, tc.op, scriptDir, scriptContent) reqID := server.requestID() msg := newRequest(reqID, "disklist", tc.op) server.messageBus <- msg resp, err := server.waitForResponse(ctx, scriptTimeout+10, reqID) if err != nil { t.Errorf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_SCRIPT_TIMED_OUT { t.Errorf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_SCRIPT_TIMED_OUT) } }) } } func TestScriptExitCode(t *testing.T) { tmp := t.TempDir() socket := filepath.Join(tmp, "test.sock") scriptDir := filepath.Join(tmp, "scripts") server := newSnapshotServer() defer server.Close() clientOpts := clientOptions{ protocol: testProtocol, address: socket, timeoutInSeconds: time.Duration(10) * time.Second, scriptDir: scriptDir, } client, err := newClient(clientOpts) if err != nil { t.Fatalf("Failed to create snapshot client: %v", err) } testOpts := testOptions{ client: client, server: server, } ctx, cancel := context.WithCancel(context.Background()) defer cancel() runClientAndServer(ctx, t, testOpts) if err := os.MkdirAll(scriptDir, 0700); err != nil { t.Fatalf("failed to create scripts directory %q: %v", scriptDir, err) } tests := []struct { name string op sspb.OperationType exitCode int32 }{ { name: "pre", op: sspb.OperationType_PRE_SNAPSHOT, exitCode: 254, }, { name: "post", op: sspb.OperationType_POST_SNAPSHOT, exitCode: 255, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { scriptContent := fmt.Sprintf("exit %d", tc.exitCode) // NOLINT writeScript(t, tc.op, scriptDir, scriptContent) reqID := server.requestID() msg := newRequest(reqID, "disklist", tc.op) server.messageBus <- msg resp, err := server.waitForResponse(ctx, defaultTestGetResponseTimeoutInSeconds, reqID) if err != nil { t.Errorf("Failed to get response: %v, expected success", err) } if resp.GetAgentReturnCode() != sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR { t.Errorf("Response.ReturnCode = %v, want %v", resp.GetAgentReturnCode(), sspb.AgentErrorCode_UNHANDLED_SCRIPT_ERROR) } if resp.GetScriptsReturnCode() != tc.exitCode { t.Errorf("Response.ExitCode = %v, want %v", resp.GetScriptsReturnCode(), tc.exitCode) } }) } } func TestNewModule(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } module := NewModule(context.Background()) if module.ID != snapshotModuleID { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, snapshotModuleID) } if module.Setup == nil { t.Errorf("NewModule() returned module with nil Setup") } if module.BlockSetup != nil { t.Errorf("NewModule() returned module with not nil BlockSetup, want nil") } if module.Description == "" { t.Errorf("NewModule() returned module with empty Description") } } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/snapshot_windows.go000066400000000000000000000016551524141036400265610ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package snapshot import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule is no-op on Windows. func NewModule(context.Context) *manager.Module { galog.V(2).Debug("Snapshot module is not supported on windows.") return nil } google-guest-agent-20260819.00/cmd/core_plugin/snapshot/snapshot_windows_test.go000066400000000000000000000014431524141036400276130ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package snapshot import ( "context" "testing" ) func TestModuleSetup(t *testing.T) { if NewModule(context.Background()) != nil { t.Errorf("NewModule() = nil, want non-nil") } } google-guest-agent-20260819.00/cmd/core_plugin/stages/000077500000000000000000000000001524141036400222415ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/stages/early/000077500000000000000000000000001524141036400233555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/stages/early/early.go000066400000000000000000000071751524141036400250320ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package early implements the core-plugin's early initialization steps such as // setting up metadata server routes on windows, platform hardware configuration // etc. Any implementation in this path assumes either network is not up or that // metadata is not accessible yet. package early import ( "context" "fmt" "sync/atomic" "time" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/firstboot" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/iosched" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" ) const ( // metadataMaxAttempts is the maximum number of attempts to get // metadata descriptor. Given the policies Jitter we wait for 30s. metadataMaxAttempts = 30 ) var ( // instance is the singleton handle to the early initialization. instance *Handle // In early initialization we want to have control of sequence in which the // modules are registered so the well crafted slice here. modsFcs = []stages.ModuleFc{ firstboot.NewEarlyModule, network.NewModule, iosched.NewModule, } ) // Handle is the handle to the early initialization. type Handle struct { // earlyInitDone tracks whether early initialization is done. earlyInitDone atomic.Bool // modulesFc is the list of early initialization modules. modulesFc []stages.ModuleFc // mdsClient is the metadata client. mdsClient metadata.MDSClientInterface // mdsRetryPolicy is the retry policy to use when checking metadata // availability. mdsRetryPolicy retry.Policy } // init initializes the singleton handle to the early initialization. func init() { policy := retry.Policy{MaxAttempts: metadataMaxAttempts, BackoffFactor: 1, Jitter: time.Second} instance = &Handle{ modulesFc: modsFcs, mdsClient: metadata.New(), mdsRetryPolicy: policy, } } // Retrieve returns the handle to the early initialization. func Retrieve() *Handle { return instance } // Initialized returns if early initialization is done successfully or not. func (h *Handle) Initialized() bool { return h.earlyInitDone.Load() } // Run fires up the early initialization process. func (h *Handle) Run(ctx context.Context) error { manager.Register(stages.InitModulesSlice(ctx, h.modulesFc), manager.EarlyStage) // Run the early initialization modules. The are ran in sequence, if a module // fails, the error is returned and assumed the early initialization is // failed. if err := manager.RunBlocking(ctx, manager.EarlyStage, nil); err != nil { return fmt.Errorf("early initialization module's registration failed: %w", err) } h.earlyInitDone.Store(true) return nil } // ListModules returns the list of early initialization modules. func (h *Handle) ListModules() []*manager.Module { return stages.InitModulesSlice(context.Background(), h.modulesFc) } google-guest-agent-20260819.00/cmd/core_plugin/stages/early/early_test.go000066400000000000000000000070061524141036400260620ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package early import ( "context" "errors" "fmt" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" ) type noopMDSClient struct{} func (cl *noopMDSClient) Get(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("error") } func (cl *noopMDSClient) GetKey(context.Context, string, map[string]string) (string, error) { return "", nil } func (cl *noopMDSClient) GetKeyRecursive(context.Context, string) (string, error) { return "", nil } func (cl *noopMDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, nil } func (cl *noopMDSClient) WriteGuestAttributes(context.Context, string, string) error { return nil } func TestSingleton(t *testing.T) { handle := Retrieve() if handle == nil { t.Errorf("Retrieve() = nil, want non-nil") } if got := Retrieve(); got != handle { t.Errorf("Retrieve() = %v, want %v", got, handle) } } func TestRun(t *testing.T) { tests := []struct { name string modulesFc []stages.ModuleFc wantError bool wantInitialized bool }{ { name: "no-modules", modulesFc: []stages.ModuleFc{}, wantError: false, wantInitialized: true, }, { name: "failing-module", modulesFc: []stages.ModuleFc{ func(ctx context.Context) *manager.Module { return &manager.Module{ BlockSetup: func(_ context.Context, _ any) error { return errors.New("error") }, } }, }, wantError: true, wantInitialized: false, }, { name: "success", modulesFc: []stages.ModuleFc{ func(ctx context.Context) *manager.Module { return &manager.Module{ BlockSetup: func(_ context.Context, _ any) error { return nil }, } }, }, wantError: false, wantInitialized: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { manager.Shutdown() }) handle := &Handle{ modulesFc: tc.modulesFc, mdsClient: &noopMDSClient{}, mdsRetryPolicy: retry.Policy{MaxAttempts: 1, BackoffFactor: 1, Jitter: time.Millisecond}, } got := handle.Run(ctx) if (got != nil) != tc.wantError { t.Errorf("Run() = %v, want %v", got, tc.wantError) } if tc.wantInitialized != handle.Initialized() { t.Errorf("Run() = %v, want %v", handle.Initialized(), tc.wantInitialized) } if len(handle.ListModules()) != len(tc.modulesFc) { t.Errorf("ListModules() = %v, want %v", len(handle.ListModules()), len(tc.modulesFc)) } if len(manager.List(manager.EarlyStage)) != len(tc.modulesFc) { t.Errorf("manager.List() = %v, want %v", len(manager.List(manager.EarlyStage)), len(tc.modulesFc)) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/stages/late/000077500000000000000000000000001524141036400231665ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/stages/late/late.go000066400000000000000000000123111524141036400244400ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package late implements the core-plugin's late initialization steps such as // initializing the configuration managers i.e. oslogin, metadata based ssh keys // manager, snapshot etc. package late import ( "context" "fmt" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/agentcrypto" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/clock" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/command" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/diagnostics" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/firstboot" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/metadatasshkey" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/network/hostname" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/oslogin" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/platscript" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/snapshot" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/telemetry" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/winpassreset" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/wsfchealthcheck" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" ) const ( // metadataMaxAttempts is the maximum number of attempts to get metadata // descriptor. Given the policies Jitter we wait for 30s. metadataMaxAttempts = 30 ) var ( // modsFcs is the list of modules that are registered for the late // initialization stage. This list of modules is arranged alphabetically and // does not imply any specific order for running them. Each module executes // independently within its own Go routine and must not rely on any other // module being executed before or after it. modsFcs = []stages.ModuleFc{ agentcrypto.NewModule, clock.NewModule, command.NewModule, diagnostics.NewModule, firstboot.NewModule, hostname.NewModule, metadatasshkey.NewModule, oslogin.NewModule, platscript.NewModule, snapshot.NewModule, telemetry.NewModule, winpassreset.NewModule, wsfchealthcheck.NewModule, } // instance is the singleton handle to the late initialization. instance *Handle ) // Handle is the handle to the late initialization. type Handle struct { // modsFcs is the list of late initialization modules. modulesFc []stages.ModuleFc // mdsClient is the metadata client. mdsClient metadata.MDSClientInterface // mdsRetryPolicy is the retry policy to use when checking metadata // availability. mdsRetryPolicy retry.Policy } // init initializes the singleton handle to the late initialization. func init() { policy := retry.Policy{MaxAttempts: metadataMaxAttempts, BackoffFactor: 1, Jitter: time.Second} instance = &Handle{ modulesFc: modsFcs, mdsClient: metadata.New(), mdsRetryPolicy: policy, } } // Retrieve returns the handle to the late initialization. func Retrieve() *Handle { return instance } // Run fires up the late initialization process. func (h *Handle) Run(ctx context.Context) error { manager.Register(stages.InitModulesSlice(ctx, h.modulesFc), manager.LateStage) var ( desc *metadata.Descriptor err error ) // getDescriptor is the retry callback implementation to retrieve the metadata // descriptor. getDescriptor := func() error { desc, err = h.mdsClient.Get(ctx) return err } // At this point metadata should be accessible, we are employing a retry // strategy just in case mds faces a temporary issue. if err := retry.Run(ctx, h.mdsRetryPolicy, getDescriptor); err != nil { return fmt.Errorf("getting metadata descriptor: %w", err) } // Run the modules setup. The modules can use the first metadata descriptor // to apply the initial configuration. // If a module fails to initialize, we assume that the module is disabled and // continue the execution. errs := manager.RunConcurrent(ctx, manager.LateStage, desc) if errs != nil { galog.Errorf("Failed to initialize late stage module(s)...") errs.Each(func(moduleID string, err error) { galog.Errorf("Failed module: %s, with error: %v", moduleID, err) }) } return nil } // ListModules returns the list of modules that are registered for the late // initialization stage. func (h *Handle) ListModules() []*manager.Module { return stages.InitModulesSlice(context.Background(), h.modulesFc) } google-guest-agent-20260819.00/cmd/core_plugin/stages/late/late_test.go000066400000000000000000000073121524141036400255040ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package late import ( "context" "errors" "fmt" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/stages" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" ) type noopMDSClient struct { getMustFail bool } func (cl *noopMDSClient) Get(context.Context) (*metadata.Descriptor, error) { if cl.getMustFail { return nil, fmt.Errorf("error") } return nil, nil } func (cl *noopMDSClient) GetKey(context.Context, string, map[string]string) (string, error) { return "", nil } func (cl *noopMDSClient) GetKeyRecursive(context.Context, string) (string, error) { return "", nil } func (cl *noopMDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, nil } func (cl *noopMDSClient) WriteGuestAttributes(context.Context, string, string) error { return nil } func TestSingleton(t *testing.T) { handle := Retrieve() if handle == nil { t.Errorf("Retrieve() = nil, want non-nil") } if got := Retrieve(); got != handle { t.Errorf("Retrieve() = %v, want %v", got, handle) } } func TestRun(t *testing.T) { tests := []struct { name string modulesFc []stages.ModuleFc wantMdsError bool wantError bool }{ { name: "no-modules", modulesFc: []stages.ModuleFc{}, wantError: false, wantMdsError: false, }, { name: "failing-module", modulesFc: []stages.ModuleFc{ func(ctx context.Context) *manager.Module { return &manager.Module{ Setup: func(_ context.Context, _ any) error { return errors.New("error") }, } }, }, wantError: false, wantMdsError: false, }, { name: "success", modulesFc: []stages.ModuleFc{ func(ctx context.Context) *manager.Module { return &manager.Module{ Setup: func(_ context.Context, _ any) error { return nil }, } }, }, wantError: false, wantMdsError: false, }, { name: "mds-error", modulesFc: []stages.ModuleFc{ func(ctx context.Context) *manager.Module { return &manager.Module{ Setup: func(_ context.Context, _ any) error { return nil }, } }, }, wantError: true, wantMdsError: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { manager.Shutdown() }) handle := &Handle{ modulesFc: tc.modulesFc, mdsClient: &noopMDSClient{getMustFail: tc.wantMdsError}, mdsRetryPolicy: retry.Policy{MaxAttempts: 1, BackoffFactor: 1, Jitter: time.Millisecond}, } got := handle.Run(ctx) if (got != nil) != tc.wantError { t.Errorf("Run() = %v, want %v", got, tc.wantError) } if len(handle.ListModules()) != len(tc.modulesFc) { t.Errorf("ListModules() = %v, want %v", len(handle.ListModules()), len(tc.modulesFc)) } if len(manager.List(manager.LateStage)) != len(tc.modulesFc) { t.Errorf("manager.List() = %v, want %v", len(manager.List(manager.LateStage)), len(tc.modulesFc)) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/stages/stages.go000066400000000000000000000023361524141036400240620ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package stages implements common utils for all core plugin stages. package stages import ( "context" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // ModuleFc is the function to instantiate a module. type ModuleFc func(context.Context) *manager.Module // InitModulesSlice initializes the modules slice. func InitModulesSlice(ctx context.Context, modFcs []ModuleFc) []*manager.Module { var mods []*manager.Module // Initialize the modules. for _, f := range modFcs { mod := f(ctx) if mod == nil || mod.Enabled != nil && !*mod.Enabled { continue } mods = append(mods, mod) } return mods } google-guest-agent-20260819.00/cmd/core_plugin/stages/stages_test.go000066400000000000000000000031441524141036400251170ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package stages import ( "context" "slices" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) func TestInitModulesSlice(t *testing.T) { disabledModules := []string{"test-module-2", "test-module-3"} modIDs := []string{"test-module", "test-module-2", "test-module-3", "test-module-4"} fcs := []ModuleFc{} falseValue := false for _, modID := range modIDs { fcs = append(fcs, func(context.Context) *manager.Module { mod := &manager.Module{ ID: modID, } if slices.Contains(disabledModules, mod.ID) { mod.Enabled = &falseValue } return mod }) } mods := InitModulesSlice(context.Background(), fcs) if len(mods) != len(fcs)-len(disabledModules) { t.Errorf("InitModulesSlice() returned %d modules, want %d", len(mods), len(fcs)) } for ii, mod := range mods { containsInDisabled := slices.Contains(disabledModules, mod.ID) if containsInDisabled { t.Errorf("InitModulesSlice() returned module with ID %q, want %q", mod.ID, disabledModules[ii]) } } } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/000077500000000000000000000000001524141036400227655ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry.go000066400000000000000000000140151524141036400253270ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package telemetry implements the scheduler for collecting and publishing // telemetry data. package telemetry import ( "context" "encoding/base64" "fmt" "runtime" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/client" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/scheduler" "google.golang.org/protobuf/proto" ) const ( // telemetryModuleID is the module ID for telemetry scheduler. telemetryModuleID = "telemetry-publisher" // telemetryInterval is the interval at which telemetry data is recorded. telemetryInterval = 24 * time.Hour // programName is the name of the program used in telemetry data. programName = "GCEGuestAgent" // messageType is key in labels for message type. messageType = "message_type" // guestAgentPlatformInfoMsg is the message type label to use with platform // info sent by agent. guestAgentPlatformInfoMsg = "agent_controlplane.PlatformInfo" ) // Job implements job scheduler interface for recording telemetry. type Job struct { // client is the MDS client. client metadata.MDSClientInterface // agentVersion is the current agent version. agentVersion string // osInfoReader is the reader for osinfo. Setting here allows unit testing. osInfoReader func() osinfo.OSInfo } // NewModule returns the first boot module for late stage registration. func NewModule(context.Context) *manager.Module { return &manager.Module{ ID: telemetryModuleID, Setup: moduleSetup, Quit: teardown, Description: "Telemetry module collects and publishes telemetry data to MDS", } } // teardown unschedules the telemetry job. func teardown(context.Context) { scheduler.Instance().UnscheduleJob(telemetryModuleID) } // moduleSetup schedules a job to collect and publish telemetry data. func moduleSetup(ctx context.Context, data any) error { galog.Debugf("Initializing telemetry module.") job := &Job{client: metadata.New(), osInfoReader: osinfo.Read} // Send one time platform info to ACS to indicate if running on GCE. onGCE, err := isOnGCE(ctx) msg := &acppb.PlatformInfo{OnGce: onGCE} if err != nil { msg.Error = fmt.Sprintf("Unable to determine if running on GCE: %v", err) } go func() { if !job.ShouldEnable(ctx) { galog.Debugf("Telemetry module is disabled, skipping platform info send to ACS.") return } if _, err := client.SendMessage(ctx, map[string]string{messageType: guestAgentPlatformInfoMsg}, msg); err != nil { galog.Warnf("Failed to send platform info to ACS: %v", err) } }() err = scheduler.Instance().ScheduleJob(ctx, job) if err == nil { galog.Debugf("Successfully initialized telemetry job.") } return err } // ID returns the ID for this job. func (j *Job) ID() string { return telemetryModuleID } // MetricName returns the metric name for the job. func (j *Job) MetricName() acppb.GuestAgentModuleMetric_Metric { return acppb.GuestAgentModuleMetric_TELEMETRY_INITIALIZATION } // Run records telemetry data. func (j *Job) Run(ctx context.Context) (bool, error) { osInfo, err := formatOSInfo(j.osInfoReader()) if err != nil { return j.ShouldEnable(ctx), err } agentInfo, err := formatAgentInfo(cfg.Retrieve().Core.Version) if err != nil { return j.ShouldEnable(ctx), err } return j.ShouldEnable(ctx), j.record(ctx, osInfo, agentInfo) } // Interval returns the interval at which job is executed. func (j *Job) Interval() (time.Duration, bool) { return telemetryInterval, true } // ShouldEnable returns true as long as DisableTelemetry is not set in metadata. func (j *Job) ShouldEnable(ctx context.Context) bool { md, err := j.client.Get(ctx) if err != nil { return false } return !md.Instance().Attributes().DisableTelemetry() && !md.Project().Attributes().DisableTelemetry() } // record records telemetry data. func (j *Job) record(ctx context.Context, osinfo, agentInfo string) error { headers := map[string]string{ "X-Google-Guest-Agent": agentInfo, "X-Google-Guest-OS": osinfo, } // We don't care about any return value, all we need to do is make some call // with the telemetry headers. _, err := j.client.GetKey(ctx, "", headers) return err } // formatAgentInfo marshals agent info in required proto and returns in base64 // encoded form. func formatAgentInfo(version string) (string, error) { data, err := proto.Marshal(&acppb.AgentInfo{ Name: programName, Architecture: runtime.GOARCH, Version: version, }) if err != nil { return "", fmt.Errorf("error marshalling AgentInfo: %w", err) } return base64.StdEncoding.EncodeToString(data), nil } // formatOSInfo marshals osinfo in required proto and returns in base64 encoded // form. func formatOSInfo(os osinfo.OSInfo) (string, error) { data, err := proto.Marshal(&acppb.OSInfo{ Architecture: os.Architecture, Type: runtime.GOOS, Version: os.VersionID, ShortName: os.OS, LongName: os.PrettyName, KernelRelease: os.KernelRelease, KernelVersion: os.KernelVersion, }) if err != nil { return "", fmt.Errorf("error marshalling OSInfo: %w", err) } return base64.StdEncoding.EncodeToString(data), nil } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry_linux.go000066400000000000000000000022641524141036400265510ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package telemetry import ( "context" "fmt" "os" "strings" ) // gceIdentifier is the string identifier for GCE in smbios table. const gceIdentifier = "GoogleCloud" // smbiosPath is the path to board_serial DMI info. This is used to determine if // the instance is running on GCE. var smbiosPath = "/sys/class/dmi/id/board_serial" func isOnGCE(ctx context.Context) (bool, error) { data, err := os.ReadFile(smbiosPath) if err != nil { return false, fmt.Errorf("failed to read smbios file %q: %w", smbiosPath, err) } return strings.Contains(string(data), gceIdentifier), nil } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry_linux_test.go000066400000000000000000000035571524141036400276160ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package telemetry import ( "context" "os" "testing" ) func TestIsOnGCE(t *testing.T) { ctx := context.Background() tests := []struct { name string content string readFile bool want bool wantErr bool }{ { name: "on-gce", content: "Board-GoogleCloud-12345", readFile: true, want: true, }, { name: "not-on-gce", content: "something else", readFile: true, want: false, }, { name: "read-file-error", readFile: false, wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { originalSMBiosPath := smbiosPath t.Cleanup(func() { smbiosPath = originalSMBiosPath }) if tc.readFile { f, err := os.CreateTemp(t.TempDir(), "smbios") if err != nil { t.Fatalf("Failed to create temp file: %v", err) } if _, err := f.WriteString(tc.content); err != nil { t.Fatalf("Failed to write to temp file: %v", err) } f.Close() smbiosPath = f.Name() } else { smbiosPath = "/path/does/not/exist" } got, err := isOnGCE(ctx) if (err != nil) != tc.wantErr { t.Errorf("isOnGCE(ctx) error = %v, wantErr %t", err, tc.wantErr) } if got != tc.want { t.Errorf("isOnGCE(ctx) = %t, want %t", got, tc.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry_test.go000066400000000000000000000176141524141036400263760ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package telemetry import ( "context" "encoding/base64" "fmt" "runtime" "testing" acppb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" ) func TestNewModule(t *testing.T) { m := NewModule(context.Background()) if m.ID != telemetryModuleID { t.Errorf("m.ID = %s, want %s", m.ID, telemetryModuleID) } if m.Description == "" { t.Errorf("m.Description = empty, want non-empty") } if m.Setup == nil { t.Errorf("m.Setup = nil, want non-nil") } if m.Quit == nil { t.Errorf("m.Quit = nil, want non-nil") } } // MDSClient implements fake metadata server. type MDSClient struct { seenHeaders map[string]string seenKey string projectDisable bool instanceDisable bool enableBoth bool throwErr bool } const attrJSON = `{"instance": {"attributes": {"disable-guest-telemetry": "%s"}}, "project": {"attributes": {"disable-guest-telemetry": "%s"}}}` // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s *MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", nil } // GetKey implements fake GetKey MDS method. func (s *MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { s.seenHeaders = headers s.seenKey = key return "", nil } // Get method implements fake Get on MDS. func (s *MDSClient) Get(context.Context) (*metadata.Descriptor, error) { if s.throwErr { return nil, fmt.Errorf("test error") } jsonData := attrJSON switch { case s.instanceDisable: jsonData = fmt.Sprintf(attrJSON, "true", "false") case s.projectDisable: jsonData = fmt.Sprintf(attrJSON, "false", "true") case s.enableBoth: jsonData = fmt.Sprintf(attrJSON, "false", "false") } return metadata.UnmarshalDescriptor(jsonData) } // Watch method implements fake watcher on MDS. func (s *MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s *MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } func TestJobInterface(t *testing.T) { version := "12345" j := &Job{agentVersion: version} if j.ID() != telemetryModuleID { t.Errorf("j.ID() = %s, want %s", j.ID(), telemetryModuleID) } if j.MetricName() != acppb.GuestAgentModuleMetric_TELEMETRY_INITIALIZATION { t.Errorf("j.MetricName() = %s, want %s", j.MetricName().String(), acppb.GuestAgentModuleMetric_TELEMETRY_INITIALIZATION.String()) } interval, enable := j.Interval() if interval != telemetryInterval { t.Errorf("j.Interval() = interval %v, want %v", interval, telemetryInterval) } if !enable { t.Errorf("j.Interval() = enable %t, want true", enable) } } func TestShouldEnable(t *testing.T) { ctx := context.Background() tests := []struct { name string client *MDSClient want bool }{ { name: "enabled", client: &MDSClient{enableBoth: true}, want: true, }, { name: "mds_error", client: &MDSClient{throwErr: true}, want: false, }, { name: "project_disable", client: &MDSClient{projectDisable: true}, want: false, }, { name: "instance_disabled", client: &MDSClient{instanceDisable: true}, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { job := &Job{client: tc.client} if got := job.ShouldEnable(ctx); got != tc.want { t.Errorf("job.ShouldEnable(ctx) = %t, want %t", got, tc.want) } }) } } func TestFormatAgentInfo(t *testing.T) { version := "1.2.3" got, err := formatAgentInfo(version) if err != nil { t.Fatalf("formatAgentInfo(%s) = %v, want nil error", version, err) } wantMsg := &acppb.AgentInfo{ Name: programName, Architecture: runtime.GOARCH, Version: version, } gotbytes, err := base64.StdEncoding.DecodeString(got) if err != nil { t.Errorf("base64.StdEncoding.DecodeString(%s) = %v, want nil error", got, err) } gotMsg := &acppb.AgentInfo{} if err := proto.Unmarshal(gotbytes, gotMsg); err != nil { t.Errorf("proto.Unmarshal(%s) = %v, want nil error", got, err) } if diff := cmp.Diff(wantMsg, gotMsg, protocmp.Transform()); diff != "" { t.Errorf("formatAgentInfo(%s) returned unexpected diff (-want +got):\n%s", version, diff) } } func TestFormatOSInfo(t *testing.T) { data := osinfo.OSInfo{ Architecture: "x86", VersionID: "1.2.3", OS: "rhel", PrettyName: "Redhat Linux", KernelRelease: "1", KernelVersion: "1.2", } wantMsg := &acppb.OSInfo{ Architecture: data.Architecture, Type: runtime.GOOS, Version: data.VersionID, ShortName: data.OS, LongName: data.PrettyName, KernelRelease: data.KernelRelease, KernelVersion: data.KernelVersion, } got, err := formatOSInfo(data) if err != nil { t.Fatalf("formatOSInfo(%+v) = %v, want nil error", data, err) } gotbytes, err := base64.StdEncoding.DecodeString(got) if err != nil { t.Errorf("base64.StdEncoding.DecodeString(%s) = %v, want nil error", got, err) } gotMsg := &acppb.OSInfo{} if err := proto.Unmarshal(gotbytes, gotMsg); err != nil { t.Errorf("proto.Unmarshal(%s) = %v, want nil error", got, err) } if diff := cmp.Diff(wantMsg, gotMsg, protocmp.Transform()); diff != "" { t.Errorf("formatOSInfo(%+v) returned unexpected diff (-want +got):\n%s", data, diff) } } func TestRecord(t *testing.T) { testClient := &MDSClient{} job := &Job{client: testClient} if err := job.record(context.Background(), "osinfo", "agentinfo"); err != nil { t.Errorf("job.record(ctx) = %v, want nil error", err) } wantHeaders := map[string]string{ "X-Google-Guest-Agent": "agentinfo", "X-Google-Guest-OS": "osinfo", } if diff := cmp.Diff(wantHeaders, testClient.seenHeaders); diff != "" { t.Errorf("job.record(ctx) returned unexpected diff (-want +got):\n%s", diff) } if testClient.seenKey != "" { t.Errorf("job.record(ctx) = %s, want empty", testClient.seenKey) } } func TestRun(t *testing.T) { version := "1.2.3" if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(ctx) = %v, want nil error", err) } cfg.Retrieve().Core.Version = version testClient := &MDSClient{enableBoth: true} info := osinfo.OSInfo{ Architecture: "x86", } osinfoReader := func() osinfo.OSInfo { return info } agentInfo, err := formatAgentInfo(version) if err != nil { t.Fatalf("formatAgentInfo(%s) = %v, want nil error", version, err) } osInfo, err := formatOSInfo(info) if err != nil { t.Fatalf("formatOSInfo(%+v) = %v, want nil error", info, err) } wantHeaders := map[string]string{ "X-Google-Guest-Agent": agentInfo, "X-Google-Guest-OS": osInfo, } job := &Job{client: testClient, osInfoReader: osinfoReader} rerun, err := job.Run(context.Background()) if err != nil { t.Errorf("job.Run(ctx) = %v, want nil error", err) } if !rerun { t.Errorf("job.Run(ctx) = %t, want true", rerun) } if diff := cmp.Diff(wantHeaders, testClient.seenHeaders); diff != "" { t.Errorf("job.record(ctx) returned unexpected diff (-want +got):\n%s", diff) } } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry_windows.go000066400000000000000000000030251524141036400271000ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package telemetry import ( "context" "strings" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" ) const ( // gceIdentifier is the string identifier for GCE in smbios table. gceIdentifier = "Google" // regPath is the registry path to read the system product name from. regPath = `SYSTEM\HardwareConfig\Current` // regKey is the registry key to read the system product name from. regKey = "SystemProductName" ) // regReadString is the function to read string from registry. It is stubbed out // for unit testing. var regReadString = reg.ReadString func isOnGCE(ctx context.Context) (bool, error) { // Read the system product name from the registry. // Equivalent to `(Get-ItemProperty -Path "HKLM:\SYSTEM\HardwareConfig\Current").SystemProductName` s, err := regReadString(regPath, regKey) if err != nil { return false, err } s = strings.TrimSpace(s) return strings.HasPrefix(s, gceIdentifier), nil } google-guest-agent-20260819.00/cmd/core_plugin/telemetry/telemetry_windows_test.go000066400000000000000000000031471524141036400301440ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package telemetry import ( "context" "fmt" "testing" ) func TestIsOnGCE(t *testing.T) { ctx := context.Background() tests := []struct { name string regVal string regErr error want bool wantErr bool }{ { name: "on-gce", regVal: "Google Compute Engine", want: true, }, { name: "not-on-gce", regVal: "Other", want: false, }, { name: "reg-error", regErr: fmt.Errorf("reg error"), wantErr: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { origRegReadString := regReadString t.Cleanup(func() { regReadString = origRegReadString }) regReadString = func(key, name string) (string, error) { if tc.regErr != nil { return "", tc.regErr } return tc.regVal, nil } got, err := isOnGCE(ctx) if (err != nil) != tc.wantErr { t.Errorf("isOnGCE(ctx) error = %v, wantErr %t", err, tc.wantErr) } if got != tc.want { t.Errorf("isOnGCE(ctx) = %t, want %t", got, tc.want) } }) } } google-guest-agent-20260819.00/cmd/core_plugin/winpassreset/000077500000000000000000000000001524141036400235025ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/winpassreset/winpassreset_linux.go000066400000000000000000000017111524141036400277770ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package winpassreset import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" ) // NewModule returns the windows password reset module. func NewModule(_ context.Context) *manager.Module { galog.V(2).Debugf("windows password reset not supported on linux") return nil } google-guest-agent-20260819.00/cmd/core_plugin/winpassreset/winpassreset_linux_test.go000066400000000000000000000014701524141036400310400ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package winpassreset import ( "context" "testing" ) func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module != nil { t.Errorf("NewModule() = %v, want nil", module) } } google-guest-agent-20260819.00/cmd/core_plugin/winpassreset/winpassreset_windows.go000066400000000000000000000307661524141036400303460ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows // Package winpassreset is responsible for managing windows password resets. package winpassreset import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/json" "errors" "fmt" "hash" "math/big" "reflect" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/lru" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/serialport" "golang.org/x/sys/windows/registry" // allowlist:crypto/rsa // allowlist:crypto/sha1 ) const ( // accountsRegKey is the registry key where the user accounts are stored. accountsRegKey = "PublicKeys" // winpassModuleID is the name of the module. winpassModuleID = "winpassreset" ) var ( // badReg is a list of bad registry keys that we don't want to log. badReg = lru.New[string](64) // credsWriter is the serial port logger for writing credentials. credsWriter = &serialport.Writer{Port: "COM4"} // sshdVersion is the minimum version of sshd that is supported. sshdVersionMajor = 8 sshdVersionMinor = 6 // The following are stubbed out for error injection in tests. regWriteMultiString = reg.WriteMultiString regReadMultiString = reg.ReadMultiString resetPassword = defaultResetPassword modifiedKeys = defaultModifiedKeys newCredentials = defaultNewCredentials ) // module is the windows password reset module. type module struct { // prevKeys is the previous windows keys. prevKeys []*metadata.WindowsKey } // NewModule returns the windows password reset module. func NewModule(_ context.Context) *manager.Module { mod := &module{} return &manager.Module{ ID: winpassModuleID, Setup: mod.moduleSetup, Description: "Resets the password for a user on a Windows VM", } } // moduleSetup initializes the module. func (mod *module) moduleSetup(ctx context.Context, data any) error { if cfg.Retrieve().AccountManager != nil && cfg.Retrieve().AccountManager.Disable { galog.Infof("Account manager is disabled, skipping windows password reset module setup.") return nil } galog.Debug("Initializing windows password reset module.") desc, ok := data.(*metadata.Descriptor) if !ok { return fmt.Errorf("winpass module expects a metadata descriptor in the data pointer") } if _, err := mod.setupAccounts(ctx, desc.Instance().Attributes().WindowsKeys()); err != nil { galog.Errorf("failed to reset password: %v", err) } eManager := events.FetchManager() sub := events.EventSubscriber{Name: winpassModuleID, Callback: mod.eventCallback, MetricName: acmpb.GuestAgentModuleMetric_WINDOWS_PASSWORD_RESET} eManager.Subscribe(metadata.LongpollEvent, sub) galog.Debug("Finished initializing windows password reset module.") return nil } // eventCallback is the callback event handler for the winpass module. func (mod *module) eventCallback(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type we log it and // don't renew the handler. if !ok { return false, true, fmt.Errorf("event's data is not a metadata descriptor: %+v", evData.Data) } // If the event manager is passing/reporting an error we log it and keep // renewing the handler. if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, will retry setup", evData.Error) } // Return early if nothing has changed. if !mod.metadataChanged(desc.Instance().Attributes().WindowsKeys()) { return true, true, nil } noop, err := mod.setupAccounts(ctx, desc.Instance().Attributes().WindowsKeys()) return true, noop, err } func (mod *module) metadataChanged(newKeys []*metadata.WindowsKey) bool { return len(mod.prevKeys) != len(newKeys) || !reflect.DeepEqual(mod.prevKeys, newKeys) } // setupAccounts sets up accounts in the registry and creates and updates them // as needed. func (mod *module) setupAccounts(ctx context.Context, keys []*metadata.WindowsKey) (bool, error) { galog.Infof("Setting up Windows accounts.") mod.prevKeys = keys // Get windows keys difference. regKeys, err := regReadMultiString(reg.GCEKeyBase, accountsRegKey) if err != nil && !errors.Is(err, registry.ErrNotExist) { return true, fmt.Errorf("failed to read registry keys: %w", err) } diffKeys := modifiedKeys(regKeys, keys) // If there are no new keys, skip account setup. if len(diffKeys) == 0 { galog.Info("No new keys found, skipping account setup.") return true, nil } galog.Debugf("Found %d keys to add", len(diffKeys)) // Create or update the accounts in the machine. for _, key := range diffKeys { creds, err := resetPassword(ctx, key) if err != nil { galog.Errorf("error setting password for user %s: %v", key.UserName(), err) creds = &credentials{ PasswordFound: false, Exponent: key.Exponent(), Modulus: key.Modulus(), UserName: key.UserName(), ErrorMessage: err.Error(), } } if err := creds.writeToSerialPort(); err != nil { return false, fmt.Errorf("failed to print credentials to serial port: %w", err) } } // Update the registry with the new keys. galog.Debug("Updating registry with new keys") var jsonKeys []string for _, key := range keys { jsonKey, err := key.MarshalJSON() if err != nil { return false, fmt.Errorf("failed to marshal key: %w", err) } jsonKeys = append(jsonKeys, string(jsonKey)) } if err = regWriteMultiString(reg.GCEKeyBase, accountsRegKey, jsonKeys); err != nil { return false, fmt.Errorf("failed to write registry keys: %w", err) } galog.Debug("Successfully updated registry with new keys") galog.Infof("Finished setting up Windows accounts.") return false, nil } // credentials is the JSON representation of an account's credentials. type credentials struct { ErrorMessage string `json:"errorMessage,omitempty"` EncryptedPassword string `json:"encryptedPassword,omitempty"` UserName string `json:"userName,omitempty"` PasswordFound bool `json:"passwordFound,omitempty"` Exponent string `json:"exponent,omitempty"` Modulus string `json:"modulus,omitempty"` HashFunction string `json:"hashFunction,omitempty"` } // defaultNewCredentials creates a new credentials object using the given key and password. func defaultNewCredentials(k *metadata.WindowsKey, pwd string) (*credentials, error) { mod, err := base64.StdEncoding.DecodeString(k.Modulus()) if err != nil { return nil, fmt.Errorf("error decoding modulus: %v", err) } exp, err := base64.StdEncoding.DecodeString(k.Exponent()) if err != nil { return nil, fmt.Errorf("error decoding exponent: %v", err) } key := &rsa.PublicKey{ N: new(big.Int).SetBytes(mod), E: int(new(big.Int).SetBytes(exp).Int64()), } // TODO(b/429651111): Revisit usage of sha1 by default. hashFunction := k.HashFunction() if hashFunction == "" { hashFunction = "sha1" } var hashFunc hash.Hash switch hashFunction { case "sha1": hashFunc = sha1.New() case "sha256": hashFunc = sha256.New() case "sha512": hashFunc = sha512.New() default: return nil, fmt.Errorf("unknown hash function requested: %q", hashFunction) } encPwd, err := rsa.EncryptOAEP(hashFunc, rand.Reader, key, []byte(pwd), nil) if err != nil { return nil, fmt.Errorf("error encrypting password: %v", err) } return &credentials{ PasswordFound: true, Exponent: k.Exponent(), Modulus: k.Modulus(), UserName: k.UserName(), HashFunction: k.HashFunction(), EncryptedPassword: base64.StdEncoding.EncodeToString(encPwd), }, nil } func (c *credentials) writeToSerialPort() error { data, err := json.Marshal(c) if err != nil { return fmt.Errorf("failed to marshal credsJSON: %v", err) } if _, err = credsWriter.Write(append(data, []byte("\n")...)); err != nil { return fmt.Errorf("failed to write credsJSON to serial port: %v", err) } return nil } // defaultModifiedKeys determines which keys are new or modified. This does not // handle keys that are in the registry but not in the metadata. func defaultModifiedKeys(regKeys []string, newKeys []*metadata.WindowsKey) []*metadata.WindowsKey { if len(newKeys) == 0 { return nil } if len(regKeys) == 0 { return newKeys } // Convert the registry keys to WindowsKey. oldKeys := regKeysToWindowsKey(regKeys) var toAdd []*metadata.WindowsKey for _, key := range newKeys { isDiff := true for _, oldKey := range oldKeys { // If the user name, modulus and expiry are the same, the key is not // different. if oldKey.UserName() == key.UserName() && oldKey.Modulus() == key.Modulus() && oldKey.ExpireOn() == key.ExpireOn() { isDiff = false break } } if isDiff { toAdd = append(toAdd, key) } } return toAdd } // regKeysToWindowsKey converts a list of registry keys to a list of WindowsKey. // Ignores bad registry keys. func regKeysToWindowsKey(regKeys []string) []*metadata.WindowsKey { var winKeys []*metadata.WindowsKey for _, s := range regKeys { key := &metadata.WindowsKey{} if err := key.UnmarshalJSON([]byte(s)); err != nil { if _, found := badReg.Get(s); !found { galog.Warnf("bad windows key from registry: %s", err) badReg.Put(s, true) } continue } winKeys = append(winKeys, key) } return winKeys } // defaultResetPassword resets the password of the user specified in the key. // If the user does not exist, it will create it. func defaultResetPassword(ctx context.Context, key *metadata.WindowsKey) (*credentials, error) { newPassword, err := accounts.GeneratePassword(key.PasswordLength()) if err != nil { return nil, fmt.Errorf("failed to generate password: %w", err) } u, err := accounts.FindUser(ctx, key.UserName()) if err != nil { galog.Debugf("User %s does not exist (lookup returned %v), creating it.", key.UserName(), err) // If the user does not exist, we create it. newUser := &accounts.User{ Name: key.UserName(), Password: newPassword, } if err = accounts.CreateUser(ctx, newUser); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } u, err = accounts.FindUser(ctx, key.UserName()) if err != nil { return nil, fmt.Errorf("failed to find user %s after creation: %w", key.UserName(), err) } // Add the user to the administrator group if needed. if key.AddToAdministrator() == nil || *key.AddToAdministrator() { if err = accounts.AddUserToGroup(ctx, u, accounts.AdminGroup); err != nil { return nil, fmt.Errorf("failed to add user %s to administrator group: %w", key.UserName(), err) } } galog.Infof("Successfully created user %s", key.UserName()) } else { galog.Debugf("Updating password for user %s", key.UserName()) // If the user exists, we simply update the password. if err = u.SetPassword(ctx, newPassword); err != nil { return nil, fmt.Errorf("failed to set password: %w", err) } // Add the user to the administrator group if needed. if key.AddToAdministrator() != nil && *key.AddToAdministrator() { if err = accounts.AddUserToGroup(ctx, u, accounts.AdminGroup); err != nil { return nil, fmt.Errorf("failed to add user %s to administrator group: %w", key.UserName(), err) } } galog.Infof("Successfully updated password for user %s", key.UserName()) } // Create the credsJSON object. creds, err := newCredentials(key, newPassword) if err != nil { return nil, fmt.Errorf("failed to create credsJSON: %w", err) } galog.Debugf("Successfully created credentials for user %s", key.UserName()) return creds, nil } google-guest-agent-20260819.00/cmd/core_plugin/winpassreset/winpassreset_windows_test.go000066400000000000000000000410201524141036400313660ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package winpassreset import ( "context" "fmt" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/accounts" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/reg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/serialport" ) // winpassTestOpts is a set of options to control the behavior of the // winpassreset package for testing purposes. type winpassTestOpts struct { // overrideResetPassword overrides the resetPassword function to be no-op. overrideResetPassword bool // overrideModifiedKeys overrides the modifiedKeys function to return a single // test key. overrideModifiedKeys bool // overrideRegWrite overrides the regWriteMultiString function to return an // error if regWriteErr is set to true. Otherwise it is no-op. overrideRegWrite bool regWriteErr bool // overrideRegRead overrides the regReadMultiString function to return an // error if regReadErr is set to true. Otherwise it returns the // testRegEntries. overrideRegRead bool testRegEntries []string regReadErr bool } func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module == nil { t.Fatalf("NewModule() returned nil module") } if module.ID != "winpassreset" { t.Errorf("NewModule() returned module with ID %q, want %q", module.ID, "winpassreset") } } func TestModuleSetup(t *testing.T) { mdsJSON := ` { "instance": { "attributes": { } } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", mdsJSON, err) } tests := []struct { name string desc any accountManagerDisabled bool opts winpassTestOpts expectErr bool }{ { name: "success", desc: desc, opts: winpassTestOpts{ overrideResetPassword: true, overrideModifiedKeys: true, overrideRegWrite: true, overrideRegRead: true, testRegEntries: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, }, }, { name: "account_manager_disabled", accountManagerDisabled: true, }, { name: "fail_setup_accounts", desc: desc, opts: winpassTestOpts{ overrideRegRead: true, regReadErr: true, }, }, { name: "invalid_desc", desc: "", expectErr: true, }, } ctx := context.Background() for _, test := range tests { t.Run(test.name, func(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed: %v", err) } winpassTestSetup(t, test.opts) if test.accountManagerDisabled { cfg.Retrieve().AccountManager = &cfg.AccountManager{Disable: true} } mod := &module{} if err := mod.moduleSetup(ctx, test.desc); (err == nil) == test.expectErr { t.Fatalf("moduleSetup(ctx, %v) = %v, want %t", desc, err, test.expectErr) } }) } } func TestEventCallback(t *testing.T) { ctx := context.Background() mdsJSON := ` { "instance": { "attributes": { } } }` desc, err := metadata.UnmarshalDescriptor(mdsJSON) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%q) = %v, want nil", mdsJSON, err) } tests := []struct { name string evData *events.EventData opts winpassTestOpts expectBool bool wantErr bool wantNoop bool }{ { name: "invalid_metadata", evData: &events.EventData{Data: "invalid-metadata", Error: nil}, expectBool: false, wantErr: true, wantNoop: true, }, { name: "event_error", evData: &events.EventData{Data: desc, Error: fmt.Errorf("event error")}, expectBool: true, wantErr: true, wantNoop: true, }, { name: "setup_accounts_error", evData: &events.EventData{Data: desc, Error: nil}, opts: winpassTestOpts{overrideRegRead: true, regReadErr: true}, expectBool: true, wantErr: true, wantNoop: true, }, { name: "success", evData: &events.EventData{Data: desc, Error: nil}, opts: winpassTestOpts{ overrideResetPassword: true, overrideModifiedKeys: true, overrideRegWrite: true, overrideRegRead: true, testRegEntries: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, }, expectBool: true, wantErr: false, wantNoop: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { winpassTestSetup(t, test.opts) mod := &module{ prevKeys: regKeysToWindowsKey([]string{`{"UserName": "test-user", "PasswordLength": 20}`}), } got, noop, err := mod.eventCallback(ctx, "metadata_changed", nil, test.evData) if (err != nil) != test.wantErr { t.Fatalf("eventCallback(ctx, %q, '', %v) = %v, want error: %t", "metadata_changed", test.evData, err, test.wantErr) } if noop != test.wantNoop { t.Errorf("eventCallback(ctx, %q, '', %v) = %t, want noop: %t", "metadata_changed", test.evData, noop, test.wantNoop) } if got != test.expectBool { t.Errorf("eventCallback(ctx, %q, '', %v) = %t, want %t", "metadata_changed", test.evData, got, test.expectBool) } }) } } func TestMetadataChanged(t *testing.T) { tests := []struct { name string prevKeys string newKeys string want bool }{ { name: "same", prevKeys: `{"UserName": "test-user", "PasswordLength": 20}`, newKeys: `{"UserName": "test-user", "PasswordLength": 20}`, want: false, }, { name: "different", prevKeys: `{"UserName": "test-user", "PasswordLength": 20}`, newKeys: `{"UserName": "test-user-2", "PasswordLength": 20}`, want: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { prevKeys := regKeysToWindowsKey([]string{test.prevKeys}) newKeys := regKeysToWindowsKey([]string{test.newKeys}) mod := &module{} mod.prevKeys = prevKeys got := mod.metadataChanged(newKeys) if got != test.want { t.Errorf("metadataChanged(ctx, %v) = %t, want %t", newKeys, got, test.want) } }) } } func TestResetPassword(t *testing.T) { tests := []struct { name string testUsername string isAdmin bool createUser bool key string }{ { name: "user_exists", testUsername: "test-user", createUser: true, key: `{"UserName": "test-user", "PasswordLength": 20}`, }, { name: "user_does_not_exist", key: `{"UserName": "test-user", "PasswordLength": 20}`, }, { name: "user_exists_admin", testUsername: "test-user-admin", isAdmin: true, createUser: true, key: `{"UserName": "test-user-admin", "PasswordLength": 20, "AddToAdministrators": true}`, }, { name: "user_does_not_exist_admin", testUsername: "test-user-admin", isAdmin: true, key: `{"UserName": "test-user-admin", "PasswordLength": 20, "AddToAdministrators": true}`, }, } ctx := context.Background() for _, test := range tests { t.Run(test.name, func(t *testing.T) { var user *accounts.User if test.createUser { user = createTestUser(t, test.testUsername) } keys := regKeysToWindowsKey([]string{test.key}) if len(keys) == 0 { t.Fatalf("Failed to parse test key") } newCredentials = func(k *metadata.WindowsKey, pwd string) (*credentials, error) { return &credentials{PasswordFound: true}, nil } t.Cleanup(func() { newCredentials = defaultNewCredentials }) _, err := resetPassword(context.Background(), keys[0]) if err != nil { t.Fatalf("ResetPassword(ctx, %q) failed: %v", test.key, err) } t.Cleanup(func() { accounts.DelUser(context.Background(), &accounts.User{Name: test.testUsername}) }) if test.isAdmin { if user == nil { user, err = accounts.FindUser(ctx, test.testUsername) } if err != nil { t.Fatalf("Failed to find user: %v", err) } if err := accounts.RemoveUserFromGroup(ctx, user, accounts.AdminGroup); err != nil { t.Fatalf("user not in administrators group: %s", err) } } }) } } func TestModifiedKeys(t *testing.T) { tests := []struct { name string regKeys []string newKeys []string expectedNames []string }{ { name: "no_new_keys", regKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, newKeys: []string{}, expectedNames: []string{}, }, { name: "no_reg_keys", regKeys: []string{}, newKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, expectedNames: []string{"test-user"}, }, { name: "no_match", regKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, newKeys: []string{`{"UserName": "test-user-2", "PasswordLength": 20}`}, expectedNames: []string{"test-user-2"}, }, { name: "both_match", regKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, newKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, expectedNames: []string{}, }, { name: "bad_key", regKeys: []string{`{Not a valid key}`}, newKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, expectedNames: []string{"test-user"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { newKeys := regKeysToWindowsKey(test.newKeys) diff := modifiedKeys(test.regKeys, newKeys) if len(diff) != len(test.expectedNames) { t.Fatalf("compareAccounts(%v, %v) = Length %d, want %d", test.regKeys, test.newKeys, len(diff), len(test.expectedNames)) } for i, name := range test.expectedNames { if diff[i].UserName() != name { t.Errorf("compareAccounts(%v, %v)[%d] = %s, want %s", test.regKeys, test.newKeys, i, diff[i].UserName(), name) } } }) } } func TestSetupAccounts(t *testing.T) { tests := []struct { name string opts winpassTestOpts testKeys []string expectErr bool expectNoop bool }{ { name: "success", opts: winpassTestOpts{ overrideResetPassword: true, overrideModifiedKeys: true, overrideRegWrite: true, overrideRegRead: true, testRegEntries: []string{`{"UserName": "test-user0", "PasswordLength": 20}`}, }, testKeys: []string{`{"UserName": "test-user", "PasswordLength": 20}`}, expectNoop: false, }, { name: "read_reg_err", opts: winpassTestOpts{ overrideResetPassword: true, overrideModifiedKeys: true, overrideRegWrite: true, overrideRegRead: true, regReadErr: true, }, expectErr: true, expectNoop: true, }, { name: "write_reg_err", opts: winpassTestOpts{ overrideResetPassword: true, overrideModifiedKeys: true, overrideRegWrite: true, overrideRegRead: true, regWriteErr: true, }, expectErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { winpassTestSetup(t, test.opts) testKeys := regKeysToWindowsKey(test.testKeys) mod := &module{} noop, err := mod.setupAccounts(context.Background(), testKeys) if (err == nil) == test.expectErr { t.Errorf("setupAccounts(ctx, %v) = Err %v, want %v", test.testKeys, err, test.expectErr) } if noop != test.expectNoop { t.Errorf("setupAccounts(ctx, %v) = No-op %v, want %v", test.testKeys, noop, test.expectNoop) } t.Cleanup(func() { accounts.DelUser(context.Background(), &accounts.User{Name: "test-user"}) }) }) } } func TestDefaultNewCredentials(t *testing.T) { testModKey := "0MgqnC9ZGb8ATHuc00d/12AnnIzfq5TlEiubx0P8f5BidytVT6ZZ1Oa0IPBYkd0ZqXqxTtHXqrjtU/QX40eGe+T15ySMeE0VdH/UVOPvslWYxpIWnP+1jQEUhuuG5Af2Lq1qODRxnY7eMFzclrAzE2O+EoYN3Pq5JQMBcOaADDsKH91i8oCpeVuFZMZo7KKe1U87XJESLERP7lNkdgOHt9IJ1Q8rZGKAWKqx2GlYkxM7jh8xLhYGQ/mJq+tNAthyfXlosNzYiNbP7278H/OiHVrVrnC9S4kRuvM+U6BMdsFt7hRBSEWlGcKdpBiYTtqsGDzYDjisHuKbRTGl/O8FPw==" tests := []struct { name string key string pwd string wantErr bool }{ { name: "success_sha1", key: fmt.Sprintf(`{"UserName": "test-user", "PasswordLength": 20, "Modulus": "%s", "Exponent": "AQAB"}`, testModKey), pwd: "password123456789", wantErr: false, }, { name: "invalid_key", key: `{"UserName": "test-user", "PasswordLength": 20}`, pwd: "password123456789", wantErr: true, }, { name: "success_sha256", key: fmt.Sprintf(`{"UserName": "test-user", "PasswordLength": 20, "Modulus": "%s", "Exponent": "AQAB", "HashFunction": "sha256"}`, testModKey), pwd: "password123456789", wantErr: false, }, { name: "success_sha512", key: fmt.Sprintf(`{"UserName": "test-user", "PasswordLength": 20, "Modulus": "%s", "Exponent": "AQAB", "HashFunction": "sha512"}`, testModKey), pwd: "password123456789", wantErr: false, }, { name: "invalid_hash_function", key: fmt.Sprintf(`{"UserName": "test-user", "PasswordLength": 20, "Modulus": "%s", "Exponent": "AQAB", "HashFunction": "invalid"}`, testModKey), pwd: "password123456789", wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { keys := regKeysToWindowsKey([]string{test.key}) if len(keys) == 0 { t.Fatalf("Failed to parse test key") } creds, err := newCredentials(keys[0], test.pwd) if (err != nil) != test.wantErr { t.Errorf("newCredentials(%v, %q) = %v, want error: %t", test.key, test.pwd, err, test.wantErr) } if err == nil && creds == nil { t.Errorf("newCredentials(%v, %q) = nil, want non-nil", test.key, test.pwd) } }) } } // testSerialPortWriter is a fake serial port writer for testing purposes. type testSerialPortWriter struct{} // Write writes the given data to the serial port. func (t *testSerialPortWriter) Write(b []byte) (int, error) { return len(b), nil } // winpassTestSetup sets up the winpassreset package for testing purposes. func winpassTestSetup(t *testing.T, opts winpassTestOpts) { if opts.overrideResetPassword { resetPassword = func(ctx context.Context, key *metadata.WindowsKey) (*credentials, error) { return &credentials{PasswordFound: true}, nil } } if opts.overrideModifiedKeys { modifiedKeys = func(regKeys []string, newKeys []*metadata.WindowsKey) []*metadata.WindowsKey { key := &metadata.WindowsKey{} key.UnmarshalJSON([]byte(`{"UserName": "test-user", "PasswordLength": 20}`)) return []*metadata.WindowsKey{key} } } if opts.overrideRegWrite { regWriteMultiString = func(key string, name string, value []string) error { if opts.regWriteErr { return fmt.Errorf("failed to write registry key") } return nil } } if opts.overrideRegRead { regReadMultiString = func(key string, name string) ([]string, error) { if opts.regReadErr { return nil, fmt.Errorf("failed to read registry key") } return opts.testRegEntries, nil } } newCredentials = func(k *metadata.WindowsKey, pwd string) (*credentials, error) { return &credentials{PasswordFound: true}, nil } oldCredsWriter := credsWriter credsWriter = &serialport.Writer{IsTest: true} t.Cleanup(func() { modifiedKeys = defaultModifiedKeys resetPassword = defaultResetPassword regWriteMultiString = reg.WriteMultiString regReadMultiString = reg.ReadMultiString newCredentials = defaultNewCredentials credsWriter = oldCredsWriter }) } func createTestUser(t *testing.T, username string) *accounts.User { t.Helper() ctx := context.Background() // Don't create the user if it already exists. u, err := accounts.FindUser(ctx, username) if err == nil { t.Cleanup(func() { accounts.DelUser(ctx, u) }) return u } user := &accounts.User{ Name: username, Password: "password123456789", } if err := accounts.CreateUser(ctx, user); err != nil { t.Fatalf("Failed to create test user: %v", err) } newUser, err := accounts.FindUser(ctx, user.Name) t.Cleanup(func() { accounts.DelUser(ctx, newUser) }) return newUser } google-guest-agent-20260819.00/cmd/core_plugin/wsfchealthcheck/000077500000000000000000000000001524141036400241015ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/core_plugin/wsfchealthcheck/agent.go000066400000000000000000000133231524141036400255300ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wsfchealthcheck import ( "context" "errors" "fmt" "net" "strings" "sync/atomic" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" ) var ( // policy is the retry policy to check if agent is stopped. policy = retry.Policy{MaxAttempts: 5, BackoffFactor: 1, Jitter: time.Second} ) // healthCheck is the interface agent answering health check ping needs to // implement. type healthCheck interface { // isRunning returns true if agent is running. isRunning() bool // address returns the address where agent listens on for health check ping. address() string // setAddress sets the port where agent listens on for health check ping. setAddress(string) // run starts the agent that begin listening for requests. run(context.Context) error // stop stops the agent from listening for requests. stop(context.Context) error } // connectOpts contains net connection config. type connectOpts struct { // protocol is the protocol of the connection. Its must be either tcp/uds // where UDS must be used *only* for unit testing. protocol string // addr is the address of the connection where its just a port number for TCP // and socket path for UDS. addr string } // wsfcAgent implements the healthCheck interface. type wsfcAgent struct { // running tracks agent status. running atomic.Bool // opts contains net connection config. opts connectOpts // listener is where this agent is listening on. listener net.Listener } // newWSFCAgent creates a new wsfcAgent instance. func newWSFCAgent(opts connectOpts) *wsfcAgent { return &wsfcAgent{opts: opts} } // isRunning returns true if agent is running. func (w *wsfcAgent) isRunning() bool { return w.running.Load() } // address returns the current address agent is listening on. func (w *wsfcAgent) address() string { return w.opts.addr } // setAddress sets the address for agent to listening on. func (w *wsfcAgent) setAddress(addr string) { galog.Infof("Re-setting address from %q -> %q", w.address(), addr) w.opts.addr = addr } // run starts the agent to listen on address configured in [connectOpts]. func (w *wsfcAgent) run(ctx context.Context) error { if w.isRunning() { galog.Debugf("wsfc agent is already running, ignoring run request") return nil } galog.Infof("Starting WSFC agent on %+v", w.opts) var err error w.listener, err = net.Listen(w.opts.protocol, w.opts.addr) if err != nil { return fmt.Errorf("failed to start listener on %q (%s): %w", w.opts.addr, w.opts.protocol, err) } w.running.Store(true) // Go routine for listening requests. This keeps running until context is // cancelled or the underlying listener is closed. go func() { // Reset state while returning from this go routine to indicate that agent // is stopped. defer func() { w.listener = nil w.running.Store(false) }() for ctx.Err() == nil { // Listener is closed in agent stop(). conn, err := w.listener.Accept() if err != nil { if errors.Is(err, net.ErrClosed) { // Its ok to simply return and exit on error as wsfc agent manager // will restart the agent if wsfc is still enabled. galog.Infof("Listener is closed, stopping agent...") return } // If connection is not closed and there's some other error just log and // retry. galog.Errorf("Failed to accept connection with error: %v", err) continue } go func() { if err := w.handleHealthCheckRequest(ctx, conn); err != nil { galog.Errorf("Failed to handle health check request: %v", err) } }() } }() return nil } // handleHealthCheckRequest handles health check request. func (w *wsfcAgent) handleHealthCheckRequest(ctx context.Context, conn net.Conn) error { galog.Debugf("Handling WSFC health check request") defer func() { if err := conn.Close(); err != nil { galog.Errorf("Failed to close a connection: %v", err) } }() conn.SetDeadline(time.Now().Add(time.Second)) buf := make([]byte, 1024) reqLen, err := conn.Read(buf) if err != nil { return fmt.Errorf("failed to read from connection: %w", err) } wsfcIP := strings.TrimSpace(string(buf[:reqLen])) reply, err := checkIPExist(ctx, wsfcIP) if err != nil { return fmt.Errorf("failed to check IP %q existence: %w", wsfcIP, err) } galog.Debugf("IP existence check for %q returned %q", wsfcIP, reply) writeBytes := []byte(reply) wrote, err := conn.Write(writeBytes) if err != nil || wrote != len(writeBytes) { return fmt.Errorf("writing to connection: bytes written = %d, err = %w, expected bytes = %d", wrote, err, len(writeBytes)) } return nil } // stop stops the agent. func (w *wsfcAgent) stop(ctx context.Context) error { if !w.isRunning() { galog.Debugf("WSFC agent is already stopped, ignoring stop request") return nil } galog.Infof("Stopping WSFC agent") if w.listener != nil { if err := w.listener.Close(); err != nil { galog.Errorf("Failed to close listener: %v", err) } } // Wait to confirm if agent has stopped or give up with error after retry // policy exhausts. err := retry.Run(ctx, policy, func() error { if w.isRunning() { return fmt.Errorf("WSFC agent is still running") } return nil }) return err } google-guest-agent-20260819.00/cmd/core_plugin/wsfchealthcheck/agent_test.go000066400000000000000000000100731524141036400265660ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distrbuted under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wsfchealthcheck import ( "bufio" "context" "fmt" "net" "path/filepath" "testing" "time" ) const unixProtocol = "unix" // sendReq sends a request [data] to the given address and returns response to it. func sendReq(t *testing.T, protocol, addr, data string) (string, error) { t.Helper() conn, err := net.Dial(protocol, addr) if err != nil { t.Fatalf("net.Dial(%s, %s) = %v, want nil", protocol, addr, err) } defer conn.Close() conn.SetDeadline(time.Now().Add(time.Second * 20)) if n, err := fmt.Fprint(conn, data); err != nil || n != len(data) { t.Fatalf("conn.Write(%s) = wrote %d bytes, expected %d bytes, err: %v", data, n, len(data), err) } return bufio.NewReader(conn).ReadString('\n') } func TestAgentApi(t *testing.T) { socket := filepath.Join(t.TempDir(), "wsfc_socket") agent := newWSFCAgent(connectOpts{protocol: unixProtocol, addr: socket}) if agent.isRunning() { t.Errorf("agent.isRunning() = %v, want %v", agent.isRunning(), false) } if agent.address() != socket { t.Errorf("agent.address() = %s, want %s", agent.address(), socket) } newAddress := "new_address" agent.setAddress(newAddress) if agent.address() != newAddress { t.Errorf("agent.address() = %s, want %s", agent.address(), newAddress) } } func TestRun(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) test := []struct { desc string ip string want string protocol string }{ { desc: "valid_ip_unix", protocol: unixProtocol, ip: "1.2.3.4", want: "1", }, { desc: "valid_ip_tcp", protocol: tcpProtocol, ip: "1.2.3.4", want: "1", }, { desc: "invalid_ip_unix", protocol: unixProtocol, ip: "5.6.7.8", want: "0", }, { desc: "invalid_ip_tcp", protocol: tcpProtocol, ip: "5.6.7.8", want: "0", }, } for _, tc := range test { t.Run(tc.desc, func(t *testing.T) { var agent *wsfcAgent if tc.protocol == unixProtocol { socket := filepath.Join(t.TempDir(), "wsfc_socket") agent = newWSFCAgent(connectOpts{protocol: unixProtocol, addr: socket}) } else { port := freePort(t, 1)[0] agent = newWSFCAgent(connectOpts{protocol: tcpProtocol, addr: fmt.Sprintf(":%d", port)}) } nCtx := context.WithValue(ctx, overrideIPExistCheck, tc.want) // Re-running agent run should be a no-op. for i := 0; i < 2; i++ { if err := agent.run(nCtx); err != nil { t.Fatalf("agent.run(ctx) = %v, want nil error", err) } if !agent.isRunning() { t.Errorf("agent.isRunning() = %t, want %t", agent.isRunning(), true) } } if got, err := sendReq(t, tc.protocol, agent.address(), tc.ip); got != tc.want { t.Errorf("health check response = %q, want %q, err: %v", got, "1", err) } // Re-running agent stop should be a no-op. for i := 0; i < 2; i++ { if err := agent.stop(nCtx); err != nil { t.Errorf("agent.stop(ctx) = %v, want nil error", err) } if agent.isRunning() { t.Errorf("agent.isRunning() = %t, want %t", agent.isRunning(), false) } } }) } } func freePort(t *testing.T, n int) []int { t.Helper() var ports []int for i := 0; i < n; i++ { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen(tcp, 127.0.0.1:0) = %v, want nil", err) } defer listener.Close() addr, ok := listener.Addr().(*net.TCPAddr) if !ok { t.Fatalf("could not get TCP address") } ports = append(ports, addr.Port) } return ports } google-guest-agent-20260819.00/cmd/core_plugin/wsfchealthcheck/wsfchealthcheck.go000066400000000000000000000206141524141036400275610ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package wsfchealthcheck implements an agent that is used to support Windows // Server Failover Cluster (WSFC) in GCE. The agent will listen on a TCP port // and respond to health check requests from the WSFC cluster. Agent checks if // the IP address in the request is present on any of the interfaces and return // a response accordingly. package wsfchealthcheck import ( "context" "fmt" "net" "strings" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/core_plugin/manager" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) type overrideContextKey string const ( // wsfcModuleID is the ID of the WSFC health check module. wsfcModuleID = "wsfc-health-check" // wsfcDefaultAgentPort is the default port where agent listens on for health // check requests. wsfcDefaultAgentPort = "59998" // tcpProtocol is the protocol used for health check connections. tcpProtocol = "tcp" // overrideIPExistCheck is the context key for overriding the IP check in unit // tests. overrideIPExistCheck overrideContextKey = "override-ip-check" ) // NewModule returns a new WSFC health check module for late registration. func NewModule(context.Context) *manager.Module { m := newWsfcManager(connectOpts{protocol: tcpProtocol}) // Register the cert refresher module. return &manager.Module{ ID: wsfcModuleID, Setup: m.moduleSetup, Quit: m.teardown, } } // moduleSetup is the initialization function for wsfc module that registers // itself to listen MDS events. func (wm *wsfcManager) moduleSetup(ctx context.Context, _ any) error { galog.Debugf("Initializing WSFC health check module.") sub := events.EventSubscriber{Name: wsfcModuleID, Callback: wm.metadataSubscriber, MetricName: acmpb.GuestAgentModuleMetric_WSFC_HEALTH_CHECK_INITIALIZATION} events.FetchManager().Subscribe(metadata.LongpollEvent, sub) galog.Debugf("Finished initializing WSFC health check module.") return nil } // teardown unsubscribes the wsfc module from listening any new MDS events. func (wm *wsfcManager) teardown(ctx context.Context) { events.FetchManager().Unsubscribe(metadata.LongpollEvent, wsfcModuleID) if err := wm.agent.stop(ctx); err != nil { galog.Errorf("Failed to stop wsfc agent: %v", err) } } // wsfcManager is the handler for the health check agent. type wsfcManager struct { // agent is the health check agent implementation reference. agent healthCheck // prevDescriptor is the previous metadata descriptor that was passed to the // agent. prevDescriptor *metadata.Descriptor } // isWsfcEnabled returns true if its set in instance config file or instance // or project level metadata attributes. Order of precedence is instance config, // instance metadata then project metadata. By default its disabled. Note that // if its enabled via config file agent expects address to be set as well. func isWsfcEnabled(desc *metadata.Descriptor) bool { config := cfg.Retrieve() if config.WSFC != nil && config.WSFC.Enable && config.WSFC.Addresses != "" { return true } if desc.Instance().Attributes().EnableWSFC() != nil { return *desc.Instance().Attributes().EnableWSFC() } if desc.Instance().Attributes().WSFCAddresses() != "" { return true } if desc.Project().Attributes().EnableWSFC() != nil { return *desc.Project().Attributes().EnableWSFC() } if desc.Project().Attributes().WSFCAddresses() != "" { return true } return false } // listenerAddr returns the address where agent should listens on. func listenerAddr(desc *metadata.Descriptor) string { config := cfg.Retrieve() // If the address is a unix socket path return it as is otherwise assume it's // a tcp port and pack it with a colon prefix - i.e. ":59998". packAddress := func(addr string) string { if addr == "" { return "" } if strings.HasPrefix(addr, "/") || strings.HasPrefix(addr[1:], ":\\") || strings.HasPrefix(addr[1:], "://") { return addr } return ":" + addr } if config.WSFC != nil && config.WSFC.Port != "" { return packAddress(config.WSFC.Port) } if port := desc.Instance().Attributes().WSFCAgentPort(); port != "" { return packAddress(port) } if port := desc.Project().Attributes().WSFCAgentPort(); port != "" { return packAddress(port) } return packAddress(wsfcDefaultAgentPort) } // newWsfcManager returns a new wsfcManager instance. func newWsfcManager(opts connectOpts) *wsfcManager { return &wsfcManager{agent: newWSFCAgent(opts)} } // reset resets the wsfc agent state if required. func (wm *wsfcManager) reset(ctx context.Context, desc *metadata.Descriptor) (bool, error) { newAddr := listenerAddr(desc) newState := isWsfcEnabled(desc) galog.Debugf("WSFC enabled: %t, on address: %s", newState, newAddr) // If WSFC is disabled or listener address has changed stop the currently // running agent. noop := true if !newState || newAddr != wm.agent.address() { if wm.agent.isRunning() { if err := wm.agent.stop(ctx); err != nil { return false, fmt.Errorf("failed to stop wsfc agent: %w", err) } noop = false } } if !newState { return noop, nil } // If WSFC is enabled or listener address has changed start the agent. if newAddr != wm.agent.address() { wm.agent.setAddress(newAddr) } if wm.agent.isRunning() { galog.Debugf("WSFC agent is already running, ignoring run request") return true, nil } if err := wm.agent.run(ctx); err != nil { return false, fmt.Errorf("failed to run agent: %w", err) } galog.Infof("WSFC agent started successfully on address: %q", newAddr) return false, nil } // metadataSubscriber is the callback function for MDS events, any new MDS // response will trigger it. Always return true to continue listening. func (wm *wsfcManager) metadataSubscriber(ctx context.Context, evType string, data any, evData *events.EventData) (bool, bool, error) { // There could be transient errors with MDS, just log and continue. if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %v, will retry setup", evData.Error) } desc, ok := evData.Data.(*metadata.Descriptor) // If the event manager is passing a non expected data type log it and // don't renew the subscriber. if !ok { galog.Errorf("Metadata event watcher reported data type %T, expected *metadata.Descriptor", evData.Data) return false, true, fmt.Errorf("event's data (%T) is not a metadata descriptor: %+v", evData.Data, evData.Data) } if !wm.hasDescriptorChanged(desc) { return true, true, nil } noop, err := wm.reset(ctx, desc) if err != nil { return true, noop, fmt.Errorf("failed to reset wsfc agent: %w", err) } // Update the previous metadata descriptor to the current one on success so // it retries on failure. wm.prevDescriptor = desc return true, noop, nil } // hasDescriptorChanged returns true if the metadata descriptor has changed. func (wm *wsfcManager) hasDescriptorChanged(desc *metadata.Descriptor) bool { if wm.prevDescriptor == nil { return true } if isWsfcEnabled(desc) != isWsfcEnabled(wm.prevDescriptor) { return true } if listenerAddr(desc) != listenerAddr(wm.prevDescriptor) { return true } return false } // checkIPExist returns 1 if IP exists on any of the interfaces otherwise 0. // 0/1 is based off of the protocol and the values expected by the server. func checkIPExist(ctx context.Context, ip string) (string, error) { if got := ctx.Value(overrideIPExistCheck); got != nil { return got.(string), nil } addrs, err := net.InterfaceAddrs() if err != nil { return "0", err } for _, address := range addrs { if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ip == ipnet.IP.String() { return "1", nil } } } return "0", nil } google-guest-agent-20260819.00/cmd/core_plugin/wsfchealthcheck/wsfchealthcheck_test.go000066400000000000000000000373671524141036400306350ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wsfchealthcheck import ( "context" "fmt" "path/filepath" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) func TestNewModule(t *testing.T) { module := NewModule(context.Background()) if module.ID != wsfcModuleID { t.Errorf("module.ID = %q, want %q", module.ID, wsfcModuleID) } if module.Setup == nil { t.Error("module.Setup = nil, want non-nil") } if module.BlockSetup != nil { t.Error("module.BlockSetup = non-nil, want nil") } if module.Quit == nil { t.Error("module.BlockSetup = nil, want non-nil") } } func TestModuleSetupTeardown(t *testing.T) { ctx := context.Background() evMgr := events.FetchManager() m := newWsfcManager(connectOpts{protocol: unixProtocol}) if err := m.moduleSetup(ctx, nil); err != nil { t.Errorf("moduleSetup(ctx, nil) failed unexpectedly with error: %v", err) } if !evMgr.IsSubscribed(metadata.LongpollEvent, wsfcModuleID) { t.Errorf("moduleSetup(ctx, nil) did not subscribe to MDS longpoll events") } m.teardown(ctx) if evMgr.IsSubscribed(metadata.LongpollEvent, wsfcModuleID) { t.Errorf("teardown(ctx) did not unsubscribe from MDS longpoll events") } if m.agent.isRunning() { t.Error("teardown(ctx) did not stop the agent") } } func TestIsWsfcEnabled(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } tests := []struct { name string addr string desc string want bool }{ { name: "config_enabled", addr: "some-ip", want: true, desc: `{"instance": {"attributes": {}}}`, }, { name: "config_enabled_address_empty", want: false, desc: `{"instance": {"attributes": {}}}`, }, { name: "config_disabled", want: false, desc: `{"instance": {"attributes": {}}}`, }, { name: "instance_disabled", want: false, desc: `{"instance": {"attributes": {"enable-wsfc": "false"}}}`, }, { name: "instance_enabled", want: true, desc: `{"instance": {"attributes": {"enable-wsfc": "true"}}}`, }, { name: "instance_addrs_set", want: true, desc: `{"instance": {"attributes": {"wsfc-addrs": "some-ip"}}}`, }, { name: "project_enabled", want: true, desc: `{"project": {"attributes": {"enable-wsfc": "true"}}}`, }, { name: "project_addrs_set", want: true, desc: `{"project": {"attributes": {"wsfc-addrs": "some-ip"}}}`, }, { name: "default", want: false, desc: `{"instance": {"attributes": {}}}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if strings.Contains(tc.name, "config") { cfg.Retrieve().WSFC = &cfg.WSFC{Enable: tc.want, Addresses: tc.addr} t.Cleanup(func() { cfg.Retrieve().WSFC = nil }) } desc, err := metadata.UnmarshalDescriptor(tc.desc) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.desc, err) } if got := isWsfcEnabled(desc); got != tc.want { t.Errorf("isWsfcEnabled(%+v) = %t, want %t", tc.desc, got, tc.want) } }) } } func TestListenerAddr(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } tests := []struct { name string desc string want string configPort string }{ { name: "default_port", want: ":" + wsfcDefaultAgentPort, desc: `{"instance": {"attributes": {}}}`, }, { name: "config_port", want: ":12345", configPort: "12345", desc: `{"instance": {"attributes": {}}}`, }, { name: "instance_port", desc: `{"instance": {"attributes": {"wsfc-agent-port": "54321"}}}`, want: ":54321", }, { name: "project_port", desc: `{"project": {"attributes": {"wsfc-agent-port": "13579"}}}`, want: ":13579", }, { name: "unix_socket", desc: `{"project": {"attributes": {"wsfc-agent-port": "/tmp/wsfc_socket"}}}`, want: "/tmp/wsfc_socket", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.configPort != "" { cfg.Retrieve().WSFC = &cfg.WSFC{Port: tc.configPort} t.Cleanup(func() { cfg.Retrieve().WSFC.Port = "" }) } desc, err := metadata.UnmarshalDescriptor(tc.desc) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed: %v", tc.desc, err) } if got := listenerAddr(desc); got != tc.want { t.Errorf("listenerAddr(%+v) = %q, want %q", tc.desc, got, tc.want) } }) } } func TestCheckIPExist(t *testing.T) { ctx := context.Background() tests := []struct { desc string want string ip string }{ { desc: "invalid_ipv4", ip: "256.256.256.256", want: "0", }, { desc: "invalid_ipv6", ip: "2001:db8:g000:1001::1", want: "0", }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { got, err := checkIPExist(ctx, test.ip) if err != nil { t.Fatalf("checkIPExist(%s) failed unexpectedly with error: %v", test.ip, err) } if got != test.want { t.Errorf("checkIPExist(%s) = %q, want %q", test.ip, got, test.want) } }) } } func TestResetStartAndStop(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } sock := filepath.Join(t.TempDir(), "sock") cfg.Retrieve().WSFC = &cfg.WSFC{Enable: true, Port: sock, Addresses: "some-ip"} mgr := newWsfcManager(connectOpts{protocol: unixProtocol, addr: sock}) ctx := context.WithValue(context.Background(), overrideIPExistCheck, "1") noop, err := mgr.reset(ctx, &metadata.Descriptor{}) if err != nil { t.Fatalf("reset(ctx, &metadata.Descriptor{}) failed unexpectedly with error: %v", err) } if noop { t.Errorf("reset(ctx, &metadata.Descriptor{}) returned noop on start = %t, want false", noop) } if !mgr.agent.isRunning() { t.Error("with wsfc-enabled reset did not start the agent") } if mgr.agent.address() != sock { t.Errorf("mgr.reset started agent on address = %q, want %q", mgr.agent.address(), sock) } cfg.Retrieve().WSFC = &cfg.WSFC{Enable: false, Port: sock, Addresses: "some-ip"} disable := `{"instance": {"attributes": {"enable-wsfc": "false"}}}` desc, err := metadata.UnmarshalDescriptor(disable) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed unexpectedly with error: %v", disable, err) } noop, err = mgr.reset(ctx, desc) if err != nil { t.Fatalf("reset(ctx, &metadata.Descriptor{}) failed unexpectedly with error: %v", err) } if noop { t.Errorf("reset(ctx, &metadata.Descriptor{}) returned noop on disable = %t, want false", noop) } if mgr.agent.isRunning() { t.Error("with wsfc-disabled reset did not stop the agent") } } func TestResetAddressChange(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } sock := filepath.Join(t.TempDir(), "sock") cfg.Retrieve().WSFC = &cfg.WSFC{Enable: true, Port: sock, Addresses: "some-ip"} mgr := newWsfcManager(connectOpts{protocol: unixProtocol, addr: sock}) ctx := context.WithValue(context.Background(), overrideIPExistCheck, "1") wantNoop := []bool{false, true} for _, wantNoop := range wantNoop { noop, err := mgr.reset(ctx, &metadata.Descriptor{}) if err != nil { t.Fatalf("reset(ctx, &metadata.Descriptor{}) failed unexpectedly with error: %v", err) } if noop != wantNoop { t.Errorf("reset(ctx, &metadata.Descriptor{}) returned noop on address set = %t, want %t", noop, wantNoop) } } if !mgr.agent.isRunning() { t.Error("mgr.reset did not start the agent with wsfc-enabled") } if mgr.agent.address() != sock { t.Errorf("mgr.reset started agent on address = %q, want %q", mgr.agent.address(), sock) } newAddr := filepath.Join(t.TempDir(), "new-socket") cfg.Retrieve().WSFC.Port = newAddr for _, wantNoop := range wantNoop { noop, err := mgr.reset(ctx, &metadata.Descriptor{}) if err != nil { t.Fatalf("reset(ctx, &metadata.Descriptor{}) failed unexpectedly with error: %v", err) } if noop != wantNoop { t.Errorf("reset(ctx, &metadata.Descriptor{}) returned noop on address reset = %t, want %t", noop, wantNoop) } } if !mgr.agent.isRunning() { t.Error("mgr.reset did not restart the agent on address change") } if mgr.agent.address() != newAddr { t.Errorf("mgr.reset restarted agent on address = %q, want %q", mgr.agent.address(), newAddr) } } func TestMetadataSubscriber(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } ctx := context.Background() test := []struct { desc string data events.EventData want bool wantErr bool wantNoop bool prevDesc *metadata.Descriptor wantPrevDescNil bool }{ { desc: "success", data: events.EventData{Data: &metadata.Descriptor{}}, want: true, wantErr: false, wantNoop: false, wantPrevDescNil: false, }, { desc: "reset_error", data: events.EventData{Data: &metadata.Descriptor{}}, want: true, wantErr: true, wantNoop: false, wantPrevDescNil: true, }, { desc: "invalid_data", data: events.EventData{}, want: false, wantErr: true, wantNoop: true, wantPrevDescNil: true, }, { desc: "error", data: events.EventData{Error: fmt.Errorf("test error")}, want: true, wantErr: true, wantNoop: true, wantPrevDescNil: true, }, { desc: "noop", data: events.EventData{Data: &metadata.Descriptor{}}, want: true, wantErr: false, wantNoop: true, prevDesc: &metadata.Descriptor{}, wantPrevDescNil: false, }, } for _, tc := range test { t.Run(tc.desc, func(t *testing.T) { sock := filepath.Join(t.TempDir(), "sock") cfg.Retrieve().WSFC = &cfg.WSFC{Enable: true, Port: sock, Addresses: "some-ip"} opts := connectOpts{protocol: unixProtocol, addr: sock} if tc.desc == "reset_error" { opts.protocol = "random" } mgr := newWsfcManager(opts) mgr.prevDescriptor = tc.prevDesc t.Cleanup(func() { mgr.agent.stop(ctx) }) got, noop, err := mgr.metadataSubscriber(ctx, tc.desc, nil, &tc.data) if (err != nil) != tc.wantErr { t.Fatalf("metadataSubscriber(ctx, %s, nil, &tc.data) error = %v, want error: %t", tc.desc, err, tc.wantErr) } if noop != tc.wantNoop { t.Errorf("metadataSubscriber(ctx, %s, nil, &tc.data) = %t, want noop: %t", tc.desc, noop, tc.wantNoop) } if got != tc.want { t.Errorf("metadataSubscriber(ctx, %s, nil, &tc.data) = %t, want %t", tc.desc, got, tc.want) } if tc.wantPrevDescNil != (mgr.prevDescriptor == nil) { t.Errorf("metadataSubscriber(ctx, %s, nil, &tc.data) prevDescriptor = %v, want nil: %t", tc.desc, mgr.prevDescriptor, tc.wantPrevDescNil) } if tc.desc != "success" { return } if !mgr.agent.isRunning() { t.Error("mgr.metadataSubscriber did not start the agent") } if mgr.agent.address() != sock { t.Errorf("mgr.metadataSubscriber started agent on address = %q, want %q", mgr.agent.address(), sock) } }) } } func TestHasDescriptorChanged(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } tests := []struct { desc string newDesc string prevDesc string want bool }{ { desc: "nil_prev_desc", newDesc: `{"instance": {"attributes": {"enable-wsfc": "true"}}}`, want: true, }, { desc: "same_desc", newDesc: `{"instance": {"attributes": {"enable-wsfc": "true", "wsfc-agent-port": "some-port"}}}`, prevDesc: `{"instance": {"attributes": {"enable-wsfc": "true", "wsfc-agent-port": "some-port"}}}`, want: false, }, { desc: "different_desc_addr", newDesc: `{"instance": {"attributes": {"enable-wsfc": "true", "wsfc-agent-port": "some-port"}}}`, prevDesc: `{"instance": {"attributes": {"enable-wsfc": "true", "wsfc-agent-port": "some-port-2"}}}`, want: true, }, { desc: "different_desc_wsfc_enabled", newDesc: `{"instance": {"attributes": {"enable-wsfc": "true", "wsfc-agent-port": "some-ip"}}}`, prevDesc: `{"instance": {"attributes": {"enable-wsfc": "false"}}}`, want: true, }, { desc: "different_desc_wsfc_disabled", newDesc: `{"instance": {"attributes": {"enable-wsfc": "false"}}}`, prevDesc: `{"instance": {"attributes": {"enable-wsfc": "true"}}}`, want: true, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { var prevDesc *metadata.Descriptor if tc.prevDesc != "" { var err error prevDesc, err = metadata.UnmarshalDescriptor(tc.prevDesc) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed unexpectedly with error: %v", tc.prevDesc, err) } } newDesc, err := metadata.UnmarshalDescriptor(tc.newDesc) if err != nil { t.Fatalf("UnmarshalDescriptor(%v) failed unexpectedly with error: %v", tc.newDesc, err) } mgr := &wsfcManager{prevDescriptor: prevDesc} if got := mgr.hasDescriptorChanged(newDesc); got != tc.want { t.Errorf("hasDescriptorChanged(%v) = %t, want %t", tc.newDesc, got, tc.want) } }) } } func TestReset(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } template := `{"instance": {"attributes": {"enable-wsfc": "%s", "wsfc-agent-port": "%d"}}}` ports := freePort(t, 2) ctx := context.WithValue(context.Background(), overrideIPExistCheck, "1") mgr := newWsfcManager(connectOpts{protocol: tcpProtocol}) t.Cleanup(func() { mgr.agent.stop(ctx) }) tests := []struct { name string wsfcEnabled string port int wantNoop bool wantRunning bool wantAddr string }{ { name: "agent_disabled", wsfcEnabled: "false", port: ports[0], wantNoop: true, wantRunning: false, }, { name: "agent_enabled", wsfcEnabled: "true", port: ports[0], wantNoop: false, wantRunning: true, wantAddr: fmt.Sprintf(":%d", ports[0]), }, { name: "address_change", wsfcEnabled: "true", port: ports[1], wantNoop: false, wantRunning: true, wantAddr: fmt.Sprintf(":%d", ports[1]), }, { name: "agent_disabled_again", wsfcEnabled: "false", port: ports[1], wantNoop: false, wantRunning: false, }, } // Run tests in sequence to verify state changes. for _, tt := range tests { t.Logf("Running test: %s", tt.name) desc, err := metadata.UnmarshalDescriptor(fmt.Sprintf(template, tt.wsfcEnabled, tt.port)) if err != nil { t.Fatalf("UnmarshalDescriptor() failed unexpectedly with error: %v", err) } noop, err := mgr.reset(ctx, desc) if err != nil { t.Fatalf("mgr.reset(ctx, desc) failed: %v", err) } if noop != tt.wantNoop { t.Errorf("mgr.reset() got noop %t, want %t", noop, tt.wantNoop) } if mgr.agent.isRunning() != tt.wantRunning { t.Errorf("Agent running state %t, want %t", mgr.agent.isRunning(), tt.wantRunning) } if tt.wantRunning && mgr.agent.address() != tt.wantAddr { t.Errorf("Agent address %q, want %q", mgr.agent.address(), tt.wantAddr) } } } google-guest-agent-20260819.00/cmd/examples/000077500000000000000000000000001524141036400202635ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/examples/basic_plugin/000077500000000000000000000000001524141036400227225ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/examples/basic_plugin/plugin.go000066400000000000000000000061441524141036400245540ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main represents how sample basic plugin binary looks like within // Guest Agent Plugin framework. Plugin is basically the executable binary // that is dynamically downloaded and launched by the Guest Agent on request. // // Guest Agent will manage deployment and lifecycle including starting, stopping // or upgrading the revision of this binary by communicating over a // well-established gRPC [interface]. // // Additionally, Agent will also monitor Plugin process for CPU/Memory usage // and set limits if provided by the service. // // [interface]: third_party/guest_agent/dev/pkg/proto/plugin_comm.proto package main import ( "context" "flag" "fmt" "net" "os" "time" "github.com/GoogleCloudPlatform/galog" pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" "google.golang.org/grpc" ) var ( // protocol is the protocol to use tcp/uds. protocol string // address is the address to start server listening on. address string // logfile is the path to the log file to capture error logs. logfile string ) func init() { flag.StringVar(&protocol, "protocol", "", "protocol to use uds/tcp") flag.StringVar(&address, "address", "", "address to start server listening on") flag.StringVar(&logfile, "errorlogfile", "", "path to the error log file") } func main() { flag.Parse() if _, err := os.Stat(address); err == nil { if err := os.RemoveAll(address); err != nil { // Unix sockets must be unlinked (listener.Close()) before // being reused again. If file already exist bind can fail. fmt.Fprintf(os.Stderr, "Failed to remove %q: %v\n", address, err) os.Exit(1) } } if logfile != "" { galog.RegisterBackend(context.Background(), galog.NewFileBackend(logfile)) defer galog.Shutdown(time.Second * 5) } listener, err := net.Listen(protocol, address) if err != nil { fmt.Fprintf(os.Stderr, "Failed to start listening on %q using %q: %v\n", address, protocol, err) os.Exit(1) } defer listener.Close() // This is the grpc server in communication with the Guest Agent. server := grpc.NewServer() defer server.GracefulStop() ps := &PluginServer{server: server} // Successfully registering the server and starting to listen on the address // offered mean Guest Agent was successful in installing/launching the plugin // & will manage the lifecycle (start, stop, or revision change) here onwards. pb.RegisterGuestAgentPluginServer(server, ps) if err := server.Serve(listener); err != nil { fmt.Fprintf(os.Stderr, "Exiting, cannot continue serving: %v\n", err) os.Exit(1) } } google-guest-agent-20260819.00/cmd/examples/basic_plugin/service.go000066400000000000000000000055501524141036400247160ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "github.com/GoogleCloudPlatform/galog" "google.golang.org/grpc" pb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" ) // PluginServer implements the plugin RPC server interface. type PluginServer struct { pb.UnimplementedGuestAgentPluginServer server *grpc.Server } // Apply applies the config sent or performs the work defined in the message. // ApplyRequest is opaque to the agent and is expected to be well known contract // between Plugin and the server itself. For e.g. service might want to update // plugin config to enable/disable feature here plugins can react to such requests. func (ps *PluginServer) Apply(ctx context.Context, msg *pb.ApplyRequest) (*pb.ApplyResponse, error) { galog.Infof("Received apply request: %+v", msg) return &pb.ApplyResponse{}, nil } // Start starts the plugin and initiates the plugin functionality. // Until plugin receives Start request plugin is expected to be not functioning // and just listening on the address handed off waiting for the request. func (ps *PluginServer) Start(ctx context.Context, msg *pb.StartRequest) (*pb.StartResponse, error) { galog.Infof("Received start request: %+v", msg) return &pb.StartResponse{}, nil } // Stop is the stop hook and implements any cleanup if required. // Stop maybe called if plugin revision is being changed. // For e.g. if plugins want to stop some task it was performing or remove some // state before exiting it can be done on this request. func (ps *PluginServer) Stop(ctx context.Context, msg *pb.StopRequest) (*pb.StopResponse, error) { galog.Infof("Received stop request: %+v", msg) return &pb.StopResponse{}, nil } // GetStatus is the health check agent would perform to make sure plugin process // is alive. If request fails process is considered dead and relaunched. Plugins // can share any additional information to report it to the service. For e.g. if // plugins detect some non-fatal errors causing it unable to offer some features // it can reported in status which is sent back to the service by agent. func (ps *PluginServer) GetStatus(ctx context.Context, msg *pb.GetStatusRequest) (*pb.Status, error) { galog.Infof("Received get status request: %+v", msg) return &pb.Status{Code: 0, Results: []string{"Plugin is running ok"}}, nil } google-guest-agent-20260819.00/cmd/gce_metadata_script_runner/000077500000000000000000000000001524141036400240205ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/gce_metadata_script_runner/scriptrunner.go000066400000000000000000000434121524141036400271110ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main handles the running of metadata scripts on Google Compute Engine // instances. Its generally triggered on VM startup or shutdown. package main import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "regexp" "runtime" "strings" "time" "cloud.google.com/go/storage" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "google.golang.org/api/option" ) // contextKey is the context key type to use for overriding storage client. type contextKey string const ( // storageURL stores storage API host name. storageURL = "storage.googleapis.com" // bucketRegex is a required regex for bucket name. bucketRegex = "([a-z0-9][-_.a-z0-9]*)" // objectRegex is a required regex for object name. objectRegex = "(.+)" // overrideStorageClient is a context key to override the storage client. overrideStorageClient contextKey = "override_storage_client" // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second // defaultUniverseDomain is the default universe domain. defaultUniverseDomain = "googleapis.com" ) var ( // version is the version of the binary. version = "unknown" // powerShellArgs is the list of arguments to pass when powershell script is // executed. powerShellArgs = []string{"-NoProfile", "-NoLogo", "-ExecutionPolicy", "Unrestricted", "-File"} // Many of the Google Storage URLs are supported below. // It is preferred that customers specify their object using // its gs:/// URL. gsRegex = regexp.MustCompile(fmt.Sprintf(`^gs://%s/%s$`, bucketRegex, objectRegex)) // defaultRetryPolicy is default policy to retry up to 3 times, only wait 1 second between retries. defaultRetryPolicy = retry.Policy{MaxAttempts: 3, BackoffFactor: 1, Jitter: time.Second} ) // supportedStorageURLRegexx returns a list of regexes that match supported // storage URLs for a given universe domain. // http://.storage./ // https://.storage./ // http://storage.cloud.// // https://storage.cloud.// // http://storage.// // https://storage.// // The following are deprecated but also checked: // http://commondatastorage.// // https://commondatastorage.// // // If universeDomain is the default universe domain, the following are also // checked: // http://storage.cloud.google.com// // https://storage.cloud.google.com// func supportedStorageURLRegexx(universeDomain string) []*regexp.Regexp { domainRegex := regexp.QuoteMeta(universeDomain) res := []*regexp.Regexp{ regexp.MustCompile(fmt.Sprintf(`^http[s]?://%s\.storage\.%s/%s$`, bucketRegex, domainRegex, objectRegex)), regexp.MustCompile(fmt.Sprintf(`^http[s]?://storage\.cloud\.%s/%s/%s$`, domainRegex, bucketRegex, objectRegex)), regexp.MustCompile(fmt.Sprintf(`^http[s]?://(?:commondata)?storage\.%s/%s/%s$`, domainRegex, bucketRegex, objectRegex)), } if universeDomain == defaultUniverseDomain { res = append(res, regexp.MustCompile(fmt.Sprintf(`^http[s]?://storage\.cloud\.google\.com/%s/%s$`, bucketRegex, objectRegex))) } return res } // newStorageClient creates and returns a new storage client. func newStorageClient(ctx context.Context, universeDomain string) (*storage.Client, error) { if ctx.Value(overrideStorageClient) != nil { return ctx.Value(overrideStorageClient).(*storage.Client), nil } return storage.NewClient(ctx, option.WithUniverseDomain(universeDomain)) } // downloadGSURL downloads the object from GCS bucket and writes to a file. func downloadGSURL(ctx context.Context, universeDomain string, bucket, object string, file *os.File) error { client, err := newStorageClient(ctx, universeDomain) if err != nil { return fmt.Errorf("unable to create storage client: %w", err) } defer client.Close() r, err := retry.RunWithResponse(ctx, defaultRetryPolicy, func() (*storage.Reader, error) { return client.Bucket(bucket).Object(object).NewReader(ctx) }) if err != nil { return err } defer r.Close() _, err = io.Copy(file, r) return err } // downloadURL downloads the object from a URL and writes to a file. func downloadURL(ctx context.Context, url string, file *os.File) error { galog.Debugf("Downloading script from URL: %s", url) res, err := retry.RunWithResponse(ctx, defaultRetryPolicy, func() (*http.Response, error) { res, err := http.Get(url) if err != nil { return res, err } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %q, bad status: %s", url, res.Status) } return res, nil }) if err != nil { return err } defer res.Body.Close() _, err = io.Copy(file, res.Body) return err } // downloadScript downloads the script to execute. func downloadScript(ctx context.Context, universeDomain, path string, file *os.File) error { bucket, object := parseGCS(universeDomain, path) var gcsErr error if bucket != "" && object != "" { gcsErr = downloadGSURL(ctx, universeDomain, bucket, object, file) if gcsErr == nil { galog.Debugf("Succesfully downloaded using GSURL, bucket: %s, object: %s to file: %s", bucket, object, file.Name()) return nil } gcsErr = fmt.Errorf("downloading object [%s], from GCS bucket [%s]: %w", object, bucket, gcsErr) galog.Warnf("Failed to download from GCS: %v", gcsErr) galog.Infof("Trying unauthenticated download") path = fmt.Sprintf("https://%s/%s/%s", storageURL, bucket, object) } // Fall back to an HTTP GET of the URL. if err := downloadURL(ctx, path, file); err != nil { urlErr := fmt.Errorf("downloading from URL [%s]: %w", path, err) return errors.Join(urlErr, gcsErr) } return nil } // parseGCS parses the path and returns the bucket and object. It tries all 3 // supported regexes to parse the URL. // universeDomain must not be empty. func parseGCS(universeDomain, path string) (string, string) { var allSupportedRgx []*regexp.Regexp allSupportedRgx = append(allSupportedRgx, gsRegex) allSupportedRgx = append(allSupportedRgx, supportedStorageURLRegexx(universeDomain)...) for _, re := range allSupportedRgx { match := re.FindStringSubmatch(path) if len(match) == 3 { return match[1], match[2] } } return "", "" } // getMetadataAttributes does a recursive MDS GET for a given key. func getMetadataAttributes(ctx context.Context, client metadata.MDSClientInterface, key string) (map[string]string, error) { resp, err := client.GetKeyRecursive(ctx, key) if err != nil { return nil, fmt.Errorf("unable to get metadata attributes for key %q: %w", key, err) } var attr map[string]string return attr, json.Unmarshal([]byte(resp), &attr) } // normalizeFilePathForWindows forms the absolute path for Windows scripts. // If either the metadataKey ends in one of these known extensions OR if this is // a url startup script and if the url path ends in one of these extensions, // append the extension to the filePath name so that Windows can recognize it. func normalizeFilePathForWindows(filePath string, metadataKey string, gcsScriptURL *url.URL) string { for _, ext := range []string{"bat", "cmd", "ps1", "exe"} { if strings.HasSuffix(metadataKey, "-"+ext) || (gcsScriptURL != nil && strings.HasSuffix(gcsScriptURL.Path, "."+ext)) { filePath = fmt.Sprintf("%s.%s", filePath, ext) break } } return filePath } // waitForDNS waits for DNS to become available by testing lookup on [storageURL]. // Startup scripts may run before DNS is running on some systems, particularly // once a system is promoted to a domain controller. Try to lookup // storage.googleapis.com host for up to 100s. func waitForDNS(ctx context.Context) error { if ctx.Value(overrideStorageClient) != nil { // Running in test environment skip lookup. return nil } policy := retry.Policy{MaxAttempts: 20, BackoffFactor: 1, Jitter: time.Second * 5} err := retry.Run(ctx, policy, func() error { _, err := net.LookupHost(storageURL) return err }) return err } // writeScriptToFile waits for DNS to become available if downloading from GCS, // and writes to a file. func writeScriptToFile(ctx context.Context, universeDomain string, value string, filePath string, gcsScriptURL *url.URL) error { galog.Debugf("Writing script (%s) to file: %s", value, filePath) if gcsScriptURL != nil { if err := waitForDNS(ctx); err != nil { return fmt.Errorf("error waiting for DNS: %v", err) } file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { return fmt.Errorf("error opening temp file: %v", err) } if err := downloadScript(ctx, universeDomain, value, file); err != nil { if err := file.Close(); err != nil { // Just log and return original error. galog.Warnf("Failed to close temp file: %v", err) } return err } if err := file.Close(); err != nil { return fmt.Errorf("error closing temp file: %w", err) } } else { // Trim leading spaces and newlines. value = strings.TrimLeft(value, " \n\v\f\t\r") if err := os.WriteFile(filePath, []byte(value), 0755); err != nil { return fmt.Errorf("error writing temp file: %w", err) } } return nil } // setupAndRunScript sets up like downloading script locally and executes it. func setupAndRunScript(ctx context.Context, universeDomain, metadataKey, value string) error { galog.Debugf("Setting up and running script %s: (%s)", metadataKey, value) // Make sure that the URL is valid for URL startup scripts. var gcsScriptURL *url.URL if strings.HasSuffix(metadataKey, "-url") { var err error value = strings.TrimSpace(value) gcsScriptURL, err = url.Parse(value) if err != nil { return fmt.Errorf("unable to parse URL (%q): %v", value, err) } } // Make temp directory to write scripts. tmpDir, err := os.MkdirTemp(cfg.Retrieve().MetadataScripts.RunDir, "metadata-scripts") if err != nil { return err } defer os.RemoveAll(tmpDir) tmpFile := filepath.Join(tmpDir, metadataKey) if runtime.GOOS == "windows" { tmpFile = normalizeFilePathForWindows(tmpFile, metadataKey, gcsScriptURL) } if err := writeScriptToFile(ctx, universeDomain, value, tmpFile, gcsScriptURL); err != nil { return fmt.Errorf("unable to write script to file: %v", err) } return runScript(ctx, tmpFile, metadataKey) } // runScript crafts the command and executes the script. func runScript(ctx context.Context, filePath, metadataKey string) error { var name string var args []string if strings.HasSuffix(filePath, ".ps1") { name = "powershell.exe" args = append(args, append(powerShellArgs, filePath)...) } else { if runtime.GOOS == "windows" { name = filePath } else { name = cfg.Retrieve().MetadataScripts.DefaultShell args = append(args, "-c", filePath) } } // These are arbitrary scripts ran by agent which could be long running and // generating substantial output. Use stream output for these scripts to // prevent buffer overflow (which can cause logs to be lost entirely) and // ensure continuous log visibility and immediate feedback for the user. opts := run.Options{OutputType: run.OutputStream, Name: name, Args: args} res, err := run.WithContext(ctx, opts) if err != nil { return fmt.Errorf("run script %q failed with error: %v", metadataKey, err) } streams := res.OutputScanners // Go routines will exit once all output is consumed. Run library guarantees // that all channels are closed after use. go func() { for line := range streams.StdOut { galog.Infof("Metadata key(%q), command(%q): %s", metadataKey, opts.Name, line) } }() go func() { for line := range streams.StdErr { galog.Errorf("Metadata key(%q), command(%q): %s", metadataKey, opts.Name, line) } }() return <-streams.Result } // mdsScriptKeys validates the event type and returns the list of MDS keys to // check for a given event and OS. The keys to check vary based on the event // (startup/shutdown/sysprep) and OS (linux/windows). func mdsScriptKeys(prefix string, os string) ([]string, error) { config := cfg.Retrieve() switch prefix { case "specialize": if !config.MetadataScripts.SysprepSpecialize { return nil, fmt.Errorf("sysprep-specialize scripts disabled in instance config") } prefix = "sysprep-specialize" case "startup": if os == "windows" { prefix = "windows-" + prefix if !config.MetadataScripts.StartupWindows { return nil, fmt.Errorf("windows startup scripts disabled in instance config") } } else { if !config.MetadataScripts.Startup { return nil, fmt.Errorf("startup scripts disabled in instance config") } } case "shutdown": if os == "windows" { prefix = "windows-" + prefix if !config.MetadataScripts.ShutdownWindows { return nil, fmt.Errorf("windows shutdown scripts disabled in instance config") } } else { if !config.MetadataScripts.Shutdown { return nil, fmt.Errorf("shutdown scripts disabled in instance config") } } default: return nil, fmt.Errorf("unsupported event type %q, should be one of [specialize, startup, shutdown]", prefix) } var mdkeys []string var suffixes []string if os == "windows" { suffixes = []string{"ps1", "cmd", "bat", "url"} } else { suffixes = []string{"url"} // The 'bare' startup-script or shutdown-script key, not supported on Windows. mdkeys = append(mdkeys, fmt.Sprintf("%s-script", prefix)) } for _, suffix := range suffixes { mdkeys = append(mdkeys, fmt.Sprintf("%s-script-%s", prefix, suffix)) } return mdkeys, nil } // parseMetadata parses the metadata and returns map of attributes found in MDS // that are in the wanted list. func parseMetadata(md map[string]string, wanted []string) map[string]string { found := make(map[string]string) for _, key := range wanted { val, ok := md[key] if !ok || val == "" { continue } found[key] = val } return found } // readExistingKeys returns the wanted keys that are set in metadata. func readExistingKeys(ctx context.Context, mdsClient metadata.MDSClientInterface, wanted []string) (map[string]string, error) { for _, attrs := range []string{"/instance/attributes", "/project/attributes"} { md, err := getMetadataAttributes(ctx, mdsClient, attrs) if err != nil { return nil, err } if found := parseMetadata(md, wanted); len(found) != 0 { return found, nil } } return nil, nil } // handleEvent is the entrypoint for metadata script runner. This is expected to // be invoked on receiving VM startup/shutdown/specialze(windows sysprep) event. func handleEvent(ctx context.Context, mdsClient metadata.MDSClientInterface, event string) error { galog.Infof("Running metadata script runner (version %s) for %q event", version, event) wantedKeys, err := mdsScriptKeys(event, runtime.GOOS) if err != nil { return fmt.Errorf("unable to read keys for event %q: %w", event, err) } galog.Debugf("Expecting zero or more of following MDS script runner keys: %v", wantedKeys) scripts, err := readExistingKeys(ctx, mdsClient, wantedKeys) if err != nil { return fmt.Errorf("unable to read keys: %w", err) } if len(scripts) == 0 { galog.Infof("No %s scripts to run", event) return nil } universeDomain, err := mdsClient.GetKey(ctx, "universe/universe-domain", nil) // TODO(b/452437458): Remove handle error as error as soon as the MDS key is // rolled out. if err != nil { galog.Debugf("Failed to get universe domain: %v, using default universe domain", err) universeDomain = defaultUniverseDomain } for _, key := range wantedKeys { value, ok := scripts[key] if !ok { continue } galog.Infof("Found %s in metadata", key) if err := setupAndRunScript(ctx, universeDomain, key, value); err != nil { galog.Warnf("Script %q failed with error: %v", key, err) continue } galog.Debugf("Completed %q script execution", key) } galog.Infof("Finished running %s scripts", event) return nil } func cloudLoggingName() string { if runtime.GOOS == "windows" { return "GCEMetadataScripts" } return "google_metadata_script_runner" } func main() { ctx := context.Background() if err := cfg.Load(nil); err != nil { fmt.Fprintln(os.Stderr, "Failed to load config:", err) os.Exit(1) } coreCfg := cfg.Retrieve().Core logOpts := logger.Options{ Ident: "google_metadata_script_runner", CloudIdent: cloudLoggingName(), ProgramVersion: version, LogToCloudLogging: coreCfg.CloudLoggingEnabled, LogFile: coreCfg.LogFile, Level: coreCfg.LogLevel, Verbosity: coreCfg.LogVerbosity, InitCloudLoggingImmediately: true, } if err := logger.Init(ctx, logOpts); err != nil { fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) os.Exit(1) } defer galog.Shutdown(galogShutdownTimeout) if len(os.Args) != 2 { galog.Fatalf("No valid event type (%v) provided, usage: %s ", os.Args, os.Args[0]) } if err := handleEvent(ctx, metadata.New(), os.Args[1]); err != nil { galog.Fatalf("Failed to handle event %q: %v", os.Args[1], err) } } google-guest-agent-20260819.00/cmd/gce_metadata_script_runner/scriptrunner_test.go000066400000000000000000000505421524141036400301520ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "runtime" "strings" "testing" "time" "cloud.google.com/go/storage" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" "google.golang.org/api/option" ) func TestMain(m *testing.M) { if err := cfg.Load(nil); err != nil { os.Exit(1) } os.Exit(m.Run()) } func TestMdsScriptKeys(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } getWantedTests := []struct { event string os string want []string }{ { event: "specialize", os: "windows", want: []string{"sysprep-specialize-script-ps1", "sysprep-specialize-script-cmd", "sysprep-specialize-script-bat", "sysprep-specialize-script-url"}, }, { event: "startup", os: "windows", want: []string{"windows-startup-script-ps1", "windows-startup-script-cmd", "windows-startup-script-bat", "windows-startup-script-url"}, }, { event: "shutdown", os: "windows", want: []string{"windows-shutdown-script-ps1", "windows-shutdown-script-cmd", "windows-shutdown-script-bat", "windows-shutdown-script-url"}, }, { event: "startup", os: "linux", want: []string{"startup-script", "startup-script-url"}, }, { event: "shutdown", os: "linux", want: []string{"shutdown-script", "shutdown-script-url"}, }, } for _, tt := range getWantedTests { got, err := mdsScriptKeys(tt.event, tt.os) if err != nil { t.Errorf("mdsScriptKeys(%s, %s) failed unexpectedly with error: %v", tt.event, tt.os, err) } if got := cmp.Diff(tt.want, got); got != "" { t.Errorf("mdsScriptKeys returned unexpected diff (-want +got):\n%s", got) } } } func TestMdsScriptKeysError(t *testing.T) { // Reset original value. defer cfg.Load(nil) tests := []struct { desc string cfg string arg string os string }{ { desc: "linux_shutdown_disabled", cfg: `[MetadataScripts] shutdown = false`, arg: "shutdown", os: "linux", }, { desc: "linux_startup_disabled", cfg: `[MetadataScripts] startup = false`, arg: "startup", os: "linux", }, { desc: "unknown_event", cfg: `[MetadataScripts] startup = true`, arg: "unknown-event", os: "linux", }, { desc: "windows_shutdown_disabled", cfg: `[MetadataScripts] shutdown-windows = false`, arg: "shutdown", os: "windows", }, { desc: "windows_startup_disabled", cfg: `[MetadataScripts] startup-windows = false`, arg: "startup", os: "windows", }, { desc: "windows_specialize_disabled", cfg: `[MetadataScripts] sysprep-specialize = false`, arg: "specialize", os: "windows", }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { if err := cfg.Load([]byte(test.cfg)); err != nil { t.Fatalf("cfg.Load(%s) failed unexpectedly with error: %v", test.cfg, err) } if _, err := mdsScriptKeys(test.arg, test.os); err == nil { t.Errorf("mdsScriptKeys(%s, %s) succeeded, want error", test.arg, test.os) } }) } } func TestParseMetadata(t *testing.T) { wantedKeys := []string{ "sysprep-specialize-script-cmd", "sysprep-specialize-script-ps1", "sysprep-specialize-script-bat", "sysprep-specialize-script-url", "startup-script", } md := map[string]string{ "sysprep-specialize-script-cmd": "cmd", "startup-script-cmd": "cmd", "shutdown-script-ps1": "ps1", "sysprep-specialize-script-url": "url", "sysprep-specialize-script-ps1": "ps1", "key": "value", "startup-script": "", "sysprep-specialize-script-bat": "bat", } want := map[string]string{ "sysprep-specialize-script-ps1": "ps1", "sysprep-specialize-script-cmd": "cmd", "sysprep-specialize-script-bat": "bat", "sysprep-specialize-script-url": "url", } got := parseMetadata(md, wantedKeys) if diff := cmp.Diff(want, got); diff != "" { t.Errorf("parseMetadata returned unexpected diff (-want +got):\n%s", diff) } } type testRunner struct { seenCommand string seenArgs []string seenMode run.ExecMode throwErr bool } func (r *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { r.seenCommand = opts.Name r.seenArgs = opts.Args r.seenMode = opts.ExecMode if r.throwErr { return nil, fmt.Errorf("test error") } out := make(chan string) err := make(chan string) res := make(chan error, 1) go func() { out <- "test output" err <- "test error" res <- nil close(out) close(err) close(res) }() return &run.Result{OutputScanners: &run.StreamOutput{Result: res, StdOut: out, StdErr: err}}, nil } func TestRunScript(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } psArgs := append(powerShellArgs, "test.ps1") cmd := "testcmd" var cmdName string var cmdArgs []string if runtime.GOOS == "linux" { cmdName = cfg.Retrieve().MetadataScripts.DefaultShell cmdArgs = append(cmdArgs, "-c", cmd) } else { cmdName = cmd } tests := []struct { name string filePath string wantCommand string wantArgs []string wantErr bool }{ { name: "powershell_script", filePath: "test.ps1", wantCommand: "powershell.exe", wantArgs: psArgs, }, { name: "some_command", filePath: cmd, wantCommand: cmdName, wantArgs: cmdArgs, }, { name: "some_command", filePath: cmd, wantCommand: cmdName, wantArgs: cmdArgs, wantErr: true, }, } ctx := context.Background() for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { runner := &testRunner{throwErr: tc.wantErr} run.Client = runner gotErr := runScript(ctx, tc.filePath, "some-key") if tc.wantErr != (gotErr != nil) { t.Errorf("runScript(ctx, %s, some-key) error = [%v], want error = %t", tc.filePath, gotErr, tc.wantErr) } if tc.wantCommand != runner.seenCommand { t.Errorf("runScript(ctx, %s, some-key) executed command = %s, want %s", tc.filePath, runner.seenCommand, tc.wantCommand) } if diff := cmp.Diff(tc.wantArgs, runner.seenArgs); diff != "" { t.Errorf("runScript(ctx, %s, some-key) executed with args diff (-want +got):\n%s", tc.filePath, diff) } }) } } func TestParseGCS(t *testing.T) { tests := []struct { desc string path string bucket string object string universeDomain string }{ { desc: "gs_root", path: "gs://bucket/object", bucket: "bucket", object: "object", }, { desc: "gs_folder_path", path: "gs://bucket/some/object", bucket: "bucket", object: "some/object", }, { desc: "http_bucket_url", path: "http://bucket.storage.googleapis.com/object", bucket: "bucket", object: "object", }, { desc: "https_bucket_root_url", path: "https://bucket.storage.googleapis.com/object", bucket: "bucket", object: "object", }, { desc: "https_bucket_folder_url", path: "https://bucket.storage.googleapis.com/some/object", bucket: "bucket", object: "some/object", }, { desc: "http_storage_url", path: "http://storage.googleapis.com/bucket/object", bucket: "bucket", object: "object", }, { desc: "https_storage_url", path: "https://storage.googleapis.com/bucket/object", bucket: "bucket", object: "object", }, { desc: "http_commondatastorage_url", path: "http://commondatastorage.googleapis.com/bucket/object", bucket: "bucket", object: "object", }, { desc: "https_commondatastorage_url", path: "https://commondatastorage.googleapis.com/bucket/object", bucket: "bucket", object: "object", }, { desc: "https_storage_folder_url", path: "https://storage.googleapis.com/bucket/some/object", bucket: "bucket", object: "some/object", }, { desc: "https_commondatastorage_folder_url", path: "https://commondatastorage.googleapis.com/bucket/some/object", bucket: "bucket", object: "some/object", }, { desc: "some_random_link", path: "https://test.com/bucket/some/object", bucket: "", object: "", }, { desc: "non_empty _universe_domain", path: "https://storage.apis-tpclp.goog/bucket/some/object", bucket: "bucket", object: "some/object", universeDomain: "apis-tpclp.goog", }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { // parseGCS does not support being called with an empty universe domain. if tt.universeDomain == "" { tt.universeDomain = defaultUniverseDomain } bucket, object := parseGCS(tt.universeDomain, tt.path) if bucket != tt.bucket { t.Errorf("parseGCS(%s) = bucket %s, want %s", tt.path, bucket, tt.bucket) } if object != tt.object { t.Errorf("parseGCS(%s) = object %s, want %s", tt.path, object, tt.object) } }) } } type mdsClient struct { throwErr bool toSend string } func (mds *mdsClient) Get(ctx context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("Get() not yet implemented") } func (mds *mdsClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { return "", nil } func (mds *mdsClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { if mds.throwErr { return "", fmt.Errorf("test error") } if mds.toSend != "" { return mds.toSend, nil } return `{"key1":"value1","key2":"value2"}`, nil } func (mds *mdsClient) Watch(ctx context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("Watch() not yet implemented") } func (mds *mdsClient) WriteGuestAttributes(ctx context.Context, key string, value string) error { return fmt.Errorf("WriteGuestattributes() not yet implemented") } func TestGetMetadataAttributes(t *testing.T) { ctx := context.Background() tests := []struct { desc string throwErr bool want map[string]string }{ { desc: "client_error", throwErr: true, }, { desc: "client_success", want: map[string]string{"key1": "value1", "key2": "value2"}, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { got, err := getMetadataAttributes(ctx, &mdsClient{throwErr: tc.throwErr}, "") if tc.throwErr != (err != nil) { t.Errorf("getMetadataAttributes(ctx, client, '') error = [%v], want %t", err, tc.throwErr) } if diff := cmp.Diff(tc.want, got); diff != "" { t.Errorf("getMetadataAttributes(ctx, client, '') returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestNormalizeFilePathForWindows(t *testing.T) { tmpFilePath := "C:/Temp/file" tests := []struct { desc string metadataKey string gcsScriptURLPath string want string }{ { desc: "path_exe_suffix", metadataKey: "windows-startup-script-url", gcsScriptURLPath: "gs://gcs-bucket/binary.exe", want: "C:/Temp/file.exe", }, { desc: "path_no_suffix", metadataKey: "windows-startup-script-url", gcsScriptURLPath: "gs://gcs-bucket/binary", want: "C:/Temp/file", }, { desc: "path_ps1_suffix", metadataKey: "windows-startup-script-ps1", gcsScriptURLPath: "gs://gcs-bucket/binary.ps1", want: "C:/Temp/file.ps1", }, { desc: "path_ps1_key_suffix", metadataKey: "windows-startup-script-ps1", gcsScriptURLPath: "gs://gcs-bucket/binary", want: "C:/Temp/file.ps1", }, { desc: "path_bat_suffix", metadataKey: "windows-startup-script-bat", gcsScriptURLPath: "gs://gcs-bucket/binary.bat", want: "C:/Temp/file.bat", }, { desc: "path_cmd_suffix", metadataKey: "windows-startup-script-cmd", gcsScriptURLPath: "gs://gcs-bucket/binary.cmd", want: "C:/Temp/file.cmd", }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { url := url.URL{Path: tc.gcsScriptURLPath} got := normalizeFilePathForWindows(tmpFilePath, tc.metadataKey, &url) if got != tc.want { t.Errorf("normalizeFilePathForWindows(%s, %s, %s) = %s, want %s", tmpFilePath, tc.metadataKey, url.Path, got, tc.want) } }) } } func TestDownloadScript(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "ok") })) defer server.Close() httpClient := &http.Client{Transport: &http.Transport{}} ctx := context.Background() testStorageClient, err := storage.NewClient(ctx, option.WithHTTPClient(httpClient), option.WithEndpoint(server.URL)) if err != nil { t.Fatalf("Failed to setup test storage client, err: %+v", err) } defer testStorageClient.Close() ctx = context.WithValue(ctx, overrideStorageClient, testStorageClient) tests := []struct { name string path string }{ { name: "gs_path", path: "gs://bucket/object", }, { name: "http_path", path: server.URL, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { file := filepath.Join(t.TempDir(), "out") out, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { t.Fatalf("Failed to setup test file: %v", err) } defer out.Close() if err := downloadScript(ctx, "", tt.path, out); err != nil { t.Fatalf("downloadScript(ctx, %s, %s) failed unexpectedly with error %v", "gs://bucket/object", out.Name(), err) } got, err := os.ReadFile(file) if err != nil { t.Errorf("failed to read output file %q, with error: %v", file, err) } if string(got) != "ok" { t.Errorf("downloadScript(ctx, %s, %s) wrote = [%s], want [%s]", "gs://bucket/object", out.Name(), string(got), "ok") } }) } } func TestDownloadURL(t *testing.T) { ctx := context.Background() ctr := make(map[string]int) // No need to wait longer, override for testing. defaultRetryPolicy.Jitter = time.Millisecond server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // /retry should succeed within 2 retries; /fail should always fail. if (r.URL.Path == "/retry" && ctr["/retry"] != 1) || strings.Contains(r.URL.Path, "fail") { w.WriteHeader(400) } w.Write([]byte(r.URL.Path)) ctr[r.URL.Path] = ctr[r.URL.Path] + 1 })) defer server.Close() tests := []struct { name string key string wantErr bool retries int }{ { name: "succeed_immediately", key: "/immediate_download", wantErr: false, retries: 1, }, { name: "succeed_after_retry", key: "/retry", wantErr: false, retries: 2, }, { name: "fail_retry_exhaust", key: "/fail", wantErr: true, retries: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f, err := os.OpenFile(filepath.Join(t.TempDir(), tt.name), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { t.Fatalf("Failed to setup test file: %v", err) } defer f.Close() url := server.URL + tt.key if err := downloadURL(ctx, url, f); (err != nil) != tt.wantErr { t.Errorf("downloadURL(ctx, %s, %s) error = [%v], wantErr %t", url, f.Name(), err, tt.wantErr) } if !tt.wantErr { gotBytes, err := os.ReadFile(f.Name()) if err != nil { t.Errorf("failed to read output file %q, with error: %v", f.Name(), err) } if string(gotBytes) != tt.key { t.Errorf("downloadURL(ctx, %s, %s) wrote = [%s], want [%s]", url, f.Name(), string(gotBytes), tt.key) } } if ctr[tt.key] != tt.retries { t.Errorf("downloadURL(ctx, %s, %s) retried [%d] times, should have returned after [%d] retries", url, f.Name(), ctr[tt.key], tt.retries) } }) } } func TestDownloadGSURL(t *testing.T) { ctx := context.Background() ctr := make(map[string]int) // No need to wait longer, override for testing. defaultRetryPolicy.Jitter = time.Millisecond server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Println("got request for:", r.URL) // Fake error for invalid object request. if strings.Contains(r.URL.Path, "invalid") { w.WriteHeader(404) } w.Write([]byte(r.URL.Path)) ctr[r.URL.Path] = ctr[r.URL.Path] + 1 })) defer server.Close() httpClient := &http.Client{Transport: &http.Transport{}} tests := []struct { name string bucket string object string wantErr bool retries int }{ { name: "valid_object", bucket: "valid", object: "obj1", wantErr: false, retries: 1, }, { name: "invalid_object", bucket: "invalid", object: "obj1", wantErr: true, retries: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testStorageClient, err := storage.NewClient(ctx, option.WithHTTPClient(httpClient), option.WithEndpoint(server.URL)) if err != nil { t.Fatalf("Failed to setup test storage client, err: %+v", err) } defer testStorageClient.Close() ctx = context.WithValue(ctx, overrideStorageClient, testStorageClient) f, err := os.OpenFile(filepath.Join(t.TempDir(), tt.name), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) if err != nil { t.Fatalf("Failed to setup test file: %v", err) } defer f.Close() if err := downloadGSURL(ctx, "", tt.bucket, tt.object, f); (err != nil) != tt.wantErr { t.Errorf("downloadGSURL(ctx, %s, %s, %s) error = [%+v], wantErr %t", tt.bucket, tt.object, f.Name(), err, tt.wantErr) } want := fmt.Sprintf("/%s/%s", tt.bucket, tt.object) if !tt.wantErr { gotBytes, err := os.ReadFile(f.Name()) if err != nil { t.Errorf("failed to read output file %q, with error: %v", f.Name(), err) } if string(gotBytes) != want { t.Errorf("downloadGSURL(ctx, %s, %s, %s) wrote = [%s], want [%s]", tt.bucket, tt.object, f.Name(), string(gotBytes), want) } } if ctr[want] != tt.retries { t.Errorf("downloadGSURL(ctx, %s, %s, %s) retried [%d] times, should have returned after [%d] retries", tt.bucket, tt.object, f.Name(), ctr[want], tt.retries) } }) } } func TestHandleEvent(t *testing.T) { ctx := context.Background() tmpDir := t.TempDir() config := `[MetadataScripts] startup = true startup-windows = true run_dir = %s` config = fmt.Sprintf(config, tmpDir) if err := cfg.Load([]byte(config)); err != nil { t.Fatalf("Failed to load config: %v", err) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "echo") })) defer server.Close() // linux - wantedKeys: [startup-script startup-script-url] // windows - wantedKeys: [windows-startup-script-ps1 windows-startup-script-cmd windows-startup-script-bat windows-startup-script-url] var toSend, toSendURL string if runtime.GOOS == "windows" { toSend = `{"windows-startup-script-cmd":"echo"}` toSendURL = `{"windows-startup-script-url":"%s"}` toSendURL = fmt.Sprintf(toSendURL, server.URL) } else { toSend = `{"startup-script":"echo"}` toSendURL = `{"startup-script-url":"%s"}` toSendURL = fmt.Sprintf(toSendURL, server.URL) } tests := []struct { name string toSend string }{ { name: "run_cmd", toSend: toSend, }, { name: "run_url", toSend: toSendURL, }, { name: "skip_no_keys", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpClient := &http.Client{Transport: &http.Transport{}} testStorageClient, err := storage.NewClient(ctx, option.WithHTTPClient(httpClient), option.WithEndpoint(server.URL)) if err != nil { t.Fatalf("Failed to setup test storage client, err: %v", err) } ctx = context.WithValue(ctx, overrideStorageClient, testStorageClient) client := &mdsClient{toSend: tt.toSend} if err := handleEvent(ctx, client, "startup"); err != nil { t.Errorf("Run(ctx, %+v, startup, %s) error = [%v], want nil", client, runtime.GOOS, err) } }) } } google-guest-agent-20260819.00/cmd/ggactl/000077500000000000000000000000001524141036400177065ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/000077500000000000000000000000001524141036400215075ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/common.go000066400000000000000000000065661524141036400233430ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package commands provides common helper methods for all commands implemented // by CLI. package commands import ( "context" "encoding/json" "fmt" "io" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/spf13/cobra" ) // TestOverrideKey is a context key to override cleanup behavior in tests. var TestOverrideKey any = "test_override" // sendlistener returns the command listener for send command. func sendlistener(sendCmd *cobra.Command) (command.KnownListeners, error) { switch sendCmd.Parent().Name() { case command.ListenerGuestAgent.String(): return command.ListenerGuestAgent, nil case sendCmd.CommandPath(), command.ListenerCorePlugin.String(): return command.ListenerCorePlugin, nil default: return 0, fmt.Errorf("no known listener for command: %s", sendCmd.CommandPath()) } } // NewSendCmd returns a new cobra command that implements generic send JSON command. func NewSendCmd() *cobra.Command { send := &cobra.Command{ Use: "send ", Short: "Sends a generic JSON", Long: "Sends a generic JSON. It supports both guest agent and core plugin.", Args: cobra.ExactArgs(1), PreRun: func(cmd *cobra.Command, args []string) { // Disable cmd produced stdout and stderr logs in non-test env to avoid // duplication with galog. if cmd.Context().Value("enable_stdlogs") == nil { cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) } }, RunE: SendCmdRunner, } return send } // isValidRequest checks if request is a valid JSON as expected by command monitor. func isValidRequest(req string) bool { r := &command.Request{} if err := json.Unmarshal([]byte(req), r); err != nil { return false } return true } // SendCmdRunner is the callback to send command. func SendCmdRunner(cmd *cobra.Command, args []string) error { req := args[0] if !isValidRequest(req) { return fmt.Errorf(`invalid request: [%s %s], arg must be a valid JSON like {"Command":"echo"}`, cmd.CommandPath(), req) } lis, err := sendlistener(cmd) if err != nil { return fmt.Errorf("generic send command failed: %w", err) } galog.Debugf("Sending request [%s %s]", cmd.CommandPath(), req) resp := command.SendCommand(cmd.Context(), []byte(req), lis) galog.Infof("Command result: %s", string(resp)) // This is used only for unit testing to capture test outputs. Command on // initialization sets to discard any stdout/stderr messages to avoid any // duplication with `galog`. cmd.Println(string(resp)) return nil } // FetchInstanceID returns the instance ID of the instance. func FetchInstanceID(ctx context.Context) (string, error) { if ctx.Value(TestOverrideKey) != nil { return "test", nil } return metadata.New().GetKey(ctx, "/instance/id", nil) } google-guest-agent-20260819.00/cmd/ggactl/commands/common_test.go000066400000000000000000000033331524141036400243670ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package commands import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/testhelper" "github.com/spf13/cobra" ) func TestInvalidParent(t *testing.T) { cmd := &cobra.Command{ Use: "other", } sendCmd := NewSendCmd() cmd.AddCommand(sendCmd) _, err := testhelper.ExecuteCommand(context.Background(), cmd, []string{"send", `{"Command":"Echo"}`}) if err == nil { t.Errorf("send command succeeded for invalid parent (other), want error") } } func TestSendError(t *testing.T) { sendCmd := NewSendCmd() ctx := context.Background() if sendCmd.RunE == nil { t.Errorf("NewSend() did not set RunE callback") } tests := []struct { name string args []string wantErr bool }{ { name: "no_args", }, { name: "extra_args", args: []string{"arg1", "arg2", "arg3"}, }, { name: "invalid_json", args: []string{"invalid_json"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := testhelper.ExecuteCommand(ctx, sendCmd, test.args) if err == nil { t.Errorf("send command succeeded for %s, want error", test.name) } }) } } google-guest-agent-20260819.00/cmd/ggactl/commands/coreplugin/000077500000000000000000000000001524141036400236565ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/coreplugin/coreplugin.go000066400000000000000000000067531524141036400263670ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package coreplugin implements ggactl commands meant for core plugin. package coreplugin import ( "context" "fmt" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/ps" "github.com/spf13/cobra" ) // New returns a new cobra command for core plugin. func New() *cobra.Command { corePlugin := &cobra.Command{ Use: command.ListenerCorePlugin.String(), Short: "Command Core Plugin", Long: "Command Core Plugin. It is for interacting with guest-agent's core plugin.", RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("no subcommand specified for core plugin") }, } corePlugin.AddCommand(newRestartCmd(), newStopCmd()) return corePlugin } // NewStopCmd returns a new cobra command that implements stop command // for core plugin. func newStopCmd() *cobra.Command { stop := &cobra.Command{ Use: "stop", Short: "Stop core plugin", Long: "Stops the guest agent core plugin.", Args: cobra.NoArgs, RunE: stopCorePlugin, } return stop } func stopCorePlugin(cmd *cobra.Command, args []string) error { ctx := cmd.Context() pluginManager, err := newPluginManager(ctx) if err != nil { return fmt.Errorf("unable to restart core plugin, initialize plugin manager failed with error: %w", err) } return pluginManager.StopPlugin(ctx, manager.CorePluginName) } // NewRestartCmd returns a new cobra command that implements restart command // for core plugin. func newRestartCmd() *cobra.Command { restart := &cobra.Command{ Use: "restart", Short: "Restart core plugin", Long: "Restarts the guest agent core plugin.", Args: cobra.NoArgs, RunE: restartCorePlugin, } return restart } func restartCorePlugin(cmd *cobra.Command, args []string) error { pluginManager, err := newPluginManager(cmd.Context()) if err != nil { return fmt.Errorf("unable to restart core plugin, initialize plugin manager failed with error: %w", err) } pm, err := pluginManager.Fetch(manager.CorePluginName) if err != nil { return fmt.Errorf("unable to restart core plugin, verify if it is running") } if err := ps.KillProcess(pm.RuntimeInfo.Pid, ps.KillModeNoWait); err != nil { return fmt.Errorf("unable to restart core plugin process: %w", err) } cmd.Println("Restarting core plugin...") return nil } func newPluginManager(ctx context.Context) (*manager.PluginManager, error) { id, err := commands.FetchInstanceID(ctx) if err != nil { return nil, fmt.Errorf("unable to restart core plugin, fetch instance ID failed with error: %w", err) } pluginManager, err := manager.InitAdHocPluginManager(ctx, id) if err != nil { return nil, fmt.Errorf("unable to restart core plugin, initialize plugin manager failed with error: %w", err) } return pluginManager, nil } google-guest-agent-20260819.00/cmd/ggactl/commands/coreplugin/coreplugin_test.go000066400000000000000000000043141524141036400274150ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package coreplugin import ( "context" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/testhelper" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" ) func TestCorePluginCommands(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } ctx := context.WithValue(context.Background(), commands.TestOverrideKey, true) cmd := New() cmd.SetContext(ctx) tests := []struct { name string args []string wantErr string shouldFail bool }{ { name: "no_subcommand_error", wantErr: "no subcommand", shouldFail: true, }, { name: "invalid_args", args: []string{"restart", "invalid_arg"}, wantErr: "unknown command", shouldFail: true, }, { name: "no_plugin_found", args: []string{"restart"}, wantErr: "unable to restart core plugin", shouldFail: true, }, { name: "stop_success", args: []string{"stop"}, shouldFail: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { out, err := testhelper.ExecuteCommand(ctx, cmd, test.args) if (err != nil) != test.shouldFail { t.Errorf("ExecuteCommand(%s, %v) = %v, want error: %t", cmd.Name(), test.args, err, test.shouldFail) } if test.wantErr == "" { return } if !strings.Contains(out, test.wantErr) { t.Errorf("ExecuteCommand(%s, %v) = %q, want error containing %q", cmd.Name(), test.args, out, test.wantErr) } }) } } google-guest-agent-20260819.00/cmd/ggactl/commands/guestagent/000077500000000000000000000000001524141036400236555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/guestagent/guestagent.go000066400000000000000000000051411524141036400263530ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package guestagent implements ggactl commands meant for guest agent. package guestagent import ( "encoding/json" "fmt" "io" "strings" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/spf13/cobra" ) // New returns a new guest agent command. func New() *cobra.Command { agent := &cobra.Command{ Use: command.ListenerGuestAgent.String(), Short: "Command Guest Agent", Long: "Command Guest Agent. It is for interacting with guest-agent.", RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("no subcommand specified for guest agent") }, } agent.AddCommand(commands.NewSendCmd(), newVMEventCmd()) return agent } // newVMEventCmd creates a new VM event command. func newVMEventCmd() *cobra.Command { vmCmd := &cobra.Command{ Use: "vmevent ", Short: "Trigger VM event workflows", Long: fmt.Sprintf("Trigger VM event workflows. It requires an argument from [%s] indicating the event type.", strings.Join(manager.SupportedEvents, "|")), Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), ValidArgs: manager.SupportedEvents, PreRun: func(cmd *cobra.Command, args []string) { // Disable cmd produced stdout and stderr logs in non-test env to avoid // duplication with galog. if cmd.Context().Value("enable_stdlogs") == nil { cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) } }, RunE: vmeventCmdRunner, } return vmCmd } // vmeventCmdRunner is the callback to vmevent command. func vmeventCmdRunner(cmd *cobra.Command, args []string) error { eventType := args[0] req := &manager.Request{Request: command.Request{Command: manager.VMEventCmd}, Event: eventType} reqBytes, err := json.Marshal(req) if err != nil { return fmt.Errorf("failed to generate VM event request: %w", err) } return commands.SendCmdRunner(cmd, []string{string(reqBytes)}) } google-guest-agent-20260819.00/cmd/ggactl/commands/guestagent/guestagent_test.go000066400000000000000000000122161524141036400274130ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package guestagent import ( "context" "encoding/json" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/testhelper" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/google/go-cmp/cmp" ) func TestGuestAgentVMEventCommand(t *testing.T) { ctx := context.Background() resp := command.Response{Status: 200, StatusMessage: "Success"} respBytes, err := json.Marshal(resp) if err != nil { t.Fatalf("json.Marshal(%+v) failed, %v", resp, err) } handler := &testhelper.CommandHandler{Cmd: manager.VMEventCmd, SendResp: respBytes} testhelper.SetupCommandMonitor(ctx, t, command.ListenerGuestAgent, handler) cmd := New() cmd.SetContext(ctx) if cmd.Name() != command.ListenerGuestAgent.String() { t.Errorf("newRootCommand.Name = %s, want %s", cmd.Name(), command.ListenerGuestAgent.String()) } test := []struct { name string event string command string args []string wantErr bool }{ { name: "vmevent_startup", event: "startup", command: manager.VMEventCmd, args: []string{"vmevent", "startup"}, }, { name: "vmevent_shutdown", event: "shutdown", command: manager.VMEventCmd, args: []string{"vmevent", "shutdown"}, }, { name: "vmevent_specialize", event: "specialize", command: manager.VMEventCmd, args: []string{"vmevent", "specialize"}, }, { name: "unknown_command", event: "specialize", command: "unknown", args: []string{"unknown", "specialize"}, wantErr: true, }, { name: "unknown_argument", event: "unknown", command: manager.VMEventCmd, args: []string{"vmevent", "unknown"}, wantErr: true, }, { name: "no_argument", event: "shutdown", command: manager.VMEventCmd, args: []string{"vmevent"}, wantErr: true, }, { name: "extra_arguments", event: "shutdown", command: manager.VMEventCmd, args: []string{"vmevent", "shutdown", "extra"}, wantErr: true, }, } for _, test := range test { t.Run(test.name, func(t *testing.T) { wantReq := &manager.Request{Request: command.Request{Command: test.command}, Event: test.event} got, err := testhelper.ExecuteCommand(ctx, cmd, test.args) if test.wantErr != (err != nil) { t.Fatalf("testhelper.ExecuteCommand(ctx, %s, %v) error: [%v], want error: [%t]", cmd.Name(), test.args, err, test.wantErr) } if test.wantErr { return } gotReq := &manager.Request{} if err := json.Unmarshal([]byte(handler.SeenReq), gotReq); err != nil { t.Fatalf("json.Unmarshal(%s) failed, %v", handler.SeenReq, err) } if diff := cmp.Diff(wantReq, gotReq); diff != "" { t.Errorf("guestagent vmevent startup command returned unexpected request diff (-want +got):\n%s", diff) } got = strings.TrimSpace(got) if got != string(respBytes) { t.Errorf("guestagent vmevent startup command: %s, want: %s", got, respBytes) } }) } } func TestGuestAgentSendCommand(t *testing.T) { ctx := context.Background() resp := command.Response{Status: 200, StatusMessage: "Success"} respBytes, err := json.Marshal(resp) if err != nil { t.Fatalf("json.Marshal(%+v) failed, %v", resp, err) } handler := &testhelper.CommandHandler{Cmd: "echo", SendResp: respBytes} testhelper.SetupCommandMonitor(ctx, t, command.ListenerGuestAgent, handler) cmd := New() cmd.SetContext(ctx) req := `{"Command":"echo", "Data":"test"}` tests := []struct { desc string args []string wantReq string wantResp string wantErr bool }{ { desc: "no_subcommand", wantErr: true, }, { desc: "valid_send_subcommand", args: []string{"send", req}, wantReq: req, wantResp: string(respBytes), }, { desc: "no_subcommand_args", args: []string{"send"}, wantErr: true, }, { desc: "more_than_1_subcommand_args", args: []string{"send", req, req}, wantErr: true, }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { out, err := testhelper.ExecuteCommand(ctx, cmd, test.args) if test.wantErr != (err != nil) { t.Errorf("testhelper.ExecuteCommand(ctx, %s, %v) = error %v, want error: %t", cmd.Name(), test.args, err, test.wantErr) } if test.wantErr { return } if handler.SeenReq != test.wantReq { t.Errorf("handler.SeenReq = %s, want = %s", handler.SeenReq, test.wantReq) } if strings.TrimSpace(out) != test.wantResp { t.Errorf("handler.SentResponse = %s, want = %s", out, test.wantResp) } }) } } google-guest-agent-20260819.00/cmd/ggactl/commands/plugincleanup/000077500000000000000000000000001524141036400243555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/plugincleanup/plugincleanup.go000066400000000000000000000035351524141036400275600ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package plugincleanup provides commands to remove on demand plugins. package plugincleanup import ( "context" "fmt" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/spf13/cobra" ) // New returns a new plugin cleanup command. func New() *cobra.Command { cmd := &cobra.Command{ Use: "dynamic-cleanup", Short: "Remove all dynamic plugins", Long: "Remove all dynamic plugins. It is for stopping and removing all active plugins on the host.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return removeAllPlugins(cmd.Context()) }, } return cmd } func removeAllPlugins(ctx context.Context) error { instanceID, err := commands.FetchInstanceID(ctx) if err != nil { return fmt.Errorf("failed to fetch instance ID: %w", err) } pm, err := manager.InitAdHocPluginManager(ctx, instanceID) if err != nil { return fmt.Errorf("failed to initialize plugin manager: %w", err) } if err := pm.RemoveAllDynamicPlugins(ctx); err != nil { return fmt.Errorf("unable to remove all dynamic plugins: %w", err) } galog.Infof("Successfully removed all dynamic plugins") return nil } google-guest-agent-20260819.00/cmd/ggactl/commands/routes/000077500000000000000000000000001524141036400230305ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/routes/routes.go000066400000000000000000000047061524141036400247070ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package routes implements ggactl commands for route setup. package routes import ( "context" "fmt" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/address" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/nic" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/route" "github.com/GoogleCloudPlatform/google-guest-agent/internal/network/service" "github.com/spf13/cobra" ) var ( routesSetupCmd = &cobra.Command{ Use: "setup", Short: "Setup routes", Long: "Setup routes", Example: "ggactl routes setup", RunE: setupRoutes, } ) // New returns a new cobra command for core plugin. func New() *cobra.Command { routes := &cobra.Command{ Use: "routes setup", Short: "Command Routes", Long: "Command Routes. It is used for setting up routes via the guest agent.", Example: "ggactl routes setup", RunE: func(cmd *cobra.Command, args []string) error { return fmt.Errorf("no subcommand specified for core plugin") }, } routes.AddCommand(routesSetupCmd) return routes } // setupRoutes sets up the routes. func setupRoutes(cmd *cobra.Command, args []string) error { if len(args) > 0 { return fmt.Errorf("no arguments expected for setup command") } ctx := context.Background() mds, err := metadata.New().Get(ctx) if err != nil { return fmt.Errorf("failed to get metadata: %w", err) } nicConfigs, err := nic.NewConfigs(mds, cfg.Retrieve(), address.NewIPAddressMap(nil, nil)) if err != nil { return fmt.Errorf("failed to get nic configs: %w", err) } opts := service.NewOptions(nil, nicConfigs) // Add the routes, if any. if err := route.Setup(ctx, opts); err != nil { return fmt.Errorf("failed to setup routes: %v", err) } return nil } google-guest-agent-20260819.00/cmd/ggactl/commands/testhelper/000077500000000000000000000000001524141036400236665ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/ggactl/commands/testhelper/testhelper.go000066400000000000000000000056511524141036400264030ustar00rootroot00000000000000// Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package testhelper contains helper functions for tests and is accessible only // within tests. package testhelper import ( "bytes" "context" "io" "path/filepath" "runtime" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/spf13/cobra" ) // ctxKey is a context key to override stdout/stderr log behavior in tests. var ctxKey any = "enable_stdlogs" func socketPath(t *testing.T) string { t.Helper() if runtime.GOOS == "windows" { return filepath.Join(`\\.\pipe`, t.TempDir()) } return t.TempDir() } // CommandHandler is a test command handler. type CommandHandler struct { // Cmd is the command name handled by this handler. Cmd string // SeenReq is the request that was sent to this handler. SeenReq string // SendResp is the stubbed response returned by this handler. SendResp []byte } func (h *CommandHandler) handle(_ context.Context, req []byte) ([]byte, error) { h.SeenReq = string(req) return h.SendResp, nil } // SetupCommandMonitor sets up command monitor for unit tests. func SetupCommandMonitor(ctx context.Context, t *testing.T, lis command.KnownListeners, h *CommandHandler) { t.Helper() ctx, cancel := context.WithCancel(ctx) if err := cfg.Load(nil); err != nil { t.Fatalf("Failed to load config: %v", err) } cfg.Retrieve().Unstable = &cfg.Unstable{ CommandMonitorEnabled: true, CommandPipePath: socketPath(t), CommandRequestTimeout: "2s", } if err := command.Setup(ctx, lis); err != nil { t.Fatalf("Failed to setup command monitor: %v", err) } if err := command.CurrentMonitor().RegisterHandler(h.Cmd, h.handle); err != nil { t.Fatalf("Failed to register handler: %v", err) } t.Cleanup(func() { command.CurrentMonitor().UnregisterHandler(h.Cmd) command.Close(ctx) cancel() }) } func captureOutput(ctx context.Context, cmd *cobra.Command, out io.Writer) { cmd.SetOut(out) cmd.SetErr(out) cmd.SetContext(ctx) for _, subCmd := range cmd.Commands() { captureOutput(ctx, subCmd, out) } } // ExecuteCommand executes the given command and returns its output. func ExecuteCommand(ctx context.Context, cmd *cobra.Command, args []string) (string, error) { out := new(bytes.Buffer) ctx = context.WithValue(ctx, ctxKey, "true") captureOutput(ctx, cmd, out) cmd.SetArgs(args) err := cmd.Execute() return out.String(), err } google-guest-agent-20260819.00/cmd/ggactl/ggactl.go000066400000000000000000000047421524141036400215050ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the implementation of CLI for communicating with Guest Agent // over command monitor. package main import ( "context" "fmt" "os" "path/filepath" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/coreplugin" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/plugincleanup" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/routes" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/spf13/cobra" ) const ( // galogShutdownTimeout is the period of time we should wait for galog to // shutdown. galogShutdownTimeout = time.Second ) // newRootCommand generates new root command with [guestagent] and [coreplugin] // subcommands. func newRootCommand() *cobra.Command { root := &cobra.Command{ Use: "ggactl_plugin", Short: "Guest Agent CLI for plugin cleanup.", Long: "Guest Agent CLI for removing all dynamic plugins.", } root.AddCommand(coreplugin.New()) root.AddCommand(plugincleanup.New()) root.AddCommand(routes.New()) return root } func main() { ctx := context.Background() if err := cfg.Load(nil); err != nil { fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) os.Exit(1) } logOpts := logger.Options{ Ident: filepath.Base(os.Args[0]), LogToStderr: true, LogToCloudLogging: cfg.Retrieve().Core.CloudLoggingEnabled, Level: cfg.Retrieve().Core.LogLevel, LogFile: cfg.Retrieve().Core.LogFile, } if err := logger.Init(ctx, logOpts); err != nil { fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) os.Exit(1) } defer galog.Shutdown(galogShutdownTimeout) rootCmd := newRootCommand() if err := rootCmd.ExecuteContext(ctx); err != nil { galog.Fatalf("Failed to execute: %v", err) } } google-guest-agent-20260819.00/cmd/ggactl/ggactl_test.go000066400000000000000000000040551524141036400225410ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "strings" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/ggactl/commands/testhelper" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" ) func TestNewRootCommand(t *testing.T) { ctx := context.WithValue(context.Background(), commands.TestOverrideKey, true) cmd := newRootCommand() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly: %v", err) } if cmd.Name() != "ggactl_plugin" { t.Errorf("newRootCommand.Name = %s, want ggactl_plugin", cmd.Name()) } if len(cmd.Commands()) != 3 { t.Errorf("newRootCommand.Commands() = %d, want 2", len(cmd.Commands())) } tests := []struct { name string args []string lis command.KnownListeners handler *testhelper.CommandHandler want string }{ { name: "plugin_cleanup_all", args: []string{"dynamic-cleanup"}, want: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := testhelper.ExecuteCommand(ctx, cmd, test.args) if err != nil { t.Fatalf("testhelper.ExecuteCommand(%s, %v) failed unexpectedly: %v", cmd.Name(), test.args, err) } got = strings.TrimSpace(got) if got != test.want { t.Errorf("testhelper.ExecuteCommand(%s, %v) = %q, want %q", cmd.Name(), test.args, got, test.want) } }) } } google-guest-agent-20260819.00/cmd/google_authorized_keys/000077500000000000000000000000001524141036400232125ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys/google_authorized_keys.go000066400000000000000000000062661524141036400303200ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the google_authorized_keys tool. package main import ( "context" "flag" "fmt" "os" "path" "runtime" "strings" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) var ( // logOpts holds the logger options. logOpts = logger.Options{Ident: path.Base(os.Args[0]), LogToStderr: true, Level: 3} // version is the version of the binary. version = "unknown" // galogShutdownTimeout is the max time galog will take to shutdown. galogShutdownTimeout = 10 * time.Millisecond // versionFlag is the flag that forces the program to print the version // and exit. versionFlag = false ) func setupFlags() { flag.BoolVar(&versionFlag, "version", versionFlag, "prints this program version and exit") flag.Parse() } func main() { var ( username string err error ) setupFlags() // If the user has passed -version flag just print the version and exit. if versionFlag { fmt.Println(logOpts.Ident, "version:", version) os.Exit(0) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err = logger.Init(ctx, logOpts); err != nil { fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v", err) os.Exit(1) } defer galog.Shutdown(galogShutdownTimeout) // Get the username from the first parameter to the program. if username, err = usernameCliArg(os.Args); err != nil { galog.Fatal(err.Error()) } mdsClient := metadata.New() descriptor, err := mdsClient.Get(ctx) if err != nil { galog.Fatalf("Failed to get descriptor: %v", err) } if runtime.GOOS == "windows" && !descriptor.WindowsSSHEnabled() { galog.Fatalf("Windows SSH not enabled with 'enable-windows-ssh' metadata key.") } keys, err := descriptor.UserSSHKeys(username) if err != nil { galog.Fatalf("Failed to get user SSH keys: %v", err) } fmt.Print(strings.Join(keys, "\n")) } // Following the openssh-server default configuration the username is provided // as argument to the program - make sure we got one. The last argument without // a dash prefix is returned. func usernameCliArg(osArgs []string) (string, error) { if len(osArgs) == 0 { return "", fmt.Errorf("Malformed os.Args, expected at least prog name in it") } if len(osArgs) == 1 { return "", fmt.Errorf("Username must be specified") } var res string // Select the latest argument without a dash prefix. for _, arg := range osArgs[1:] { if !strings.HasPrefix(arg, "-") { res = arg } } if res != "" { return res, nil } return "", fmt.Errorf("Username must be specified") } google-guest-agent-20260819.00/cmd/google_authorized_keys/google_authorized_keys_test.go000066400000000000000000000045301524141036400313470ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "testing" ) func TestGetUserFail(t *testing.T) { tests := []struct { name string osArgs []string }{ { name: "invalid-os-args", osArgs: []string{}, }, { name: "no-username", osArgs: []string{"test_program"}, }, { name: "no-username-with-flag", osArgs: []string{"test_program", "-random-flag"}, }, { name: "no-username-with-multiple-flags", osArgs: []string{"test_program", "-random-flagA", "-random-flagB"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := usernameCliArg(tc.osArgs) if err == nil { t.Errorf("usernameCliArg() succeeded, want error") } }) } } func TestGetUserSuccess(t *testing.T) { tests := []struct { name string osArgs []string want string }{ { name: "username-provided", osArgs: []string{"test_program", "username"}, want: "username", }, { name: "multiple-usernames-provided", osArgs: []string{"test_program", "usernameA", "usernameB"}, want: "usernameB", }, { name: "username-mixed-flags", osArgs: []string{"test_program", "-random-flag", "usernameA"}, want: "usernameA", }, { name: "username-mixed-flags-username-first", osArgs: []string{"test_program", "usernameA", "-random-flag"}, want: "usernameA", }, { name: "multiple-usernames-mixed-flags", osArgs: []string{"test_program", "-random-flag", "usernameA", "usernameB"}, want: "usernameB", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { username, err := usernameCliArg(tc.osArgs) if err != nil { t.Fatalf("usernameCliArg() failed: %v", err) } if username != tc.want { t.Errorf("usernameCliArg() = %v, want %v", username, tc.want) } }) } } google-guest-agent-20260819.00/cmd/google_authorized_keys_compat/000077500000000000000000000000001524141036400245555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys_compat/google_authorized_keys_compat.go000066400000000000000000000044631524141036400332230ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the entry point for the google authorized keys compat. It is // responsible for enabling either the new authorized keys system or that in the // legacy guest agent. package main import ( "context" "fmt" "os" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) const ( // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second ) var ( // version is the version of the binary. version = "unknown" ) func setupLogger(ctx context.Context) error { conf := cfg.Retrieve() logOpts := logger.Options{ Ident: "google_authorized_keys_compat", CloudIdent: "GCEAuthorizedKeysCompat", ProgramVersion: version, Level: conf.Core.LogLevel, Verbosity: conf.Core.LogVerbosity, LogFile: conf.Core.LogFile, } if err := logger.Init(ctx, logOpts); err != nil { return fmt.Errorf("failed to initialize logger: %w", err) } return nil } func main() { ctx := context.Background() if err := cfg.Load(nil); err != nil { fmt.Fprintln(os.Stderr, "Failed to load config:", err) os.Exit(1) } if err := setupLogger(ctx); err != nil { fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) os.Exit(1) } if len(os.Args) != 2 { galog.Fatalf("No username (%s) provided, usage: %s ", os.Args, os.Args[0]) } if err := launchAuthorizedKeys(ctx, metadata.New(), os.Args[1]); err != nil { galog.Fatalf("Failed to launch authorized keys: %v", err) } galog.Infof("Successfully launched authorized keys") } google_authorized_keys_compat_linux.go000066400000000000000000000021341524141036400343540ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys_compat// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux // Package main is the entry point for the google authorized keys compat. It is // responsible for enabling either the new authorized keys system or that in the // legacy guest agent. package main import ( "context" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) func launchAuthorizedKeys(ctx context.Context, mdsClient metadata.MDSClientInterface, username string) error { galog.Infof("Not implemented for Linux") return nil } google_authorized_keys_compat_linux_test.go000066400000000000000000000013431524141036400354140ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys_compat// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package main import ( "testing" ) func TestLaunchAuthorizedKeys(t *testing.T) { t.Skipf("Not implemented for Linux") } google_authorized_keys_compat_windows.go000066400000000000000000000044111524141036400347070ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys_compat// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows // Package main is the entry point for the google authorized keys compat. It is // responsible for enabling either the new authorized keys system or that in the // legacy guest agent. package main import ( "context" "fmt" "os" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" ) const ( // authorizedKeysNew is the path to the new authorized keys script. // This is the binary that will be used if the core plugin is enabled. authorizedKeysNew = "C:\\Program Files\\Google\\Compute Engine\\agent\\GCEAuthorizedKeysNew.exe" // authorizedKeysLegacy is the path to the legacy authorized keys script. // This is the binary that will be used if the core plugin is disabled. authorizedKeysLegacy = "C:\\Program Files\\Google\\Compute Engine\\agent\\GCEAuthorizedKeys.exe" ) func launchAuthorizedKeys(ctx context.Context, mdsClient metadata.MDSClientInterface, username string) error { var enabled bool opts := run.Options{ Name: authorizedKeysNew, OutputType: run.OutputCombined, Args: []string{username}, } mds, err := mdsClient.Get(ctx) if err != nil { galog.Warnf("Failed to fetch MDS descriptor: [%v], falling back to legacy authorized keys", err) } else { if enabled = mds.HasCorePluginEnabled(); !enabled { opts.Name = authorizedKeysLegacy } } galog.Infof("Enable core plugin set to: [%t], launching authorized keys from %q", enabled, opts.Name) res, err := run.WithContext(ctx, opts) if err != nil { return fmt.Errorf("failed to run authorized keys: %v", err) } fmt.Fprint(os.Stdout, res.Output) return nil } google_authorized_keys_compat_windows_test.go000066400000000000000000000111201524141036400357410ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_authorized_keys_compat// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package main import ( "context" "fmt" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" ) // MDSClient implements fake metadata server. type MDSClient struct { instanceEnable bool throwErr bool } const instanceMdsTemplate = ` { "instance": { "attributes": { "enable-guest-agent-core-plugin": "%t" } } } ` // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s *MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", fmt.Errorf("not yet implemented") } // GetKey implements fake GetKey MDS method. func (s *MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { return "", fmt.Errorf("not yet implemented") } // Get method implements fake Get on MDS. func (s *MDSClient) Get(context.Context) (*metadata.Descriptor, error) { if s.throwErr { return nil, fmt.Errorf("test error") } jsonData := fmt.Sprintf(instanceMdsTemplate, s.instanceEnable) return metadata.UnmarshalDescriptor(jsonData) } // Watch method implements fake watcher on MDS. func (s *MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s *MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } type testRunner struct { returnErr bool seenCommand string seenOutputType run.OutputType seenArgs []string } func (t *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { t.seenCommand = opts.Name t.seenArgs = opts.Args t.seenOutputType = opts.OutputType if t.returnErr { return nil, fmt.Errorf("test error") } return &run.Result{Output: "test output"}, nil } func setupTestRunner(t *testing.T, runner *testRunner) { t.Helper() oldClient := run.Client run.Client = runner t.Cleanup(func() { run.Client = oldClient }) } // TestLaunchAuthorizedKeys tests the launchAuthorizedKeys function. func TestLaunchAuthorizedKeys(t *testing.T) { ctx := context.Background() tests := []struct { name string runner *testRunner mdsClient *MDSClient wantCommand string wantErr bool }{ { name: "core_plugin_enabled", runner: &testRunner{}, mdsClient: &MDSClient{instanceEnable: true}, wantCommand: authorizedKeysNew, wantErr: false, }, { name: "core_plugin_disabled", runner: &testRunner{}, mdsClient: &MDSClient{instanceEnable: false}, wantCommand: authorizedKeysLegacy, wantErr: false, }, { name: "mds_error", runner: &testRunner{}, wantCommand: authorizedKeysNew, mdsClient: &MDSClient{throwErr: true}, wantErr: false, }, { name: "runner_error", runner: &testRunner{returnErr: true}, wantCommand: authorizedKeysNew, mdsClient: &MDSClient{instanceEnable: true}, wantErr: true, }, } wantArgs := []string{"test-user"} for _, test := range tests { t.Run(test.name, func(t *testing.T) { setupTestRunner(t, test.runner) err := launchAuthorizedKeys(ctx, test.mdsClient, "test-user") if (err == nil) == test.wantErr { t.Errorf("launchScriptRunner(ctx, %+v) error = %v, want %v", test.mdsClient, err, test.wantErr) } if test.runner.seenOutputType != run.OutputCombined { t.Errorf("launchScriptRunner(ctx, %+v) executed output type = %v, want %v", test.mdsClient, test.runner.seenOutputType, run.OutputCombined) } if test.runner.seenCommand != test.wantCommand { t.Errorf("launchScriptRunner(ctx, %+v) executed command = %q, want %q", test.mdsClient, test.runner.seenCommand, test.wantCommand) } if diff := cmp.Diff(test.runner.seenArgs, wantArgs); diff != "" { t.Errorf("launchScriptRunner(ctx, %+v) executed args diff (-want +got):\n%s", test.mdsClient, diff) } }) } } google-guest-agent-20260819.00/cmd/google_guest_agent/000077500000000000000000000000001524141036400223065ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_guest_agent/google_guest_agent.go000066400000000000000000000165131524141036400265040ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the google_guest_agent binary. package main import ( "context" "flag" "fmt" "os" "runtime" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/google_guest_agent/setup" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/config" "github.com/GoogleCloudPlatform/google-guest-agent/internal/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) var ( // logOpts holds the logger options. It's mapped to command line flags. logOpts = logger.Options{ Ident: logger.ManagerLocalLoggerIdent, Prefix: logger.ManagerLogPrefix, CloudIdent: logger.ManagerCloudLoggingLogID, } // version is the version of the binary. version = "unknown" // forceOnDemandPlugins is the flag to force on-demand plugins, it takes // precedence over the config. forceOnDemandPlugins = false // corePluginPath is the path to core plugin binary. corePluginPath = "" // skipCorePlugin determines if core plugin initialization should be skipped. // Core plugin is supported and must be enabled by default. skipCorePlugin = false ) const ( // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second // defaultLinuxCorePath is the default path where core plugin is installed on Linux. defaultLinuxCorePath = "/usr/lib/google/guest_agent/GuestAgentCorePlugin/core_plugin" // defaultWindowsCorePath is the default path where core plugin is installed on Windows. defaultWindowsCorePath = `C:\Program Files\Google\Compute Engine\agent\GuestAgentCorePlugin\CorePlugin.exe` ) func setupFlags() { // Log flags. flag.StringVar(&logOpts.LogFile, "logfile", cfg.Retrieve().Core.LogFile, "path to the log file") flag.BoolVar(&logOpts.LogToStderr, "logtostderr", false, "write logs to stderr") flag.BoolVar(&logOpts.LogToCloudLogging, "logtocloud", cfg.Retrieve().Core.CloudLoggingEnabled, "write logs to cloud logging") flag.IntVar(&logOpts.Level, "loglevel", cfg.Retrieve().Core.LogLevel, "log level: "+galog.ValidLevels()) flag.IntVar(&logOpts.Verbosity, "logverbosity", cfg.Retrieve().Core.LogVerbosity, "log verbosity") // On-demand plugins flags. flag.BoolVar(&forceOnDemandPlugins, "on_demand_plugins", false, "force on-demand plugins (even if disabled on the configuration)") // Core plugin flags. flag.StringVar(&corePluginPath, "core_plugin_path", entryPath(), "path to core plugin binary") flag.BoolVar(&skipCorePlugin, "core_plugins", false, "skip core plugin installation") flag.Parse() } // entryPath returns the path from where core plugin should be started. func entryPath() string { if runtime.GOOS == "windows" { return defaultWindowsCorePath } return defaultLinuxCorePath } // readExtraConfig reads the extra config from file set in environment variable. func readExtraConfig() ([]byte, error) { var configs []byte configPath := os.Getenv("GUEST_AGENT_EXTRA_CONFIG") if configPath == "" { // No extra config found, return. return configs, nil } return os.ReadFile(configPath) } func main() { extraCfg, err := readExtraConfig() if err != nil { fmt.Fprintln(os.Stderr, "Failed to read extra config:", err) os.Exit(1) } if err := cfg.Load(extraCfg); err != nil { fmt.Fprintln(os.Stderr, "Failed to load config:", err) os.Exit(1) } // Set the version of the binary as soon as config is loaded for any other // modules to use. Setting value explicitly after cfg load makes sure version // is as expected and its not coming from instance config or any other files. cfg.Retrieve().Core.Version = version setupFlags() ctx, cancel := context.WithCancel(context.Background()) logOpts.ProgramVersion = version logOpts.ACSClientDebugLogging = cfg.Retrieve().ACS.ClientDebugLogging if err := logger.Init(ctx, logOpts); err != nil { fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) os.Exit(1) } // Log the config read by the guest agent to debug. cfg.Log() if err := service.Init(ctx, func() { galog.Info("Google Guest Agent Leaving (canceling context)...") galog.Shutdown(galogShutdownTimeout) cancel() }, daemon.GuestAgentManager); err != nil { galog.Fatalf("Failed to initialize service manager: %s", err) } // MDS watcher is disabled in test environment as its not accessible. It must // not be set otherwise. if os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR") != "" { galog.Infof("MDS watcher is disabled in config, skipping MDS watcher initialization") } else { if err := events.FetchManager().AddWatcher(ctx, metadata.NewWatcher()); err != nil { galog.Fatalf("Failed to add metadata watcher: %v", err) } } opts := setup.Config{Version: version, CorePluginPath: corePluginPath, SkipCorePlugin: ignoreCorePlugin(), EnableLocalPlugins: cfg.Retrieve().Core.EnableLocalPlugins} // ACS watcher requires ACS client enabled. if (forceOnDemandPlugins || cfg.Retrieve().Core.OnDemandPlugins) && cfg.Retrieve().Core.ACSClient { opts.EnableACSWatcher = true } galog.Infof("Initializing Google Guest Agent...") if err := setup.Run(ctx, opts); err != nil { galog.Fatalf("Failed to initialize Guest Agent with required Core Plugin: %v", err) } if err := events.FetchManager().Run(ctx); err != nil { galog.Fatalf("Failed to run events manager: %v", err) } } // ignoreCorePlugin returns true if core plugin should be skipped. func ignoreCorePlugin() bool { binaryPath := agentBinaryPath() // This is a configuration guardrail to see if the guest agent binary is // present. If it is not present, we enable the core plugin. Ignore this // check in test environment as binary path is expected to be not present. if !file.Exists(binaryPath, file.TypeFile) && os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR") == "" { galog.Infof("Guest agent binary %q not found, enabling core plugin", binaryPath) return false } // If core plugin config is written in config file, use that. Otherwise, use // the command line flag. Test environment do rely on the command line flag. if config.IsConfigFilePresent() { enabled := config.IsCorePluginEnabled() galog.Infof("Core plugin config file [%q] is present and set to [%t]", config.CorePluginEnabledConfigFile, enabled) return !enabled } return skipCorePlugin } // agentBinaryPath returns the path to the guest agent binary based on the OS. func agentBinaryPath() string { if runtime.GOOS == "windows" { return `C:\Program Files\Google\Compute Engine\agent\GCEWindowsAgent.exe` } return "/usr/bin/google_guest_agent" } google-guest-agent-20260819.00/cmd/google_guest_agent/setup/000077500000000000000000000000001524141036400234465ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_guest_agent/setup/setup.go000066400000000000000000000253521524141036400251440ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package setup provides the guest-agent setup functionality. package setup import ( "context" "fmt" "os" "time" "github.com/GoogleCloudPlatform/galog" acpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/handler" "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/watcher" "github.com/GoogleCloudPlatform/google-guest-agent/internal/command" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "github.com/GoogleCloudPlatform/google-guest-agent/internal/service" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" dpb "google.golang.org/protobuf/types/known/durationpb" ) const ( // pluginStatusRequest defines the specific status we want to check. In this case // we're checking if core plugin has completed its early initialization. pluginStatusRequest = "early-initialization" // successStatusCode is the expected status code for status request. // 0 means plugin has successfully completed initialization. successStatusCode = 0 ) // PluginManagerInterface is the minimum PluginManager interface required for // Guest Agent setup. type PluginManagerInterface interface { // ListPluginStates returns the plugin states and cached health check information. ListPluginStates(context.Context, *acpb.ListPluginStates) *acpb.CurrentPluginStates // ConfigurePluginStates configures the plugin states as stated in the request. ConfigurePluginStates(context.Context, *acpb.ConfigurePluginStates) // VerifyPluginRunning verifies that the configured plugins are running. VerifyPluginRunning(context.Context, *acpb.ConfigurePluginStates_ConfigurePlugin) error } // coreReady executes components that are dependent/waiting on core plugin to be ready. func coreReady(ctx context.Context, opts Config) { galog.Debugf("Received %s ready event, setting service state to running", manager.CorePluginName) service.SetState(ctx, service.StateRunning) galog.Infof("Google Guest Agent (version: %q) Initialized...", opts.Version) } // handlePluginEvent handles the event received from plugin watcher. func handlePluginEvent(ctx context.Context, evType string, opts any, evData *events.EventData) (bool, bool, error) { if evType != manager.EventID { return true, true, fmt.Errorf("unexpected event type: %s", evType) } if evData.Error != nil { // This is expected to happen until core plugin is initialized, just log and // return true to keep the watcher running. galog.Debugf("Still waiting for plugin status, got error: %v", evData.Error) return true, true, nil } c, ok := opts.(Config) if !ok { return true, false, fmt.Errorf("unexpected data type: %T, opts expected to be of type %T", opts, Config{}) } // Nil error means we detected the event successfully and can // run components waiting on core plugin initialization. coreReady(ctx, c) // We received the required event, no need to continue listening. return false, false, nil } // Config contains options for Guest Agent setup. type Config struct { // Version is the version of the guest agent we're setting up. Version string // EnableACSWatcher determines if ACS watcher should be enabled for on-demand plugins. EnableACSWatcher bool // CorePluginPath is the path to the core plugin binary. CorePluginPath string // SkipCorePlugin determines if core plugin should be skipped. // This is used only for testing and must not be set in non-test environments. SkipCorePlugin bool // EnableLocalPlugins determines if the core plugin should be launched // using a hardcoded config, or by dynamically reading the config from disk. EnableLocalPlugins bool } // runTimeConfig contains the runtime configuration of the instance. type runTimeConfig struct { // ID is the instance ID. id string // svcActPresent is true if the instance has service accounts attached. svcActPresent bool } func fetchRuntimeConfig(ctx context.Context, mds metadata.MDSClientInterface) (runTimeConfig, error) { // Its most likely unset and only used for testing. if got := os.Getenv("TEST_COMPUTE_INSTANCE_ID"); got != "" { return runTimeConfig{id: got, svcActPresent: true}, nil } desc, err := mds.Get(ctx) if err != nil { return runTimeConfig{}, fmt.Errorf("failed to get metadata descriptor: %w", err) } return runTimeConfig{id: desc.Instance().ID().String(), svcActPresent: desc.HasServiceAccount()}, nil } // Run orchestrates the minimum required steps for initializing Guest Agent // with core plugin. func Run(ctx context.Context, c Config) error { // Try adding MDS route before attempting any other setup steps. retryPolicy := retry.Policy{MaxAttempts: 5, BackoffFactor: 2, Jitter: time.Second} err := retry.Run(ctx, retryPolicy, func() error { return addMDSRoute(ctx) }) if err != nil { galog.Warnf("Failed to add MDS route, continuing with setup: %v", err) } conf, err := fetchRuntimeConfig(ctx, metadata.New()) if err != nil { return fmt.Errorf("failed to get instance ID: %w", err) } galog.Infof("Running Guest Agent setup with config: %+v, runtime config: %+v", c, conf) // Registers the acs event watcher and initializes the acs handler if // on-demand plugins are enabled in the configuration file. // This is done as early as possible to ensure that the handler is ready // to handle to respond to non-plugin configuration requests as they serve as // heartbeat for the agent. if c.EnableACSWatcher && conf.svcActPresent { if err := events.FetchManager().AddWatcher(ctx, watcher.New()); err != nil { galog.Fatalf("Failed to add ACS watcher: %v", err) } handler.Init(c.Version) galog.Infof("Registered ACS watcher and handler") } else { galog.Infof("ACS watcher config enabled: %t, service account is present: %t, skipping ACS watcher and handler initialization. On Demand plugins will not be available.", c.EnableACSWatcher, conf.svcActPresent) } pm, err := manager.InitPluginManager(ctx, conf.id) if err != nil { return fmt.Errorf("plugin manager initialization: %w", err) } galog.Infof("Plugin manager initialized") go func() { if err := command.Setup(ctx, command.ListenerGuestAgent); err != nil { galog.Errorf("Failed to setup command monitor for Guest Agent: %v", err) } }() // Check if the core plugin is disabled or not present. If either is true, we // can skip the watcher as the core plugin will not be installed. skipWatcher := c.SkipCorePlugin || !file.Exists(c.CorePluginPath, file.TypeFile) if skipWatcher { // Watcher is skipped only if core plugin is disabled or not present. // In this case, we skip checking for the core plugin, so we just mark the // plugin manager service as ready as if the core-plugin has already sent // the ready event. coreReady(ctx, c) } if c.EnableLocalPlugins { if err := pm.StartLocalPlugins(ctx, map[string]manager.LocalPluginInstallation{ manager.CorePluginName: manager.LocalPluginInstallation{ // Only enable core plugin locally if core plugin initialization is not // skipped and local launch is enabled. Enable: !c.SkipCorePlugin, }, }); err != nil { return fmt.Errorf("start local plugins: %w", err) } } else { galog.Debugf("Skipped dynamic local launch of core plugin, attempting to install core plugin with hardcoded config...") err := install(ctx, pm, c) // This will only fail if the core plugin is present, but failed to install. if err != nil { return fmt.Errorf("core plugin installation: %w", err) } } events.FetchManager().Subscribe(manager.EventID, events.EventSubscriber{Name: "GuestAgent", Data: c, Callback: handlePluginEvent, MetricName: acpb.GuestAgentModuleMetric_CORE_PLUGIN_INITIALIZATION}) // Ignore returned [watcher] as it takes care of deregistering itself. if !skipWatcher { _, err = manager.InitWatcher(ctx, manager.CorePluginName, successStatusCode, pluginStatusRequest) if err != nil { return fmt.Errorf("init %s watcher: %w", manager.CorePluginName, err) } } return nil } // install installs the core plugin and verifies if its running. func install(ctx context.Context, pm PluginManagerInterface, c Config) error { if c.SkipCorePlugin { galog.Debug("Core plugin installation is skipped, skipping core plugin installation") return nil } // Skip installation if the core plugin binary is not present. if !file.Exists(c.CorePluginPath, file.TypeFile) { galog.Debugf("Core plugin binary not found at %q, skipping core plugin installation", c.CorePluginPath) return nil } req := &acpb.ConfigurePluginStates{ ConfigurePlugins: []*acpb.ConfigurePluginStates_ConfigurePlugin{ &acpb.ConfigurePluginStates_ConfigurePlugin{ Action: acpb.ConfigurePluginStates_INSTALL, Plugin: &acpb.ConfigurePluginStates_Plugin{ Name: manager.CorePluginName, RevisionId: c.Version, EntryPoint: c.CorePluginPath, }, Manifest: &acpb.ConfigurePluginStates_Manifest{ StartAttemptCount: 5, StartTimeout: &dpb.Duration{Seconds: 30}, StopTimeout: &dpb.Duration{Seconds: 30}, PluginType: acpb.PluginType_DAEMON, PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, }, }, }, } // If guest-agent is restarting and previously had installed core-plugin once // it will reconnect on [InitPluginManager]. Verify and return if running. // Requesting install again would be a no-op but will generate unnecessary // [PLUGIN_INSTALL_FAILED] event as plugin will be already present. err := pm.VerifyPluginRunning(ctx, req.GetConfigurePlugins()[0]) if err == nil { galog.Debugf("Core plugin found in running state, skipping installation") return nil } galog.Infof("Current plugin state: %v installing core plugin...", err) // ConfigurePluginStates will launch the core plugin. This is blocking call // and would wait until request is completed. pm.ConfigurePluginStates(ctx, req) // As above request is completed this check should pass/fail right away // no need to retry or wait. return pm.VerifyPluginRunning(ctx, req.GetConfigurePlugins()[0]) } google-guest-agent-20260819.00/cmd/google_guest_agent/setup/setup_linux.go000066400000000000000000000014351524141036400263570ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "context" "github.com/GoogleCloudPlatform/galog" ) func addMDSRoute(ctx context.Context) error { galog.V(2).Debugf("Adding route to MDS on linux is no-op") return nil } google-guest-agent-20260819.00/cmd/google_guest_agent/setup/setup_test.go000066400000000000000000000254001524141036400261750ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" acpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/acs/watcher" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" dpb "google.golang.org/protobuf/types/known/durationpb" ) type plugin struct { name string revision string status acpb.CurrentPluginStates_StatusValue } type testPluginManager struct { plugins map[string]plugin setOnInstall map[string]plugin seenRequest *acpb.ConfigurePluginStates runError []bool timesRun int seenLocal bool } func (m *testPluginManager) ListPluginStates(context.Context, *acpb.ListPluginStates) *acpb.CurrentPluginStates { var states []*acpb.CurrentPluginStates_DaemonPluginState for n, s := range m.plugins { status := &acpb.CurrentPluginStates_Status{Status: s.status} state := &acpb.CurrentPluginStates_DaemonPluginState{Name: n, CurrentRevisionId: s.revision, CurrentPluginStatus: status} states = append(states, state) } return &acpb.CurrentPluginStates{DaemonPluginStates: states} } func (m *testPluginManager) ConfigurePluginStates(ctx context.Context, req *acpb.ConfigurePluginStates) { m.seenRequest = req m.plugins = m.setOnInstall } func (m *testPluginManager) VerifyPluginRunning(ctx context.Context, req *acpb.ConfigurePluginStates_ConfigurePlugin) error { defer func() { m.timesRun++ }() if len(m.runError) <= m.timesRun { return nil } if m.runError[m.timesRun] { return fmt.Errorf("test run error") } return nil } func TestInstall(t *testing.T) { c := Config{Version: "123", CorePluginPath: filepath.Join(t.TempDir(), "core_plugin")} wantReq := &acpb.ConfigurePluginStates{ ConfigurePlugins: []*acpb.ConfigurePluginStates_ConfigurePlugin{ &acpb.ConfigurePluginStates_ConfigurePlugin{ Action: acpb.ConfigurePluginStates_INSTALL, Plugin: &acpb.ConfigurePluginStates_Plugin{ Name: manager.CorePluginName, RevisionId: c.Version, EntryPoint: c.CorePluginPath, }, Manifest: &acpb.ConfigurePluginStates_Manifest{ StartAttemptCount: 5, StartTimeout: &dpb.Duration{Seconds: 30}, StopTimeout: &dpb.Duration{Seconds: 30}, PluginType: acpb.PluginType_DAEMON, PluginInstallationType: acpb.PluginInstallationType_LOCAL_INSTALLATION, }, }, }, } ctx := context.Background() tests := []struct { desc string name string writeFile bool shouldSkip bool wantErr bool runError []bool wantReq *acpb.ConfigurePluginStates }{ { desc: "install_success", writeFile: true, wantReq: wantReq, runError: []bool{true, false}, }, { desc: "install_skipped", writeFile: true, runError: []bool{false}, shouldSkip: true, }, { desc: "install_failure", writeFile: true, wantErr: true, runError: []bool{true, true}, wantReq: wantReq, }, { desc: "install_failure_doesn't_exist", wantErr: false, runError: []bool{true, true}, wantReq: nil, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { plugins := make(map[string]plugin) if !tc.wantErr { plugins[manager.CorePluginName] = plugin{name: manager.CorePluginName, revision: c.Version, status: acpb.CurrentPluginStates_RUNNING} } testManager := &testPluginManager{setOnInstall: plugins, runError: tc.runError} if tc.shouldSkip { testManager.plugins = plugins } if tc.writeFile { if err := os.WriteFile(c.CorePluginPath, []byte("test"), 0644); err != nil { t.Fatalf("os.WriteFile(%q, %q) failed unexpectedly with error: %v", c.CorePluginPath, "test", err) } t.Cleanup(func() { if err := os.Remove(c.CorePluginPath); err != nil { t.Fatalf("os.Remove(%q) failed unexpectedly with error: %v", c.CorePluginPath, err) } }) } gotErr := install(ctx, testManager, c) if (gotErr != nil) != tc.wantErr { t.Errorf("install(ctx, %+v, %+v) = %v, want error %t", testManager, c, gotErr, tc.wantErr) } if diff := cmp.Diff(tc.wantReq, testManager.seenRequest, protocmp.Transform()); diff != "" { t.Errorf("install(ctx, %+v, %+v) returned unexpected diff (-want +got):\n%s", testManager, c, diff) } }) } } func TestHandlePluginEvent(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } tests := []struct { desc string evType string config any data *events.EventData want bool wantErr bool wantNoop bool }{ { desc: "invalid_event", evType: "invalid_event", want: true, wantErr: true, wantNoop: true, }, { desc: "event_error", evType: "plugin-watcher,status", data: &events.EventData{Error: fmt.Errorf("test error")}, want: true, wantNoop: true, wantErr: false, }, { desc: "invalid_config_type", evType: "plugin-watcher,status", want: true, data: &events.EventData{}, wantErr: true, wantNoop: false, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { got, noop, err := handlePluginEvent(ctx, tc.evType, tc.config, tc.data) if (err != nil) != tc.wantErr { t.Errorf("handlePluginEvent(ctx, %q, nil, %+v) error = %v, want error %t", tc.evType, tc.data, err, tc.wantErr) } if noop != tc.wantNoop { t.Errorf("handlePluginEvent(ctx, %q, nil, %+v) = %t, want noop %t", tc.evType, tc.data, noop, tc.wantNoop) } if got != tc.want { t.Errorf("handlePluginEvent(ctx, %q, nil, %+v) = %t, want %t", tc.evType, tc.data, got, tc.want) } }) } } func TestRun(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } if err := os.Setenv("TEST_COMPUTE_INSTANCE_ID", "1234567890"); err != nil { t.Fatalf("os.Setenv(%q, %q) failed unexpectedly with error: %v", "TEST_COMPUTE_INSTANCE_ID", "1234567890", err) } t.Cleanup(func() { os.Setenv("TEST_COMPUTE_INSTANCE_ID", "") }) c := Config{Version: "123", EnableACSWatcher: true, SkipCorePlugin: true} ctx := context.Background() if err := Run(ctx, c); err != nil { t.Fatalf("Run(ctx, %+v) failed unexpectedly with error: %v", c, err) } if !manager.Instance().IsInitialized.Load() { t.Errorf("Run(ctx, %+v) did not initialize plugin manager", c) } if !events.FetchManager().IsSubscribed(watcher.MessageReceiver, "ACS-message-handler") { t.Errorf("Run(ctx, %+v) did not subscribe to ACS-message-handler", c) } if err := events.FetchManager().AddWatcher(ctx, watcher.New()); err == nil { t.Errorf("Run(ctx, %+v) successfully added ACS watcher, setup should have already added it", c) } } // MDSClient implements fake metadata server. type MDSClient struct { id int throwErr bool svcActPresent bool } // GetKey implements fake GetKey MDS method. func (s *MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { return "", fmt.Errorf("GetKey() not yet implemented") } // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s *MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", fmt.Errorf("GetKeyRecursive() not yet implemented") } const ( mdsWithServiceAccounts = ` { "instance": { "serviceAccounts": { "default": { "aliases": [ "default" ], "scopes": [ "https://www.googleapis.com/auth/cloud-platform" ] } }, "id": %d } } ` mdsJustID = ` { "instance": { "id": %d } } ` ) // Get method implements fake Get on MDS. func (s *MDSClient) Get(context.Context) (*metadata.Descriptor, error) { if s.throwErr { return nil, fmt.Errorf("test error") } var jsonData string if s.svcActPresent { jsonData = fmt.Sprintf(mdsWithServiceAccounts, s.id) } else { jsonData = fmt.Sprintf(mdsJustID, s.id) } // This is a valid test response and would never fail. desc, _ := metadata.UnmarshalDescriptor(jsonData) return desc, nil } // Watch method implements fake watcher on MDS. func (s *MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s *MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } func TestFetchRuntimeConfig(t *testing.T) { if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly with error: %v", err) } ctx := context.Background() tests := []struct { desc string want runTimeConfig env string mds *MDSClient shouldFail bool }{ { desc: "mds_success", mds: &MDSClient{id: 12234}, want: runTimeConfig{id: "12234", svcActPresent: false}, }, { desc: "mds_success_svc_act_present", mds: &MDSClient{id: 7890, svcActPresent: true}, want: runTimeConfig{id: "7890", svcActPresent: true}, }, { desc: "cfg_success", env: "test-instance-id2", want: runTimeConfig{id: "test-instance-id2", svcActPresent: true}, }, { desc: "mds_failure", mds: &MDSClient{throwErr: true}, shouldFail: true, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { if err := os.Setenv("TEST_COMPUTE_INSTANCE_ID", tc.env); err != nil { t.Fatalf("os.Setenv(%q, %q) failed unexpectedly with error: %v", "TEST_COMPUTE_INSTANCE_ID", tc.env, err) } got, err := fetchRuntimeConfig(ctx, tc.mds) if (err != nil) != tc.shouldFail { t.Errorf("fetchInstanceID(ctx, %+v) = %v, want error %t", tc.mds, err, tc.shouldFail) } if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(runTimeConfig{})); diff != "" { t.Errorf("fetchInstanceID(ctx, %+v) returned unexpected diff (-want +got):\n%s", tc.mds, diff) } }) } } google-guest-agent-20260819.00/cmd/google_guest_agent/setup/setup_windows.go000066400000000000000000000134211524141036400267100ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "context" "encoding/binary" "fmt" "net" "sort" "syscall" "unsafe" "github.com/GoogleCloudPlatform/galog" "golang.org/x/sys/windows" ) const ( mibIPRouteTypeIndirect = 4 mibIPProtoNetmgmt mibIPForwardProto = 3 ) var ( ipHlpAPI = windows.NewLazySystemDLL("iphlpapi.dll") procCreateIPForwardEntry = ipHlpAPI.NewProc("CreateIpForwardEntry") procGetIPForwardTable = ipHlpAPI.NewProc("GetIpForwardTable") ) // https://www.ietf.org/rfc/rfc1354.txt // Only fields that we currently care about. type ipForwardEntry struct { ipForwardDest net.IP ipForwardMask net.IPMask ipForwardNextHop net.IP ipForwardIfIndex int32 ipForwardMetric1 int32 } type dword uint32 type ifIndex dword type mibIPForwardType dword type mibIPForwardProto dword type mobIPForwardRow struct { dwForwardDest uint32 dwForwardMask uint32 dwForwardPolicy uint32 dwForwardNextHop uint32 dwForwardIfIndex ifIndex dwForwardType mibIPForwardType dwForwardProto mibIPForwardProto dwForwardAge int32 dwForwardNextHopAS int32 dwForwardMetric1 int32 dwForwardMetric2 int32 dwForwardMetric3 int32 dwForwardMetric4 int32 dwForwardMetric5 int32 } // addMDSRoute adds a route to MDS on windows. func addMDSRoute(ctx context.Context) error { fes, err := getIPForwardEntries() if err != nil { return err } defaultRoute, err := getDefaultAdapter(fes) if err != nil { return err } forwardEntry := ipForwardEntry{ ipForwardDest: net.ParseIP("169.254.169.254"), ipForwardMask: net.IPv4Mask(255, 255, 255, 255), ipForwardNextHop: net.ParseIP("0.0.0.0"), ipForwardMetric1: defaultRoute.ipForwardMetric1, // Must be <= the default route metric. ipForwardIfIndex: defaultRoute.ipForwardIfIndex, } for _, fe := range fes { if fe.ipForwardDest.Equal(forwardEntry.ipForwardDest) && fe.ipForwardIfIndex == forwardEntry.ipForwardIfIndex { // No need to add entry, it's already setup. return nil } } galog.V(2).Debugf("Adding route to metadata server on adapter with index %d", defaultRoute.ipForwardIfIndex) err = addIPForwardEntry(forwardEntry) galog.Infof("Adding MDS route completed with result") return err } func getDefaultAdapter(fes []ipForwardEntry) (*ipForwardEntry, error) { // Choose the first adapter index that has the default route setup. // This is equivalent to how route.exe works when interface is not provided. defaultRoute := net.ParseIP("0.0.0.0") sort.Slice(fes, func(i, j int) bool { return fes[i].ipForwardIfIndex < fes[j].ipForwardIfIndex }) for _, fe := range fes { if fe.ipForwardDest.Equal(defaultRoute) { return &fe, nil } } return nil, fmt.Errorf("no default route to %s found in %+v forward entries", defaultRoute.String(), fes) } func addIPForwardEntry(fe ipForwardEntry) error { // https://docs.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-createipforwardentry fr := &mobIPForwardRow{ dwForwardDest: binary.LittleEndian.Uint32(fe.ipForwardDest.To4()), dwForwardMask: binary.LittleEndian.Uint32(fe.ipForwardMask), dwForwardPolicy: 0, // unused dwForwardNextHop: binary.LittleEndian.Uint32(fe.ipForwardNextHop.To4()), dwForwardIfIndex: ifIndex(fe.ipForwardIfIndex), dwForwardType: mibIPRouteTypeIndirect, // unused dwForwardProto: mibIPProtoNetmgmt, dwForwardAge: 0, // unused dwForwardNextHopAS: 0, // unused dwForwardMetric1: fe.ipForwardMetric1, dwForwardMetric2: -1, // unused dwForwardMetric3: -1, // unused dwForwardMetric4: -1, // unused dwForwardMetric5: -1, // unused } if ret, _, _ := procCreateIPForwardEntry.Call(uintptr(unsafe.Pointer(fr))); ret != 0 { return fmt.Errorf("nonzero return code from CreateIpForwardEntry: %s", syscall.Errno(ret)) } return nil } func getIPForwardEntries() ([]ipForwardEntry, error) { buf := make([]byte, 1) size := uint32(len(buf)) // First call gets the size of MIB_IPFORWARDTABLE. procGetIPForwardTable.Call( uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)), 0, ) buf = make([]byte, size) if ret, _, _ := procGetIPForwardTable.Call( uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)), 0, ); ret != 0 { return nil, fmt.Errorf("nonzero return code from GetIpForwardTable: %s", syscall.Errno(ret)) } numEntries := *(*uint32)(unsafe.Pointer(&buf[0])) // Walk through the returned table for each entry. var fes []ipForwardEntry for i := uint32(0); i < numEntries; i++ { // Extract each MIB_IPFORWARDROW from MIB_IPFORWARDTABLE fr := *((*mobIPForwardRow)(unsafe.Pointer( (uintptr(unsafe.Pointer(&buf[0])) + unsafe.Sizeof(numEntries)) + (unsafe.Sizeof(mobIPForwardRow{}) * uintptr(i)), ))) fd := make([]byte, 4) binary.LittleEndian.PutUint32(fd, uint32(fr.dwForwardDest)) fm := make([]byte, 4) binary.LittleEndian.PutUint32(fm, uint32(fr.dwForwardMask)) nh := make([]byte, 4) binary.LittleEndian.PutUint32(nh, uint32(fr.dwForwardNextHop)) fe := ipForwardEntry{ ipForwardDest: net.IP(fd), ipForwardMask: net.IPMask(fm), ipForwardNextHop: net.IP(nh), ipForwardIfIndex: int32(fr.dwForwardIfIndex), ipForwardMetric1: fr.dwForwardMetric1, } fes = append(fes, fe) } return fes, nil } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/000077500000000000000000000000001524141036400241655ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_guest_compat_manager/main.go000066400000000000000000000075001524141036400254420ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the entry point for the google-guest-compat-manager. It is // responsible for enabling either Core Plugin or the legacy guest agent. package main import ( "context" "fmt" "os" "time" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/google_guest_compat_manager/watcher" "github.com/GoogleCloudPlatform/galog" acmpb "github.com/GoogleCloudPlatform/google-guest-agent/internal/acp/proto/google_guest_agent/acp" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/service" ) const ( // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second ) var ( // logOpts holds the logger options. It's mapped to command line flags. logOpts = logger.Options{ Ident: "google_guest_compat_manager", Prefix: "GCEGuestCompatManager", CloudIdent: "GCEGuestCompatManager", } // version is the version of the binary. version = "unknown" ) func setupLogger(ctx context.Context) error { conf := cfg.Retrieve() logOpts.ProgramVersion = version logOpts.Level = conf.Core.LogLevel logOpts.Verbosity = conf.Core.LogVerbosity logOpts.LogFile = conf.Core.LogFile if err := logger.Init(ctx, logOpts); err != nil { return fmt.Errorf("failed to initialize logger: %w", err) } return nil } func main() { ctx, cancel := context.WithCancel(context.Background()) if err := cfg.Load(nil); err != nil { fmt.Fprintln(os.Stderr, "Failed to load config:", err) os.Exit(1) } if err := setupLogger(ctx); err != nil { fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) os.Exit(1) } if err := service.Init(ctx, func() { galog.Info("Google Guest Agent Compat Manager Leaving (canceling context)...") galog.Shutdown(galogShutdownTimeout) cancel() }, daemon.GuestAgentCompatManager); err != nil { galog.Fatalf("Failed to initialize service manager: %s", err) } if err := setup(ctx); err != nil { galog.Fatalf("Failed to setup guest compat manager: %v", err) } if err := events.FetchManager().Run(ctx); err != nil { galog.Fatalf("Failed to run events manager: %v", err) } } // setup sets up the config to setup the guest compat manager. func setup(ctx context.Context) error { galog.Infof("Setting up guest compat manager") if err := events.FetchManager().AddWatcher(ctx, metadata.NewWatcher()); err != nil { return fmt.Errorf("failed to add metadata watcher: %w", err) } watcher := watcher.NewManager() subscriber := events.EventSubscriber{Name: "GuestCompatManager", Data: nil, Callback: watcher.Setup, MetricName: acmpb.GuestAgentModuleMetric_GUEST_COMPAT_MANAGER_INITIALIZATION} events.FetchManager().Subscribe(metadata.LongpollEvent, subscriber) galog.Debug("Compat manager subscriber registered for metadata longpoll event, setting service state to running...") service.SetState(ctx, service.StateRunning) galog.Infof("Google Guest Compat Manager (version: %q) Initialized...", version) return nil } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/main_test.go000066400000000000000000000024541524141036400265040ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" ) func TestSetup(t *testing.T) { ctx := context.Background() if err := setup(ctx); err != nil { t.Errorf("setup(ctx) returned error: %v, want: nil", err) } if !events.FetchManager().IsSubscribed(metadata.LongpollEvent, "GuestCompatManager") { t.Errorf("IsSubscribed(metadata.LongpollEvent, GuestCompatManager) returned false, want: true") } if err := events.FetchManager().AddWatcher(ctx, metadata.NewWatcher()); err == nil { t.Errorf("AddWatcher(ctx, metadata.NewWatcher()) returned nil, want: previously added error") } } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/watcher/000077500000000000000000000000001524141036400256225ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/google_guest_compat_manager/watcher/watcher.go000066400000000000000000000151531524141036400276130ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package watcher implements the event watcher callback for the guest compat // manager and configures the guest agent accordingly. package watcher import ( "context" "fmt" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/config" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/manager" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) // Manager is the event watcher for the guest compat. It watches for metadata // changes and updates the configuration accordingly. type Manager struct { corePluginsEnabled bool guestAgentProcessName string instanceID string guestAgentManagerProcessName string osInfoReader func() osinfo.OSInfo } // NewManager creates a new Manager. func NewManager() *Manager { // IsCorePluginEnabled defaults to [true] if the config file is not present. alreadyEnabled := config.IsCorePluginEnabled() galog.Infof("Compat manager created with core plugin already enabled: %t", alreadyEnabled) return &Manager{guestAgentProcessName: daemon.GuestAgent, guestAgentManagerProcessName: daemon.GuestAgentManager, corePluginsEnabled: alreadyEnabled, osInfoReader: osinfo.Read} } // Setup sets up the configuration to enable/disable the Core Plugin and the // Guest Agent. func (w *Manager) Setup(ctx context.Context, evType string, opts any, evData *events.EventData) (bool, bool, error) { if evData.Error != nil { return true, true, fmt.Errorf("metadata event watcher reported error: %w", evData.Error) } mds, ok := evData.Data.(*metadata.Descriptor) if !ok { return true, true, fmt.Errorf("invalid event.Data type passed to event callback") } // If guest agent is not present and core plugin is we launch core plugin. In // this case we don't need to enable/disable guest agent. if !file.Exists(guestAgentBinaryPath, file.TypeFile) { galog.Infof("Guest agent binary %q not found, running in test environment, skipping setup.", guestAgentBinaryPath) return true, true, nil } enabled := mds.HasCorePluginEnabled() noop, err := w.enableDisableAgent(ctx, enabled) return true, noop, err } // enableDisableAgent enables or disables the guest agent based on the new // enabled state and restarts the relevant services. func (w *Manager) enableDisableAgent(ctx context.Context, newEnabled bool) (bool, error) { if w.corePluginsEnabled == newEnabled { galog.Debugf("Core plugin enabled state (%t) is unchanged, skipping guest agent enable/disable.", newEnabled) return true, nil } if newEnabled { if err := w.enableCorePlugin(ctx); err != nil { return false, fmt.Errorf("failed to enable core plugin: %w", err) } } else { if err := w.disableCorePlugin(ctx); err != nil { return false, fmt.Errorf("failed to disable core plugin: %w", err) } } // Reset the state only after the Core Plugin is enabled/disabled successfully. // This will allow us to retry the enable/disable operation in case of any // failure. w.corePluginsEnabled = newEnabled return false, nil } // enableCorePlugin enables the core plugin & restarts Guest Agent Manager. func (w *Manager) enableCorePlugin(ctx context.Context) error { galog.Infof("Enabling core plugin") if err := daemon.DisableService(ctx, w.guestAgentProcessName); err != nil { return fmt.Errorf("failed to stop guest agent: %w", err) } if err := daemon.StopDaemon(ctx, w.guestAgentProcessName); err != nil { return fmt.Errorf("failed to stop guest agent: %w", err) } if err := config.SetCorePluginEnabled(true); err != nil { return fmt.Errorf("failed to enable core plugin config: %w", err) } if err := w.disableCertRefresher(ctx); err != nil { return fmt.Errorf("failed to disable cert refresher: %w", err) } if err := daemon.RestartService(ctx, w.guestAgentManagerProcessName, daemon.Restart); err != nil { return fmt.Errorf("failed to restart guest agent manager: %w", err) } galog.Infof("Successfully enabled core plugin") return nil } // disableCorePlugin disables the core plugin & restarts Guest Agent Manager. func (w *Manager) disableCorePlugin(ctx context.Context) error { galog.Infof("Disabling core plugin") if err := config.SetCorePluginEnabled(false); err != nil { return fmt.Errorf("failed to disable core plugin config: %w", err) } if err := daemon.StopDaemon(ctx, w.guestAgentManagerProcessName); err != nil { return fmt.Errorf("failed to restart guest agent manager: %w", err) } if err := w.stopCorePlugin(ctx); err != nil { return fmt.Errorf("failed to stop core plugin: %w", err) } if err := daemon.StartDaemon(ctx, w.guestAgentManagerProcessName); err != nil { return fmt.Errorf("failed to restart guest agent manager: %w", err) } if err := w.enableCertRefresher(ctx); err != nil { return fmt.Errorf("failed to disable cert refresher: %w", err) } if err := daemon.EnableService(ctx, w.guestAgentProcessName); err != nil { return fmt.Errorf("failed to stop guest agent: %w", err) } if err := daemon.StartDaemon(ctx, w.guestAgentProcessName); err != nil { return fmt.Errorf("failed to stop guest agent: %w", err) } galog.Infof("Successfully disabled core plugin") return nil } func (w *Manager) stopCorePlugin(ctx context.Context) error { galog.Infof("Stopping core plugin") if err := w.readInstanceID(ctx); err != nil { return fmt.Errorf("failed to fetch instance ID: %w", err) } pm, err := manager.InitAdHocPluginManager(ctx, w.instanceID) if err != nil { return fmt.Errorf("failed to initialize plugin manager: %w", err) } return pm.StopPlugin(ctx, manager.CorePluginName) } func (w *Manager) readInstanceID(ctx context.Context) error { if w.instanceID != "" { return nil } id, err := metadata.New().GetKey(ctx, "/instance/id", nil) if err != nil { return err } w.instanceID = id return nil } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/watcher/watcher_linux.go000066400000000000000000000050231524141036400310250ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package watcher import ( "context" "fmt" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" ) var ( // guestAgentBinaryPath is the path to the guest agent binary. guestAgentBinaryPath = "/usr/bin/google_guest_agent" ) const ( // workloadCertRefresherServiceName is the name of the workload cert refresher // service. workloadCertRefresherServiceName = "gce-workload-cert-refresh.timer" ) // disableCertRefresher disables and stops the workload cert refresher service. func (w *Manager) disableCertRefresher(ctx context.Context) error { if w.osInfoReader().OS == "sles" { galog.Infof("SLES does not install workload cert refresher service, skipping disable") return nil } galog.Infof("Disabling workload cert refresher service") if err := daemon.DisableService(ctx, workloadCertRefresherServiceName); err != nil { return fmt.Errorf("failed to disable workload cert refresher service: %w", err) } if err := daemon.StopDaemon(ctx, workloadCertRefresherServiceName); err != nil { return fmt.Errorf("failed to stop workload cert refresher service: %w", err) } galog.Infof("Successfully disabled workload cert refresher service") return nil } // enableCertRefresher enables the workload cert refresher service. func (w *Manager) enableCertRefresher(ctx context.Context) error { if w.osInfoReader().OS == "sles" { galog.Infof("SLES does not install workload cert refresher service, skipping enable") return nil } galog.Infof("Enabling workload cert refresher service") if err := daemon.EnableService(ctx, workloadCertRefresherServiceName); err != nil { return fmt.Errorf("failed to enable workload cert refresher service: %w", err) } if err := daemon.StartDaemon(ctx, workloadCertRefresherServiceName); err != nil { return fmt.Errorf("failed to start workload cert refresher service: %w", err) } galog.Infof("Successfully enabled workload cert refresher service") return nil } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/watcher/watcher_test.go000066400000000000000000000370211524141036400306500ustar00rootroot00000000000000// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package watcher import ( "context" "fmt" "os" "path/filepath" "runtime" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/daemon" "github.com/GoogleCloudPlatform/google-guest-agent/internal/events" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/osinfo" "github.com/GoogleCloudPlatform/google-guest-agent/internal/plugin/config" "github.com/google/go-cmp/cmp" ) const ( instanceMdsTemplate = ` { "instance": { "attributes": { "enable-guest-agent-core-plugin": "%t" } } } ` ) func TestSetupError(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly: %v", err) } cfg.Retrieve().Plugin.StateDir = t.TempDir() orig := config.CorePluginEnabledConfigFile t.Cleanup(func() { config.CorePluginEnabledConfigFile = orig }) mdsEnableData := fmt.Sprintf(instanceMdsTemplate, true) mdsEnable, err := metadata.UnmarshalDescriptor(mdsEnableData) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly: %v", mdsEnableData, err) } mdsDisableData := fmt.Sprintf(instanceMdsTemplate, false) mdsDisable, err := metadata.UnmarshalDescriptor(mdsDisableData) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly: %v", mdsDisableData, err) } guestAgentBinaryPath = filepath.Join(t.TempDir(), "guest_agent") if err := os.WriteFile(guestAgentBinaryPath, []byte("test"), 0755); err != nil { t.Fatalf("Failed to write test file: %v", err) } tests := []struct { name string event *events.EventData wantCfgFileEnabled bool prevEnabled bool stopErr error restartErr error disableErr error enableErr error startErr error wantNoop bool }{ { name: "event_data_error", event: &events.EventData{Error: fmt.Errorf("test error")}, wantCfgFileEnabled: true, wantNoop: true, }, { name: "invalid_event_data", event: &events.EventData{Data: "invalid"}, wantCfgFileEnabled: true, wantNoop: true, }, { name: "enable_core_plugin_disable_error", event: &events.EventData{Data: mdsEnable}, disableErr: fmt.Errorf("test error"), wantCfgFileEnabled: true, wantNoop: false, }, { name: "enable_core_plugin_stop_error", event: &events.EventData{Data: mdsEnable}, stopErr: fmt.Errorf("test error"), wantCfgFileEnabled: true, wantNoop: false, }, { name: "enable_core_plugin_restart_error", event: &events.EventData{Data: mdsEnable}, restartErr: fmt.Errorf("test error"), wantCfgFileEnabled: true, wantNoop: false, }, { name: "disable_core_plugin_stop_error", event: &events.EventData{Data: mdsDisable}, stopErr: fmt.Errorf("test error"), wantCfgFileEnabled: false, prevEnabled: true, wantNoop: false, }, { name: "disable_core_plugin_enable_error", event: &events.EventData{Data: mdsDisable}, enableErr: fmt.Errorf("test error"), wantCfgFileEnabled: false, prevEnabled: true, wantNoop: false, }, { name: "disable_core_plugin_start_error", event: &events.EventData{Data: mdsDisable}, startErr: fmt.Errorf("test error"), wantCfgFileEnabled: false, prevEnabled: true, wantNoop: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { testRunner := fakeDaemonClient{wantStopDaemonErr: test.stopErr, wantRestartServiceErr: test.restartErr, wantStartDaemonErr: test.startErr, wantEnableServiceErr: test.enableErr, wantDisableServiceErr: test.disableErr} setTestDaemonClient(t, &testRunner) watcher := Manager{corePluginsEnabled: test.prevEnabled, instanceID: "test-instance-id", osInfoReader: func() osinfo.OSInfo { return osinfo.OSInfo{OS: "debian"} }} cfgFile := filepath.Join(t.TempDir(), "core-plugin-enabled") config.CorePluginEnabledConfigFile = cfgFile got, noop, err := watcher.Setup(ctx, "LongpollEvent", nil, test.event) if err == nil { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned no error, want error", test.event) } if noop != test.wantNoop { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned noop: %t, want: %t", test.event, noop, test.wantNoop) } if !got { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned false, want: true", test.event) } if got := config.IsCorePluginEnabled(); got != test.wantCfgFileEnabled { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) set core plugin enabled to: %t, want: %t", test.event, got, test.wantCfgFileEnabled) } // If there was an error, the state should not be updated. if watcher.corePluginsEnabled != test.prevEnabled { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) resulted in set corePluginsEnabled to %t, want: %t", test.event, watcher.corePluginsEnabled, test.prevEnabled) } }) } } func TestSetup(t *testing.T) { ctx := context.Background() mdsEnableData := fmt.Sprintf(instanceMdsTemplate, true) mdsEnable, err := metadata.UnmarshalDescriptor(mdsEnableData) if err != nil { t.Fatalf("metadata.UnmarshalDescriptor(%s) failed unexpectedly: %v", mdsEnableData, err) } guestAgentBinaryPath = filepath.Join(t.TempDir(), "non-existent") // Should be no-op if the guest agent binary does not exist. watcher := Manager{} got, noop, err := watcher.Setup(ctx, "LongpollEvent", nil, &events.EventData{Data: mdsEnable}) if err != nil { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned error: %v, want: nil", mdsEnable, err) } if !noop { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned noop: %t, want: false", mdsEnable, noop) } if !got { t.Errorf("Setup(ctx, LongpollEvent, nil, %+v) returned false, want: true", mdsEnable) } } type fakeDaemonClient struct { seenServiceName []string commandRun []string wantStopDaemonErr error wantStartDaemonErr error wantRestartServiceErr error wantEnableServiceErr error wantDisableServiceErr error } func (f *fakeDaemonClient) DisableService(ctx context.Context, service string) error { f.seenServiceName = append(f.seenServiceName, service) f.commandRun = append(f.commandRun, "disable") return f.wantDisableServiceErr } func (f *fakeDaemonClient) EnableService(ctx context.Context, service string) error { f.seenServiceName = append(f.seenServiceName, service) f.commandRun = append(f.commandRun, "enable") return f.wantEnableServiceErr } func (f *fakeDaemonClient) RestartService(ctx context.Context, service string, method daemon.RestartMethod) error { f.seenServiceName = append(f.seenServiceName, service) f.commandRun = append(f.commandRun, "restart") return f.wantRestartServiceErr } func (f *fakeDaemonClient) StopDaemon(ctx context.Context, daemon string) error { f.seenServiceName = append(f.seenServiceName, daemon) f.commandRun = append(f.commandRun, "stop") return f.wantStopDaemonErr } func (f *fakeDaemonClient) StartDaemon(ctx context.Context, daemon string) error { f.seenServiceName = append(f.seenServiceName, daemon) f.commandRun = append(f.commandRun, "start") return f.wantStartDaemonErr } func (f *fakeDaemonClient) CheckUnitExists(ctx context.Context, unit string) (bool, error) { return false, fmt.Errorf("checking unit existence not implemented") } func (f *fakeDaemonClient) ReloadDaemon(ctx context.Context, daemon string) error { return fmt.Errorf("reloading daemons not implemented") } func (f *fakeDaemonClient) UnitStatus(ctx context.Context, unit string) (daemon.ServiceStatus, error) { return daemon.Unknown, fmt.Errorf("unit status not not implemented") } func setTestDaemonClient(t *testing.T, client daemon.ClientInterface) { t.Helper() orig := daemon.Client t.Cleanup(func() { daemon.Client = orig }) daemon.Client = client } func TestEnableDisableAgent(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load(nil) failed unexpectedly: %v", err) } cfg.Retrieve().Plugin.StateDir = t.TempDir() cfgFile := filepath.Join(t.TempDir(), "core-plugin-enabled") orig := config.CorePluginEnabledConfigFile config.CorePluginEnabledConfigFile = cfgFile t.Cleanup(func() { config.CorePluginEnabledConfigFile = orig }) type osBehaviour struct { os string wantEnableCmds []string wantEnableServices []string wantDisableCmds []string wantDisableServices []string osInfoReader func() osinfo.OSInfo } behaviours := []osBehaviour{ { os: "debian", wantEnableCmds: []string{"disable", "stop", "disable", "stop", "restart"}, wantEnableServices: []string{"test-guest-agent", "test-guest-agent", "gce-workload-cert-refresh.timer", "gce-workload-cert-refresh.timer", "test-guest-agent-manager"}, wantDisableCmds: []string{"stop", "start", "enable", "start", "enable", "start"}, wantDisableServices: []string{"test-guest-agent-manager", "test-guest-agent-manager", "gce-workload-cert-refresh.timer", "gce-workload-cert-refresh.timer", "test-guest-agent", "test-guest-agent"}, osInfoReader: func() osinfo.OSInfo { return osinfo.OSInfo{OS: "debian"} }, }, { os: "sles", wantEnableCmds: []string{"disable", "stop", "restart"}, wantEnableServices: []string{"test-guest-agent", "test-guest-agent", "test-guest-agent-manager"}, wantDisableCmds: []string{"stop", "start", "enable", "start"}, wantDisableServices: []string{"test-guest-agent-manager", "test-guest-agent-manager", "test-guest-agent", "test-guest-agent"}, osInfoReader: func() osinfo.OSInfo { return osinfo.OSInfo{OS: "sles"} }, }, } if runtime.GOOS == "windows" { behaviours = []osBehaviour{ { os: "windows", wantEnableCmds: []string{"disable", "stop", "restart"}, wantEnableServices: []string{"test-guest-agent", "test-guest-agent", "test-guest-agent-manager"}, wantDisableCmds: []string{"stop", "start", "enable", "start"}, wantDisableServices: []string{"test-guest-agent-manager", "test-guest-agent-manager", "test-guest-agent", "test-guest-agent"}, osInfoReader: func() osinfo.OSInfo { return osinfo.OSInfo{OS: "windows"} }, }, } } tests := []struct { name string runEnableDisable bool wantCorePluginEnabled bool wantNoop bool wantCmds func(b osBehaviour) []string wantServices func(b osBehaviour) []string }{ { name: "enable_core_plugin", runEnableDisable: true, wantCorePluginEnabled: true, wantNoop: false, wantCmds: func(b osBehaviour) []string { return b.wantEnableCmds }, wantServices: func(b osBehaviour) []string { return b.wantEnableServices }, }, { name: "no_change_core_plugin_enabled", runEnableDisable: true, wantCorePluginEnabled: true, wantNoop: true, }, { name: "disable_core_plugin", runEnableDisable: false, wantCorePluginEnabled: false, wantNoop: false, wantCmds: func(b osBehaviour) []string { return b.wantDisableCmds }, wantServices: func(b osBehaviour) []string { return b.wantDisableServices }, }, { name: "no_change_core_plugin_disabled", runEnableDisable: false, wantCorePluginEnabled: false, wantNoop: true, }, } for _, b := range behaviours { t.Run(b.os, func(t *testing.T) { t.Logf("Running test for %s", b.os) watcher := Manager{corePluginsEnabled: false, guestAgentProcessName: "test-guest-agent", guestAgentManagerProcessName: "test-guest-agent-manager", instanceID: "test-instance-id", osInfoReader: b.osInfoReader} for _, test := range tests { t.Run(test.name, func(t *testing.T) { testRunner := fakeDaemonClient{} setTestDaemonClient(t, &testRunner) gotNoop, err := watcher.enableDisableAgent(ctx, test.runEnableDisable) if err != nil { t.Errorf("enableDisableAgent(ctx, %t) returned error for %q: %v, want: nil", test.runEnableDisable, test.name, err) } if gotNoop != test.wantNoop { t.Errorf("enableDisableAgent(ctx, %t) returned noop: %t, want: %t", test.runEnableDisable, gotNoop, test.wantNoop) } var wantCmds []string if test.wantCmds != nil { wantCmds = test.wantCmds(b) } var wantServices []string if test.wantServices != nil { wantServices = test.wantServices(b) } if diff := cmp.Diff(wantCmds, testRunner.commandRun); diff != "" { t.Errorf("enableDisableAgent(ctx, %t) did not run expected commands for %q, diff (-want +got):\n%s", test.runEnableDisable, test.name, diff) } if diff := cmp.Diff(wantServices, testRunner.seenServiceName); diff != "" { t.Errorf("enableDisableAgent(ctx, %t) did not run commands on expected services for %q, diff (-want +got):\n%s", test.runEnableDisable, test.name, diff) } if got := config.IsCorePluginEnabled(); got != test.wantCorePluginEnabled { t.Errorf("enableCorePlugin(ctx) set enable core plugin for %q to: %t, want: %t", test.name, got, test.wantCorePluginEnabled) } if watcher.corePluginsEnabled != test.wantCorePluginEnabled { t.Errorf("enableDisableAgent(ctx, %t) set corePluginsEnabled for %q to: %t, want: %t", test.runEnableDisable, test.name, watcher.corePluginsEnabled, test.wantCorePluginEnabled) } }) } }) } } func TestNewManager(t *testing.T) { daemon := "google-guest-agent" managerDaemon := "google-guest-agent-manager" if runtime.GOOS == "windows" { daemon = "GCEAgent" managerDaemon = "GCEAgentManager" } gotManager := NewManager() if got := gotManager.guestAgentProcessName; got != daemon { t.Errorf("NewManager() returned manager with guest agent process name: %s, want: %s", got, daemon) } if got := gotManager.guestAgentManagerProcessName; got != managerDaemon { t.Errorf("NewManager() returned manager with guest agent manager process name: %s, want: %s", got, managerDaemon) } if !gotManager.corePluginsEnabled { t.Errorf("NewManager() returned manager with core plugins enabled: %t, want: true", gotManager.corePluginsEnabled) } } func TestReadInstanceID(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() w := Manager{} // If the instance ID is not already set, it should be read from metadata. // In test environments we don't have access to metadata so this should fail. // Use a context that is already cancelled to simulate an immediate timeout. if err := w.readInstanceID(ctx); err == nil { t.Errorf("readInstanceID(ctx) succeeded, want error on context cancellation") } } google-guest-agent-20260819.00/cmd/google_guest_compat_manager/watcher/watcher_windows.go000066400000000000000000000023611524141036400313620ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package watcher import ( "context" "github.com/GoogleCloudPlatform/galog" ) var ( guestAgentBinaryPath = `C:\Program Files\Google\Compute Engine\agent\GCEWindowsAgent.exe` ) // disableCertRefresher disables and stops the workload cert refresher service. func (w *Manager) disableCertRefresher(ctx context.Context) error { galog.Infof("Windows does not support cert refresher service, skipping disable") return nil } // enableCertRefresher enables and starts the workload cert refresher service. func (w *Manager) enableCertRefresher(ctx context.Context) error { galog.Infof("Windows does not support cert refresher service, skipping enable") return nil } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/000077500000000000000000000000001524141036400237625ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/LICENSE000066400000000000000000000261361524141036400247770ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. google-guest-agent-20260819.00/cmd/guest_telemetry_extension/README.md000066400000000000000000000004441524141036400252430ustar00rootroot00000000000000# Guest Telemetry Extension This repository contains the Guest Telemetry Extension. The extension is intended to enable the gathering of information about Independent Software Vendors (ISVs) running on a VM. ## License and Copyright Copyright 2025 Google LLC. Apache License, Version 2.0 google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/000077500000000000000000000000001524141036400265135ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/README.md000066400000000000000000000030541524141036400277740ustar00rootroot00000000000000# ISV Discovery This directory contains the ISV Discovery module for the Guest Telemetry Extension. This module is intended to enable the gathering of information about Independent Software Vendors (ISVs) running on a VM. ## Edge Cases and Judgement Calls for Workload Detection - We consider detecting the process named `httpd` to mean Apache Web Server is present. This may result in false positives. - We consider the process named `mysqld` to mean that MySQL is present. Older versions of MariaDB also use this process name. We only consider MariaDB as a present workload if the process named `mariadbd` is running. - We consider the process named `memurai` to mean Redis is present. Memurai is a Redis-compatible data store built to run natively on Windows. - In many cases the best version command would require using a restricted command line argument such as a pipe or semicolon. Consequently, we lack functional version detection for certain workloads. Additionally, some of the commands used are not what would be preferred without these restrictions. - Some of the version commands will need to be in the global PATH in order to work. - We originally planned to detect SAP System and SAP Web AS as two separate workloads. However, SAP System is a cluster made up of several instances running various workloads. To identify it, we'd really just be looking for the process names that indicate SAP Web AS is running. As such, we just identify SAP Web AS and do not identify SAP System. ## License and Copyright Copyright 2025 Google LLC. Apache License, Version 2.0 google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor/000077500000000000000000000000001524141036400325605ustar00rootroot00000000000000commandlineexecutor.go000066400000000000000000000242701524141036400371020ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor/* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* Package commandlineexecutor creates an interface to streamline execution of shell commands across multiple platforms. This package provides a simple interface to execute shell commands across multiple platforms. It also provides a way to check if a command exists and to get the exit code from an error. Example usage: // Create a new commandlineexecutor.Params struct with the executable and arguments to run. params := commandlineexecutor.Params{ Executable: "/bin/ls", Args: []string{"-l", "/usr/local/google"}, } // Execute the command. result := commandlineexecutor.ExecuteCommand(context.Background(), params) // Check the result for any errors. if result.Error != nil { log.Error(result.Error) } // Print the standard output and standard error. fmt.Printf("Standard output:\n%s", result.StdOut) fmt.Printf("Standard error:\n%s", result.StdErr) // Check the exit code. if result.ExitCode != 0 { log.Error("Command failed with exit code", result.ExitCode) } */ package commandlineexecutor import ( "bytes" "context" "errors" "fmt" "log/slog" "os/exec" "regexp" "strconv" "strings" "time" ) var ( exitStatusPattern = regexp.MustCompile("exit status ([0-9]+)") exists = CommandExists exitCode = commandExitCode run Run = nil exeForPlatform SetupExeForPlatform = nil ) type ( // Execute is a function to execute a command. Production callers // to pass commandlineexecutor.ExecuteCommand while calling this package's APIs. Execute func(context.Context, Params) Result // Exists is a function to check if a command exists. Production callers // to pass commandlineexecutor.CommandExists while calling this package's APIs. Exists func(string) bool // ExitCode is a function to get the exit code from an error. Production callers // to pass commandlineexecutor.CommandExitCode while calling this package's APIs. ExitCode func(err error) int // Run is a testable version of the exec.Run method. Should only be used during testing. Run func() error // SetupExeForPlatform is a testable version of the setupExeForPlatform call. // Should only be used during testing. SetupExeForPlatform func(exe *exec.Cmd, params Params) error // Params encapsulates the parameters used by the Exec* and RunWithEnv funcs. Params struct { Executable string // One of ArgsToSplit or Args should be defined on the Params. // ArgsToSplit should be preferred when issuing commands with a subshell and using "-c". // An example would be an invocation like: // Executable: "/bin/sh" // ArgsToSplit: "-c 'ls /usr/sap/*/SYS/global/hdb/custom/config/global.ini'" // In this case ArgsToSplit will be split up correctly as: // []string{"-c", "'ls /usr/sap/*/SYS/global/hdb/custom/config/global.ini'"} ArgsToSplit string Args []string Timeout int // defaults to 60, so timeout will occur in 60 seconds User string Env []string Stdin string } // Result holds the stdout, stderr, exit code, and error from the execution. Result struct { StdOut, StdErr string ExitCode int Error error ExecutableFound bool ExitStatusParsed bool // Will be true if "exit status ([0-9]+)" is in the error result } ) /* ExecuteCommand takes Params and returns a Result. If the params.Executable does not exist it will return early with the Result.Error filled If the Params ArgsToSplit is not empty then it will be split into an arguments array Else the Args will be used as the arguments array If the User is not empty then the command will be executed as that user If Env is defined then that environment will be used to execute the command The returned Result will contain the standard out, standard error, the exit code and an error if one was encountered during execution. */ func ExecuteCommand(ctx context.Context, params Params) Result { if !exists(params.Executable) { slog.DebugContext(ctx, "Command executable not found", "executable", params.Executable) msg := fmt.Sprintf("Command executable: %q not found.", params.Executable) return Result{"", msg, 0, fmt.Errorf("command executable: %s not found", params.Executable), false, false} } stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) // Timeout the command at 60 seconds by default. timeout := 60 * time.Second if params.Timeout > 0 { timeout = time.Duration(params.Timeout) * time.Second } // Context tctx has a Timeout while running the commands. tctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() args := params.Args if params.ArgsToSplit != "" { args = splitParams(params.ArgsToSplit) } restrictedArg := checkRestrictedArgs(args) if restrictedArg != "" { slog.DebugContext(ctx, "Command contains restricted argument", "executable", params.Executable, "args", args, "restrictedArg", restrictedArg) return Result{"", fmt.Sprintf("Command contains restricted argument: %q", restrictedArg), 0, fmt.Errorf("command contains restricted argument: %q", restrictedArg), true, false} } exe := exec.CommandContext(tctx, params.Executable, args...) exe.Stdin = strings.NewReader(params.Stdin) exe.Stdout = stdout exe.Stderr = stderr var err error if exeForPlatform != nil { err = exeForPlatform(exe, params) } else { // We pass ctx because this calls back into ExecuteCommand which adds the timeout before running the command. err = setupExeForPlatform(ctx, exe, params, ExecuteCommand) } if err != nil { slog.DebugContext(ctx, "Could not setup the executable environment", "executable", params.Executable, "args", args, "error", err) return Result{stdout.String(), stderr.String(), 0, err, true, false} } slog.DebugContext(ctx, "Executing command", "executable", params.Executable, "args", args, "timeout", timeout, "user", params.User, "env", params.Env) if run != nil { err = run() } else { err = exe.Run() } if err != nil { // Set the exit code based on the error first, then see if we can get it from the error message. exitCode := exitCode(err) m := exitStatusPattern.FindStringSubmatch(err.Error()) exitStatusParsed := false if len(m) > 0 { atoi, serr := strconv.Atoi(m[1]) if serr != nil { slog.DebugContext(ctx, "Failed to get command exit code from string match", "executable", params.Executable, "args", args, "error", serr) } else { // This is the case where we expect to have an Error but want the exit code from the "exit status #" string exitCode = atoi exitStatusParsed = true } } else { slog.DebugContext(ctx, "Error encountered when executing command", "executable", params.Executable, "args", args, "exitcode", exitCode, "error", err, "stdout", stdout.String(), "stderr", stderr.String()) } return Result{stdout.String(), stderr.String(), exitCode, err, true, exitStatusParsed} } // Exit code can assumed to be 0 slog.DebugContext(ctx, "Successfully executed command", "executable", params.Executable, "args", args, "stdout", stdout.String(), "stderr", stderr.String()) return Result{stdout.String(), stderr.String(), 0, nil, true, false} } /* checkRestrictedArgs returns the restricted argument found in the command args, if any. Restricted arguments are those that can cause security issues if used in certain contexts. */ func checkRestrictedArgs(args []string) string { // These are restricted arguments that should not be allowed in a command. // They can cause security issues if used in certain contexts. restrictedArgs := []string{";", "&", "|", ">", "<", "`", "\n", "$("} for _, arg := range args { for _, restrictedArg := range restrictedArgs { if strings.Contains(arg, restrictedArg) { return restrictedArg } } } return "" } /* CommandExists returns whether or not an executable command exists within the current os runtime environment. */ func CommandExists(executable string) bool { _, err := exec.LookPath(executable) return err == nil } /* commandExitCode returns the exit code attached to the error produced by a call to exec.Command or 0 if the error is null. */ func commandExitCode(err error) int { var exitErr *exec.ExitError if err != nil && errors.As(err, &exitErr) { return exitErr.ExitCode() } return 0 } /* splitParams performs a custom splitting operation around spaces and substrings contained within single quotes, exclusively, on command strings in order to parse them into a list of valid shell arguments for exec.Command structs ex: bash -c 'ls $0 $1' /etc /home becomes: {"bash", "-c", "ls $0 $1", "/etc", "/home"} */ func splitParams(executable string) []string { // This regex pattern matches substrings without spaces or those with spaces between single quotes pattern := regexp.MustCompile(`[^\s']+|('([^']*)')`) arr := pattern.FindAllString(executable, -1) // This for loop removes the single quote characters surrounding the matched substring for i := range arr { // convert "'ls $0 $1'" to "ls $0 $1" if arr[i][0] == '\'' && arr[i][len(arr[i])-1] == '\'' { arr[i] = arr[i][1 : len(arr[i])-1] } } // This for loop is to substitute backtick character \` with single quotes \' // Single quotes are used to filter for a specific substring (using grep) matching a given expression. // To prevent malformed matching, it is recommended to write the expression, // normally contained within single quotes, to be contained within back ticks. // This is helpful for bash commands using '-c' flag where the command statement // is enclosed within single quotes. // // For eg: instead of // grep -Eo '([0-9]{1,3}\.){1,3}[0-9]{1,3}\:[0-9]{3,5}' // use // grep -Eo `([0-9]{1,3}\.){1,3}[0-9]{1,3}\:[0-9]{3,5}` for i := range arr { arr[i] = strings.ReplaceAll(arr[i], "`", "'") } return arr } commandlineexecutor_test.go000066400000000000000000000343771524141036400401520ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor/* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package commandlineexecutor import ( "context" "fmt" "os/exec" "runtime" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func normalize(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") } func setDefaults() { exists = CommandExists exitCode = commandExitCode run = nil exeForPlatform = nil } func TestExecuteCommandWithArgsToSplit(t *testing.T) { input := []struct { name string cmd string args string wantOut string wantOutWindows string wantErr bool }{ { name: "echo", cmd: func() string { if runtime.GOOS == "windows" { return "cmd" } return "echo" }(), args: func() string { if runtime.GOOS == "windows" { return "/c echo hello, world" } return "hello, world" }(), wantOut: "hello, world\n", wantOutWindows: "hello, world\n", wantErr: false, }, { name: "path with spaces in single quotes", cmd: func() string { if runtime.GOOS == "windows" { return "cmd" } return "echo" }(), args: func() string { if runtime.GOOS == "windows" { return "/c echo 'a path with spaces'" } return "'a path with spaces'" }(), wantOut: "a path with spaces\n", wantOutWindows: "\"a path with spaces\"\n", wantErr: false, }, { name: "pipedCommand", cmd: "bash", args: "-c 'echo $0 | md5sum' 'test hashing functions'", wantOut: "", wantOutWindows: "", wantErr: true, }, { name: "andCommand", cmd: "bash", args: "-c 'echo test && sha1sum'", wantOut: "", wantOutWindows: "", wantErr: true, }, } for _, test := range input { t.Run(test.name, func(t *testing.T) { setDefaults() result := ExecuteCommand(context.Background(), Params{ Executable: test.cmd, ArgsToSplit: test.args, }) if (result.Error != nil) != test.wantErr { t.Fatalf("ExecuteCommand with argstosplit returned unexpected error: %v, wantErr: %v", result.Error, test.wantErr) } wantOut := test.wantOut if runtime.GOOS == "windows" { wantOut = test.wantOutWindows } if diff := cmp.Diff(wantOut, normalize(result.StdOut)); diff != "" { t.Fatalf("ExecuteCommand with argstosplit returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestExecuteCommandWithArgs(t *testing.T) { input := []struct { name string cmd string args []string wantOut string wantOutWindows string wantErr bool }{ { name: "echo", cmd: func() string { if runtime.GOOS == "windows" { return "cmd" } return "echo" }(), args: func() []string { if runtime.GOOS == "windows" { return []string{"/c", "echo", "hello, world"} } return []string{"hello, world"} }(), wantOut: "hello, world\n", wantOutWindows: "\"hello, world\"\n", wantErr: false, }, { name: "env", cmd: func() string { if runtime.GOOS == "windows" { return "cmd" } return "env" }(), args: func() []string { if runtime.GOOS == "windows" { return []string{"/c", "echo", "test", "sha1sum"} } return []string{"--", "echo", "test sha1sum"} }(), wantOut: "test sha1sum\n", wantOutWindows: "test sha1sum\n", wantErr: false, }, { name: "pipedCommand", cmd: "bash", args: []string{"-c", "echo $0$1$2 | sha1sum", "section1,", "section2,", "section3"}, wantOut: "", wantOutWindows: "", wantErr: true, }, } for _, test := range input { t.Run(test.name, func(t *testing.T) { setDefaults() result := ExecuteCommand(context.Background(), Params{ Executable: test.cmd, Args: test.args, }) if (result.Error != nil) != test.wantErr { t.Fatalf("ExecuteCommand with args returned unexpected error: %v, wantErr: %v", result.Error, test.wantErr) } wantOut := test.wantOut if runtime.GOOS == "windows" { wantOut = test.wantOutWindows } if diff := cmp.Diff(wantOut, normalize(result.StdOut)); diff != "" { t.Fatalf("ExecuteCommand with args returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestCommandExists(t *testing.T) { input := []struct { name string cmd string exists bool }{ { name: "echoExists", cmd: func() string { if runtime.GOOS == "windows" { return "cmd" } return "echo" }(), exists: true, }, { name: "lsExists", cmd: func() string { if runtime.GOOS == "windows" { return "findstr" } return "ls" }(), exists: true, }, { name: "encryptDoesNotExist", cmd: "encrypt", exists: false, }, } for _, test := range input { t.Run(test.name, func(t *testing.T) { setDefaults() if got := CommandExists(test.cmd); got != test.exists { t.Fatalf("CommandExists returned unexpected result, got: %t want: %t", got, test.exists) } }) } } func TestExecuteCommandAsUser(t *testing.T) { tests := []struct { name string cmd string fakeExists Exists fakeRun Run fakeExitCode ExitCode fakeSetupExe SetupExeForPlatform wantExitCode int64 wantErr error }{ { name: "ExistingCmd", cmd: "ls", fakeExists: func(string) bool { return true }, fakeRun: func() error { return nil }, fakeSetupExe: func(exe *exec.Cmd, params Params) error { return nil }, wantErr: nil, }, { name: "NonExistingCmd", cmd: "encrypt", fakeExists: func(string) bool { return false }, wantExitCode: 0, wantErr: cmpopts.AnyError, }, { name: "ExitCode15", cmd: "ls", fakeExists: func(string) bool { return true }, fakeRun: func() error { return fmt.Errorf("some failure") }, fakeExitCode: func(error) int { return 15 }, fakeSetupExe: func(exe *exec.Cmd, params Params) error { return nil }, wantExitCode: 15, wantErr: cmpopts.AnyError, }, { name: "NoExitCodeDoNotPanic", cmd: "echo", fakeExists: func(string) bool { return true }, fakeRun: func() error { return fmt.Errorf("exit status no-num") }, fakeExitCode: func(error) int { return 1 }, fakeSetupExe: func(exe *exec.Cmd, params Params) error { return nil }, wantErr: cmpopts.AnyError, wantExitCode: 1, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { exists = test.fakeExists exitCode = test.fakeExitCode run = test.fakeRun exeForPlatform = test.fakeSetupExe result := ExecuteCommand(context.Background(), Params{ Executable: test.cmd, User: "test-user", }) if !cmp.Equal(result.Error, test.wantErr, cmpopts.EquateErrors()) { t.Fatalf("ExecuteCommand with user got an error: %v, want: %v", result.Error, test.wantErr) } if test.wantExitCode != int64(result.ExitCode) { t.Fatalf("ExecuteCommand with user got an unexpected exit code: %d, want: %d", result.ExitCode, test.wantExitCode) } }) } } func TestExecuteWithEnv(t *testing.T) { tests := []struct { name string params Params wantStdOut string wantExitCode int wantErr error }{ { name: "ExistingCmd", params: Params{ Executable: func() string { if runtime.GOOS == "windows" { return "cmd" } return "echo" }(), ArgsToSplit: func() string { if runtime.GOOS == "windows" { return "/c echo test" } return "test" }(), }, wantStdOut: "test\n", wantExitCode: 0, wantErr: nil, }, { name: "NonExistingCmd", params: Params{ Executable: "encrypt", }, wantErr: cmpopts.AnyError, }, { name: "CommandFailure", params: Params{ Executable: func() string { if runtime.GOOS == "windows" { return "cmd" } return "cat" }(), ArgsToSplit: func() string { if runtime.GOOS == "windows" { return "/c type nonexisting.txtjson" } return "nonexisting.txtjson" }(), }, wantExitCode: 1, wantErr: cmpopts.AnyError, }, { name: "InvalidUser", params: Params{ Executable: "ls", User: "invalidUser", }, wantErr: cmpopts.AnyError, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { setDefaults() result := ExecuteCommand(context.Background(), test.params) if !cmp.Equal(result.Error, test.wantErr, cmpopts.EquateErrors()) { t.Errorf("ExecuteCommand with env got error: %v, want: %v", result.Error, test.wantErr) } if test.wantExitCode != result.ExitCode { t.Errorf("ExecuteCommand with env got exit code: %d, want: %d", result.ExitCode, test.wantExitCode) } if diff := cmp.Diff(test.wantStdOut, normalize(result.StdOut)); diff != "" { t.Errorf("ExecuteCommand with env returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestSetupExeForPlatform(t *testing.T) { tests := []struct { name string params Params executeCommand Execute want error wantWindows error }{ { name: "NoUserWithEnv", params: Params{ Env: []string{"test-env"}, }, executeCommand: ExecuteCommand, want: nil, wantWindows: nil, }, { name: "UserNotFound", params: Params{ User: "test-user", }, executeCommand: ExecuteCommand, want: cmpopts.AnyError, wantWindows: nil, }, { name: "UserFailedToParse", params: Params{ User: "test-user", }, executeCommand: func(context.Context, Params) Result { return Result{} }, want: cmpopts.AnyError, wantWindows: nil, }, { name: "UserFound", params: Params{ User: "test-user", }, executeCommand: func(context.Context, Params) Result { return Result{StdOut: "123"} }, want: nil, wantWindows: nil, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { setDefaults() got := setupExeForPlatform(context.Background(), &exec.Cmd{}, test.params, test.executeCommand) want := test.want if runtime.GOOS == "windows" { want = test.wantWindows } if !cmp.Equal(got, want, cmpopts.EquateErrors()) { t.Errorf("setupExeForPlatform(%#v) = %v, want: %v", test.params, got, want) } }) } } func TestSplitParams(t *testing.T) { tests := []struct { name string args string wantOut []string }{ { name: "echo", args: "echo hello, world", wantOut: []string{"echo", "hello,", "world"}, }, { name: "bashMd5sum", args: "-c 'echo $0 | md5sum' 'test hashing functions'", wantOut: []string{"-c", "echo $0 | md5sum", "test hashing functions"}, }, { name: "tcpFiltering", args: "-c 'lsof -nP -p $(pidof hdbnameserver) | grep LISTEN | grep -v 127.0.0.1 | grep -Eo `(([0-9]{1,3}\\.){1,3}[0-9]{1,3})|(\\*)\\:[0-9]{3,5}`'", wantOut: []string{"-c", "lsof -nP -p $(pidof hdbnameserver) | grep LISTEN | grep -v 127.0.0.1 | grep -Eo '(([0-9]{1,3}\\.){1,3}[0-9]{1,3})|(\\*)\\:[0-9]{3,5}'"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { splitArgs := splitParams(test.args) if diff := cmp.Diff(test.wantOut, splitArgs); diff != "" { t.Fatalf("splitParams returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestExecuteCommandWithStdin(t *testing.T) { tests := []struct { name string cmd string args []string input string wantOut string wantErr string }{ { name: "grep hello", cmd: func() string { if runtime.GOOS == "windows" { return "findstr" } return "grep" }(), args: []string{"hello"}, input: "hello world\nhello Go\nbye world\n", wantOut: "hello world\nhello Go\n", wantErr: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { setDefaults() result := ExecuteCommand(context.Background(), Params{ Executable: test.cmd, Args: test.args, Stdin: test.input, }) if result.Error != nil { t.Fatal(result.Error) } if diff := cmp.Diff(test.wantOut, normalize(result.StdOut)); diff != "" { t.Fatalf("ExecuteCommand returned unexpected diff (-want +got):\n%s", diff) } if diff := cmp.Diff(test.wantErr, result.StdErr); diff != "" { t.Fatalf("ExecuteCommand returned unexpected diff (-want +got):\n%s", diff) } }) } } func TestCheckRestrictedArgs(t *testing.T) { tests := []struct { name string args []string want string }{ { name: "No restricted args", args: []string{"-c", "echo hello"}, want: "", }, { name: "No restricted args with file path", args: []string{"-l", "/usr/local/google"}, want: "", }, { name: "Semicolon restricted arg", args: []string{"-c", "echo hello; ls"}, want: ";", }, { name: "Ampersand restricted arg", args: []string{"-c", "echo hello & ls"}, want: "&", }, { name: "Double ampersand restricted arg", args: []string{"-c", "echo hello && ls"}, want: "&", }, { name: "Pipe restricted arg", args: []string{"-c", "echo hello | grep hello"}, want: "|", }, { name: "Double pipe restricted arg", args: []string{"-c", "echo hello || grep hello"}, want: "|", }, { name: "Redirect restricted arg", args: []string{"-c", "echo hello > file"}, want: ">", }, { name: "Double redirect restricted arg", args: []string{"-c", "echo hello >> file"}, want: ">", }, { name: "Restricted arg in separate arg", args: []string{"-c", "echo hello", ";", "ls"}, want: ";", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := checkRestrictedArgs(tt.args); got != tt.want { t.Errorf("checkRestrictedArgs() = %v, want %v", got, tt.want) } }) } } exec_linux.go000066400000000000000000000037571524141036400352070ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor//go:build linux /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package commandlineexecutor import ( "context" "fmt" "os/exec" "strconv" "strings" "syscall" ) // setupExeForPlatform sets up the env and user if provided in the params. // returns an error if it could not be setup func setupExeForPlatform(ctx context.Context, exe *exec.Cmd, params Params, executeCommand Execute) error { // set the execution environment if params Env exists if len(params.Env) > 0 { exe.Env = append(exe.Environ(), params.Env...) } // if params.User exists run as the user if params.User != "" { uid, err := getUID(ctx, params.User, executeCommand) if err != nil { return err } exe.SysProcAttr = &syscall.SysProcAttr{} exe.SysProcAttr.Credential = &syscall.Credential{Uid: uid} } return nil } /* getUID takes user string and returns the numeric LINUX UserId and an Error. Returns (0, error) in case of failure, and (uid, nil) when successful. Note: This is intended for Linux based system only. */ func getUID(ctx context.Context, user string, executeCommand Execute) (uint32, error) { result := executeCommand(ctx, Params{ Executable: "id", ArgsToSplit: fmt.Sprintf("-u %s", user), }) if result.Error != nil { return 0, fmt.Errorf("getUID failed with: %s. StdErr: %s", result.Error, result.StdErr) } uid, err := strconv.Atoi(strings.TrimSuffix(result.StdOut, "\n")) if err != nil { return 0, fmt.Errorf("could not parse UID from StdOut: %s", result.StdOut) } return uint32(uid), nil } exec_windows.go000066400000000000000000000014631524141036400355320ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor//go:build windows /* Copyright 2022 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package commandlineexecutor import ( "context" "os/exec" ) // setupExeForPlatform is not implemented for windows. func setupExeForPlatform(ctx context.Context, exe *exec.Cmd, params Params, executeCommand Execute) error { return nil } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/communication/000077500000000000000000000000001524141036400313605ustar00rootroot00000000000000communication.go000066400000000000000000000053011524141036400344740ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/communication/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package communication provides capability to communicate via Agent Communication Service (ACS). package communication import ( "context" "fmt" "log/slog" "github.com/GoogleCloudPlatform/agentcommunication_client" "google.golang.org/api/option" "google.golang.org/protobuf/encoding/prototext" "github.com/GoogleCloudPlatform/agentcommunication_client/gapic" acpb "github.com/GoogleCloudPlatform/agentcommunication_client/gapic/agentcommunicationpb" anypb "google.golang.org/protobuf/types/known/anypb" ) var sendAgentMessage = func(ctx context.Context, channelID string, acsClient *agentcommunication.Client, msg *acpb.MessageBody) (*acpb.SendAgentMessageResponse, error) { return client.SendAgentMessage(ctx, channelID, acsClient, msg) } // SendDiscoveryDefinitionRequest sends a message to ACS to request the discovery definition. func SendDiscoveryDefinitionRequest(ctx context.Context, channelID string, acsClient *agentcommunication.Client) (*acpb.SendAgentMessageResponse, error) { // Message to indicate that the agent is requesting the discovery definition. msg := &acpb.MessageBody{ Body: &anypb.Any{}, Labels: map[string]string{ "message_type": "guesttelemetryextension.isvdiscovery.DiscoveryRules", }, } slog.Debug(fmt.Sprintf("Sending discovery definition request: %s", prototext.Format(msg))) return sendAgentMessage(ctx, channelID, acsClient, msg) } // SendDiscoveryResult sends a message to ACS to send the discovery result. func SendDiscoveryResult(ctx context.Context, channelID string, acsClient *agentcommunication.Client, body *anypb.Any) (*acpb.SendAgentMessageResponse, error) { msg := &acpb.MessageBody{ Body: body, Labels: map[string]string{ "message_type": "guesttelemetryextension.isvdiscovery.DiscoveryResult", }, } slog.Debug(fmt.Sprintf("Sending discovery result: %s", prototext.Format(msg))) return sendAgentMessage(ctx, channelID, acsClient, msg) } // CreateClient creates a new ACS client. func CreateClient(ctx context.Context, endpoint string) (*agentcommunication.Client, error) { if endpoint == "" { return client.NewClient(ctx, false) } return client.NewClient(ctx, false, option.WithEndpoint(endpoint)) } communication_test.go000066400000000000000000000073301524141036400355370ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/communication/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package communication import ( "context" "errors" "testing" "github.com/GoogleCloudPlatform/agentcommunication_client/gapic" acpb "github.com/GoogleCloudPlatform/agentcommunication_client/gapic/agentcommunicationpb" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" apb "google.golang.org/protobuf/types/known/anypb" ) type mockSendAgentMessage struct { gotMsg *acpb.MessageBody wantErr error } func (m *mockSendAgentMessage) SendAgentMessage(_ context.Context, _ string, _ *agentcommunication.Client, msg *acpb.MessageBody) (*acpb.SendAgentMessageResponse, error) { m.gotMsg = msg if m.wantErr != nil { return nil, m.wantErr } return &acpb.SendAgentMessageResponse{}, nil } func TestSendDiscoveryDefinitionRequest(t *testing.T) { tests := []struct { name string channelID string wantErr error }{ { name: "success", channelID: "channel-1", wantErr: nil, }, { name: "failure", channelID: "channel-2", wantErr: errors.New("send agent message error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := &mockSendAgentMessage{wantErr: tc.wantErr} origSendAgentMessage := sendAgentMessage sendAgentMessage = mock.SendAgentMessage defer func() { sendAgentMessage = origSendAgentMessage }() _, err := SendDiscoveryDefinitionRequest(context.Background(), tc.channelID, nil) if (err != nil) != (tc.wantErr != nil) { t.Errorf("SendDiscoveryDefinitionRequest(%q) got error %v, want error %v", tc.channelID, err, tc.wantErr) } wantMsg := &acpb.MessageBody{ Body: &apb.Any{}, Labels: map[string]string{ "message_type": "guesttelemetryextension.isvdiscovery.DiscoveryRules", }, } if diff := cmp.Diff(wantMsg, mock.gotMsg, protocmp.Transform()); diff != "" { t.Errorf("SendDiscoveryDefinitionRequest(%q) sent unexpected message diff (-want +got):\n%s", tc.channelID, diff) } }) } } func TestSendDiscoveryResult(t *testing.T) { tests := []struct { name string channelID string body *apb.Any wantErr error }{ { name: "success", channelID: "channel-1", body: &apb.Any{}, wantErr: nil, }, { name: "failure", channelID: "channel-2", body: &apb.Any{}, wantErr: errors.New("send agent message error"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { mock := &mockSendAgentMessage{wantErr: tc.wantErr} origSendAgentMessage := sendAgentMessage sendAgentMessage = mock.SendAgentMessage defer func() { sendAgentMessage = origSendAgentMessage }() _, err := SendDiscoveryResult(context.Background(), tc.channelID, nil, tc.body) if (err != nil) != (tc.wantErr != nil) { t.Errorf("SendDiscoveryResult(%q) got error %v, want error %v", tc.channelID, err, tc.wantErr) } wantMsg := &acpb.MessageBody{ Body: tc.body, Labels: map[string]string{ "message_type": "guesttelemetryextension.isvdiscovery.DiscoveryResult", }, } if diff := cmp.Diff(wantMsg, mock.gotMsg, protocmp.Transform()); diff != "" { t.Errorf("SendDiscoveryResult(%q) sent unexpected message diff (-want +got):\n%s", tc.channelID, diff) } }) } } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/definition/000077500000000000000000000000001524141036400306435ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/definition/proto/000077500000000000000000000000001524141036400320065ustar00rootroot00000000000000definition.pb.go000066400000000000000000001722371524141036400350220ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/definition/proto// //Copyright 2025 Google LLC // //Licensed under the Apache License, Version 2.0 (the "License"); //you may not use this file except in compliance with the License. //You may obtain a copy of the License at // //https://www.apache.org/licenses/LICENSE-2.0 // //Unless required by applicable law or agreed to in writing, software //distributed under the License is distributed on an "AS IS" BASIS, //WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. //See the License for the specific language governing permissions and //limitations under the License. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.12 // protoc v3.21.12 // source: isvdiscovery/definition/definition.proto package proto import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // These will match to a bundled executable within the extension to run to // gather version information // DO NOT ADD NEW COMMANDS TO THIS ENUM. USE EXTENDED_VERSION_COMMAND INSTEAD // TO AVOID INDEX OUT OF BOUNDS PANICS ON OLDER VERSIONS OF THE EXTENSION. type VersionCommand int32 const ( VersionCommand_VERSION_COMMAND_UNSPECIFIED VersionCommand = 0 VersionCommand_CAT VersionCommand = 1 VersionCommand_APACHE2 VersionCommand = 2 VersionCommand_HTTPD VersionCommand = 3 VersionCommand_POSTGRES VersionCommand = 4 VersionCommand_PSQL VersionCommand = 5 VersionCommand_NODETOOL VersionCommand = 6 VersionCommand_MONGOD VersionCommand = 7 VersionCommand_MYSQLD VersionCommand = 8 VersionCommand_SQLPLUS VersionCommand = 9 VersionCommand_REDISSERVER VersionCommand = 10 VersionCommand_MARIADB VersionCommand = 11 VersionCommand_GWRD VersionCommand = 12 VersionCommand_GREP VersionCommand = 13 VersionCommand_GETCOMMAND VersionCommand = 14 VersionCommand_IQ15 VersionCommand = 15 VersionCommand_IQ16 VersionCommand = 16 VersionCommand_IQ15WINDOWS VersionCommand = 17 VersionCommand_IQ16WINDOWS VersionCommand = 18 // Deprecated: Marked as deprecated in isvdiscovery/definition/definition.proto. VersionCommand_FINDSAPWEBAS VersionCommand = 19 // Deprecated: This command is not in use. VersionCommand_PACEMAKERD VersionCommand = 20 VersionCommand_SQLSERVR VersionCommand = 21 VersionCommand_GETITEMPROPERTYVALUE VersionCommand = 22 VersionCommand_OPATCH VersionCommand = 23 VersionCommand_DGMGRL VersionCommand = 24 VersionCommand_JAVA VersionCommand = 25 VersionCommand_GGSCI VersionCommand = 26 VersionCommand_PSADMIN VersionCommand = 27 VersionCommand_HDBLCM VersionCommand = 28 VersionCommand_MYSQL VersionCommand = 29 VersionCommand_CASSANDRA_NODETOOL VersionCommand = 30 VersionCommand_SBIN_NODETOOL VersionCommand = 31 VersionCommand_SQLSERVR_PATH VersionCommand = 32 VersionCommand_USE_DISCOVERED_PROCESS_PATH VersionCommand = 33 ) // Enum value maps for VersionCommand. var ( VersionCommand_name = map[int32]string{ 0: "VERSION_COMMAND_UNSPECIFIED", 1: "CAT", 2: "APACHE2", 3: "HTTPD", 4: "POSTGRES", 5: "PSQL", 6: "NODETOOL", 7: "MONGOD", 8: "MYSQLD", 9: "SQLPLUS", 10: "REDISSERVER", 11: "MARIADB", 12: "GWRD", 13: "GREP", 14: "GETCOMMAND", 15: "IQ15", 16: "IQ16", 17: "IQ15WINDOWS", 18: "IQ16WINDOWS", 19: "FINDSAPWEBAS", 20: "PACEMAKERD", 21: "SQLSERVR", 22: "GETITEMPROPERTYVALUE", 23: "OPATCH", 24: "DGMGRL", 25: "JAVA", 26: "GGSCI", 27: "PSADMIN", 28: "HDBLCM", 29: "MYSQL", 30: "CASSANDRA_NODETOOL", 31: "SBIN_NODETOOL", 32: "SQLSERVR_PATH", 33: "USE_DISCOVERED_PROCESS_PATH", } VersionCommand_value = map[string]int32{ "VERSION_COMMAND_UNSPECIFIED": 0, "CAT": 1, "APACHE2": 2, "HTTPD": 3, "POSTGRES": 4, "PSQL": 5, "NODETOOL": 6, "MONGOD": 7, "MYSQLD": 8, "SQLPLUS": 9, "REDISSERVER": 10, "MARIADB": 11, "GWRD": 12, "GREP": 13, "GETCOMMAND": 14, "IQ15": 15, "IQ16": 16, "IQ15WINDOWS": 17, "IQ16WINDOWS": 18, "FINDSAPWEBAS": 19, "PACEMAKERD": 20, "SQLSERVR": 21, "GETITEMPROPERTYVALUE": 22, "OPATCH": 23, "DGMGRL": 24, "JAVA": 25, "GGSCI": 26, "PSADMIN": 27, "HDBLCM": 28, "MYSQL": 29, "CASSANDRA_NODETOOL": 30, "SBIN_NODETOOL": 31, "SQLSERVR_PATH": 32, "USE_DISCOVERED_PROCESS_PATH": 33, } ) func (x VersionCommand) Enum() *VersionCommand { p := new(VersionCommand) *p = x return p } func (x VersionCommand) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (VersionCommand) Descriptor() protoreflect.EnumDescriptor { return file_isvdiscovery_definition_definition_proto_enumTypes[0].Descriptor() } func (VersionCommand) Type() protoreflect.EnumType { return &file_isvdiscovery_definition_definition_proto_enumTypes[0] } func (x VersionCommand) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Extended version commands to be used to gather version information. // Any new version commands should be added to this enum. // This avoids index out of bounds panics on older versions of the extension. type ExtendedVersionCommand int32 const ( ExtendedVersionCommand_EXTENDED_VERSION_COMMAND_UNSPECIFIED ExtendedVersionCommand = 0 ExtendedVersionCommand_HDB ExtendedVersionCommand = 1 ExtendedVersionCommand_DISP_WORK ExtendedVersionCommand = 2 ExtendedVersionCommand_MSG_SERVER ExtendedVersionCommand = 3 ExtendedVersionCommand_EN_SERVER ExtendedVersionCommand = 4 ExtendedVersionCommand_AWK ExtendedVersionCommand = 5 ExtendedVersionCommand_SAPWEBDISP ExtendedVersionCommand = 6 ExtendedVersionCommand_START_IQ ExtendedVersionCommand = 7 ExtendedVersionCommand_ENREP ExtendedVersionCommand = 8 ExtendedVersionCommand_SPARK_SUBMIT ExtendedVersionCommand = 9 ExtendedVersionCommand_SPARK_SUBMIT_PATH ExtendedVersionCommand = 10 ExtendedVersionCommand_CRICTL ExtendedVersionCommand = 11 ExtendedVersionCommand_HADOOP ExtendedVersionCommand = 12 ExtendedVersionCommand_HADOOP_PATH ExtendedVersionCommand = 13 ExtendedVersionCommand_DPKG ExtendedVersionCommand = 14 ExtendedVersionCommand_RPM ExtendedVersionCommand = 15 ExtendedVersionCommand_GETPACKAGE ExtendedVersionCommand = 16 ) // Enum value maps for ExtendedVersionCommand. var ( ExtendedVersionCommand_name = map[int32]string{ 0: "EXTENDED_VERSION_COMMAND_UNSPECIFIED", 1: "HDB", 2: "DISP_WORK", 3: "MSG_SERVER", 4: "EN_SERVER", 5: "AWK", 6: "SAPWEBDISP", 7: "START_IQ", 8: "ENREP", 9: "SPARK_SUBMIT", 10: "SPARK_SUBMIT_PATH", 11: "CRICTL", 12: "HADOOP", 13: "HADOOP_PATH", 14: "DPKG", 15: "RPM", 16: "GETPACKAGE", } ExtendedVersionCommand_value = map[string]int32{ "EXTENDED_VERSION_COMMAND_UNSPECIFIED": 0, "HDB": 1, "DISP_WORK": 2, "MSG_SERVER": 3, "EN_SERVER": 4, "AWK": 5, "SAPWEBDISP": 6, "START_IQ": 7, "ENREP": 8, "SPARK_SUBMIT": 9, "SPARK_SUBMIT_PATH": 10, "CRICTL": 11, "HADOOP": 12, "HADOOP_PATH": 13, "DPKG": 14, "RPM": 15, "GETPACKAGE": 16, } ) func (x ExtendedVersionCommand) Enum() *ExtendedVersionCommand { p := new(ExtendedVersionCommand) *p = x return p } func (x ExtendedVersionCommand) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (ExtendedVersionCommand) Descriptor() protoreflect.EnumDescriptor { return file_isvdiscovery_definition_definition_proto_enumTypes[1].Descriptor() } func (ExtendedVersionCommand) Type() protoreflect.EnumType { return &file_isvdiscovery_definition_definition_proto_enumTypes[1] } func (x ExtendedVersionCommand) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Which of the fields should be matched. type StringMatchCondition_VmField int32 const ( StringMatchCondition_VM_FIELD_UNSPECIFIED StringMatchCondition_VmField = 0 StringMatchCondition_VM_PROCESS_NAME StringMatchCondition_VmField = 1 StringMatchCondition_VM_PROCESS_PATH StringMatchCondition_VmField = 2 StringMatchCondition_VM_OS_NAME StringMatchCondition_VmField = 3 StringMatchCondition_VM_CLI_ARGS StringMatchCondition_VmField = 4 StringMatchCondition_VM_ENV_VARS StringMatchCondition_VmField = 5 ) // Enum value maps for StringMatchCondition_VmField. var ( StringMatchCondition_VmField_name = map[int32]string{ 0: "VM_FIELD_UNSPECIFIED", 1: "VM_PROCESS_NAME", 2: "VM_PROCESS_PATH", 3: "VM_OS_NAME", 4: "VM_CLI_ARGS", 5: "VM_ENV_VARS", } StringMatchCondition_VmField_value = map[string]int32{ "VM_FIELD_UNSPECIFIED": 0, "VM_PROCESS_NAME": 1, "VM_PROCESS_PATH": 2, "VM_OS_NAME": 3, "VM_CLI_ARGS": 4, "VM_ENV_VARS": 5, } ) func (x StringMatchCondition_VmField) Enum() *StringMatchCondition_VmField { p := new(StringMatchCondition_VmField) *p = x return p } func (x StringMatchCondition_VmField) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (StringMatchCondition_VmField) Descriptor() protoreflect.EnumDescriptor { return file_isvdiscovery_definition_definition_proto_enumTypes[2].Descriptor() } func (StringMatchCondition_VmField) Type() protoreflect.EnumType { return &file_isvdiscovery_definition_definition_proto_enumTypes[2] } func (x StringMatchCondition_VmField) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Request to get the discovery rules for the agent. type GetDiscoveryRulesRequest struct { state protoimpl.MessageState `protogen:"opaque.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetDiscoveryRulesRequest) Reset() { *x = GetDiscoveryRulesRequest{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetDiscoveryRulesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetDiscoveryRulesRequest) ProtoMessage() {} func (x *GetDiscoveryRulesRequest) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } type GetDiscoveryRulesRequest_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. } func (b0 GetDiscoveryRulesRequest_builder) Build() *GetDiscoveryRulesRequest { m0 := &GetDiscoveryRulesRequest{} b, x := &b0, m0 _, _ = b, x return m0 } type DiscoveryConfiguration struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_ScanIntervalSeconds int32 `protobuf:"varint,1,opt,name=scan_interval_seconds,json=scanIntervalSeconds,proto3"` xxx_hidden_MinimumReportingIntervalSeconds int32 `protobuf:"varint,2,opt,name=minimum_reporting_interval_seconds,json=minimumReportingIntervalSeconds,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DiscoveryConfiguration) Reset() { *x = DiscoveryConfiguration{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DiscoveryConfiguration) String() string { return protoimpl.X.MessageStringOf(x) } func (*DiscoveryConfiguration) ProtoMessage() {} func (x *DiscoveryConfiguration) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DiscoveryConfiguration) GetScanIntervalSeconds() int32 { if x != nil { return x.xxx_hidden_ScanIntervalSeconds } return 0 } func (x *DiscoveryConfiguration) GetMinimumReportingIntervalSeconds() int32 { if x != nil { return x.xxx_hidden_MinimumReportingIntervalSeconds } return 0 } func (x *DiscoveryConfiguration) SetScanIntervalSeconds(v int32) { x.xxx_hidden_ScanIntervalSeconds = v } func (x *DiscoveryConfiguration) SetMinimumReportingIntervalSeconds(v int32) { x.xxx_hidden_MinimumReportingIntervalSeconds = v } type DiscoveryConfiguration_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Interval between agent scans in seconds. // Default: 15 minutes = 15 * 60. ScanIntervalSeconds int32 // Minimum reporting interval between agent scans in seconds. // This is also the frequency of fetching the discovery rules from the // backend. Default: 24 hours = 24 * 60 * 60. MinimumReportingIntervalSeconds int32 } func (b0 DiscoveryConfiguration_builder) Build() *DiscoveryConfiguration { m0 := &DiscoveryConfiguration{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_ScanIntervalSeconds = b.ScanIntervalSeconds x.xxx_hidden_MinimumReportingIntervalSeconds = b.MinimumReportingIntervalSeconds return m0 } // Request containing the rules to be used for discovery. type DiscoveryRules struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Rules *[]*DiscoveryRule `protobuf:"bytes,1,rep,name=rules,proto3"` xxx_hidden_Config *DiscoveryConfiguration `protobuf:"bytes,2,opt,name=config,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DiscoveryRules) Reset() { *x = DiscoveryRules{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DiscoveryRules) String() string { return protoimpl.X.MessageStringOf(x) } func (*DiscoveryRules) ProtoMessage() {} func (x *DiscoveryRules) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DiscoveryRules) GetRules() []*DiscoveryRule { if x != nil { if x.xxx_hidden_Rules != nil { return *x.xxx_hidden_Rules } } return nil } func (x *DiscoveryRules) GetConfig() *DiscoveryConfiguration { if x != nil { return x.xxx_hidden_Config } return nil } func (x *DiscoveryRules) SetRules(v []*DiscoveryRule) { x.xxx_hidden_Rules = &v } func (x *DiscoveryRules) SetConfig(v *DiscoveryConfiguration) { x.xxx_hidden_Config = v } func (x *DiscoveryRules) HasConfig() bool { if x == nil { return false } return x.xxx_hidden_Config != nil } func (x *DiscoveryRules) ClearConfig() { x.xxx_hidden_Config = nil } type DiscoveryRules_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. Rules []*DiscoveryRule Config *DiscoveryConfiguration } func (b0 DiscoveryRules_builder) Build() *DiscoveryRules { m0 := &DiscoveryRules{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Rules = &b.Rules x.xxx_hidden_Config = b.Config return m0 } // Rule used for classification. // // Multiple different conditions can be nested using any and all, e.g.: // // all: { // any: { // all: { // conditions: {} // conditions: {} // } // conditions: {} // } // conditions: {} // } type DiscoveryRule struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Id string `protobuf:"bytes,1,opt,name=id,proto3"` xxx_hidden_Rule isDiscoveryRule_Rule `protobuf_oneof:"rule"` xxx_hidden_VersionRules *[]*DiscoveryVersionRule `protobuf:"bytes,6,rep,name=version_rules,json=versionRules,proto3"` xxx_hidden_DiscoveredWorkloadName string `protobuf:"bytes,7,opt,name=discovered_workload_name,json=discoveredWorkloadName,proto3"` xxx_hidden_DisplayName string `protobuf:"bytes,8,opt,name=display_name,json=displayName,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DiscoveryRule) Reset() { *x = DiscoveryRule{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DiscoveryRule) String() string { return protoimpl.X.MessageStringOf(x) } func (*DiscoveryRule) ProtoMessage() {} func (x *DiscoveryRule) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DiscoveryRule) GetId() string { if x != nil { return x.xxx_hidden_Id } return "" } func (x *DiscoveryRule) GetCondition() *Condition { if x != nil { if x, ok := x.xxx_hidden_Rule.(*discoveryRule_Condition); ok { return x.Condition } } return nil } func (x *DiscoveryRule) GetAll() *AllCondition { if x != nil { if x, ok := x.xxx_hidden_Rule.(*discoveryRule_All); ok { return x.All } } return nil } func (x *DiscoveryRule) GetAny() *AnyCondition { if x != nil { if x, ok := x.xxx_hidden_Rule.(*discoveryRule_Any); ok { return x.Any } } return nil } func (x *DiscoveryRule) GetVersionRules() []*DiscoveryVersionRule { if x != nil { if x.xxx_hidden_VersionRules != nil { return *x.xxx_hidden_VersionRules } } return nil } func (x *DiscoveryRule) GetDiscoveredWorkloadName() string { if x != nil { return x.xxx_hidden_DiscoveredWorkloadName } return "" } func (x *DiscoveryRule) GetDisplayName() string { if x != nil { return x.xxx_hidden_DisplayName } return "" } func (x *DiscoveryRule) SetId(v string) { x.xxx_hidden_Id = v } func (x *DiscoveryRule) SetCondition(v *Condition) { if v == nil { x.xxx_hidden_Rule = nil return } x.xxx_hidden_Rule = &discoveryRule_Condition{v} } func (x *DiscoveryRule) SetAll(v *AllCondition) { if v == nil { x.xxx_hidden_Rule = nil return } x.xxx_hidden_Rule = &discoveryRule_All{v} } func (x *DiscoveryRule) SetAny(v *AnyCondition) { if v == nil { x.xxx_hidden_Rule = nil return } x.xxx_hidden_Rule = &discoveryRule_Any{v} } func (x *DiscoveryRule) SetVersionRules(v []*DiscoveryVersionRule) { x.xxx_hidden_VersionRules = &v } func (x *DiscoveryRule) SetDiscoveredWorkloadName(v string) { x.xxx_hidden_DiscoveredWorkloadName = v } func (x *DiscoveryRule) SetDisplayName(v string) { x.xxx_hidden_DisplayName = v } func (x *DiscoveryRule) HasRule() bool { if x == nil { return false } return x.xxx_hidden_Rule != nil } func (x *DiscoveryRule) HasCondition() bool { if x == nil { return false } _, ok := x.xxx_hidden_Rule.(*discoveryRule_Condition) return ok } func (x *DiscoveryRule) HasAll() bool { if x == nil { return false } _, ok := x.xxx_hidden_Rule.(*discoveryRule_All) return ok } func (x *DiscoveryRule) HasAny() bool { if x == nil { return false } _, ok := x.xxx_hidden_Rule.(*discoveryRule_Any) return ok } func (x *DiscoveryRule) ClearRule() { x.xxx_hidden_Rule = nil } func (x *DiscoveryRule) ClearCondition() { if _, ok := x.xxx_hidden_Rule.(*discoveryRule_Condition); ok { x.xxx_hidden_Rule = nil } } func (x *DiscoveryRule) ClearAll() { if _, ok := x.xxx_hidden_Rule.(*discoveryRule_All); ok { x.xxx_hidden_Rule = nil } } func (x *DiscoveryRule) ClearAny() { if _, ok := x.xxx_hidden_Rule.(*discoveryRule_Any); ok { x.xxx_hidden_Rule = nil } } const DiscoveryRule_Rule_not_set_case case_DiscoveryRule_Rule = 0 const DiscoveryRule_Condition_case case_DiscoveryRule_Rule = 3 const DiscoveryRule_All_case case_DiscoveryRule_Rule = 4 const DiscoveryRule_Any_case case_DiscoveryRule_Rule = 5 func (x *DiscoveryRule) WhichRule() case_DiscoveryRule_Rule { if x == nil { return DiscoveryRule_Rule_not_set_case } switch x.xxx_hidden_Rule.(type) { case *discoveryRule_Condition: return DiscoveryRule_Condition_case case *discoveryRule_All: return DiscoveryRule_All_case case *discoveryRule_Any: return DiscoveryRule_Any_case default: return DiscoveryRule_Rule_not_set_case } } type DiscoveryRule_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Unique rule identifier. Id string // Rule is a simple condition or . // Fields of oneof xxx_hidden_Rule: // Single simple condition. Condition *Condition // All of the nested conditions must be satisfied. All *AllCondition // Any of the nested conditions must be satisfied. Any *AnyCondition // -- end of xxx_hidden_Rule // Use the result from the first version rule that successfully retrieves a // version. VersionRules []*DiscoveryVersionRule // Internal name used to reference the workload. DiscoveredWorkloadName string // User-friendly name of the workload. DisplayName string } func (b0 DiscoveryRule_builder) Build() *DiscoveryRule { m0 := &DiscoveryRule{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Id = b.Id if b.Condition != nil { x.xxx_hidden_Rule = &discoveryRule_Condition{b.Condition} } if b.All != nil { x.xxx_hidden_Rule = &discoveryRule_All{b.All} } if b.Any != nil { x.xxx_hidden_Rule = &discoveryRule_Any{b.Any} } x.xxx_hidden_VersionRules = &b.VersionRules x.xxx_hidden_DiscoveredWorkloadName = b.DiscoveredWorkloadName x.xxx_hidden_DisplayName = b.DisplayName return m0 } type case_DiscoveryRule_Rule protoreflect.FieldNumber func (x case_DiscoveryRule_Rule) String() string { md := file_isvdiscovery_definition_definition_proto_msgTypes[3].Descriptor() if x == 0 { return "not set" } return protoimpl.X.MessageFieldStringOf(md, protoreflect.FieldNumber(x)) } type isDiscoveryRule_Rule interface { isDiscoveryRule_Rule() } type discoveryRule_Condition struct { // Single simple condition. Condition *Condition `protobuf:"bytes,3,opt,name=condition,proto3,oneof"` } type discoveryRule_All struct { // All of the nested conditions must be satisfied. All *AllCondition `protobuf:"bytes,4,opt,name=all,proto3,oneof"` } type discoveryRule_Any struct { // Any of the nested conditions must be satisfied. Any *AnyCondition `protobuf:"bytes,5,opt,name=any,proto3,oneof"` } func (*discoveryRule_Condition) isDiscoveryRule_Rule() {} func (*discoveryRule_All) isDiscoveryRule_Rule() {} func (*discoveryRule_Any) isDiscoveryRule_Rule() {} // One of the composed conditions which requires that all of the subconditions // must be satisfied. type AllCondition struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Any *AnyCondition `protobuf:"bytes,1,opt,name=any,proto3,oneof"` xxx_hidden_Conditions *[]*Condition `protobuf:"bytes,2,rep,name=conditions,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AllCondition) Reset() { *x = AllCondition{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AllCondition) String() string { return protoimpl.X.MessageStringOf(x) } func (*AllCondition) ProtoMessage() {} func (x *AllCondition) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *AllCondition) GetAny() *AnyCondition { if x != nil { return x.xxx_hidden_Any } return nil } func (x *AllCondition) GetConditions() []*Condition { if x != nil { if x.xxx_hidden_Conditions != nil { return *x.xxx_hidden_Conditions } } return nil } func (x *AllCondition) SetAny(v *AnyCondition) { x.xxx_hidden_Any = v } func (x *AllCondition) SetConditions(v []*Condition) { x.xxx_hidden_Conditions = &v } func (x *AllCondition) HasAny() bool { if x == nil { return false } return x.xxx_hidden_Any != nil } func (x *AllCondition) ClearAny() { x.xxx_hidden_Any = nil } type AllCondition_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Allows further nesting of conditions. Any *AnyCondition // All of these subconditions must be satisfied. Conditions []*Condition } func (b0 AllCondition_builder) Build() *AllCondition { m0 := &AllCondition{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Any = b.Any x.xxx_hidden_Conditions = &b.Conditions return m0 } // One of the composed conditions which requires that any of the subconditions // must be satisfied. type AnyCondition struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_All *AllCondition `protobuf:"bytes,1,opt,name=all,proto3,oneof"` xxx_hidden_Conditions *[]*Condition `protobuf:"bytes,2,rep,name=conditions,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AnyCondition) Reset() { *x = AnyCondition{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AnyCondition) String() string { return protoimpl.X.MessageStringOf(x) } func (*AnyCondition) ProtoMessage() {} func (x *AnyCondition) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *AnyCondition) GetAll() *AllCondition { if x != nil { return x.xxx_hidden_All } return nil } func (x *AnyCondition) GetConditions() []*Condition { if x != nil { if x.xxx_hidden_Conditions != nil { return *x.xxx_hidden_Conditions } } return nil } func (x *AnyCondition) SetAll(v *AllCondition) { x.xxx_hidden_All = v } func (x *AnyCondition) SetConditions(v []*Condition) { x.xxx_hidden_Conditions = &v } func (x *AnyCondition) HasAll() bool { if x == nil { return false } return x.xxx_hidden_All != nil } func (x *AnyCondition) ClearAll() { x.xxx_hidden_All = nil } type AnyCondition_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Allows further nesting of conditions. All *AllCondition // Any of these subconditions must be satisfied. Conditions []*Condition } func (b0 AnyCondition_builder) Build() *AnyCondition { m0 := &AnyCondition{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_All = b.All x.xxx_hidden_Conditions = &b.Conditions return m0 } // One of the rule's conditions. type Condition struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Condition isCondition_Condition `protobuf_oneof:"condition"` xxx_hidden_Negated bool `protobuf:"varint,3,opt,name=negated,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Condition) Reset() { *x = Condition{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Condition) String() string { return protoimpl.X.MessageStringOf(x) } func (*Condition) ProtoMessage() {} func (x *Condition) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *Condition) GetStringMatch() *StringMatchCondition { if x != nil { if x, ok := x.xxx_hidden_Condition.(*condition_StringMatch); ok { return x.StringMatch } } return nil } func (x *Condition) GetNegated() bool { if x != nil { return x.xxx_hidden_Negated } return false } func (x *Condition) SetStringMatch(v *StringMatchCondition) { if v == nil { x.xxx_hidden_Condition = nil return } x.xxx_hidden_Condition = &condition_StringMatch{v} } func (x *Condition) SetNegated(v bool) { x.xxx_hidden_Negated = v } func (x *Condition) HasCondition() bool { if x == nil { return false } return x.xxx_hidden_Condition != nil } func (x *Condition) HasStringMatch() bool { if x == nil { return false } _, ok := x.xxx_hidden_Condition.(*condition_StringMatch) return ok } func (x *Condition) ClearCondition() { x.xxx_hidden_Condition = nil } func (x *Condition) ClearStringMatch() { if _, ok := x.xxx_hidden_Condition.(*condition_StringMatch); ok { x.xxx_hidden_Condition = nil } } const Condition_Condition_not_set_case case_Condition_Condition = 0 const Condition_StringMatch_case case_Condition_Condition = 1 func (x *Condition) WhichCondition() case_Condition_Condition { if x == nil { return Condition_Condition_not_set_case } switch x.xxx_hidden_Condition.(type) { case *condition_StringMatch: return Condition_StringMatch_case default: return Condition_Condition_not_set_case } } type Condition_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Fields of oneof xxx_hidden_Condition: StringMatch *StringMatchCondition // -- end of xxx_hidden_Condition // Negates the result of the condition. Negated bool } func (b0 Condition_builder) Build() *Condition { m0 := &Condition{} b, x := &b0, m0 _, _ = b, x if b.StringMatch != nil { x.xxx_hidden_Condition = &condition_StringMatch{b.StringMatch} } x.xxx_hidden_Negated = b.Negated return m0 } type case_Condition_Condition protoreflect.FieldNumber func (x case_Condition_Condition) String() string { md := file_isvdiscovery_definition_definition_proto_msgTypes[6].Descriptor() if x == 0 { return "not set" } return protoimpl.X.MessageFieldStringOf(md, protoreflect.FieldNumber(x)) } type isCondition_Condition interface { isCondition_Condition() } type condition_StringMatch struct { StringMatch *StringMatchCondition `protobuf:"bytes,1,opt,name=string_match,json=stringMatch,proto3,oneof"` } func (*condition_StringMatch) isCondition_Condition() {} // Generic string matching condition. type StringMatchCondition struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Fields isStringMatchCondition_Fields `protobuf_oneof:"fields"` xxx_hidden_RegexMatch string `protobuf:"bytes,2,opt,name=regex_match,json=regexMatch,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StringMatchCondition) Reset() { *x = StringMatchCondition{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StringMatchCondition) String() string { return protoimpl.X.MessageStringOf(x) } func (*StringMatchCondition) ProtoMessage() {} func (x *StringMatchCondition) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *StringMatchCondition) GetVmField() StringMatchCondition_VmField { if x != nil { if x, ok := x.xxx_hidden_Fields.(*stringMatchCondition_VmField_); ok { return x.VmField } } return StringMatchCondition_VM_FIELD_UNSPECIFIED } func (x *StringMatchCondition) GetRegexMatch() string { if x != nil { return x.xxx_hidden_RegexMatch } return "" } func (x *StringMatchCondition) SetVmField(v StringMatchCondition_VmField) { x.xxx_hidden_Fields = &stringMatchCondition_VmField_{v} } func (x *StringMatchCondition) SetRegexMatch(v string) { x.xxx_hidden_RegexMatch = v } func (x *StringMatchCondition) HasFields() bool { if x == nil { return false } return x.xxx_hidden_Fields != nil } func (x *StringMatchCondition) HasVmField() bool { if x == nil { return false } _, ok := x.xxx_hidden_Fields.(*stringMatchCondition_VmField_) return ok } func (x *StringMatchCondition) ClearFields() { x.xxx_hidden_Fields = nil } func (x *StringMatchCondition) ClearVmField() { if _, ok := x.xxx_hidden_Fields.(*stringMatchCondition_VmField_); ok { x.xxx_hidden_Fields = nil } } const StringMatchCondition_Fields_not_set_case case_StringMatchCondition_Fields = 0 const StringMatchCondition_VmField_case case_StringMatchCondition_Fields = 1 func (x *StringMatchCondition) WhichFields() case_StringMatchCondition_Fields { if x == nil { return StringMatchCondition_Fields_not_set_case } switch x.xxx_hidden_Fields.(type) { case *stringMatchCondition_VmField_: return StringMatchCondition_VmField_case default: return StringMatchCondition_Fields_not_set_case } } type StringMatchCondition_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // Fields of oneof xxx_hidden_Fields: VmField *StringMatchCondition_VmField // -- end of xxx_hidden_Fields // Regex that will be run to match the field. RegexMatch string } func (b0 StringMatchCondition_builder) Build() *StringMatchCondition { m0 := &StringMatchCondition{} b, x := &b0, m0 _, _ = b, x if b.VmField != nil { x.xxx_hidden_Fields = &stringMatchCondition_VmField_{*b.VmField} } x.xxx_hidden_RegexMatch = b.RegexMatch return m0 } type case_StringMatchCondition_Fields protoreflect.FieldNumber func (x case_StringMatchCondition_Fields) String() string { md := file_isvdiscovery_definition_definition_proto_msgTypes[7].Descriptor() if x == 0 { return "not set" } return protoimpl.X.MessageFieldStringOf(md, protoreflect.FieldNumber(x)) } type isStringMatchCondition_Fields interface { isStringMatchCondition_Fields() } type stringMatchCondition_VmField_ struct { VmField StringMatchCondition_VmField `protobuf:"varint,1,opt,name=vm_field,json=vmField,proto3,enum=guesttelemetryextension.isvdiscovery.StringMatchCondition_VmField,oneof"` } func (*stringMatchCondition_VmField_) isStringMatchCondition_Fields() {} // Defines a single step in a sequential version command pipeline. type VersionCommandStep struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Command VersionCommand `protobuf:"varint,1,opt,name=command,proto3,enum=guesttelemetryextension.isvdiscovery.VersionCommand"` xxx_hidden_CommandArgs []string `protobuf:"bytes,2,rep,name=command_args,json=commandArgs,proto3"` xxx_hidden_RegexMatch string `protobuf:"bytes,3,opt,name=regex_match,json=regexMatch,proto3"` xxx_hidden_RunAsDiscoveredProcessUser bool `protobuf:"varint,4,opt,name=run_as_discovered_process_user,json=runAsDiscoveredProcessUser,proto3"` xxx_hidden_ExtendedCommand ExtendedVersionCommand `protobuf:"varint,5,opt,name=extended_command,json=extendedCommand,proto3,enum=guesttelemetryextension.isvdiscovery.ExtendedVersionCommand"` xxx_hidden_UsePreviousOutputAsStdin bool `protobuf:"varint,6,opt,name=use_previous_output_as_stdin,json=usePreviousOutputAsStdin,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *VersionCommandStep) Reset() { *x = VersionCommandStep{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *VersionCommandStep) String() string { return protoimpl.X.MessageStringOf(x) } func (*VersionCommandStep) ProtoMessage() {} func (x *VersionCommandStep) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *VersionCommandStep) GetCommand() VersionCommand { if x != nil { return x.xxx_hidden_Command } return VersionCommand_VERSION_COMMAND_UNSPECIFIED } func (x *VersionCommandStep) GetCommandArgs() []string { if x != nil { return x.xxx_hidden_CommandArgs } return nil } func (x *VersionCommandStep) GetRegexMatch() string { if x != nil { return x.xxx_hidden_RegexMatch } return "" } func (x *VersionCommandStep) GetRunAsDiscoveredProcessUser() bool { if x != nil { return x.xxx_hidden_RunAsDiscoveredProcessUser } return false } func (x *VersionCommandStep) GetExtendedCommand() ExtendedVersionCommand { if x != nil { return x.xxx_hidden_ExtendedCommand } return ExtendedVersionCommand_EXTENDED_VERSION_COMMAND_UNSPECIFIED } func (x *VersionCommandStep) GetUsePreviousOutputAsStdin() bool { if x != nil { return x.xxx_hidden_UsePreviousOutputAsStdin } return false } func (x *VersionCommandStep) SetCommand(v VersionCommand) { x.xxx_hidden_Command = v } func (x *VersionCommandStep) SetCommandArgs(v []string) { x.xxx_hidden_CommandArgs = v } func (x *VersionCommandStep) SetRegexMatch(v string) { x.xxx_hidden_RegexMatch = v } func (x *VersionCommandStep) SetRunAsDiscoveredProcessUser(v bool) { x.xxx_hidden_RunAsDiscoveredProcessUser = v } func (x *VersionCommandStep) SetExtendedCommand(v ExtendedVersionCommand) { x.xxx_hidden_ExtendedCommand = v } func (x *VersionCommandStep) SetUsePreviousOutputAsStdin(v bool) { x.xxx_hidden_UsePreviousOutputAsStdin = v } type VersionCommandStep_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. Command VersionCommand CommandArgs []string RegexMatch string RunAsDiscoveredProcessUser bool ExtendedCommand ExtendedVersionCommand UsePreviousOutputAsStdin bool } func (b0 VersionCommandStep_builder) Build() *VersionCommandStep { m0 := &VersionCommandStep{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Command = b.Command x.xxx_hidden_CommandArgs = b.CommandArgs x.xxx_hidden_RegexMatch = b.RegexMatch x.xxx_hidden_RunAsDiscoveredProcessUser = b.RunAsDiscoveredProcessUser x.xxx_hidden_ExtendedCommand = b.ExtendedCommand x.xxx_hidden_UsePreviousOutputAsStdin = b.UsePreviousOutputAsStdin return m0 } // Defines the commands that will be used to gather the version information. // Each version rule command will be run in order until one of them gives back a // string that contains a regex match defined by the rule. type DiscoveryVersionRule struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Command VersionCommand `protobuf:"varint,1,opt,name=command,proto3,enum=guesttelemetryextension.isvdiscovery.VersionCommand"` xxx_hidden_CommandArgs []string `protobuf:"bytes,2,rep,name=command_args,json=commandArgs,proto3"` xxx_hidden_RegexMatch string `protobuf:"bytes,3,opt,name=regex_match,json=regexMatch,proto3"` xxx_hidden_RunAsDiscoveredProcessUser bool `protobuf:"varint,4,opt,name=run_as_discovered_process_user,json=runAsDiscoveredProcessUser,proto3"` xxx_hidden_ExtendedCommand ExtendedVersionCommand `protobuf:"varint,5,opt,name=extended_command,json=extendedCommand,proto3,enum=guesttelemetryextension.isvdiscovery.ExtendedVersionCommand"` xxx_hidden_Steps *[]*VersionCommandStep `protobuf:"bytes,6,rep,name=steps,proto3"` xxx_hidden_VersionExtractPattern string `protobuf:"bytes,7,opt,name=version_extract_pattern,json=versionExtractPattern,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DiscoveryVersionRule) Reset() { *x = DiscoveryVersionRule{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DiscoveryVersionRule) String() string { return protoimpl.X.MessageStringOf(x) } func (*DiscoveryVersionRule) ProtoMessage() {} func (x *DiscoveryVersionRule) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DiscoveryVersionRule) GetCommand() VersionCommand { if x != nil { return x.xxx_hidden_Command } return VersionCommand_VERSION_COMMAND_UNSPECIFIED } func (x *DiscoveryVersionRule) GetCommandArgs() []string { if x != nil { return x.xxx_hidden_CommandArgs } return nil } func (x *DiscoveryVersionRule) GetRegexMatch() string { if x != nil { return x.xxx_hidden_RegexMatch } return "" } func (x *DiscoveryVersionRule) GetRunAsDiscoveredProcessUser() bool { if x != nil { return x.xxx_hidden_RunAsDiscoveredProcessUser } return false } func (x *DiscoveryVersionRule) GetExtendedCommand() ExtendedVersionCommand { if x != nil { return x.xxx_hidden_ExtendedCommand } return ExtendedVersionCommand_EXTENDED_VERSION_COMMAND_UNSPECIFIED } func (x *DiscoveryVersionRule) GetSteps() []*VersionCommandStep { if x != nil { if x.xxx_hidden_Steps != nil { return *x.xxx_hidden_Steps } } return nil } func (x *DiscoveryVersionRule) GetVersionExtractPattern() string { if x != nil { return x.xxx_hidden_VersionExtractPattern } return "" } func (x *DiscoveryVersionRule) SetCommand(v VersionCommand) { x.xxx_hidden_Command = v } func (x *DiscoveryVersionRule) SetCommandArgs(v []string) { x.xxx_hidden_CommandArgs = v } func (x *DiscoveryVersionRule) SetRegexMatch(v string) { x.xxx_hidden_RegexMatch = v } func (x *DiscoveryVersionRule) SetRunAsDiscoveredProcessUser(v bool) { x.xxx_hidden_RunAsDiscoveredProcessUser = v } func (x *DiscoveryVersionRule) SetExtendedCommand(v ExtendedVersionCommand) { x.xxx_hidden_ExtendedCommand = v } func (x *DiscoveryVersionRule) SetSteps(v []*VersionCommandStep) { x.xxx_hidden_Steps = &v } func (x *DiscoveryVersionRule) SetVersionExtractPattern(v string) { x.xxx_hidden_VersionExtractPattern = v } type DiscoveryVersionRule_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // The bundled version command in the extension to execute Command VersionCommand // arguments to pass to the CommandArgs []string // Regex that will be run on the output of the command to match a section of // the string that contains the version. This will be further parsed to only // get numerical values. RegexMatch string // If true, use su to run the command as the discovered process user. RunAsDiscoveredProcessUser bool // Extended version command to execute if command is unspecified. ExtendedCommand ExtendedVersionCommand // A sequence of commands executed in order. The output of a previous step // can be passed as standard input (stdin) to the next step by setting // use_previous_output_as_stdin to true. // Takes precedence over command and extended_command. Steps []*VersionCommandStep // A regular expression with exactly one capturing group used to extract the // version. If provided, this overrides the default extracting logic. VersionExtractPattern string } func (b0 DiscoveryVersionRule_builder) Build() *DiscoveryVersionRule { m0 := &DiscoveryVersionRule{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Command = b.Command x.xxx_hidden_CommandArgs = b.CommandArgs x.xxx_hidden_RegexMatch = b.RegexMatch x.xxx_hidden_RunAsDiscoveredProcessUser = b.RunAsDiscoveredProcessUser x.xxx_hidden_ExtendedCommand = b.ExtendedCommand x.xxx_hidden_Steps = &b.Steps x.xxx_hidden_VersionExtractPattern = b.VersionExtractPattern return m0 } // Discovered results from the rules that were matched. type DiscoveryResult struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_DetectedData *[]*DetectedData `protobuf:"bytes,1,rep,name=detected_data,json=detectedData,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DiscoveryResult) Reset() { *x = DiscoveryResult{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DiscoveryResult) String() string { return protoimpl.X.MessageStringOf(x) } func (*DiscoveryResult) ProtoMessage() {} func (x *DiscoveryResult) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DiscoveryResult) GetDetectedData() []*DetectedData { if x != nil { if x.xxx_hidden_DetectedData != nil { return *x.xxx_hidden_DetectedData } } return nil } func (x *DiscoveryResult) SetDetectedData(v []*DetectedData) { x.xxx_hidden_DetectedData = &v } type DiscoveryResult_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. DetectedData []*DetectedData } func (b0 DiscoveryResult_builder) Build() *DiscoveryResult { m0 := &DiscoveryResult{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_DetectedData = &b.DetectedData return m0 } type DetectedData struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` xxx_hidden_Version string `protobuf:"bytes,2,opt,name=version,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DetectedData) Reset() { *x = DetectedData{} mi := &file_isvdiscovery_definition_definition_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DetectedData) String() string { return protoimpl.X.MessageStringOf(x) } func (*DetectedData) ProtoMessage() {} func (x *DetectedData) ProtoReflect() protoreflect.Message { mi := &file_isvdiscovery_definition_definition_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } func (x *DetectedData) GetName() string { if x != nil { return x.xxx_hidden_Name } return "" } func (x *DetectedData) GetVersion() string { if x != nil { return x.xxx_hidden_Version } return "" } func (x *DetectedData) SetName(v string) { x.xxx_hidden_Name = v } func (x *DetectedData) SetVersion(v string) { x.xxx_hidden_Version = v } type DetectedData_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. // The workload name that has been discovered Name string // The workload version that has been discovered Version string } func (b0 DetectedData_builder) Build() *DetectedData { m0 := &DetectedData{} b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Name = b.Name x.xxx_hidden_Version = b.Version return m0 } var File_isvdiscovery_definition_definition_proto protoreflect.FileDescriptor const file_isvdiscovery_definition_definition_proto_rawDesc = "" + "\n" + "(isvdiscovery/definition/definition.proto\x12$guesttelemetryextension.isvdiscovery\"\x1a\n" + "\x18GetDiscoveryRulesRequest\"\x99\x01\n" + "\x16DiscoveryConfiguration\x122\n" + "\x15scan_interval_seconds\x18\x01 \x01(\x05R\x13scanIntervalSeconds\x12K\n" + "\"minimum_reporting_interval_seconds\x18\x02 \x01(\x05R\x1fminimumReportingIntervalSeconds\"\xb1\x01\n" + "\x0eDiscoveryRules\x12I\n" + "\x05rules\x18\x01 \x03(\v23.guesttelemetryextension.isvdiscovery.DiscoveryRuleR\x05rules\x12T\n" + "\x06config\x18\x02 \x01(\v2<.guesttelemetryextension.isvdiscovery.DiscoveryConfigurationR\x06config\"\xc6\x03\n" + "\rDiscoveryRule\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12O\n" + "\tcondition\x18\x03 \x01(\v2/.guesttelemetryextension.isvdiscovery.ConditionH\x00R\tcondition\x12F\n" + "\x03all\x18\x04 \x01(\v22.guesttelemetryextension.isvdiscovery.AllConditionH\x00R\x03all\x12F\n" + "\x03any\x18\x05 \x01(\v22.guesttelemetryextension.isvdiscovery.AnyConditionH\x00R\x03any\x12_\n" + "\rversion_rules\x18\x06 \x03(\v2:.guesttelemetryextension.isvdiscovery.DiscoveryVersionRuleR\fversionRules\x128\n" + "\x18discovered_workload_name\x18\a \x01(\tR\x16discoveredWorkloadName\x12!\n" + "\fdisplay_name\x18\b \x01(\tR\vdisplayNameB\x06\n" + "\x04rule\"\xb2\x01\n" + "\fAllCondition\x12I\n" + "\x03any\x18\x01 \x01(\v22.guesttelemetryextension.isvdiscovery.AnyConditionH\x00R\x03any\x88\x01\x01\x12O\n" + "\n" + "conditions\x18\x02 \x03(\v2/.guesttelemetryextension.isvdiscovery.ConditionR\n" + "conditionsB\x06\n" + "\x04_any\"\xb2\x01\n" + "\fAnyCondition\x12I\n" + "\x03all\x18\x01 \x01(\v22.guesttelemetryextension.isvdiscovery.AllConditionH\x00R\x03all\x88\x01\x01\x12O\n" + "\n" + "conditions\x18\x02 \x03(\v2/.guesttelemetryextension.isvdiscovery.ConditionR\n" + "conditionsB\x06\n" + "\x04_all\"\x93\x01\n" + "\tCondition\x12_\n" + "\fstring_match\x18\x01 \x01(\v2:.guesttelemetryextension.isvdiscovery.StringMatchConditionH\x00R\vstringMatch\x12\x18\n" + "\anegated\x18\x03 \x01(\bR\anegatedB\v\n" + "\tcondition\"\xa3\x02\n" + "\x14StringMatchCondition\x12_\n" + "\bvm_field\x18\x01 \x01(\x0e2B.guesttelemetryextension.isvdiscovery.StringMatchCondition.VmFieldH\x00R\avmField\x12\x1f\n" + "\vregex_match\x18\x02 \x01(\tR\n" + "regexMatch\"\x7f\n" + "\aVmField\x12\x18\n" + "\x14VM_FIELD_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fVM_PROCESS_NAME\x10\x01\x12\x13\n" + "\x0fVM_PROCESS_PATH\x10\x02\x12\x0e\n" + "\n" + "VM_OS_NAME\x10\x03\x12\x0f\n" + "\vVM_CLI_ARGS\x10\x04\x12\x0f\n" + "\vVM_ENV_VARS\x10\x05B\b\n" + "\x06fields\"\x95\x03\n" + "\x12VersionCommandStep\x12N\n" + "\acommand\x18\x01 \x01(\x0e24.guesttelemetryextension.isvdiscovery.VersionCommandR\acommand\x12!\n" + "\fcommand_args\x18\x02 \x03(\tR\vcommandArgs\x12\x1f\n" + "\vregex_match\x18\x03 \x01(\tR\n" + "regexMatch\x12B\n" + "\x1erun_as_discovered_process_user\x18\x04 \x01(\bR\x1arunAsDiscoveredProcessUser\x12g\n" + "\x10extended_command\x18\x05 \x01(\x0e2<.guesttelemetryextension.isvdiscovery.ExtendedVersionCommandR\x0fextendedCommand\x12>\n" + "\x1cuse_previous_output_as_stdin\x18\x06 \x01(\bR\x18usePreviousOutputAsStdin\"\xdf\x03\n" + "\x14DiscoveryVersionRule\x12N\n" + "\acommand\x18\x01 \x01(\x0e24.guesttelemetryextension.isvdiscovery.VersionCommandR\acommand\x12!\n" + "\fcommand_args\x18\x02 \x03(\tR\vcommandArgs\x12\x1f\n" + "\vregex_match\x18\x03 \x01(\tR\n" + "regexMatch\x12B\n" + "\x1erun_as_discovered_process_user\x18\x04 \x01(\bR\x1arunAsDiscoveredProcessUser\x12g\n" + "\x10extended_command\x18\x05 \x01(\x0e2<.guesttelemetryextension.isvdiscovery.ExtendedVersionCommandR\x0fextendedCommand\x12N\n" + "\x05steps\x18\x06 \x03(\v28.guesttelemetryextension.isvdiscovery.VersionCommandStepR\x05steps\x126\n" + "\x17version_extract_pattern\x18\a \x01(\tR\x15versionExtractPattern\"j\n" + "\x0fDiscoveryResult\x12W\n" + "\rdetected_data\x18\x01 \x03(\v22.guesttelemetryextension.isvdiscovery.DetectedDataR\fdetectedData\"<\n" + "\fDetectedData\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aversion\x18\x02 \x01(\tR\aversion*\x93\x04\n" + "\x0eVersionCommand\x12\x1f\n" + "\x1bVERSION_COMMAND_UNSPECIFIED\x10\x00\x12\a\n" + "\x03CAT\x10\x01\x12\v\n" + "\aAPACHE2\x10\x02\x12\t\n" + "\x05HTTPD\x10\x03\x12\f\n" + "\bPOSTGRES\x10\x04\x12\b\n" + "\x04PSQL\x10\x05\x12\f\n" + "\bNODETOOL\x10\x06\x12\n" + "\n" + "\x06MONGOD\x10\a\x12\n" + "\n" + "\x06MYSQLD\x10\b\x12\v\n" + "\aSQLPLUS\x10\t\x12\x0f\n" + "\vREDISSERVER\x10\n" + "\x12\v\n" + "\aMARIADB\x10\v\x12\b\n" + "\x04GWRD\x10\f\x12\b\n" + "\x04GREP\x10\r\x12\x0e\n" + "\n" + "GETCOMMAND\x10\x0e\x12\b\n" + "\x04IQ15\x10\x0f\x12\b\n" + "\x04IQ16\x10\x10\x12\x0f\n" + "\vIQ15WINDOWS\x10\x11\x12\x0f\n" + "\vIQ16WINDOWS\x10\x12\x12\x14\n" + "\fFINDSAPWEBAS\x10\x13\x1a\x02\b\x01\x12\x0e\n" + "\n" + "PACEMAKERD\x10\x14\x12\f\n" + "\bSQLSERVR\x10\x15\x12\x18\n" + "\x14GETITEMPROPERTYVALUE\x10\x16\x12\n" + "\n" + "\x06OPATCH\x10\x17\x12\n" + "\n" + "\x06DGMGRL\x10\x18\x12\b\n" + "\x04JAVA\x10\x19\x12\t\n" + "\x05GGSCI\x10\x1a\x12\v\n" + "\aPSADMIN\x10\x1b\x12\n" + "\n" + "\x06HDBLCM\x10\x1c\x12\t\n" + "\x05MYSQL\x10\x1d\x12\x16\n" + "\x12CASSANDRA_NODETOOL\x10\x1e\x12\x11\n" + "\rSBIN_NODETOOL\x10\x1f\x12\x11\n" + "\rSQLSERVR_PATH\x10 \x12\x1f\n" + "\x1bUSE_DISCOVERED_PROCESS_PATH\x10!*\xa0\x02\n" + "\x16ExtendedVersionCommand\x12(\n" + "$EXTENDED_VERSION_COMMAND_UNSPECIFIED\x10\x00\x12\a\n" + "\x03HDB\x10\x01\x12\r\n" + "\tDISP_WORK\x10\x02\x12\x0e\n" + "\n" + "MSG_SERVER\x10\x03\x12\r\n" + "\tEN_SERVER\x10\x04\x12\a\n" + "\x03AWK\x10\x05\x12\x0e\n" + "\n" + "SAPWEBDISP\x10\x06\x12\f\n" + "\bSTART_IQ\x10\a\x12\t\n" + "\x05ENREP\x10\b\x12\x10\n" + "\fSPARK_SUBMIT\x10\t\x12\x15\n" + "\x11SPARK_SUBMIT_PATH\x10\n" + "\x12\n" + "\n" + "\x06CRICTL\x10\v\x12\n" + "\n" + "\x06HADOOP\x10\f\x12\x0f\n" + "\vHADOOP_PATH\x10\r\x12\b\n" + "\x04DPKG\x10\x0e\x12\a\n" + "\x03RPM\x10\x0f\x12\x0e\n" + "\n" + "GETPACKAGE\x10\x10B\x18B\fISVDiscoveryP\x01Z\x06/protob\x06proto3" var file_isvdiscovery_definition_definition_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_isvdiscovery_definition_definition_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_isvdiscovery_definition_definition_proto_goTypes = []any{ (VersionCommand)(0), // 0: guesttelemetryextension.isvdiscovery.VersionCommand (ExtendedVersionCommand)(0), // 1: guesttelemetryextension.isvdiscovery.ExtendedVersionCommand (StringMatchCondition_VmField)(0), // 2: guesttelemetryextension.isvdiscovery.StringMatchCondition.VmField (*GetDiscoveryRulesRequest)(nil), // 3: guesttelemetryextension.isvdiscovery.GetDiscoveryRulesRequest (*DiscoveryConfiguration)(nil), // 4: guesttelemetryextension.isvdiscovery.DiscoveryConfiguration (*DiscoveryRules)(nil), // 5: guesttelemetryextension.isvdiscovery.DiscoveryRules (*DiscoveryRule)(nil), // 6: guesttelemetryextension.isvdiscovery.DiscoveryRule (*AllCondition)(nil), // 7: guesttelemetryextension.isvdiscovery.AllCondition (*AnyCondition)(nil), // 8: guesttelemetryextension.isvdiscovery.AnyCondition (*Condition)(nil), // 9: guesttelemetryextension.isvdiscovery.Condition (*StringMatchCondition)(nil), // 10: guesttelemetryextension.isvdiscovery.StringMatchCondition (*VersionCommandStep)(nil), // 11: guesttelemetryextension.isvdiscovery.VersionCommandStep (*DiscoveryVersionRule)(nil), // 12: guesttelemetryextension.isvdiscovery.DiscoveryVersionRule (*DiscoveryResult)(nil), // 13: guesttelemetryextension.isvdiscovery.DiscoveryResult (*DetectedData)(nil), // 14: guesttelemetryextension.isvdiscovery.DetectedData } var file_isvdiscovery_definition_definition_proto_depIdxs = []int32{ 6, // 0: guesttelemetryextension.isvdiscovery.DiscoveryRules.rules:type_name -> guesttelemetryextension.isvdiscovery.DiscoveryRule 4, // 1: guesttelemetryextension.isvdiscovery.DiscoveryRules.config:type_name -> guesttelemetryextension.isvdiscovery.DiscoveryConfiguration 9, // 2: guesttelemetryextension.isvdiscovery.DiscoveryRule.condition:type_name -> guesttelemetryextension.isvdiscovery.Condition 7, // 3: guesttelemetryextension.isvdiscovery.DiscoveryRule.all:type_name -> guesttelemetryextension.isvdiscovery.AllCondition 8, // 4: guesttelemetryextension.isvdiscovery.DiscoveryRule.any:type_name -> guesttelemetryextension.isvdiscovery.AnyCondition 12, // 5: guesttelemetryextension.isvdiscovery.DiscoveryRule.version_rules:type_name -> guesttelemetryextension.isvdiscovery.DiscoveryVersionRule 8, // 6: guesttelemetryextension.isvdiscovery.AllCondition.any:type_name -> guesttelemetryextension.isvdiscovery.AnyCondition 9, // 7: guesttelemetryextension.isvdiscovery.AllCondition.conditions:type_name -> guesttelemetryextension.isvdiscovery.Condition 7, // 8: guesttelemetryextension.isvdiscovery.AnyCondition.all:type_name -> guesttelemetryextension.isvdiscovery.AllCondition 9, // 9: guesttelemetryextension.isvdiscovery.AnyCondition.conditions:type_name -> guesttelemetryextension.isvdiscovery.Condition 10, // 10: guesttelemetryextension.isvdiscovery.Condition.string_match:type_name -> guesttelemetryextension.isvdiscovery.StringMatchCondition 2, // 11: guesttelemetryextension.isvdiscovery.StringMatchCondition.vm_field:type_name -> guesttelemetryextension.isvdiscovery.StringMatchCondition.VmField 0, // 12: guesttelemetryextension.isvdiscovery.VersionCommandStep.command:type_name -> guesttelemetryextension.isvdiscovery.VersionCommand 1, // 13: guesttelemetryextension.isvdiscovery.VersionCommandStep.extended_command:type_name -> guesttelemetryextension.isvdiscovery.ExtendedVersionCommand 0, // 14: guesttelemetryextension.isvdiscovery.DiscoveryVersionRule.command:type_name -> guesttelemetryextension.isvdiscovery.VersionCommand 1, // 15: guesttelemetryextension.isvdiscovery.DiscoveryVersionRule.extended_command:type_name -> guesttelemetryextension.isvdiscovery.ExtendedVersionCommand 11, // 16: guesttelemetryextension.isvdiscovery.DiscoveryVersionRule.steps:type_name -> guesttelemetryextension.isvdiscovery.VersionCommandStep 14, // 17: guesttelemetryextension.isvdiscovery.DiscoveryResult.detected_data:type_name -> guesttelemetryextension.isvdiscovery.DetectedData 18, // [18:18] is the sub-list for method output_type 18, // [18:18] is the sub-list for method input_type 18, // [18:18] is the sub-list for extension type_name 18, // [18:18] is the sub-list for extension extendee 0, // [0:18] is the sub-list for field type_name } func init() { file_isvdiscovery_definition_definition_proto_init() } func file_isvdiscovery_definition_definition_proto_init() { if File_isvdiscovery_definition_definition_proto != nil { return } file_isvdiscovery_definition_definition_proto_msgTypes[3].OneofWrappers = []any{ (*discoveryRule_Condition)(nil), (*discoveryRule_All)(nil), (*discoveryRule_Any)(nil), } file_isvdiscovery_definition_definition_proto_msgTypes[4].OneofWrappers = []any{} file_isvdiscovery_definition_definition_proto_msgTypes[5].OneofWrappers = []any{} file_isvdiscovery_definition_definition_proto_msgTypes[6].OneofWrappers = []any{ (*condition_StringMatch)(nil), } file_isvdiscovery_definition_definition_proto_msgTypes[7].OneofWrappers = []any{ (*stringMatchCondition_VmField_)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_isvdiscovery_definition_definition_proto_rawDesc), len(file_isvdiscovery_definition_definition_proto_rawDesc)), NumEnums: 3, NumMessages: 12, NumExtensions: 0, NumServices: 0, }, GoTypes: file_isvdiscovery_definition_definition_proto_goTypes, DependencyIndexes: file_isvdiscovery_definition_definition_proto_depIdxs, EnumInfos: file_isvdiscovery_definition_definition_proto_enumTypes, MessageInfos: file_isvdiscovery_definition_definition_proto_msgTypes, }.Build() File_isvdiscovery_definition_definition_proto = out.File file_isvdiscovery_definition_definition_proto_goTypes = nil file_isvdiscovery_definition_definition_proto_depIdxs = nil } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/engine/000077500000000000000000000000001524141036400277605ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/engine/engine.go000066400000000000000000000400011524141036400315470ustar00rootroot00000000000000/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package engine provides the engine for executing the discovery rules. package engine import ( "context" "log/slog" "os" "regexp" "runtime" "strings" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor" defpb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/definition/proto" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/engine/versioncommands" ) // VMInfo contains discovered information about the VM to be used for rule evaluation. type VMInfo struct { ProcessNames []string ProcessPaths []string ProcessArgs []string ProcessEnvVars []string Usernames []string OSName string } // ProcessInfo contains discovered information about a specific process. type ProcessInfo struct { Name string Path string Arg string EnvVar string Username string OSName string } var versionNumberRegex = regexp.MustCompile(`\.?\d+(\.\d+)*`) var envVarRegex = regexp.MustCompile(`\$([a-zA-Z_][a-zA-Z0-9_]*|\{([a-zA-Z_][a-zA-Z0-9_]*)\})`) var safeShellCharsRegex = regexp.MustCompile(`^[a-zA-Z0-9_./=-]+$`) var executeCommand = commandlineexecutor.ExecuteCommand // ExecuteRules executes the discovery rules against the VM info and returns the discovery result. func ExecuteRules(ctx context.Context, req *defpb.DiscoveryRules, vmInfo *VMInfo) *defpb.DiscoveryResult { rules := req.GetRules() var detectedData []*defpb.DetectedData for _, rule := range rules { if err := ctx.Err(); err != nil { slog.Info("ExecuteRules cancelled") break } foundMatch, processInfo := executeRule(rule, vmInfo) if foundMatch { version := executeVersionRules(ctx, rule, processInfo) detectedData = append(detectedData, defpb.DetectedData_builder{ Name: rule.GetDiscoveredWorkloadName(), Version: version, }.Build()) } } return defpb.DiscoveryResult_builder{ DetectedData: detectedData, }.Build() } func evalAllCondition(all *defpb.AllCondition, vmInfo *VMInfo) (bool, *ProcessInfo) { var processInfo *ProcessInfo for _, condition := range all.GetConditions() { result, pInfo := checkCondition(condition, vmInfo) if !result { return false, nil } if pInfo != nil && pInfo.Path != "" && processInfo == nil { processInfo = pInfo } } if all.HasAny() { result, pInfo := evalAnyCondition(all.GetAny(), vmInfo) if !result { return false, nil } if pInfo != nil && pInfo.Path != "" && processInfo == nil { processInfo = pInfo } } return true, processInfo } func evalAnyCondition(any *defpb.AnyCondition, vmInfo *VMInfo) (bool, *ProcessInfo) { for _, condition := range any.GetConditions() { result, pInfo := checkCondition(condition, vmInfo) if result { return true, pInfo } } if any.HasAll() { result, pInfo := evalAllCondition(any.GetAll(), vmInfo) if result { return true, pInfo } } return false, nil } // executeRule executes a single discovery rule. // Returns true if the rule is satisfied, false otherwise. func executeRule(rule *defpb.DiscoveryRule, vmInfo *VMInfo) (bool, *ProcessInfo) { switch rule.WhichRule() { case defpb.DiscoveryRule_Condition_case: return checkCondition(rule.GetCondition(), vmInfo) case defpb.DiscoveryRule_All_case: return evalAllCondition(rule.GetAll(), vmInfo) case defpb.DiscoveryRule_Any_case: return evalAnyCondition(rule.GetAny(), vmInfo) default: // This should never happen. Return false if it does. return false, nil } } func resolveCommand(command defpb.VersionCommand, extendedCommand defpb.ExtendedVersionCommand, processInfo *ProcessInfo) (string, bool) { var cmd string if command == defpb.VersionCommand_VERSION_COMMAND_UNSPECIFIED { if extendedCommand == defpb.ExtendedVersionCommand_EXTENDED_VERSION_COMMAND_UNSPECIFIED { slog.Debug("Version command is unspecified") return "", false } if int(extendedCommand) < 0 || int(extendedCommand) >= len(versioncommands.Commands.ExtendedCmd) { slog.Debug("Received unknown ExtendedVersionCommand", "command", extendedCommand) return "", false } cmd = versioncommands.Commands.ExtendedCmd[extendedCommand] } else { if int(command) < 0 || int(command) >= len(versioncommands.Commands.Cmd) { slog.Debug("Received unknown VersionCommand", "command", command) return "", false } cmd = versioncommands.Commands.Cmd[command] } if cmd == "USE_DISCOVERED_PROCESS_PATH" { if processInfo != nil && processInfo.Path != "" { cmd = processInfo.Path } } return cmd, true } // shellQuote safely quotes a string for use as a command-line argument in a shell execution. func shellQuote(s string) string { if s == "" { return "''" } // Contains unresolved variables: use double quotes for safe shell expansion if strings.Contains(s, "$") { // 1. Escape backslashes, double quotes, and backticks for double-quoted string s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, "`", "\\`") // 2. Escape '$' unless it introduces a valid unbraced ($VAR) or braced (${VAR}) variable validVarRegex := regexp.MustCompile(`^\$([a-zA-Z_]\w*|\{[a-zA-Z_]\w*\})`) var buf strings.Builder for i := 0; i < len(s); i++ { if s[i] == '$' { if validVarRegex.MatchString(s[i:]) { buf.WriteByte('$') } else { buf.WriteString(`\$`) } } else { buf.WriteByte(s[i]) } } return `"` + buf.String() + `"` } // Contains spaces or metacharacters: use single quotes for literal interpretation if !safeShellCharsRegex.MatchString(s) { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } // Safe string: no quotes needed return s } func shellQuoteSlice(args []string) string { quoted := make([]string, len(args)) for i, arg := range args { quoted[i] = shellQuote(arg) } return strings.Join(quoted, " ") } func buildCommandParams(cmd string, args []string, runAsUser bool, processInfo *ProcessInfo) commandlineexecutor.Params { return buildCommandParamsForOS(cmd, args, runAsUser, processInfo, runtime.GOOS) } func buildCommandParamsForOS(cmd string, args []string, runAsUser bool, processInfo *ProcessInfo, goos string) commandlineexecutor.Params { shouldRunAsUser := runAsUser && processInfo != nil && processInfo.Username != "" if !shouldRunAsUser { return commandlineexecutor.Params{ Executable: cmd, Args: args, } } if goos == "windows" { // On Windows (where 'su' is not available), populate the User field on Params // to convey the target process user context to the commandlineexecutor. return commandlineexecutor.Params{ Executable: cmd, Args: args, User: processInfo.Username, } } fullCmd := shellQuote(cmd) cmdArgs := shellQuoteSlice(args) if cmdArgs != "" { fullCmd = fullCmd + " " + cmdArgs } // Note: User field must NOT be set when Executable is "su". // "su" must be launched as root so it can switch process credentials to processInfo.Username. // We pass -s /bin/sh to override disabled shells (like /sbin/nologin) for service accounts, // and -l to run as a login shell so profile environment variables are sourced. return commandlineexecutor.Params{ Executable: "su", Args: []string{"-s", "/bin/sh", "-l", processInfo.Username, "-c", fullCmd}, } } // resolveEnvVars expands environment variables (e.g., $SPARK_HOME or $ORACLE_HOME) in string s // using the captured environment block of the discovered process. If a variable is not present // in processInfo, it falls back to the host operating system environment. If still unpopulated, // the literal variable token (e.g., "$VAR" or "${VAR}") is preserved so subsequent shell executions (via su) can resolve it. func resolveEnvVars(s string, processInfo *ProcessInfo) string { if !strings.Contains(s, "$") { return s } envMap := make(map[string]string) if processInfo != nil && processInfo.EnvVar != "" { // Split on newlines, carriage returns, and null characters to handle different line endings. // This is necessary because the environment block is a single string with these delimiters. for _, line := range strings.FieldsFunc(processInfo.EnvVar, func(r rune) bool { return r == '\n' || r == '\r' || r == '\x00' }) { if k, v, ok := strings.Cut(line, "="); ok { envMap[k] = v } } } return envVarRegex.ReplaceAllStringFunc(s, func(match string) string { name := strings.Trim(match[1:], "{}") if val, ok := envMap[name]; ok { return val } if val, ok := os.LookupEnv(name); ok { return val } return match }) } // resolveEnvVarsSlice expands environment variables across each element in a command argument slice. func resolveEnvVarsSlice(args []string, processInfo *ProcessInfo) []string { if len(args) == 0 { return args } res := make([]string, len(args)) for i, arg := range args { res[i] = resolveEnvVars(arg, processInfo) } return res } func executeVersionRules(ctx context.Context, rule *defpb.DiscoveryRule, processInfo *ProcessInfo) string { for _, versionRule := range rule.GetVersionRules() { if err := ctx.Err(); err != nil { slog.Info("executeVersionRules cancelled") return "" } var versionRegex string if len(versionRule.GetSteps()) > 0 { var prevOutput string for _, step := range versionRule.GetSteps() { if err := ctx.Err(); err != nil { slog.Info("executeVersionRules step execution cancelled") break } versionRegex = step.GetRegexMatch() cmd, ok := resolveCommand(step.GetCommand(), step.GetExtendedCommand(), processInfo) if !ok { slog.Debug("Unable to resolve command", "command", step.GetCommand(), "extendedCommand", step.GetExtendedCommand()) break } cmd = resolveEnvVars(cmd, processInfo) args := resolveEnvVarsSlice(step.GetCommandArgs(), processInfo) if step.GetCommand() == defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH && (processInfo == nil || processInfo.Username == "") { slog.Debug("Skipping USE_DISCOVERED_PROCESS_PATH execution: process username is missing", "command", cmd) break } runAsUser := step.GetRunAsDiscoveredProcessUser() || step.GetCommand() == defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH params := buildCommandParams(cmd, args, runAsUser, processInfo) if step.GetUsePreviousOutputAsStdin() { params.Stdin = prevOutput } res := executeCommand(ctx, params) if res.Error != nil || res.ExitCode != 0 || !res.ExecutableFound { slog.Debug("Step command failed", "executable", params.Executable, "args", params.Args, "error", res.Error, "exitCode", res.ExitCode, "executableFound", res.ExecutableFound) break } prevOutput = "" if versionRegex != "" { re, err := regexp.Compile(versionRegex) if err == nil { if re.MatchString(res.StdOut) { prevOutput = res.StdOut } } } // If we didn't get valid output, try the next version rule. if prevOutput == "" { slog.Debug("Step command did not produce valid output", "executable", params.Executable, "args", params.Args, "output", res.StdOut) break } } if version, found := extractVersionFromOutput(prevOutput, versionRegex, versionRule.GetVersionExtractPattern()); found { return version } continue } cmd, ok := resolveCommand(versionRule.GetCommand(), versionRule.GetExtendedCommand(), processInfo) if !ok { continue } cmd = resolveEnvVars(cmd, processInfo) args := resolveEnvVarsSlice(versionRule.GetCommandArgs(), processInfo) if versionRule.GetCommand() == defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH && (processInfo == nil || processInfo.Username == "") { slog.Debug("Skipping USE_DISCOVERED_PROCESS_PATH execution: process username is missing", "command", cmd) continue } runAsUser := versionRule.GetRunAsDiscoveredProcessUser() || versionRule.GetCommand() == defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH params := buildCommandParams(cmd, args, runAsUser, processInfo) res := executeCommand(ctx, params) if res.Error != nil || res.ExitCode != 0 || !res.ExecutableFound { slog.Debug("Command failed", "executable", params.Executable, "args", params.Args, "error", res.Error, "exitCode", res.ExitCode, "executableFound", res.ExecutableFound) continue } if version, found := extractVersionFromOutput(res.StdOut, versionRule.GetRegexMatch(), versionRule.GetVersionExtractPattern()); found { return version } } return "" } func extractVersionFromOutput(stdout, versionRegex, versionExtractPattern string) (string, bool) { re, err := regexp.Compile(versionRegex) if err != nil { slog.Debug("Failed to compile version regex", "regex", versionRegex, "error", err) return "", false } var extractRe *regexp.Regexp if versionExtractPattern != "" { extractRe, err = regexp.Compile(versionExtractPattern) if err != nil { slog.Debug("Failed to compile version extract regex", "pattern", versionExtractPattern, "error", err) return "", false } } lines := strings.Split(stdout, "\n") for _, line := range lines { if re.MatchString(line) { if extractRe != nil { if sub := extractRe.FindStringSubmatch(line); len(sub) > 1 { return sub[1], true } } if version := versionFromOutput(line); version != "" { return version, true } } } return "", false } func versionFromOutput(output string) string { return versionNumberRegex.FindString(output) } func checkCondition(condition *defpb.Condition, vmInfo *VMInfo) (bool, *ProcessInfo) { result := true var processInfo *ProcessInfo switch condition.WhichCondition() { case defpb.Condition_StringMatch_case: stringMatch := condition.GetStringMatch() switch stringMatch.WhichFields() { case defpb.StringMatchCondition_VmField_case: vmField := stringMatch.GetVmField() switch vmField { case defpb.StringMatchCondition_VM_PROCESS_NAME: result, processInfo = checkStringMatch(stringMatch.GetRegexMatch(), vmInfo.ProcessNames, vmInfo, true) case defpb.StringMatchCondition_VM_PROCESS_PATH: result, processInfo = checkStringMatch(stringMatch.GetRegexMatch(), vmInfo.ProcessPaths, vmInfo, true) case defpb.StringMatchCondition_VM_OS_NAME: result, processInfo = checkStringMatch(stringMatch.GetRegexMatch(), []string{vmInfo.OSName}, vmInfo, false) case defpb.StringMatchCondition_VM_CLI_ARGS: result, processInfo = checkStringMatch(stringMatch.GetRegexMatch(), vmInfo.ProcessArgs, vmInfo, true) case defpb.StringMatchCondition_VM_ENV_VARS: result, processInfo = checkStringMatch(stringMatch.GetRegexMatch(), vmInfo.ProcessEnvVars, vmInfo, true) default: // This should never happen. Return false if it does. return false, nil } default: // This should never happen. Return false if it does. return false, nil } default: // This should never happen. Return false if it does. return false, nil } if condition.GetNegated() { result = !result } return result, processInfo } func checkStringMatch(pattern string, values []string, vmInfo *VMInfo, isProcess bool) (bool, *ProcessInfo) { for i, value := range values { match, err := regexp.MatchString(pattern, value) if err == nil && match { if isProcess && vmInfo != nil { return true, &ProcessInfo{ Name: safeGet(vmInfo.ProcessNames, i), Path: safeGet(vmInfo.ProcessPaths, i), Arg: safeGet(vmInfo.ProcessArgs, i), EnvVar: safeGet(vmInfo.ProcessEnvVars, i), Username: safeGet(vmInfo.Usernames, i), OSName: vmInfo.OSName, } } if vmInfo != nil { return true, &ProcessInfo{OSName: vmInfo.OSName} } return true, nil } } return false, nil } func safeGet(s []string, i int) string { if i < len(s) { return s[i] } return "" } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/engine/engine_test.go000066400000000000000000002230451524141036400326210ustar00rootroot00000000000000/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package engine provides unit tests for the engine for executing the discovery rules. package engine import ( "context" "runtime" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/commandlineexecutor" defpb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/definition/proto" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/engine/versioncommands" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" ) var testVMInfo = &VMInfo{ ProcessNames: []string{"proc1", "proc2"}, ProcessPaths: []string{"/path/proc1", "/path/proc2"}, ProcessArgs: []string{"--arg1", "--arg2"}, ProcessEnvVars: []string{"ENV1=val1", "ENV2=val2"}, Usernames: []string{"user1", "user2"}, OSName: "linux", } func TestCheckStringMatch(t *testing.T) { tests := []struct { name string pattern string values []string want bool }{ { name: "match", pattern: "foo", values: []string{"bar", "foo", "baz"}, want: true, }, { name: "no match", pattern: "foo", values: []string{"bar", "baz"}, want: false, }, { name: "empty values", pattern: "foo", values: []string{}, want: false, }, { name: "regex match", pattern: "foo.*", values: []string{"bar", "foobar", "baz"}, want: true, }, { name: "regex exact match", pattern: "^foobar$", values: []string{"foobar"}, want: true, }, { name: "regex exact no match", pattern: "^foobar$", values: []string{"foobar ", " foobar"}, want: false, }, { name: "regex starts with match", pattern: "^foo", values: []string{"foobar"}, want: true, }, { name: "regex starts with no match", pattern: "^foo", values: []string{"barfoo"}, want: false, }, { name: "regex ends with match", pattern: "bar$", values: []string{"foobar"}, want: true, }, { name: "regex ends with no match", pattern: "bar$", values: []string{"barfoo"}, want: false, }, { name: "regex contains match", pattern: "oba", values: []string{"foobar"}, want: true, }, { name: "regex contains no match", pattern: "baf", values: []string{"foobar"}, want: false, }, { name: "invalid regex", pattern: "[", values: []string{"bar"}, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, gotPath := checkStringMatch(tc.pattern, tc.values, nil, true) if got != tc.want { t.Errorf("checkStringMatch(%q, %v) = %v, want %v", tc.pattern, tc.values, got, tc.want) } if gotPath != nil { t.Errorf("checkStringMatch(%q, %v) path = %v, want nil", tc.pattern, tc.values, gotPath) } }) } } func TestCheckStringMatchArrayMapping(t *testing.T) { tests := []struct { name string pattern string values []string processPaths []string want bool wantPath string }{ { name: "match with same length", pattern: "foo", values: []string{"bar", "foo", "baz"}, processPaths: []string{"/path/bar", "/path/foo", "/path/baz"}, want: true, wantPath: "/path/foo", }, { name: "match with missing path", pattern: "foo", values: []string{"foo"}, processPaths: []string{}, want: true, wantPath: "", }, { name: "no match", pattern: "qux", values: []string{"bar", "foo", "baz"}, processPaths: []string{"/path/bar", "/path/foo", "/path/baz"}, want: false, wantPath: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { vmInfo := &VMInfo{ProcessPaths: tc.processPaths} got, gotPath := checkStringMatch(tc.pattern, tc.values, vmInfo, true) if got != tc.want { t.Errorf("checkStringMatch(%q, %v, %v) = %v, want %v", tc.pattern, tc.values, tc.processPaths, got, tc.want) } path := "" if gotPath != nil { path = gotPath.Path } if path != tc.wantPath { t.Errorf("checkStringMatch(%q, %v, %v) path = %q, want %q", tc.pattern, tc.values, tc.processPaths, path, tc.wantPath) } }) } } func TestCheckStringMatchOSName(t *testing.T) { vmInfo := &VMInfo{OSName: "linux"} got, gotPInfo := checkStringMatch("linux", []string{"linux"}, vmInfo, false) if !got { t.Errorf("checkStringMatch() got false, want true") } if gotPInfo == nil { t.Fatalf("checkStringMatch() got nil ProcessInfo, want non-nil") } if gotPInfo.OSName != "linux" { t.Errorf("OSName = %q, want 'linux'", gotPInfo.OSName) } } func TestCheckCondition(t *testing.T) { tests := []struct { name string condition *defpb.Condition vmInfo *VMInfo want bool wantPath string }{ { name: "process name match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "proc1", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "/path/proc1", }, { name: "process name no match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "proc3", }.Build(), }.Build(), vmInfo: testVMInfo, want: false, wantPath: "", }, { name: "process path match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_PATH.Enum(), RegexMatch: "/path/proc1", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "/path/proc1", }, { name: "process path substring match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_PATH.Enum(), RegexMatch: "proc1", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "/path/proc1", }, { name: "os name match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_OS_NAME.Enum(), RegexMatch: "linux", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "", }, { name: "negated match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_OS_NAME.Enum(), RegexMatch: "linux", }.Build(), Negated: true, }.Build(), vmInfo: testVMInfo, want: false, wantPath: "", }, { name: "negated no match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_OS_NAME.Enum(), RegexMatch: "windows", }.Build(), Negated: true, }.Build(), vmInfo: testVMInfo, want: true, wantPath: "", }, { name: "cli args match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_CLI_ARGS.Enum(), RegexMatch: "--arg1", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "/path/proc1", }, { name: "env vars match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_ENV_VARS.Enum(), RegexMatch: "ENV1=val1", }.Build(), }.Build(), vmInfo: testVMInfo, want: true, wantPath: "/path/proc1", }, { name: "unspecified field no match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_FIELD_UNSPECIFIED.Enum(), RegexMatch: ".*", }.Build(), }.Build(), vmInfo: testVMInfo, want: false, wantPath: "", }, { name: "empty condition no match", condition: &defpb.Condition{}, vmInfo: testVMInfo, want: false, wantPath: "", }, { name: "string match without fields set no match", condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ RegexMatch: ".*", }.Build(), }.Build(), vmInfo: testVMInfo, want: false, wantPath: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, gotPath := checkCondition(tc.condition, tc.vmInfo) if got != tc.want { t.Errorf("checkCondition(%v, %v) = %v, want %v", tc.condition, tc.vmInfo, got, tc.want) } path := "" if gotPath != nil { path = gotPath.Path } if path != tc.wantPath { t.Errorf("checkCondition path = %q, want %q", path, tc.wantPath) } }) } } func TestExecuteRule(t *testing.T) { vmInfo := &VMInfo{ ProcessNames: []string{"foo"}, ProcessPaths: []string{"/path/foo"}, OSName: "linux", } trueCond := defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "foo", }.Build(), }.Build() falseCond := defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "other", }.Build(), }.Build() tests := []struct { name string rule *defpb.DiscoveryRule want bool wantPath string }{ { name: "Condition_case true", rule: defpb.DiscoveryRule_builder{ Condition: trueCond, }.Build(), want: true, wantPath: "/path/foo", }, { name: "Condition_case false", rule: defpb.DiscoveryRule_builder{ Condition: falseCond, }.Build(), want: false, wantPath: "", }, { name: "AllCondition_case all true", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond, trueCond}, }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, { name: "AllCondition_case one false", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond, falseCond}, }.Build(), }.Build(), want: false, wantPath: "", }, { name: "AllCondition_case true cond then false cond then true cond", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond, falseCond, trueCond}, }.Build(), }.Build(), want: false, wantPath: "", }, { name: "AnyCondition_case one true", rule: defpb.DiscoveryRule_builder{ Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{trueCond, falseCond}, }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, { name: "AnyCondition_case all false", rule: defpb.DiscoveryRule_builder{ Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{falseCond, falseCond}, }.Build(), }.Build(), want: false, wantPath: "", }, { name: "All with Any: all=true, any=true -> true", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond}, Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{trueCond}, }.Build(), }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, { name: "All with Any: all=true, any=false -> false", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond}, Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{falseCond}, }.Build(), }.Build(), }.Build(), want: false, wantPath: "", }, { name: "Any with All: any=false, all=true -> true", rule: defpb.DiscoveryRule_builder{ Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{falseCond}, All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{trueCond}, }.Build(), }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, { name: "Any with All: any=false, all=false -> false", rule: defpb.DiscoveryRule_builder{ Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{falseCond}, All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{falseCond}, }.Build(), }.Build(), }.Build(), want: false, wantPath: "", }, { name: "unspecified rule default case", rule: &defpb.DiscoveryRule{}, want: false, wantPath: "", }, { name: "All with overriding Any populating process path", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{trueCond}, }.Build(), }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, { name: "AllCondition_case process path takes precedence over OS match", rule: defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{ defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_OS_NAME.Enum(), RegexMatch: "linux", }.Build(), }.Build(), trueCond, }, }.Build(), }.Build(), want: true, wantPath: "/path/foo", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, gotPath := executeRule(tc.rule, vmInfo) if got != tc.want { t.Errorf("executeRule(%v, %v) = %v, want %v", tc.rule, vmInfo, got, tc.want) } path := "" if gotPath != nil { path = gotPath.Path } if path != tc.wantPath { t.Errorf("executeRule path = %q, want %q", path, tc.wantPath) } }) } } func TestEvalAllCondition_KeepFirstProcess(t *testing.T) { rule := defpb.DiscoveryRule_builder{ All: defpb.AllCondition_builder{ Conditions: []*defpb.Condition{ defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "proc1", }.Build(), }.Build(), defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "proc2", }.Build(), }.Build(), }, }.Build(), }.Build() got, gotPath := executeRule(rule, testVMInfo) if !got { t.Errorf("executeRule() got false, want true") } if gotPath == nil || gotPath.Path != "/path/proc1" { t.Errorf("executeRule() path = %v, want /path/proc1", gotPath) } } func TestExecuteRules(t *testing.T) { vmInfo := &VMInfo{ ProcessNames: []string{"foo"}, ProcessPaths: []string{"/path/foo"}, OSName: "linux", } rules := []*defpb.DiscoveryRule{ defpb.DiscoveryRule_builder{ DiscoveredWorkloadName: "workload1", Condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "foo", }.Build(), }.Build(), }.Build(), defpb.DiscoveryRule_builder{ DiscoveredWorkloadName: "workload2", Condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_PATH.Enum(), RegexMatch: "missing", }.Build(), }.Build(), }.Build(), defpb.DiscoveryRule_builder{ DiscoveredWorkloadName: "workload3", Condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_OS_NAME.Enum(), RegexMatch: "linux", }.Build(), }.Build(), }.Build(), } want := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "workload1"}.Build(), defpb.DetectedData_builder{Name: "workload3"}.Build(), }, }.Build() req := defpb.DiscoveryRules_builder{ Rules: rules, }.Build() got := ExecuteRules(context.Background(), req, vmInfo) if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { t.Errorf("executeRules returned diff (-want +got):\n%s", diff) } } func TestVersionFromOutput(t *testing.T) { tests := []struct { name string output string want string }{ { name: "empty", output: "", want: "", }, { name: "no match", output: "foo", want: "", }, { name: "simple version", output: "1.2.3", want: "1.2.3", }, { name: "version with text", output: "foo 1.2.3 bar", want: "1.2.3", }, { name: "version with v prefix", output: "v1.2.3", want: "1.2.3", }, { name: "version with suffix", output: "1.2.3-rc1", want: "1.2.3", }, { name: "apache version", output: "Server version: Apache/2.4.52 (Ubuntu)", want: "2.4.52", }, { name: "nginx version", output: "nginx version: nginx/1.18.0 (Ubuntu)", want: "1.18.0", }, { name: "postgres version", output: "PostgreSQL 14.2", want: "14.2", }, { name: "mysql version", output: "MySQL version 8.0.33", want: "8.0.33", }, { name: "multiple versions", output: "foo 1.2.3 bar 4.5.6", want: "1.2.3", }, { name: "single digit version", output: "foo 8 bar", want: "8", }, { name: "double digit component version", output: "foo 10.11.12 bar", want: "10.11.12", }, { name: "trailing dot", output: "1.2.", want: "1.2", }, { name: "leading dot", output: ".1.2", want: ".1.2", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := versionFromOutput(tc.output) if got != tc.want { t.Errorf("versionFromOutput(%q) = %q, want %q", tc.output, got, tc.want) } }) } } // TestExecuteVersionRulesRunAsUser is a smoke test for the executeVersionRules function // that runs the command as the discovered process user. func TestExecuteVersionRulesRunAsUser(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--help"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "test_user", } // Since executing "su" will fail in test environments without root privileges, // we just invoke executeVersionRules and ensure it doesn't panic and processes the branches correctly. executeVersionRules(context.Background(), rule, processInfo) } // TestExecuteVersionRulesMockRunAsUser is a test that mocks the executeCommand function // to ensure that the command is run as the discovered process user when // RunAsDiscoveredProcessUser is true. func TestExecuteVersionRulesMockRunAsUser(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--help"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantArgs := []string{"-s", "/bin/sh", "-l", "cool_test_user", "-c", "cat --help"} if !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("Args mismatch: got %v, want %v", capturedParams.Args, wantArgs) } } else { if capturedParams.Executable == "su" { t.Errorf("Executable = %q, want not 'su' on windows", capturedParams.Executable) } } } func TestExecuteVersionRulesMockRunAsUserWithSpaces(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--path", "/path with spaces"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantArgs := []string{"-s", "/bin/sh", "-l", "cool_test_user", "-c", "cat --path '/path with spaces'"} if !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("Args mismatch: got %v, want %v", capturedParams.Args, wantArgs) } } } func TestExecuteVersionRulesMockRunAsUserWithMetacharacters(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--val", "$VAR"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", EnvVar: "VAR=foo; rm -rf /", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantArgs := []string{"-s", "/bin/sh", "-l", "cool_test_user", "-c", "cat --val 'foo; rm -rf /'"} if !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("Args mismatch: got %v, want %v", capturedParams.Args, wantArgs) } } } func TestExecuteVersionRulesMockRunAsUserWithSingleQuotes(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--val", "$VAR"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", EnvVar: "VAR=O'Reilly", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantArgs := []string{"-s", "/bin/sh", "-l", "cool_test_user", "-c", "cat --val 'O'\\''Reilly'"} if !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("Args mismatch: got %v, want %v", capturedParams.Args, wantArgs) } } } func TestExecuteVersionRulesMockRunAsUserUnresolvedEnvVar(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--val", "$UNRESOLVED_VAR"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantArgs := []string{"-s", "/bin/sh", "-l", "cool_test_user", "-c", "cat --val \"$UNRESOLVED_VAR\""} if !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("Args mismatch: got %v, want %v", capturedParams.Args, wantArgs) } } } func TestExecuteVersionRulesMockRunAsUserFalse(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--help"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: false, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "cool_test_user", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if capturedParams.Executable == "su" { t.Errorf("Executable = %q, want not 'su'", capturedParams.Executable) } } func TestExecuteVersionRulesMockRunAsUserEmptyUsername(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"--help"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: true, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Username: "", } var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), rule, processInfo) if capturedParams == nil { t.Fatal("executeCommand was not called") } if capturedParams.Executable == "su" { t.Errorf("Executable = %q, want not 'su'", capturedParams.Executable) } } func TestExtractVersionFromOutput(t *testing.T) { tests := []struct { name string stdout string versionRegex string extractPattern string want string wantFound bool }{ { name: "empty stdout", stdout: "", versionRegex: ".+", want: "", wantFound: false, }, { name: "single matching line", stdout: "irrelevant line\nversion output: 1.2.3\nanother line", versionRegex: "version output.*", want: "1.2.3", wantFound: true, }, { name: "multiple matching lines returns first", stdout: "version output: 1.0.0\nversion output: 2.0.0", versionRegex: "version output.*", want: "1.0.0", wantFound: true, }, { name: "no matching lines", stdout: "hello\nworld", versionRegex: "version output.*", want: "", wantFound: false, }, { name: "invalid regex", stdout: "abc", versionRegex: "[", want: "", wantFound: false, }, { name: "with extract pattern", stdout: "line 1\nfoo 1.2.3-extended bar\nline 3", versionRegex: "foo.*", extractPattern: `foo ([\w.-]+) bar`, want: "1.2.3-extended", wantFound: true, }, { name: "invalid extract pattern", stdout: "foo 1.2.3 bar", versionRegex: "foo.*", extractPattern: `[invalid`, want: "", wantFound: false, }, { name: "extract pattern non-matching fallback", stdout: "foo 1.2.3 bar", versionRegex: "foo.*", extractPattern: `baz ([\w.-]+) bar`, want: "1.2.3", wantFound: true, }, { name: "explicit empty extract pattern", stdout: "foo 1.2.3 bar", versionRegex: "foo.*", extractPattern: "", want: "1.2.3", wantFound: true, }, { name: "extract pattern no capturing groups", stdout: "line 1\nversion: 1.2.3\nline 3", versionRegex: "version:.*", extractPattern: `version: \d+\.\d+\.\d+`, // Matches but has no groups want: "1.2.3", // Falls back to versionFromOutput wantFound: true, }, { name: "false positive line fallback to subsequent line", stdout: "version info:\nfoo 1.2.3 bar", versionRegex: ".*version.*|foo.*", extractPattern: `foo ([\w.-]+) bar`, want: "1.2.3", wantFound: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, gotFound := extractVersionFromOutput(tc.stdout, tc.versionRegex, tc.extractPattern) if got != tc.want { t.Errorf("extractVersionFromOutput() got = %v, want %v", got, tc.want) } if gotFound != tc.wantFound { t.Errorf("extractVersionFromOutput() gotFound = %v, want %v", gotFound, tc.wantFound) } }) } } func TestExecuteVersionRules_DiscoveredPath(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_VERSION_COMMAND_UNSPECIFIED, }.Build(), defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Path: "/mock/path", Username: "testuser"}) wantExec := "/mock/path" if runtime.GOOS != "windows" { wantExec = "su" } if capturedParams == nil || capturedParams.Executable != wantExec { t.Errorf("executeVersionRules did not use process path, got %+v", capturedParams) } } func TestExecuteVersionRules_DiscoveredPathRunsAsUser(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", // Note: run_as_discovered_process_user is intentionally false (default) }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Path: "/mock/path", Username: "testuser"}) if capturedParams == nil { t.Fatal("capturedParams is nil") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("got executable %q, want 'su'", capturedParams.Executable) } } } func TestExecuteVersionRules_StepRunAsDiscoveredProcessUser(t *testing.T) { wantNonSuExec := "cat" tests := []struct { name string command defpb.VersionCommand runAsUserFlag bool username string wantExec string wantUser string }{ { name: "step with run_as_user false and non-discovered command does not run as user", command: defpb.VersionCommand_CAT, runAsUserFlag: false, username: "testuser", wantExec: wantNonSuExec, wantUser: "", }, { name: "step with run_as_user true and non-discovered command runs as user", command: defpb.VersionCommand_CAT, runAsUserFlag: true, username: "testuser", wantExec: "su", wantUser: "", }, { name: "step with USE_DISCOVERED_PROCESS_PATH runs as user regardless of flag", command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, runAsUserFlag: false, username: "testuser", wantExec: "su", wantUser: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if runtime.GOOS == "windows" { if tt.wantExec == "su" { if tt.command == defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH { tt.wantExec = "/mock/path" } else { tt.wantExec = wantNonSuExec } tt.wantUser = tt.username } } ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: tt.command, CommandArgs: []string{"--version"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: tt.runAsUserFlag, }.Build(), }, }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Path: "/mock/path", Username: tt.username}) if capturedParams == nil { t.Fatal("executeCommand was not called") } if capturedParams.Executable != tt.wantExec { t.Errorf("Executable = %q, want %q", capturedParams.Executable, tt.wantExec) } if capturedParams.User != tt.wantUser { t.Errorf("User = %q, want %q", capturedParams.User, tt.wantUser) } }) } } func TestExecuteVersionRules_RuleRunAsDiscoveredProcessUser(t *testing.T) { wantNonSuExec := "cat" tests := []struct { name string command defpb.VersionCommand runAsUserFlag bool username string wantExec string wantUser string }{ { name: "rule with run_as_user false and non-discovered command does not run as user", command: defpb.VersionCommand_CAT, runAsUserFlag: false, username: "testuser", wantExec: wantNonSuExec, wantUser: "", }, { name: "rule with run_as_user true and non-discovered command runs as user", command: defpb.VersionCommand_CAT, runAsUserFlag: true, username: "testuser", wantExec: "su", wantUser: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if runtime.GOOS == "windows" { if tt.wantExec == "su" { tt.wantExec = wantNonSuExec tt.wantUser = tt.username } } ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: tt.command, CommandArgs: []string{"--version"}, RegexMatch: ".*", RunAsDiscoveredProcessUser: tt.runAsUserFlag, }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Path: "/mock/path", Username: tt.username}) if capturedParams == nil { t.Fatal("executeCommand was not called") } if capturedParams.Executable != tt.wantExec { t.Errorf("Executable = %q, want %q", capturedParams.Executable, tt.wantExec) } if capturedParams.User != tt.wantUser { t.Errorf("User = %q, want %q", capturedParams.User, tt.wantUser) } }) } } func TestBuildCommandParams(t *testing.T) { t.Run("Windows runAsUser populates User field", func(t *testing.T) { pInfo := &ProcessInfo{Username: "winuser"} params := buildCommandParamsForOS("cmd.exe", []string{"/c", "ver"}, true, pInfo, "windows") if params.Executable != "cmd.exe" { t.Errorf("Executable = %q, want 'cmd.exe'", params.Executable) } if params.User != "winuser" { t.Errorf("User = %q, want 'winuser'", params.User) } }) t.Run("Linux runAsUser uses su and leaves User field empty", func(t *testing.T) { pInfo := &ProcessInfo{Username: "linuxuser"} params := buildCommandParamsForOS("mybinary", []string{"--version"}, true, pInfo, "linux") if params.Executable != "su" { t.Errorf("Executable = %q, want 'su'", params.Executable) } if params.User != "" { t.Errorf("User = %q, want empty (su runs as root)", params.User) } wantCmdStr := "mybinary --version" if len(params.Args) != 6 || params.Args[5] != wantCmdStr { t.Errorf("Args = %v, want command string %q at index 5", params.Args, wantCmdStr) } }) t.Run("runAsUser false does not populate User or su", func(t *testing.T) { pInfo := &ProcessInfo{Username: "someuser"} params := buildCommandParamsForOS("mybinary", []string{"--version"}, false, pInfo, "linux") if params.Executable != "mybinary" { t.Errorf("Executable = %q, want 'mybinary'", params.Executable) } if params.User != "" { t.Errorf("User = %q, want empty", params.User) } }) } func TestExecuteVersionRules_PathWithSpaces(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version", "--conf", "key=value with spaces"}, RegexMatch: ".*", }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Path: "/usr/bin/my app", Username: "testuser"}) if capturedParams == nil { t.Fatal("executeCommand was not called") } if runtime.GOOS != "windows" { if capturedParams.Executable != "su" { t.Errorf("Executable = %q, want 'su'", capturedParams.Executable) } wantCmdStr := "'/usr/bin/my app' --version --conf 'key=value with spaces'" if len(capturedParams.Args) < 6 || capturedParams.Args[5] != wantCmdStr { t.Errorf("capturedParams.Args = %v, want shell-quoted command string %q in su args", capturedParams.Args, wantCmdStr) } } else { if capturedParams.Executable != "/usr/bin/my app" { t.Errorf("Executable = %q, want '/usr/bin/my app'", capturedParams.Executable) } if capturedParams.User != "testuser" { t.Errorf("User = %q, want 'testuser'", capturedParams.User) } } } func TestExecuteVersionRules_MissingUsernameFailsSafe(t *testing.T) { tests := []struct { name string processInfo *ProcessInfo rule *defpb.DiscoveryRule }{ { name: "USE_DISCOVERED_PROCESS_PATH rule with empty username fails safe", processInfo: &ProcessInfo{Path: "/mock/path", Username: ""}, rule: defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build(), }, { name: "USE_DISCOVERED_PROCESS_PATH step with empty username fails safe", processInfo: &ProcessInfo{Path: "/mock/path", Username: ""}, rule: defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build(), }, }.Build(), }, { name: "nil processInfo for USE_DISCOVERED_PROCESS_PATH step fails safe", processInfo: nil, rule: defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build(), }, }.Build(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var called bool originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { called = true return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), tt.rule, tt.processInfo) if called { t.Errorf("executeCommand was unexpectedly called when Username is missing (fail-open vulnerability)") } if version != "" { t.Errorf("got %q, want empty version for fail-safe execution", version) } }) } } func TestExecuteVersionRules_OutOfBoundsCommand(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand(100), }.Build(), }, }.Build() var called bool originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { called = true return commandlineexecutor.Result{} } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if called { t.Error("executeCommand was unexpectedly called for an out-of-bounds VersionCommand") } if version != "" { t.Errorf("got %q, want empty version", version) } } func TestExecuteVersionRules_ExtendedCommandUnspecified(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_VERSION_COMMAND_UNSPECIFIED, ExtendedCommand: defpb.ExtendedVersionCommand_EXTENDED_VERSION_COMMAND_UNSPECIFIED, }.Build(), }, }.Build() var called bool originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { called = true return commandlineexecutor.Result{} } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if called { t.Error("executeCommand was unexpectedly called for unspecified extended command") } if version != "" { t.Errorf("got %q, want empty version", version) } } func TestExecuteVersionRules_ResolveEnvVars(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_OPATCH, CommandArgs: []string{"-invPtrLoc", "$ORACLE_HOME/oraInst.loc"}, RegexMatch: ".*", }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "19.0.0.0.0", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, &ProcessInfo{ EnvVar: "ORACLE_HOME=/opt/oracle/product/19c\nOTHER_VAR=foo", }) if capturedParams == nil || capturedParams.Executable != "/opt/oracle/product/19c/OPatch/opatch" { t.Errorf("executeVersionRules executable = %v, want /opt/oracle/product/19c/OPatch/opatch", capturedParams) } if len(capturedParams.Args) != 2 || capturedParams.Args[1] != "/opt/oracle/product/19c/oraInst.loc" { t.Errorf("executeVersionRules args = %v, want [-invPtrLoc /opt/oracle/product/19c/oraInst.loc]", capturedParams.Args) } } func TestExecuteVersionRules_PreserveUnresolvedEnvVars(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"$UNRESOLVED_VAR/config.ini", "${UNRESOLVED_BRACED_VAR}/config.ini"}, RegexMatch: ".*", }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "version=1.0", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, nil) wantArgs := []string{"$UNRESOLVED_VAR/config.ini", "${UNRESOLVED_BRACED_VAR}/config.ini"} if capturedParams == nil || !cmp.Equal(capturedParams.Args, wantArgs) { t.Errorf("executeVersionRules preserved args = %v, want %v", capturedParams, wantArgs) } } func TestResolveEnvVars_HostOSFallback(t *testing.T) { const hostKey = "ISVDISCOVERY_TEST_HOST_VAR" const hostVal = "/opt/host/bin" t.Setenv(hostKey, hostVal) // Verify fallback to host OS env when not present in ProcessInfo processInfo := &ProcessInfo{ EnvVar: "OTHER_VAR=foo", } got := resolveEnvVars("$ISVDISCOVERY_TEST_HOST_VAR/app", processInfo) want := "/opt/host/bin/app" if got != want { t.Errorf("resolveEnvVars() = %q, want %q", got, want) } // Verify ProcessInfo environment block overrides host OS env processInfoOverride := &ProcessInfo{ EnvVar: hostKey + "=/opt/process/bin", } gotOverride := resolveEnvVars("$ISVDISCOVERY_TEST_HOST_VAR/app", processInfoOverride) wantOverride := "/opt/process/bin/app" if gotOverride != wantOverride { t.Errorf("resolveEnvVars() with override = %q, want %q", gotOverride, wantOverride) } } func TestResolveEnvVars_PreserveBracedSyntax(t *testing.T) { tests := []struct { name string input string processInfo *ProcessInfo want string }{ { name: "unresolved braced variable with path", input: "${UNRESOLVED_VAR}/path", want: "${UNRESOLVED_VAR}/path", }, { name: "unresolved braced variable with suffix", input: "${VAR}_suffix", want: "${VAR}_suffix", }, { name: "unresolved unbraced variable", input: "$VAR_suffix", want: "$VAR_suffix", }, { name: "resolved braced variable", input: "${KNOWN_VAR}/path", processInfo: &ProcessInfo{ EnvVar: "KNOWN_VAR=/opt/app", }, want: "/opt/app/path", }, { name: "mix of resolved and unresolved variables in single string", input: "$RESOLVED_VAR/${UNRESOLVED_VAR}/path", processInfo: &ProcessInfo{ EnvVar: "RESOLVED_VAR=/usr/local", }, want: "/usr/local/${UNRESOLVED_VAR}/path", }, { name: "nul separated environment variables", input: "$ORACLE_HOME/bin:$SPARK_HOME/bin", processInfo: &ProcessInfo{ EnvVar: "ORACLE_HOME=/opt/oracle\x00SPARK_HOME=/opt/spark\x00", }, want: "/opt/oracle/bin:/opt/spark/bin", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := resolveEnvVars(tc.input, tc.processInfo) if got != tc.want { t.Errorf("resolveEnvVars(%q) = %q, want %q", tc.input, got, tc.want) } }) } } func TestExecuteVersionRules_ExtendedCommandOutOfBounds(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_VERSION_COMMAND_UNSPECIFIED, ExtendedCommand: defpb.ExtendedVersionCommand(100), }.Build(), }, }.Build() var called bool originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { called = true return commandlineexecutor.Result{} } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if called { t.Error("executeCommand was unexpectedly called for an out-of-bounds ExtendedVersionCommand") } if version != "" { t.Errorf("got %q, want empty version", version) } } func TestExecuteVersionRules_ExtendedCommandSuccess(t *testing.T) { // In order to test a valid ExtendedVersionCommand, we append a test command to the slice extendedCmdIndex := len(versioncommands.Commands.ExtendedCmd) versioncommands.Commands.ExtendedCmd = append(versioncommands.Commands.ExtendedCmd, "echo") defer func() { // Restore the original slice versioncommands.Commands.ExtendedCmd = versioncommands.Commands.ExtendedCmd[:extendedCmdIndex] }() ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_VERSION_COMMAND_UNSPECIFIED, ExtendedCommand: defpb.ExtendedVersionCommand(extendedCmdIndex), CommandArgs: []string{"1.2.3"}, RegexMatch: ".*", }.Build(), }, }.Build() var capturedParams *commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if capturedParams == nil { t.Fatal("executeCommand was not called") } if capturedParams.Executable != "echo" { t.Errorf("Executable = %q, want 'echo'", capturedParams.Executable) } if version != "1.2.3" { t.Errorf("got %q, want '1.2.3'", version) } } func TestExecuteVersionRules_SequentialSteps(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"file.txt"}, RegexMatch: ".*", }.Build(), defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_GREP, CommandArgs: []string{"version"}, UsePreviousOutputAsStdin: true, RegexMatch: ".*", }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() var captured []commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { captured = append(captured, params) if params.Executable == "cat" { return commandlineexecutor.Result{ StdOut: "some_output_from_cat", ExitCode: 0, ExecutableFound: true, } } return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if len(captured) != 2 { t.Fatalf("executeCommand was called %d times, want 2", len(captured)) } if !cmp.Equal(captured[0].Args, []string{"file.txt"}) { t.Errorf("first command args mismatch, got %v", captured[0].Args) } if !cmp.Equal(captured[1].Args, []string{"version"}) { t.Errorf("second command args mismatch, got %v", captured[1].Args) } if captured[1].Stdin != "some_output_from_cat" { t.Errorf("second command stdin mismatch, got %q", captured[1].Stdin) } if version != "1.2.3" { t.Errorf("got %q, want '1.2.3'", version) } } func TestExecuteVersionRules_UseDiscoveredProcessPathEmptyPath(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build() var executable string originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { executable = params.Executable return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() _ = executeVersionRules(context.Background(), ruleMock, &ProcessInfo{Username: "testuser"}) if executable == "" { t.Error("got empty string, want non-empty executable") } wantExec := "USE_DISCOVERED_PROCESS_PATH" if runtime.GOOS != "windows" { wantExec = "su" } if executable != wantExec { t.Errorf("got %q, want %q", executable, wantExec) } } func TestExecuteVersionRules_CommandFailureSkips(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"fake"}, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 1, // Simulates failure ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version when command fails", version) } } func TestExecuteVersionRules_StepCommandResolutionFailure(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand(999999), // Out of bounds }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() called := false originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { called = true return commandlineexecutor.Result{} } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if called { t.Error("executeCommand was unexpectedly called for an invalid step") } if version != "" { t.Errorf("got %q, want empty version for invalid step", version) } } func TestExecuteVersionRules_StepCommandFailure(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"fail_file.txt"}, }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "failed", ExitCode: 1, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version when step fails", version) } } func TestExecuteVersionRules_StepRegexHandling(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"1.txt"}, RegexMatch: `\d+`, // Valid regex }.Build(), defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_GREP, CommandArgs: []string{"version"}, UsePreviousOutputAsStdin: true, RegexMatch: `[invalid`, // Invalid regex }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { if params.Executable == "cat" { return commandlineexecutor.Result{ StdOut: "version: 123", ExitCode: 0, ExecutableFound: true, } } return commandlineexecutor.Result{ StdOut: "123", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version for invalid regex", version) } } func TestExecuteVersionRules_IntermediateStepPrevOutputCleared(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, CommandArgs: []string{"file.txt"}, RegexMatch: "version", }.Build(), defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_GREP, CommandArgs: []string{"fake"}, UsePreviousOutputAsStdin: true, RegexMatch: "version", }.Build(), }, RegexMatch: "version.*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { if params.Executable == "cat" { return commandlineexecutor.Result{ StdOut: "version: 1.2.3", ExitCode: 0, ExecutableFound: true, } } return commandlineexecutor.Result{ StdOut: "wrong_bad_output", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version when step output does not match step regex", version) } } func TestExecuteVersionRules_StepCommandFailure_CatchesNoOp(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 1, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version when step fails with error code 1", version) } } func TestExecuteVersionRules_StepRegexFindString(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_CAT, RegexMatch: `\d+\.\d+`, }.Build(), }, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "version: 1.2", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "1.2" { t.Errorf("got %q, want '1.2'", version) } } func TestExecuteVersionRules_CommandFailure_CatchesExecutableNotFound(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: false, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), ruleMock, nil) if version != "" { t.Errorf("got %q, want empty version when executable is not found", version) } } func TestExecuteVersionRules_UseDiscoveredProcessPath_ProtectsCmd(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_CAT, RegexMatch: ".*", }.Build(), }, }.Build() processInfo := &ProcessInfo{ Path: "/mutant/bad/path", } originalExec := executeCommand var captured *commandlineexecutor.Params executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { captured = ¶ms return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() executeVersionRules(context.Background(), ruleMock, processInfo) if captured == nil || captured.Executable != "cat" { t.Errorf("got %v, want executable to be 'cat'", captured) } } func TestExecuteVersionRules_UseDiscoveredProcessPath_NilProcessInfo(t *testing.T) { ruleMock := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand(33), // USE_DISCOVERED_PROCESS_PATH RegexMatch: ".*", }.Build(), }, }.Build() originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() // Expecting this not to panic when processInfo is nil. executeVersionRules(context.Background(), ruleMock, nil) } func TestShellQuote(t *testing.T) { tests := []struct { name string in string want string }{ { name: "empty string", in: "", want: "''", }, { name: "safe string", in: "foo-bar_baz/123", want: "foo-bar_baz/123", }, { name: "spaces", in: "foo bar", want: "'foo bar'", }, { name: "metacharacters", in: "foo;bar", want: "'foo;bar'", }, { name: "valid env var", in: "$VAR", want: "\"$VAR\"", }, { name: "valid env var with braces", in: "${VAR}", want: "\"${VAR}\"", }, { name: "valid env var with path", in: "$SPARK_HOME/bin/spark-submit", want: "\"$SPARK_HOME/bin/spark-submit\"", }, { name: "command substitution with parens", in: "$(whoami)", want: "\"\\$(whoami)\"", }, { name: "command substitution with backticks", in: "`whoami`", want: "'`whoami`'", }, { name: "backticks inside double quotes", in: "foo`whoami`$VAR", want: "\"foo\\`whoami\\`$VAR\"", }, { name: "dangerous substitution inside braces", in: "${VAR:-$(whoami)}", want: "\"\\${VAR:-\\$(whoami)}\"", }, { name: "ending with backslash", in: "foo\\", want: "'foo\\'", }, { name: "ending with backslash inside double quotes", in: "$VAR\\", want: "\"$VAR\\\\\"", }, { name: "single quotes inside string", in: "O'Reilly", want: "'O'\\''Reilly'", }, { name: "double quotes inside string", in: "foo\"bar", want: "'foo\"bar'", }, { name: "double quotes inside string with var", in: "foo\"bar$VAR", want: "\"foo\\\"bar$VAR\"", }, { name: "windows path with backslash", in: "C:\\Program Files", want: "'C:\\Program Files'", }, { name: "single quote and dollar sign", in: "O'Reilly's $VAR", want: "\"O'Reilly's $VAR\"", }, { name: "windows path with dollar sign", in: "C:\\$Recycle.Bin", want: "\"C:\\\\$Recycle.Bin\"", }, { name: "positional parameter", in: "$1", want: "\"\\$1\"", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := shellQuote(tc.in) if got != tc.want { t.Errorf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want) } }) } } func TestBuildCommandParamsRunAsUser(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Skipping on windows") } processInfo := &ProcessInfo{ Username: "cool_user", Path: "/opt/my app/bin/program", } tests := []struct { name string cmd string args []string want []string }{ { name: "simple cmd and args", cmd: "cat", args: []string{"--help"}, want: []string{"-s", "/bin/sh", "-l", "cool_user", "-c", "cat --help"}, }, { name: "cmd with spaces", cmd: "/opt/my app/bin/program", args: []string{"--help"}, want: []string{"-s", "/bin/sh", "-l", "cool_user", "-c", "'/opt/my app/bin/program' --help"}, }, { name: "args with spaces and vars", cmd: "cat", args: []string{"--path", "/path with spaces", "$VAR"}, want: []string{"-s", "/bin/sh", "-l", "cool_user", "-c", "cat --path '/path with spaces' \"$VAR\""}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { params := buildCommandParams(tc.cmd, tc.args, true, processInfo) if params.Executable != "su" { t.Errorf("Executable = %q, want 'su'", params.Executable) } if !cmp.Equal(params.Args, tc.want) { t.Errorf("Args = %v, want %v", params.Args, tc.want) } }) } } func TestExecuteRules_Cancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() rules := defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ defpb.DiscoveryRule_builder{ DiscoveredWorkloadName: "workload1", Condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "foo", }.Build(), }.Build(), }.Build(), }, }.Build() vmInfo := &VMInfo{ ProcessNames: []string{"foo"}, ProcessPaths: []string{"/path/foo"}, OSName: "linux", } result := ExecuteRules(ctx, rules, vmInfo) if len(result.GetDetectedData()) != 0 { t.Errorf("ExecuteRules() returned %d detected data, want 0", len(result.GetDetectedData())) } } func TestExecuteVersionRules_Cancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build() processInfo := &ProcessInfo{ Path: "/path/foo", } version := executeVersionRules(ctx, rule, processInfo) if version != "" { t.Errorf("executeVersionRules() returned %q, want empty string", version) } } func TestEvalAllCondition_Mutant97(t *testing.T) { all := defpb.AllCondition_builder{ Conditions: []*defpb.Condition{ defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_NAME.Enum(), RegexMatch: "foo", }.Build(), }.Build(), }, Any: defpb.AnyCondition_builder{ Conditions: []*defpb.Condition{ defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: defpb.StringMatchCondition_VM_PROCESS_PATH.Enum(), RegexMatch: "bar", }.Build(), }.Build(), }, }.Build(), }.Build() vmInfo := &VMInfo{ ProcessNames: []string{"foo", "bar"}, ProcessPaths: []string{"/path/foo", "/path/bar"}, OSName: "linux", } result, pInfo := evalAllCondition(all, vmInfo) if !result { t.Fatalf("evalAllCondition() = false, want true") } if pInfo == nil { t.Fatalf("evalAllCondition() pInfo = nil, want non-nil") } if pInfo.Path != "/path/foo" { t.Errorf("evalAllCondition() pInfo.Path = %q, want %q", pInfo.Path, "/path/foo") } } func TestExecuteVersionRules_ResolveEnvVarsInCmd(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), }, }.Build() processInfo := &ProcessInfo{ Path: "$MY_BIN", EnvVar: "MY_BIN=/actual/path/foo\x00", Username: "testuser", } var capturedParams commandlineexecutor.Params originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { capturedParams = params return commandlineexecutor.Result{ StdOut: "1.2.3", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), rule, processInfo) if version != "1.2.3" { t.Errorf("executeVersionRules() = %q, want %q", version, "1.2.3") } wantExec := "/actual/path/foo" if runtime.GOOS != "windows" { wantExec = "su" } if capturedParams.Executable != wantExec { t.Errorf("captured Executable = %q, want %q", capturedParams.Executable, wantExec) } } func TestExecuteVersionRules_ExecutableNotFound(t *testing.T) { rule := defpb.DiscoveryRule_builder{ VersionRules: []*defpb.DiscoveryVersionRule{ defpb.DiscoveryVersionRule_builder{ Steps: []*defpb.VersionCommandStep{ defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"--version"}, RegexMatch: ".*", }.Build(), defpb.VersionCommandStep_builder{ Command: defpb.VersionCommand_USE_DISCOVERED_PROCESS_PATH, CommandArgs: []string{"-V"}, RegexMatch: ".*", }.Build(), }, }.Build(), }, }.Build() processInfo := &ProcessInfo{ Path: "/path/foo", Username: "testuser", } var execCount int originalExec := executeCommand executeCommand = func(ctx context.Context, params commandlineexecutor.Params) commandlineexecutor.Result { execCount++ if execCount == 1 { return commandlineexecutor.Result{ Error: nil, ExitCode: 0, ExecutableFound: false, } } return commandlineexecutor.Result{ StdOut: "2.0.0", ExitCode: 0, ExecutableFound: true, } } defer func() { executeCommand = originalExec }() version := executeVersionRules(context.Background(), rule, processInfo) if version != "" { t.Errorf("executeVersionRules() = %q, want empty string", version) } if execCount != 1 { t.Errorf("execCount = %d, want 1", execCount) } } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/engine/versioncommands/000077500000000000000000000000001524141036400331675ustar00rootroot00000000000000versioncommands.go000066400000000000000000000043061524141036400366510ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/engine/versioncommands/* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package versioncommands provides the commands to be executed to gather version information. package versioncommands // VersionCommands contains the command to be executed to gather version information. type VersionCommands struct { Cmd []string ExtendedCmd []string } // Commands is the list of commands to be executed to gather version information. var Commands *VersionCommands = &VersionCommands{ // Order matters. The order here needs to match the order in the enum in definition.proto. Cmd: []string{ "unspecified", "cat", "/usr/sbin/apache2", "/usr/sbin/httpd", "postgres", "psql", "nodetool", "mongod", "/usr/sbin/mysqld", "sqlplus", "redis-server", "mariadb", // wildcard because we don't know the SID. "/usr/sap/*/SYS/exe/run/gwrd", "grep", "Get-Command", "$IQDIR15/bin64/start_iq", "$IQDIR16/bin64/start_iq", "C:\\SAP\\IQ-15_0\\bin64\\iqsrv15.exe", "C:\\SAP\\IQ-16_0\\bin64\\iqsrv16.exe", // Deprecated: This command is not in use. "DEPRECATED_FINDSAPWEBAS", "/usr/sbin/pacemakerd", "sqlservr", "Get-ItemPropertyValue", "$ORACLE_HOME/OPatch/opatch", "$ORACLE_HOME/bin/dgmgrl", "java", "$OGG_HOME/ggsci", "$PS_HOME/appserv/psadmin", "/hana/shared/WHP/hdblcm/hdblcm", "mysql", "/usr/share/cassandra/bin/nodetool", "/usr/sbin/nodetool", "/opt/mssql/bin/sqlservr", "USE_DISCOVERED_PROCESS_PATH", }, ExtendedCmd: []string{ "unspecified", "HDB", "disp+work", "msg_server", "enserver", "awk", "sapwebdisp", "start_iq", "enrep", "spark-submit", "$SPARK_HOME/bin/spark-submit", "crictl", "hadoop", "$HADOOP_HOME/bin/hadoop", "dpkg", "rpm", "Get-Package", }, } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/service/000077500000000000000000000000001524141036400301535ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/service/discovery.go000066400000000000000000000437761524141036400325320ustar00rootroot00000000000000/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package discovery provides a service for discovering workloads on the host. package discovery import ( "context" "fmt" "log/slog" "os" "runtime" "strings" "time" "cloud.google.com/go/compute/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/communication" defpb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/definition/proto" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/engine" "github.com/shirou/gopsutil/v3/process" "google.golang.org/protobuf/encoding/prototext" "google.golang.org/protobuf/proto" anypb "google.golang.org/protobuf/types/known/anypb" ) // Guest Telemetry is disabled if the value of the disable-guest-telemetry metadata key is "true". const disableGuestTelemetryMetadataKey = "disable-guest-telemetry" // ProcessWrapper is a wrapper around process.Process to support testing. type ProcessWrapper interface { Username() (string, error) Pid() int32 Name() (string, error) Exe() (string, error) Cmdline() (string, error) // command line args as a single string separated by 0x20 ascii character. CmdlineSlice() ([]string, error) // command line args as a slice of strings. Environ() ([]string, error) String() string } // processLister is a wrapper around []*process.Process. type processLister interface { listAllProcesses() ([]ProcessWrapper, error) } // DefaultProcessLister implements the ProcessLister interface for listing processes. type DefaultProcessLister struct{} // gopsProcess implements the processWrapper for abstracting process.Process. type gopsProcess struct { process *process.Process } // Username returns a username of the process. func (p gopsProcess) Username() (string, error) { return p.process.Username() } // Pid returns the PID of the process. func (p gopsProcess) Pid() int32 { return p.process.Pid } // Name returns the name of the process. func (p gopsProcess) Name() (string, error) { return p.process.Name() } // Exe returns the executable path of the process. func (p gopsProcess) Exe() (string, error) { return p.process.Exe() } // Cmdline returns the command line arguments of the process as a single string separated by 0x20 ascii character. func (p gopsProcess) Cmdline() (string, error) { return p.process.Cmdline() } // CmdlineSlice returns the command line arguments of the process as a slice of strings. func (p gopsProcess) CmdlineSlice() ([]string, error) { return p.process.CmdlineSlice() } // Environ returns the environment variables of the process. // The format of each env var string is "key=value". func (p gopsProcess) Environ() ([]string, error) { return p.process.Environ() } // String returns the string representation of the process. func (p gopsProcess) String() string { username, _ := p.Username() pid := p.Pid() name, _ := p.Name() args, _ := p.CmdlineSlice() return fmt.Sprintf("process{username: %s, pid: %d, name: %s, args: %+v}", username, pid, name, args) } var procs processLister = DefaultProcessLister{} // listAllProcesses returns a list of processes. func (DefaultProcessLister) listAllProcesses() ([]ProcessWrapper, error) { ps, err := process.Processes() if err != nil { return nil, err } processes := make([]ProcessWrapper, len(ps)) for i, p := range ps { processes[i] = &gopsProcess{process: p} } return processes, nil } // ignoreError executes fn and discards any error, returning only the value. // This satisfies internal error-checking linters without generating log spam. func ignoreError[T any](fn func() (T, error)) T { val, err := fn() if err != nil { // Expected failure due to lack of permissions (EACCES) or ephemeral processes // exiting during scan (ESRCH). We intentionally ignore the error. } return val } func processPath(p ProcessWrapper) string { return ignoreError(p.Exe) } func processArgs(p ProcessWrapper) string { return ignoreError(p.Cmdline) } func processEnvVars(p ProcessWrapper) []string { return ignoreError(p.Environ) } func processUsername(p ProcessWrapper) string { return ignoreError(p.Username) } func vmInfo() (*engine.VMInfo, error) { processes, err := procs.listAllProcesses() if err != nil { return nil, err } vmInfo := &engine.VMInfo{ OSName: runtime.GOOS, } slog.Info(fmt.Sprintf("Found %d processes", len(processes))) for _, p := range processes { name, err := p.Name() if err != nil { // If we cannot get the process name, it's typically because the process has // exited (ephemeral process) during the scan. We skip this process entirely // to avoid leaving empty entries and misaligning slices. slog.Error(fmt.Sprintf("Failed to get process name: %v", err)) continue } vmInfo.ProcessNames = append(vmInfo.ProcessNames, name) // We may not have permissions to get attributes for all processes. // These will fallback to empty strings via ignoreError wrapper. vmInfo.ProcessPaths = append(vmInfo.ProcessPaths, processPath(p)) vmInfo.ProcessArgs = append(vmInfo.ProcessArgs, processArgs(p)) vmInfo.ProcessEnvVars = append(vmInfo.ProcessEnvVars, strings.Join(processEnvVars(p), "\n")) vmInfo.Usernames = append(vmInfo.Usernames, processUsername(p)) } return vmInfo, nil } // RunEngine runs the discovery engine against the given discovery request and returns the // discovery result. func RunEngine(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { vmInfo, err := vmInfo() if err != nil { return nil, err } slog.Info(fmt.Sprintf("Discovered VM info: %+v", vmInfo)) return engine.ExecuteRules(ctx, req, vmInfo), nil } // ISVDiscovery is a struct for holding the configuration of the ISV discovery service. // "Endpoint" is the endpoint and will often be an empty string. // "Channel" is the registered channel name to be used for communication // between the agent and the service provider. // "ErrorLogger" is the logger to use for logging errors. // "DefinitionFile" is the file to read discovery definitions from. // "DataFile" is the file to write discovered data to. type ISVDiscovery struct { ErrorLogger *slog.Logger // optional configurations from env vars - will never be used by the extension in normal operation // these are meant to aid in testing and debugging of the extension // DEBUG ONLY channel string // ACS channel Id, default none endpoint string // ACS endpoint override, default none dataFile string // file to write discovered data to, default none definitionFile string // file based discovery definitions, default none envReportingInterval time.Duration envScanInterval time.Duration lastRules *defpb.DiscoveryRules lastResult *defpb.DiscoveryResult lastFetch time.Time lastReport time.Time // Function fields for mocking in tests. pollAndScanFunc func(ctx context.Context) fetchRulesFunc func(ctx context.Context) (*defpb.DiscoveryRules, error) reportResultFunc func(ctx context.Context, result *defpb.DiscoveryResult) error runEngineFunc func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) metadataDisabledFunc func(ctx context.Context) (bool, error) } // New creates a new ISVDiscovery service. func New(errorLogger *slog.Logger) *ISVDiscovery { slog.Info("Creating new ISVDiscovery") d := &ISVDiscovery{ ErrorLogger: errorLogger, } d.parseEnvVars() // Initialize default function fields. d.pollAndScanFunc = d.pollAndScan d.fetchRulesFunc = d.fetchRules d.reportResultFunc = d.reportResult d.runEngineFunc = RunEngine d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { disabled, err := metadata.InstanceAttributeValueWithContext(ctx, disableGuestTelemetryMetadataKey) if err != nil { return false, err } return strings.ToLower(disabled) == "true", nil } return d } func (d *ISVDiscovery) parseEnvVars() { // Parse environment variables. d.channel = os.Getenv("GUEST_TEL_ISV_CHANNEL") if d.channel == "" { d.channel = "compute.googleapis.com/isv-discovery" } d.endpoint = os.Getenv("GUEST_TEL_ISV_ENDPOINT") d.dataFile = os.Getenv("GUEST_TEL_ISV_DATA_FILE") d.definitionFile = os.Getenv("GUEST_TEL_ISV_DEFINITION_FILE") reportingIntervalStr := os.Getenv("GUEST_TEL_ISV_REPORTING_INTERVAL") if reportingIntervalStr != "" { if t, err := time.ParseDuration(reportingIntervalStr); err == nil { d.envReportingInterval = t } else { slog.Error(fmt.Sprintf("Failed to parse GUEST_TEL_ISV_REPORTING_INTERVAL: %v", err)) } } scanIntervalStr := os.Getenv("GUEST_TEL_ISV_SCAN_INTERVAL") if scanIntervalStr != "" { if t, err := time.ParseDuration(scanIntervalStr); err == nil { d.envScanInterval = t } else { slog.Error(fmt.Sprintf("Failed to parse GUEST_TEL_ISV_SCAN_INTERVAL: %v", err)) } } slog.Info(fmt.Sprintf("ISVDiscovery created with channel: %s, endpoint: %s, dataFile: %s, definitionFile: %s, envReportingInterval: %v, envScanInterval: %v", d.channel, d.endpoint, d.dataFile, d.definitionFile, d.envReportingInterval, d.envScanInterval)) } const ( defaultScanInterval = 15 * time.Minute defaultReportingInterval = 24 * time.Hour ) // Run runs the ISV discovery service. It gathers discovery definitions via ACS and runs discovery against them. func (d *ISVDiscovery) Run(ctx context.Context) error { slog.Info("Running ISV discovery") // If a definition file is specified, run discovery against the definitions in the file and exit. // This is a debug only feature. if d.definitionFile != "" { slog.Info("Running discovery from file") return d.runDiscoveryFromFile(ctx, d.ErrorLogger) } disabled, err := d.metadataDisabledFunc(ctx) if err != nil { slog.Info(fmt.Sprintf("Unable to get metadata key disable-guest-telemetry. %s: %s", "err", err.Error())) } if disabled { slog.Info("Guest telemetry is disabled. Skipping communication with ACS and discovery.") return nil } // Initial scan on boot. d.pollAndScanFunc(ctx) scanInterval := d.scanInterval() ticker := time.NewTicker(scanInterval) defer ticker.Stop() for { select { case <-ctx.Done(): slog.Info("ISV discovery service loop stopped due to context cancellation") return nil case <-ticker.C: d.pollAndScanFunc(ctx) newScanInterval := d.scanInterval() if newScanInterval != scanInterval { scanInterval = newScanInterval ticker.Reset(scanInterval) } } } } func (d *ISVDiscovery) scanInterval() time.Duration { if d.envScanInterval > 0 { return d.envScanInterval } if d.lastRules.GetConfig().GetScanIntervalSeconds() > 0 { return time.Duration(d.lastRules.GetConfig().GetScanIntervalSeconds()) * time.Second } return defaultScanInterval } func (d *ISVDiscovery) reportingInterval() time.Duration { if d.envReportingInterval > 0 { return d.envReportingInterval } if d.lastRules.GetConfig().GetMinimumReportingIntervalSeconds() > 0 { return time.Duration(d.lastRules.GetConfig().GetMinimumReportingIntervalSeconds()) * time.Second } return defaultReportingInterval } func (d *ISVDiscovery) bootstrapRules() *defpb.DiscoveryRules { config := defpb.DiscoveryConfiguration_builder{ ScanIntervalSeconds: int32(defaultScanInterval / time.Second), MinimumReportingIntervalSeconds: int32(defaultReportingInterval / time.Second), }.Build() return defpb.DiscoveryRules_builder{ Config: config, }.Build() } func (d *ISVDiscovery) fetchRules(ctx context.Context) (*defpb.DiscoveryRules, error) { acsClient, err := communication.CreateClient(ctx, d.endpoint) if err != nil { return nil, fmt.Errorf("failed to create ACS client: %w", err) } defer func() { if err := acsClient.Close(); err != nil { slog.Warn(fmt.Sprintf("Failed to close ACS client: %v", err)) } }() res, err := communication.SendDiscoveryDefinitionRequest(ctx, d.channel, acsClient) if err != nil { return nil, fmt.Errorf("failed to send discovery definition request: %w", err) } req := &defpb.DiscoveryRules{} if err := res.GetMessageBody().GetBody().UnmarshalTo(req); err != nil { return nil, fmt.Errorf("failed to unmarshal message to DiscoveryRules: %w", err) } return req, nil } func (d *ISVDiscovery) reportResult(ctx context.Context, result *defpb.DiscoveryResult) error { anyRes, err := anypb.New(result) if err != nil { return fmt.Errorf("failed to marshal DiscoveryResult to any: %w", err) } acsClient, err := communication.CreateClient(ctx, d.endpoint) if err != nil { return fmt.Errorf("failed to create ACS client: %w", err) } defer func() { if err := acsClient.Close(); err != nil { slog.Warn(fmt.Sprintf("Failed to close ACS client: %v", err)) } }() response, err := communication.SendDiscoveryResult(ctx, d.channel, acsClient, anyRes) if err != nil { return fmt.Errorf("failed to send discovery result: %w", err) } slog.Info(fmt.Sprintf("Discovery result sent successfully. Response: %v", response)) return nil } func (d *ISVDiscovery) pollAndScan(ctx context.Context) { if err := ctx.Err(); err != nil { slog.Info("Skipping poll and scan due to context cancellation") return } now := time.Now() needFetch := d.lastFetch.IsZero() || now.Sub(d.lastFetch) >= d.reportingInterval() if needFetch { slog.Info("Fetching discovery rules from backend") rules, err := d.fetchRulesFunc(ctx) if err != nil { slog.Warn(fmt.Sprintf("Failed to fetch discovery rules: %v. Using cached or bootstrap config.", err)) if d.lastRules == nil { slog.Info("No cached rules, using bootstrap config") d.lastRules = d.bootstrapRules() } } else { d.lastRules = rules d.lastFetch = now } } // Now run the scan with d.lastRules. slog.Info("Running discovery scan") result, err := d.runEngineFunc(ctx, d.lastRules) if err != nil { slog.Error(fmt.Sprintf("Failed to run discovery engine: %v", err)) return } result = deduplicateResult(result) resultChanged := !discoveryResultEqual(result, d.lastResult) succeededFetch := needFetch && d.lastFetch.Equal(now) needReport := resultChanged || succeededFetch || d.lastResult == nil || now.Sub(d.lastReport) >= d.reportingInterval() if !needReport { slog.Info("Discovery result unchanged, skipping report") return } slog.Info(fmt.Sprintf("Reporting discovery results. Reason: resultChanged=%v, succeededFetch=%v, firstReport=%v", resultChanged, succeededFetch, d.lastResult == nil)) if err := d.reportResultFunc(ctx, result); err != nil { slog.Error(fmt.Sprintf("Failed to report discovery results: %v", err)) return } d.lastResult = result d.lastReport = now } func (d *ISVDiscovery) runDiscoveryFromFile(ctx context.Context, errorLogger *slog.Logger) error { // Read the definitions from the definition file. definitionFileBytes, err := os.ReadFile(d.definitionFile) if err != nil { slog.Error(fmt.Sprintf("Failed to read definition file: %v", err)) errorLogger.Error(fmt.Sprintf("Failed to read definition file: %v", err)) return err } slog.Info("Read definitions from file successfully") // Parse the definitions from the file. definitions := &defpb.DiscoveryRules{} if err := prototext.Unmarshal(definitionFileBytes, definitions); err != nil { slog.Error(fmt.Sprintf("Failed to parse definitions: %v", err)) errorLogger.Error(fmt.Sprintf("Failed to parse definitions: %v", err)) return err } slog.Info("Parsed definitions from file successfully") slog.Info(fmt.Sprintf("Definitions: %s", prototext.Format(definitions))) // Run discovery against the definitions. res, err := d.runEngineFunc(ctx, definitions) if err != nil { slog.Warn(fmt.Sprintf("Failed to discover workloads. %s: %s", "err", err.Error())) errorLogger.Error(fmt.Sprintf("Failed to discover workloads: %v", err)) return err } slog.Info(fmt.Sprintf("Discovered workloads successfully. Result: %s", prototext.Format(res))) anyRes, err := anypb.New(res) if err != nil { slog.Error(fmt.Sprintf("Failed to marshal discovered data to any: %v", err)) errorLogger.Error(fmt.Sprintf("Failed to marshal discovered data to any: %v", err)) return err } slog.Info(fmt.Sprintf("Marshalled discovered data to any successfully. Data: %s", prototext.Format(anyRes))) // Write the discovered data to the data file. bytes, err := proto.Marshal(anyRes) if err != nil { slog.Error(fmt.Sprintf("Failed to marshal discovered data: %v", err)) errorLogger.Error(fmt.Sprintf("Failed to marshal discovered data: %v", err)) return err } slog.Info("Marshalled discovered data successfully") if err := os.WriteFile(d.dataFile, bytes, 0644); err != nil { slog.Error(fmt.Sprintf("Failed to write data file: %v", err)) errorLogger.Error(fmt.Sprintf("Failed to write data file: %v", err)) return err } slog.Info("Wrote discovered data to file successfully") slog.Info("Discovery from file complete") return nil } func discoveryResultEqual(a, b *defpb.DiscoveryResult) bool { if a == nil && b == nil { return true } if a == nil || b == nil { return false } if len(a.GetDetectedData()) != len(b.GetDetectedData()) { return false } matched := make([]bool, len(b.GetDetectedData())) for _, da := range a.GetDetectedData() { found := false for i, db := range b.GetDetectedData() { if !matched[i] && proto.Equal(da, db) { matched[i] = true found = true break } } if !found { return false } } return true } func deduplicateResult(r *defpb.DiscoveryResult) *defpb.DiscoveryResult { if r == nil { return nil } seen := make(map[string]bool) var unique []*defpb.DetectedData for _, d := range r.GetDetectedData() { key := d.GetName() + "|" + d.GetVersion() if !seen[key] { seen[key] = true unique = append(unique, d) } } return defpb.DiscoveryResult_builder{ DetectedData: unique, }.Build() } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/isvdiscovery/service/discovery_test.go000066400000000000000000001077371524141036400335670ustar00rootroot00000000000000/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package discovery provides a service for discovering workloads on the host. package discovery import ( "context" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "runtime" "strings" "sync/atomic" "testing" "time" defpb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/definition/proto" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/engine" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/encoding/prototext" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" anypb "google.golang.org/protobuf/types/known/anypb" ) type fakeProcess struct { username string pid int32 name string exe string cmdlines []string environ []string nameErr error exeErr error } func (p *fakeProcess) Username() (string, error) { return p.username, nil } func (p *fakeProcess) Pid() int32 { return p.pid } func (p *fakeProcess) Name() (string, error) { if p.nameErr != nil { return "", p.nameErr } return p.name, nil } func (p *fakeProcess) Exe() (string, error) { if p.exeErr != nil { return "", p.exeErr } return p.exe, nil } func (p *fakeProcess) CmdlineSlice() ([]string, error) { return p.cmdlines, nil } func (p *fakeProcess) Cmdline() (string, error) { return strings.Join(p.cmdlines, " "), nil } func (p *fakeProcess) Environ() ([]string, error) { return p.environ, nil } func (p *fakeProcess) String() string { return fmt.Sprintf("process{username: %s, pid: %d, name: %s, args: %+v}", p.username, p.pid, p.name, p.cmdlines) } type fakeProcessLister struct { processes []ProcessWrapper err error } func (l fakeProcessLister) listAllProcesses() ([]ProcessWrapper, error) { if l.err != nil { return nil, l.err } return l.processes, nil } func TestRunEngine(t *testing.T) { oldProcs := procs t.Cleanup(func() { procs = oldProcs }) tests := []struct { name string processes []ProcessWrapper req *defpb.DiscoveryRules want *defpb.DiscoveryResult wantErr bool listerErr error }{ { name: "no rules", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, }, req: defpb.DiscoveryRules_builder{}.Build(), want: defpb.DiscoveryResult_builder{}.Build(), }, { name: "process name match", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, }, req: defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ stringMatchRule("rule1", "WORKLOAD_1", defpb.StringMatchCondition_VM_PROCESS_NAME, "workload1"), }, }.Build(), want: wantResult("WORKLOAD_1"), }, { name: "process path match", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, }, req: defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ stringMatchRule("rule1", "WORKLOAD_1", defpb.StringMatchCondition_VM_PROCESS_PATH, "/usr/bin/workload1"), }, }.Build(), want: wantResult("WORKLOAD_1"), }, { name: "os name match", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, }, req: defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ stringMatchRule("rule1", "WORKLOAD_1", defpb.StringMatchCondition_VM_OS_NAME, runtime.GOOS), }, }.Build(), want: wantResult("WORKLOAD_1"), }, { name: "no match", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, }, req: defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ stringMatchRule("rule1", "WORKLOAD_1", defpb.StringMatchCondition_VM_PROCESS_NAME, "nonexistent"), }, }.Build(), want: defpb.DiscoveryResult_builder{}.Build(), }, { name: "multiple rules match", processes: []ProcessWrapper{ &fakeProcess{name: "workload1", exe: "/usr/bin/workload1"}, &fakeProcess{name: "workload2", exe: "/usr/bin/workload2"}, }, req: defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ stringMatchRule("rule1", "WORKLOAD_1", defpb.StringMatchCondition_VM_PROCESS_NAME, "workload1"), stringMatchRule("rule2", "WORKLOAD_2", defpb.StringMatchCondition_VM_PROCESS_NAME, "workload2"), }, }.Build(), want: wantResult("WORKLOAD_1", "WORKLOAD_2"), }, { name: "process lister error", processes: nil, req: defpb.DiscoveryRules_builder{}.Build(), want: nil, wantErr: true, listerErr: errors.New("listAllProcesses error"), }, { name: "process name error", processes: []ProcessWrapper{ &fakeProcess{nameErr: errors.New("name error")}, }, req: defpb.DiscoveryRules_builder{}.Build(), want: defpb.DiscoveryResult_builder{}.Build(), }, { name: "process exe error", processes: []ProcessWrapper{ &fakeProcess{name: "name", exeErr: errors.New("exe error")}, }, req: defpb.DiscoveryRules_builder{}.Build(), want: defpb.DiscoveryResult_builder{}.Build(), }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { procs = fakeProcessLister{processes: test.processes, err: test.listerErr} got, err := RunEngine(t.Context(), test.req) if (err != nil) != test.wantErr { t.Errorf("RunEngine(%v) returned an unexpected error: %v", test.req, err) } if diff := cmp.Diff(test.want, got, protocmp.Transform(), protocmp.SortRepeatedFields(&defpb.DiscoveryResult{}, "detected_data")); diff != "" { t.Errorf("RunEngine(%v) returned an unexpected diff (-want +got): %v", test.req, diff) } }) } } func TestVmInfo(t *testing.T) { oldProcs := procs t.Cleanup(func() { procs = oldProcs }) procs = fakeProcessLister{ processes: []ProcessWrapper{ &fakeProcess{ name: "workload1", username: "test_user", exe: "/usr/bin/workload1", cmdlines: []string{"arg1", "arg2"}, environ: []string{"ENV1=VAL1", "ENV2=VAL2"}, }, &fakeProcess{ nameErr: errors.New("permission denied or dead process"), }, }, } got, err := vmInfo() if err != nil { t.Fatalf("vmInfo() unexpected error: %v", err) } want := &engine.VMInfo{ ProcessNames: []string{"workload1"}, ProcessPaths: []string{"/usr/bin/workload1"}, ProcessArgs: []string{"arg1 arg2"}, ProcessEnvVars: []string{"ENV1=VAL1\nENV2=VAL2"}, Usernames: []string{"test_user"}, OSName: runtime.GOOS, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("vmInfo() returned unexpected diff (-want +got):\n%s", diff) } } func TestPollAndScan(t *testing.T) { t.Parallel() dummyResult := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "test_workload"}.Build(), }, }.Build() newResult := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "new_workload"}.Build(), }, }.Build() resultAB := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "A"}.Build(), defpb.DetectedData_builder{Name: "B"}.Build(), }, }.Build() resultBA := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "B"}.Build(), defpb.DetectedData_builder{Name: "A"}.Build(), }, }.Build() resultAAB := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "A"}.Build(), defpb.DetectedData_builder{Name: "A"}.Build(), defpb.DetectedData_builder{Name: "B"}.Build(), }, }.Build() bootstrapRules := rulesWithConfig(15*60, 24*60*60) someRules := defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ defpb.DiscoveryRule_builder{Id: "some_rule"}.Build(), }, }.Build() now := time.Now() recentTime := now.Add(-10 * time.Second) longAgo := now.Add(-24 * time.Hour) almostLongAgo := now.Add(-24*time.Hour + time.Minute) tests := []struct { name string initialRules *defpb.DiscoveryRules initialResult *defpb.DiscoveryResult initialFetch time.Time initialReport time.Time envInterval time.Duration fetchRulesFunc func(context.Context) (*defpb.DiscoveryRules, error) runEngineFunc func(context.Context, *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) reportResultFunc func(context.Context, *defpb.DiscoveryResult) error wantFetchCalled bool wantEngineCalled bool wantReportCalled bool wantRules *defpb.DiscoveryRules wantResult *defpb.DiscoveryResult wantFetchUpdated bool wantReportUpdated bool }{ { name: "first run success", wantFetchCalled: true, wantEngineCalled: true, wantReportCalled: true, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: true, wantReportUpdated: true, }, { name: "subsequent run no changes", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: now, initialReport: now, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: false, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: false, }, { name: "subsequent run result changed", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: recentTime, initialReport: recentTime, runEngineFunc: func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return newResult, nil }, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: true, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: newResult, wantFetchUpdated: false, wantReportUpdated: true, }, { name: "subsequent run reporting interval minus 1m", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: almostLongAgo, initialReport: almostLongAgo, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: false, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: false, }, { name: "subsequent run reporting interval exactly", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: longAgo, initialReport: longAgo, wantFetchCalled: true, wantEngineCalled: true, wantReportCalled: true, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: true, wantReportUpdated: true, }, { name: "first run fetch failure fallback to bootstrap", fetchRulesFunc: func(ctx context.Context) (*defpb.DiscoveryRules, error) { return nil, errors.New("fetch error") }, wantFetchCalled: true, wantEngineCalled: true, wantReportCalled: true, wantRules: bootstrapRules, wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: true, }, { name: "subsequent run report failure preserves old state", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: recentTime, initialReport: recentTime, runEngineFunc: func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return newResult, nil }, reportResultFunc: func(ctx context.Context, result *defpb.DiscoveryResult) error { return errors.New("report error") }, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: true, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: false, }, { name: "subsequent run fetch failure preserves old rules", initialRules: someRules, initialResult: dummyResult, initialFetch: longAgo, initialReport: longAgo, fetchRulesFunc: func(ctx context.Context) (*defpb.DiscoveryRules, error) { return nil, errors.New("fetch error") }, wantFetchCalled: true, wantEngineCalled: true, wantReportCalled: true, wantRules: someRules, wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: true, }, { name: "subsequent run result changed only in order", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: resultAB, initialFetch: recentTime, initialReport: recentTime, runEngineFunc: func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return resultBA, nil }, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: false, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: resultAB, wantFetchUpdated: false, wantReportUpdated: false, }, { name: "subsequent run engine failure preserves old result", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: dummyResult, initialFetch: recentTime, initialReport: recentTime, runEngineFunc: func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return nil, errors.New("engine error") }, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: false, wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: dummyResult, wantFetchUpdated: false, wantReportUpdated: false, }, { name: "subsequent run duplicate entries (deduplicated)", initialRules: defpb.DiscoveryRules_builder{}.Build(), initialResult: resultAB, initialFetch: recentTime, initialReport: recentTime, runEngineFunc: func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return resultAAB, nil }, wantFetchCalled: false, wantEngineCalled: true, wantReportCalled: false, // Expecting NO report because results are deduplicated to [A, B] wantRules: defpb.DiscoveryRules_builder{}.Build(), wantResult: resultAB, wantFetchUpdated: false, wantReportUpdated: false, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() d := New(nil) if test.envInterval > 0 { d.envReportingInterval = test.envInterval } d.lastRules = test.initialRules d.lastResult = test.initialResult d.lastFetch = test.initialFetch d.lastReport = test.initialReport var fetchCalled, engineCalled, reportCalled bool d.fetchRulesFunc = func(ctx context.Context) (*defpb.DiscoveryRules, error) { fetchCalled = true if test.fetchRulesFunc != nil { return test.fetchRulesFunc(ctx) } return defpb.DiscoveryRules_builder{}.Build(), nil } d.runEngineFunc = func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { engineCalled = true if test.runEngineFunc != nil { return test.runEngineFunc(ctx, req) } return dummyResult, nil } d.reportResultFunc = func(ctx context.Context, result *defpb.DiscoveryResult) error { reportCalled = true if test.reportResultFunc != nil { return test.reportResultFunc(ctx, result) } return nil } d.pollAndScan(t.Context()) if fetchCalled != test.wantFetchCalled { t.Errorf("fetchRulesFunc called = %v, want %v", fetchCalled, test.wantFetchCalled) } if engineCalled != test.wantEngineCalled { t.Errorf("runEngineFunc called = %v, want %v", engineCalled, test.wantEngineCalled) } if reportCalled != test.wantReportCalled { t.Errorf("reportResultFunc called = %v, want %v", reportCalled, test.wantReportCalled) } if !proto.Equal(d.lastRules, test.wantRules) { t.Errorf("lastRules = %v, want %v", d.lastRules, test.wantRules) } if !proto.Equal(d.lastResult, test.wantResult) { t.Errorf("lastResult = %v, want %v", d.lastResult, test.wantResult) } if test.wantFetchUpdated { if !d.lastFetch.After(test.initialFetch) { t.Errorf("lastFetch %v should be updated (after %v)", d.lastFetch, test.initialFetch) } } else { if !d.lastFetch.Equal(test.initialFetch) { t.Errorf("lastFetch = %v, want %v (should not be updated)", d.lastFetch, test.initialFetch) } } if test.wantReportUpdated { if !d.lastReport.After(test.initialReport) { t.Errorf("lastReport %v should be updated (after %v)", d.lastReport, test.initialReport) } } else { if !d.lastReport.Equal(test.initialReport) { t.Errorf("lastReport = %v, want %v (should not be updated)", d.lastReport, test.initialReport) } } }) } } func TestRun_LoopTicks(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, nil } // Set scan interval to 1s. d.lastRules = rulesWithConfig(1, 0) var pollCalled atomic.Int32 d.pollAndScanFunc = func(ctx context.Context) { pollCalled.Add(1) } ctx, cancel := context.WithCancel(t.Context()) errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() // Wait for 2.5 seconds to allow 2 ticks (T=0, T=1s, T=2s) time.Sleep(2500 * time.Millisecond) cancel() err := <-errChan if err != nil { t.Errorf("Run returned error: %v", err) } if pollCalled.Load() != 3 { t.Errorf("pollAndScanFunc called %d times, want 3", pollCalled.Load()) } } func TestRun_LoopTicksEnvScanInterval(t *testing.T) { t.Setenv("GUEST_TEL_ISV_SCAN_INTERVAL", "1s") d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, nil } var pollCalled atomic.Int32 d.pollAndScanFunc = func(ctx context.Context) { pollCalled.Add(1) } ctx, cancel := context.WithCancel(t.Context()) errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() // Wait for 2.5 seconds to allow 2 ticks (T=0, T=1s, T=2s) time.Sleep(2500 * time.Millisecond) cancel() err := <-errChan if err != nil { t.Errorf("Run returned error: %v", err) } if pollCalled.Load() != 3 { t.Errorf("pollAndScanFunc called %d times, want 3", pollCalled.Load()) } } func TestRun_LoopTicksDynamic(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, nil } // Set initial scan interval to 1s. d.lastRules = rulesWithConfig(1, 0) var pollCalled atomic.Int32 d.pollAndScanFunc = func(ctx context.Context) { val := pollCalled.Add(1) if val == 2 { // On the first tick (T=1s), update the scan interval to 2s. d.lastRules = rulesWithConfig(2, 0) } } ctx, cancel := context.WithCancel(t.Context()) errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() // Wait for 2.5 seconds. // T=0: pollCalled=1 // T=1s: pollCalled=2, interval updated to 2s, ticker reset. // T=2s: should NOT tick (next tick should be T=3s). // At T=2.5s, pollCalled should still be 2. time.Sleep(2500 * time.Millisecond) if pollCalled.Load() != 2 { t.Errorf("At T=2.5s, pollAndScanFunc called %d times, want 2 (it might have ticked too early)", pollCalled.Load()) } // Wait another 1 second (total 3.5s). // T=3s: should tick. // At T=3.5s, pollCalled should be 3. time.Sleep(1000 * time.Millisecond) cancel() err := <-errChan if err != nil { t.Errorf("Run returned error: %v", err) } if pollCalled.Load() != 3 { t.Errorf("At T=3.5s, pollAndScanFunc called %d times, want 3 (it might not have ticked at the new interval)", pollCalled.Load()) } } func TestRun_Disabled(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return true, nil } var pollCalled bool d.pollAndScanFunc = func(ctx context.Context) { pollCalled = true } err := d.Run(t.Context()) if err != nil { t.Errorf("Run returned error: %v", err) } if pollCalled { t.Error("pollAndScanFunc was called when disabled") } } func TestRun_MetadataError(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, errors.New("metadata error") } pollCalled := make(chan struct{}) d.pollAndScanFunc = func(ctx context.Context) { close(pollCalled) } ctx, cancel := context.WithCancel(t.Context()) defer cancel() errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() select { case <-pollCalled: // OK case <-time.After(1 * time.Second): t.Fatal("pollAndScanFunc was not called") } cancel() select { case err := <-errChan: if err != nil { t.Errorf("Run returned error: %v", err) } case <-time.After(1 * time.Second): t.Fatal("Run did not exit after context cancellation") } } func setupFileDiscoveryTest(t *testing.T, rules *defpb.DiscoveryRules, invalidContent bool) (string, string) { t.Helper() tmpDir := t.TempDir() definitionFile := filepath.Join(tmpDir, "definitions.textproto") dataFile := filepath.Join(tmpDir, "data.bin") if invalidContent { if err := os.WriteFile(definitionFile, []byte("invalid content"), 0644); err != nil { t.Fatalf("failed to write definition file: %v", err) } } else if rules != nil { rulesBytes, err := prototext.Marshal(rules) if err != nil { t.Fatalf("failed to marshal rules: %v", err) } if err := os.WriteFile(definitionFile, rulesBytes, 0644); err != nil { t.Fatalf("failed to write definition file: %v", err) } } return definitionFile, dataFile } func TestRunDiscoveryFromFile_Success(t *testing.T) { t.Parallel() rules := defpb.DiscoveryRules_builder{ Rules: []*defpb.DiscoveryRule{ defpb.DiscoveryRule_builder{Id: "rule1"}.Build(), }, }.Build() definitionFile, dataFile := setupFileDiscoveryTest(t, rules, false) d := New(nil) d.definitionFile = definitionFile d.dataFile = dataFile dummyResult := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "test_workload"}.Build(), }, }.Build() var runEngineCalled int var runEngineRules *defpb.DiscoveryRules d.runEngineFunc = func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { runEngineCalled++ runEngineRules = req return dummyResult, nil } err := d.runDiscoveryFromFile(t.Context(), slog.Default()) if err != nil { t.Errorf("runDiscoveryFromFile returned error: %v", err) } if runEngineCalled != 1 { t.Errorf("runEngineFunc called %d times, want 1", runEngineCalled) } if !proto.Equal(runEngineRules, rules) { t.Errorf("runEngineFunc called with %v, want %v", runEngineRules, rules) } // Verify data file was written. dataBytes, err := os.ReadFile(dataFile) if err != nil { t.Fatalf("failed to read data file: %v", err) } anyRes := &anypb.Any{} if err := proto.Unmarshal(dataBytes, anyRes); err != nil { t.Fatalf("failed to unmarshal data file to Any: %v", err) } gotResult := &defpb.DiscoveryResult{} if err := anyRes.UnmarshalTo(gotResult); err != nil { t.Fatalf("failed to unmarshal Any to DiscoveryResult: %v", err) } if !proto.Equal(gotResult, dummyResult) { t.Errorf("got result %v, want %v", gotResult, dummyResult) } } func TestRunDiscoveryFromFile_ReadError(t *testing.T) { t.Parallel() d := New(nil) d.definitionFile = "nonexistent_file" err := d.runDiscoveryFromFile(t.Context(), slog.New(slog.NewTextHandler(io.Discard, nil))) if err == nil { t.Error("runDiscoveryFromFile expected error, got nil") } } func TestRunDiscoveryFromFile_UnmarshalError(t *testing.T) { t.Parallel() definitionFile, _ := setupFileDiscoveryTest(t, nil, true) d := New(nil) d.definitionFile = definitionFile err := d.runDiscoveryFromFile(t.Context(), slog.New(slog.NewTextHandler(io.Discard, nil))) if err == nil { t.Error("runDiscoveryFromFile expected error, got nil") } } func TestRunDiscoveryFromFile_WriteError(t *testing.T) { t.Parallel() definitionFile, dataFile := setupFileDiscoveryTest(t, defpb.DiscoveryRules_builder{}.Build(), false) // Use a directory path as dataFile to force write error. if err := os.Mkdir(dataFile, 0755); err != nil { t.Fatalf("failed to create directory: %v", err) } d := New(nil) d.definitionFile = definitionFile d.dataFile = dataFile d.runEngineFunc = func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { return defpb.DiscoveryResult_builder{}.Build(), nil } err := d.runDiscoveryFromFile(t.Context(), slog.New(slog.NewTextHandler(io.Discard, nil))) if err == nil { t.Error("runDiscoveryFromFile expected error, got nil") } } func TestRun_CancelDuringScan(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, nil } d.fetchRulesFunc = func(ctx context.Context) (*defpb.DiscoveryRules, error) { return defpb.DiscoveryRules_builder{}.Build(), nil } scanStarted := make(chan struct{}) scanCancelled := make(chan struct{}) d.runEngineFunc = func(ctx context.Context, req *defpb.DiscoveryRules) (*defpb.DiscoveryResult, error) { close(scanStarted) select { case <-ctx.Done(): close(scanCancelled) return nil, ctx.Err() case <-time.After(5 * time.Second): return defpb.DiscoveryResult_builder{}.Build(), nil } } d.reportResultFunc = func(ctx context.Context, result *defpb.DiscoveryResult) error { return nil } ctx, cancel := context.WithCancel(t.Context()) defer cancel() errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() // Wait for scan to start. select { case <-scanStarted: // OK case <-time.After(1 * time.Second): t.Fatal("scan did not start") } // Cancel context while scan is in progress. cancel() // Verify scan was cancelled. select { case <-scanCancelled: // OK case <-time.After(1 * time.Second): t.Fatal("scan was not cancelled promptly") } // Verify Run exits. select { case err := <-errChan: if err != nil { t.Errorf("Run returned error: %v", err) } case <-time.After(1 * time.Second): t.Fatal("Run did not exit after context cancellation") } } func TestScanInterval_Boundary(t *testing.T) { t.Parallel() tests := []struct { name string lastRules *defpb.DiscoveryRules want time.Duration }{ { name: "nil_rules", lastRules: nil, want: defaultScanInterval, }, { name: "nil_config", lastRules: defpb.DiscoveryRules_builder{}.Build(), want: defaultScanInterval, }, { name: "zero_interval", lastRules: rulesWithConfig(0, 0), want: defaultScanInterval, }, { name: "negative_interval", lastRules: rulesWithConfig(-5, 0), want: defaultScanInterval, }, { name: "positive_interval", lastRules: rulesWithConfig(10, 0), want: 10 * time.Second, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() d := &ISVDiscovery{lastRules: test.lastRules} got := d.scanInterval() if got != test.want { t.Errorf("scanInterval() = %v, want %v", got, test.want) } }) } } func TestReportingInterval_Boundary(t *testing.T) { t.Parallel() tests := []struct { name string lastRules *defpb.DiscoveryRules envInterval time.Duration want time.Duration }{ { name: "nil_rules", lastRules: nil, want: defaultReportingInterval, }, { name: "nil_config", lastRules: defpb.DiscoveryRules_builder{}.Build(), want: defaultReportingInterval, }, { name: "zero_interval", lastRules: rulesWithConfig(0, 0), want: defaultReportingInterval, }, { name: "negative_interval", lastRules: rulesWithConfig(0, -5), want: defaultReportingInterval, }, { name: "positive_interval", lastRules: rulesWithConfig(0, 10), want: 10 * time.Second, }, { name: "env_overrides", lastRules: nil, envInterval: 5 * time.Second, want: 5 * time.Second, }, { name: "env_overrides_with_config", lastRules: rulesWithConfig(0, 10), envInterval: 5 * time.Second, want: 5 * time.Second, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() d := &ISVDiscovery{ lastRules: test.lastRules, envReportingInterval: test.envInterval, } got := d.reportingInterval() if got != test.want { t.Errorf("reportingInterval() = %v, want %v", got, test.want) } }) } } func TestRun_ZeroInterval(t *testing.T) { t.Parallel() d := New(nil) d.metadataDisabledFunc = func(ctx context.Context) (bool, error) { return false, nil } // Set scan interval to 0. It should fallback to defaultScanInterval (15m). d.lastRules = rulesWithConfig(0, 0) var pollCalled atomic.Int32 d.pollAndScanFunc = func(ctx context.Context) { pollCalled.Add(1) } ctx, cancel := context.WithCancel(t.Context()) errChan := make(chan error, 1) go func() { errChan <- d.Run(ctx) }() // Wait a short time. Since interval is fallback to 15m, we don't expect // it to tick. We just want to make sure it doesn't panic on startup // (e.g. time.NewTicker(0) would panic). time.Sleep(100 * time.Millisecond) cancel() err := <-errChan if err != nil { t.Errorf("Run() returned error: %v", err) } if pollCalled.Load() != 1 { t.Errorf("pollAndScanFunc called %d times, want 1", pollCalled.Load()) } } func stringMatchRule(id, workloadName string, field defpb.StringMatchCondition_VmField, regex string) *defpb.DiscoveryRule { return defpb.DiscoveryRule_builder{ Id: id, Condition: defpb.Condition_builder{ StringMatch: defpb.StringMatchCondition_builder{ VmField: field.Enum(), RegexMatch: regex, }.Build(), }.Build(), DiscoveredWorkloadName: workloadName, }.Build() } func wantResult(workloadNames ...string) *defpb.DiscoveryResult { var data []*defpb.DetectedData for _, name := range workloadNames { data = append(data, defpb.DetectedData_builder{Name: name}.Build()) } return defpb.DiscoveryResult_builder{DetectedData: data}.Build() } func rulesWithConfig(scan, report int32) *defpb.DiscoveryRules { return defpb.DiscoveryRules_builder{ Config: defpb.DiscoveryConfiguration_builder{ ScanIntervalSeconds: scan, MinimumReportingIntervalSeconds: report, }.Build(), }.Build() } func TestNew_Defaults(t *testing.T) { d := New(nil) if d.pollAndScanFunc == nil { t.Errorf("New() pollAndScanFunc is nil") } if d.fetchRulesFunc == nil { t.Errorf("New() fetchRulesFunc is nil") } if d.reportResultFunc == nil { t.Errorf("New() reportResultFunc is nil") } if d.runEngineFunc == nil { t.Errorf("New() runEngineFunc is nil") } if d.metadataDisabledFunc == nil { t.Errorf("New() metadataDisabledFunc is nil") } } func TestDiscoveryResultEqual(t *testing.T) { resultA := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "A", Version: "1.0"}.Build(), }, }.Build() resultB := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "B", Version: "2.0"}.Build(), }, }.Build() resultAB := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "A", Version: "1.0"}.Build(), defpb.DetectedData_builder{Name: "B", Version: "2.0"}.Build(), }, }.Build() resultBA := defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{ defpb.DetectedData_builder{Name: "B", Version: "2.0"}.Build(), defpb.DetectedData_builder{Name: "A", Version: "1.0"}.Build(), }, }.Build() tests := []struct { name string a *defpb.DiscoveryResult b *defpb.DiscoveryResult want bool }{ {name: "both nil", a: nil, b: nil, want: true}, {name: "a nil", a: nil, b: resultA, want: false}, {name: "b nil", a: resultA, b: nil, want: false}, {name: "different length", a: resultA, b: resultAB, want: false}, {name: "same results same order", a: resultAB, b: resultAB, want: true}, {name: "same results different order", a: resultAB, b: resultBA, want: true}, {name: "different results", a: resultA, b: resultB, want: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := discoveryResultEqual(tc.a, tc.b); got != tc.want { t.Errorf("discoveryResultEqual(%v, %v) = %v, want %v", tc.a, tc.b, got, tc.want) } }) } } func TestDeduplicateResult(t *testing.T) { itemA := defpb.DetectedData_builder{Name: "A", Version: "1.0"}.Build() itemB := defpb.DetectedData_builder{Name: "B", Version: "2.0"}.Build() tests := []struct { name string in *defpb.DiscoveryResult want *defpb.DiscoveryResult }{ {name: "nil", in: nil, want: nil}, {name: "empty", in: &defpb.DiscoveryResult{}, want: &defpb.DiscoveryResult{}}, { name: "duplicates", in: defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{itemA, itemA, itemB, itemB, itemA}, }.Build(), want: defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{itemA, itemB}, }.Build(), }, { name: "no duplicates", in: defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{itemA, itemB}, }.Build(), want: defpb.DiscoveryResult_builder{ DetectedData: []*defpb.DetectedData{itemA, itemB}, }.Build(), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := deduplicateResult(tc.in) if diff := cmp.Diff(tc.want, got, protocmp.Transform(), protocmp.SortRepeatedFields(&defpb.DiscoveryResult{}, "detected_data")); diff != "" { t.Errorf("deduplicateResult(%v) returned diff (-want +got):\n%s", tc.in, diff) } }) } } func TestParseEnvVars(t *testing.T) { tests := []struct { name string env map[string]string wantChannel string wantEndpoint string wantInterval time.Duration wantScanInterval time.Duration }{ { name: "defaults", env: map[string]string{}, wantChannel: "compute.googleapis.com/isv-discovery", wantEndpoint: "", wantInterval: 0, }, { name: "custom values", env: map[string]string{ "GUEST_TEL_ISV_CHANNEL": "custom/channel", "GUEST_TEL_ISV_ENDPOINT": "custom:endpoint", "GUEST_TEL_ISV_REPORTING_INTERVAL": "5m", "GUEST_TEL_ISV_SCAN_INTERVAL": "2s", "GUEST_TEL_ISV_DATA_FILE": "/tmp/data", "GUEST_TEL_ISV_DEFINITION_FILE": "/tmp/def", }, wantChannel: "custom/channel", wantEndpoint: "custom:endpoint", wantInterval: 5 * time.Minute, wantScanInterval: 2 * time.Second, }, { name: "invalid interval", env: map[string]string{ "GUEST_TEL_ISV_REPORTING_INTERVAL": "invalid", "GUEST_TEL_ISV_SCAN_INTERVAL": "invalid", }, wantChannel: "compute.googleapis.com/isv-discovery", wantEndpoint: "", wantInterval: 0, wantScanInterval: 0, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { for k, v := range tc.env { t.Setenv(k, v) } d := &ISVDiscovery{} d.parseEnvVars() if d.channel != tc.wantChannel { t.Errorf("parseEnvVars() channel = %q, want %q", d.channel, tc.wantChannel) } if d.endpoint != tc.wantEndpoint { t.Errorf("parseEnvVars() endpoint = %q, want %q", d.endpoint, tc.wantEndpoint) } if d.envReportingInterval != tc.wantInterval { t.Errorf("parseEnvVars() envReportingInterval = %v, want %v", d.envReportingInterval, tc.wantInterval) } if d.envScanInterval != tc.wantScanInterval { t.Errorf("parseEnvVars() envScanInterval = %v, want %v", d.envScanInterval, tc.wantScanInterval) } }) } } func TestPollAndScan_CancelledContext(t *testing.T) { t.Parallel() d := New(nil) ctx, cancel := context.WithCancel(t.Context()) cancel() d.pollAndScan(ctx) // should return immediately } google-guest-agent-20260819.00/cmd/guest_telemetry_extension/main.go000066400000000000000000000213111524141036400252330ustar00rootroot00000000000000/* Copyright 2025 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package main serves as the Main entry point for the guest telemetry extension. package main import ( "context" "flag" "fmt" "io" "log/slog" "net" "os" "strings" "time" "github.com/GoogleCloudPlatform/google-guest-agent/cmd/guest_telemetry_extension/isvdiscovery/service" "google.golang.org/grpc" pluginpb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" ) var ( // required by extensions protocol = flag.String("protocol", "", "protocol to use uds/tcp") address = flag.String("address", "", "address to start server listening on") errorlogfile = flag.String("errorlogfile", "", "extension error log file") // These will typically be set to default values, but can be overridden by the user via env vars. debugLogFile string // file to write logs to, default none runOnce bool // whether to run the extension once and then exit, default false // standalone is a flag to indicate if the code is running in a standalone mode. // false is the default and indicates that the code is running as an extension with the guest agent. // true indicates that the code is running as a standalone binary, typically for testing or // development. standalone bool ) type module interface { Run(ctx context.Context) error } type statusCode int32 const ( // A healthy status code indicates to the Guest Agent that the extension is running ok. healthy statusCode = iota // An unhealthy (non-zero) status code indicates to the Guest Agent that the extension is in a failed state. unhealthy ) // Extension is a struct that implements the Guest Agent Plugin Server interface. type Extension struct { cancel context.CancelFunc ctx context.Context errorLogger *slog.Logger grpcServer *grpc.Server pluginpb.UnimplementedGuestAgentPluginServer } // Start begins the extension execution. If the extension is already running, this is a no-op. func (e *Extension) Start(ctx context.Context, msg *pluginpb.StartRequest) (*pluginpb.StartResponse, error) { if e.cancel != nil { slog.Warn("Start called when extension is already running") return &pluginpb.StartResponse{}, nil } e.ctx, e.cancel = context.WithCancel(context.Background()) // Referencing grpcServer to satisfy gounused checks. slog.Info(fmt.Sprintf("Starting extension with parameters protocol=%s address=%s errorlogfile=%s grpcServer=%v", *protocol, *address, *errorlogfile, e.grpcServer)) go func() { ec := e.coreLoop() slog.Info(fmt.Sprintf("Extension finished. Exit code: %v", ec)) }() return &pluginpb.StartResponse{}, nil } // Stop halts the extension and puts it into a stopped state. If the extension is not running, this is a no-op. func (e *Extension) Stop(ctx context.Context, msg *pluginpb.StopRequest) (*pluginpb.StopResponse, error) { if e.cancel == nil { slog.Warn("Stop called when extension is not running") return &pluginpb.StopResponse{}, nil } err := e.ctx.Err() if err != nil { slog.Error(fmt.Sprintf("Stop called with error: %v", err)) return &pluginpb.StopResponse{}, err } slog.Info("Stopping extension") e.cancel() e.cancel = nil e.ctx = context.Background() slog.Info("Extension stopped") return &pluginpb.StopResponse{}, nil } // GetStatus is the health check the guest agent would perform to make sure plugin process is alive. func (e *Extension) GetStatus(ctx context.Context, msg *pluginpb.GetStatusRequest) (*pluginpb.Status, error) { if err := e.ctx.Err(); err != nil { return &pluginpb.Status{Code: int32(unhealthy), Results: []string{err.Error()}}, err } return &pluginpb.Status{}, nil } func main() { // Setup debug logging to be the default logger for slog. file := setupDebugLogging() if file != nil { defer file.Close() } slog.Info("Guest Telemetry Extension started") flag.Parse() // Setup error logging. This should only be used for critical errors that cause the program to crash. errorLogger := errorLogger(*errorlogfile) // Setup panic recovery. This will catch any panics caused by errors in the extension and log them to the error log file. defer func() { if r := recover(); r != nil { errorLogger.Error(fmt.Sprintf("Encountered fatal crash caused by panic: %v", r)) os.Exit(1) } }() if *protocol == "" { slog.Error("No protocol specified, exiting with an error.") errorLogger.Error("No protocol specified, exiting with an error.") os.Exit(1) } if *address == "" { slog.Error("No address specified, exiting with an error.") errorLogger.Error("No address specified, exiting with an error.") os.Exit(1) } // Parse extension level env vars. // standalone is default false unless explicitly set to true. standalone = (strings.ToLower(os.Getenv("GUEST_TEL_STANDALONE")) == "true") // runOnce is default false unless explicitly set to true. runOnce = (strings.ToLower(os.Getenv("GUEST_TEL_RUN_ONCE")) == "true") // Start the extension. e := &Extension{ errorLogger: errorLogger, } if standalone { slog.Info("Starting standalone extension") if runOnce { e.ctx, e.cancel = context.WithTimeout(context.Background(), 10*time.Second) } else { e.ctx, e.cancel = context.WithCancel(context.Background()) } defer e.cancel() ec := e.coreLoop() os.Exit(int(ec)) } listener, err := net.Listen(*protocol, *address) if err != nil { errorLogger.Error(fmt.Sprintf("failed to start listening on %q using %q: %v", *address, *protocol, err)) os.Exit(1) } defer listener.Close() // This is used to receive control messages from the Guest Agent. server := grpc.NewServer() defer server.Stop() // Enable the Guest Agent to handle the starting and stopping of the agent execution logic. pluginpb.RegisterGuestAgentPluginServer(server, e) slog.Info("Starting grpc server") if err = server.Serve(listener); err != nil { errorLogger.Error(fmt.Sprintf("failed to listen for GRPC messages: %v", err)) os.Exit(1) } } func (e *Extension) coreLoop() statusCode { isvDiscovery := discovery.New(e.errorLogger) modules := []module{ isvDiscovery, } slog.Info("Running modules") for _, m := range modules { go func(mod module) { if err := mod.Run(e.ctx); err != nil { slog.Error(fmt.Sprintf("Module failed: %v", err)) } }(m) } select { case <-e.ctx.Done(): msg := "Guest Telemetry Extension exiting due to context cancellation" slog.Info(msg) e.errorLogger.Error(msg) return healthy } } func setupDebugLogging() *os.File { debugLogFile = os.Getenv("GUEST_TEL_DEBUG_LOG_FILE") var handler slog.Handler var file *os.File if debugLogFile == "" { // If no debug log file is specified, we want log statements to be no-ops. handler = slog.NewTextHandler(io.Discard, nil) } else { file, err := os.OpenFile(debugLogFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { slog.Error(fmt.Sprintf("Failed to open debug log file: %v", err)) os.Exit(1) } handler = slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true}) } logger := slog.New(handler) slog.SetDefault(logger) return file } type logWriter struct { filename string } // Write opens the log file, writes the given byte slice to the log file, and then closes the file. func (w *logWriter) Write(p []byte) (int, error) { f, err := os.OpenFile(w.filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return 0, err } n, writeErr := f.Write(p) closeErr := f.Close() if closeErr != nil { return n, closeErr } return n, writeErr } func errorLogger(errorLogFile string) *slog.Logger { var handler slog.Handler if errorLogFile == "" { // If no error log file is specified, exit the program with an error. slog.Error("No error log file specified, exiting with an error.") fmt.Fprintln(os.Stderr, "No error log file specified, exiting with an error.") os.Exit(1) } else { file, err := os.OpenFile(errorLogFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { slog.Error(fmt.Sprintf("Failed to open error log file: %v", err)) os.Exit(1) } err = file.Close() if err != nil { slog.Error(fmt.Sprintf("Failed to close error log file: %v", err)) } handler = slog.NewTextHandler(&logWriter{filename: errorLogFile}, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true}) } slog.Info("Error log file opened successfully") return slog.New(handler) } google-guest-agent-20260819.00/cmd/metadata_script_runner_compat/000077500000000000000000000000001524141036400245455ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/metadata_script_runner_compat/metadata_script_runner_compat.go000066400000000000000000000075361524141036400332070ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package main is the entry point for the metadata script runner compat binary. // It is a wrapper that either runs the metadata script runner or the legacy // metadata script runner based on core plugin configuration. package main import ( "context" "fmt" "os" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/logger" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/GoogleCloudPlatform/google-guest-agent/internal/utils/file" ) var ( // version is the version of the binary. version = "unknown" ) const ( // galogShutdownTimeout is the period of time we should wait galog to // shutdown. galogShutdownTimeout = time.Second ) func main() { ctx := context.Background() if err := cfg.Load(nil); err != nil { fmt.Fprintln(os.Stderr, "Failed to load instance config:", err) os.Exit(1) } coreCfg := cfg.Retrieve().Core logOpts := logger.Options{ Ident: "google_compat_metadata_script_runner", CloudIdent: "GCEGuestCompatMetadataScriptRunner", ProgramVersion: version, LogFile: coreCfg.LogFile, Level: coreCfg.LogLevel, Verbosity: coreCfg.LogVerbosity, } if err := logger.Init(ctx, logOpts); err != nil { fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err) os.Exit(1) } defer galog.Shutdown(galogShutdownTimeout) if len(os.Args) != 2 { galog.Fatalf("No valid event type (%v) provided, usage: %s ", os.Args, os.Args[0]) } galog.Infof("Initialized Metadata Script Runner Compat (version %s)", version) if err := launchScriptRunner(ctx, metadata.New(), os.Args[1]); err != nil { galog.Fatalf("Failed to launch script runner: %v", err) } galog.Infof("Successfully launched script runner") } func launchScriptRunner(ctx context.Context, mdsClient metadata.MDSClientInterface, event string) error { var enabled bool opts := run.Options{ // Default to new script runner. Name: metadataScriptRunnerNew, OutputType: run.OutputStream, Args: []string{event}, InheritEnv: true, } mds, err := mdsClient.Get(ctx) if err != nil { galog.Warnf("Failed to fetch MDS descriptor: [%v], falling back to legacy script runner", err) } else { if enabled = mds.HasCorePluginEnabled(); !enabled { opts.Name = metadataScriptRunnerLegacy } } if !file.Exists(metadataScriptRunnerLegacy, file.TypeFile) { galog.Infof("Script runner binary %q not found, running in test environment, overriding to new script runner", metadataScriptRunnerLegacy) opts.Name = metadataScriptRunnerNew } galog.Infof("Enable core plugin set to: [%t], launching script runner for event %q from %q", enabled, event, opts.Name) res, err := run.WithContext(ctx, opts) if err != nil { return fmt.Errorf("failed to run script runner: %v", err) } streams := res.OutputScanners // Go routines will exit once all output is consumed. Run library guarantees // that all channels are closed after use. go func() { for line := range streams.StdOut { galog.Info(line) } }() go func() { for line := range streams.StdErr { galog.Error(line) } }() return <-streams.Result } metadata_script_runner_compat_linux.go000066400000000000000000000020761524141036400343410ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/metadata_script_runner_compat// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package main var ( // metadataScriptRunnerLegacy is the path to the legacy metadata script runner. // This is the binary that will be used if the core plugin is disabled. metadataScriptRunnerLegacy = "/usr/bin/google_metadata_script_runner" ) const ( // metadataScriptRunnerNew is the path to the new metadata script runner. // This is the binary that will be used if the core plugin is enabled. metadataScriptRunnerNew = "/usr/bin/gce_metadata_script_runner" ) metadata_script_runner_compat_test.go000066400000000000000000000134701524141036400341610ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/metadata_script_runner_compat// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "errors" "fmt" "os" "path/filepath" "testing" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/run" "github.com/google/go-cmp/cmp" ) // MDSClient implements fake metadata server. type MDSClient struct { instanceEnable bool throwErr bool } const instanceMdsTemplate = ` { "instance": { "attributes": { "enable-guest-agent-core-plugin": "%t" } } } ` // GetKeyRecursive implements fake GetKeyRecursive MDS method. func (s *MDSClient) GetKeyRecursive(ctx context.Context, key string) (string, error) { return "", fmt.Errorf("not yet implemented") } // GetKey implements fake GetKey MDS method. func (s *MDSClient) GetKey(ctx context.Context, key string, headers map[string]string) (string, error) { return "", fmt.Errorf("not yet implemented") } // Get method implements fake Get on MDS. func (s *MDSClient) Get(context.Context) (*metadata.Descriptor, error) { if s.throwErr { return nil, fmt.Errorf("test error") } jsonData := fmt.Sprintf(instanceMdsTemplate, s.instanceEnable) return metadata.UnmarshalDescriptor(jsonData) } // Watch method implements fake watcher on MDS. func (s *MDSClient) Watch(context.Context) (*metadata.Descriptor, error) { return nil, fmt.Errorf("not yet implemented") } // WriteGuestAttributes method implements fake writer on MDS. func (s *MDSClient) WriteGuestAttributes(context.Context, string, string) error { return fmt.Errorf("not yet implemented") } type testRunner struct { returnErr bool seenCommand string seenOutputType run.OutputType seenArgs []string } func (t *testRunner) WithContext(ctx context.Context, opts run.Options) (*run.Result, error) { t.seenCommand = opts.Name t.seenArgs = opts.Args t.seenOutputType = opts.OutputType stdoutstream := make(chan string) stderrstream := make(chan string) resultstream := make(chan error, 1) if t.returnErr { resultstream <- errors.New("error") } close(stdoutstream) close(stderrstream) close(resultstream) return &run.Result{ OutputScanners: &run.StreamOutput{ StdOut: stdoutstream, StdErr: stderrstream, Result: resultstream, }, }, nil } func setupTestRunner(t *testing.T, runner *testRunner) { t.Helper() oldClient := run.Client run.Client = runner t.Cleanup(func() { run.Client = oldClient }) } func TestLaunchScriptRunner(t *testing.T) { ctx := context.Background() event := "startup" metadataScriptRunnerLegacy = filepath.Join(t.TempDir(), "metadata_script_runner_legacy") if err := os.WriteFile(metadataScriptRunnerLegacy, []byte("test"), 0755); err != nil { t.Fatalf("Failed to write test file: %v", err) } tests := []struct { name string runner *testRunner mdsClient *MDSClient wantCommand string wantArgs []string wantErr bool }{ { name: "core_plugin_enabled", runner: &testRunner{}, mdsClient: &MDSClient{instanceEnable: true}, wantCommand: metadataScriptRunnerNew, wantArgs: []string{event}, wantErr: false, }, { name: "core_plugin_disabled", runner: &testRunner{}, mdsClient: &MDSClient{instanceEnable: false}, wantCommand: metadataScriptRunnerLegacy, wantArgs: []string{event}, wantErr: false, }, { name: "mds_error", runner: &testRunner{}, wantCommand: metadataScriptRunnerNew, wantArgs: []string{event}, mdsClient: &MDSClient{throwErr: true}, wantErr: false, }, { name: "runner_error", runner: &testRunner{returnErr: true}, wantCommand: metadataScriptRunnerNew, wantArgs: []string{event}, mdsClient: &MDSClient{instanceEnable: true}, wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { setupTestRunner(t, test.runner) err := launchScriptRunner(ctx, test.mdsClient, event) if (err == nil) == test.wantErr { t.Errorf("launchScriptRunner(ctx, %+v, %q) error = %v, want %v", test.mdsClient, event, err, test.wantErr) } if test.runner.seenOutputType != run.OutputStream { t.Errorf("launchScriptRunner(ctx, %+v, %q) executed output type = %v, want %v", test.mdsClient, event, test.runner.seenOutputType, run.OutputStream) } if test.runner.seenCommand != test.wantCommand { t.Errorf("launchScriptRunner(ctx, %+v, %q) executed command = %q, want %q", test.mdsClient, event, test.runner.seenCommand, test.wantCommand) } if diff := cmp.Diff(test.runner.seenArgs, test.wantArgs); diff != "" { t.Errorf("launchScriptRunner(ctx, %+v, %q) executed args = %v, want %v", test.mdsClient, event, test.runner.seenArgs, test.wantArgs) } }) } if err := os.Remove(metadataScriptRunnerLegacy); err != nil { t.Fatalf("Failed to remove test file: %v", err) } testRunner := &testRunner{} setupTestRunner(t, testRunner) mdsClient := &MDSClient{instanceEnable: false} if err := launchScriptRunner(ctx, mdsClient, event); err != nil { t.Errorf("launchScriptRunner(ctx, %+v, %q) error = %v, want nil", mdsClient, event, err) } if testRunner.seenCommand != metadataScriptRunnerNew { t.Errorf("launchScriptRunner(ctx, %+v, %q) executed command = %q, want %q", mdsClient, event, testRunner.seenCommand, metadataScriptRunnerNew) } } metadata_script_runner_compat_windows.go000066400000000000000000000022151524141036400346670ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/metadata_script_runner_compat// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package main var ( // metadataScriptRunnerLegacy is the path to the legacy metadata script runner. // This is the binary that will be used if the core plugin is disabled. metadataScriptRunnerLegacy = `C:\Program Files\Google\Compute Engine\metadata_scripts\GCEMetadataScripts.exe` ) const ( // metadataScriptRunnerNew is the path to the new metadata script runner. // This is the binary that will be used if the core plugin is enabled. metadataScriptRunnerNew = `C:\Program Files\Google\Compute Engine\agent\GCEMetadataScriptRunner.exe` ) google-guest-agent-20260819.00/cmd/mwlid_extension/000077500000000000000000000000001524141036400216555ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/mwlid_extension/LICENSE000066400000000000000000000261361524141036400226720ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. google-guest-agent-20260819.00/cmd/mwlid_extension/certrefresh.go000066400000000000000000000120341524141036400245200ustar00rootroot00000000000000/* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "context" "sync" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "google.golang.org/grpc" pluginpb "github.com/GoogleCloudPlatform/google-guest-agent/pkg/proto/plugin_comm" ) const ( healthy int32 = iota unhealthy ) // Extension is a struct that implements the Guest Agent Plugin Server interface. type Extension struct { cancel context.CancelFunc ctx context.Context grpcServer *grpc.Server lastError error statusMutex sync.RWMutex pluginpb.UnimplementedGuestAgentPluginServer } // Register enables the plugin manager to handle the starting and stopping of the extension. func Register(server *grpc.Server) { pluginpb.RegisterGuestAgentPluginServer(server, &Extension{}) } // Start begins the extension execution. // If the extension is already running, this is a no-op. func (e *Extension) Start(ctx context.Context, msg *pluginpb.StartRequest) (*pluginpb.StartResponse, error) { e.statusMutex.Lock() defer e.statusMutex.Unlock() if e.cancel != nil { galog.Warn("Start called when extension is already running") return &pluginpb.StartResponse{}, nil } e.ctx, e.cancel = context.WithCancel(context.Background()) galog.Info("Starting extension") go func(ctx context.Context) { ec := e.coreLoop(ctx) galog.Infof("Extension exited with code: %v", ec) }(e.ctx) return &pluginpb.StartResponse{}, nil } // Stop halts the extension and puts it into a stopped state. // If the extension is not running, this is a no-op. func (e *Extension) Stop(ctx context.Context, msg *pluginpb.StopRequest) (*pluginpb.StopResponse, error) { e.statusMutex.Lock() defer e.statusMutex.Unlock() if e.cancel == nil { galog.Warn("Stop called when extension is not running") return &pluginpb.StopResponse{}, nil } err := e.ctx.Err() if err != nil { galog.Errorf("Stop called with error: %v", err) return &pluginpb.StopResponse{}, err } galog.Info("Stopping extension") e.cancel() e.cancel = nil e.ctx = context.Background() galog.Info("Extension stopped") return &pluginpb.StopResponse{}, nil } // GetStatus is the health check the guest agent would perform to make sure plugin process is alive. func (e *Extension) GetStatus(ctx context.Context, msg *pluginpb.GetStatusRequest) (*pluginpb.Status, error) { e.statusMutex.RLock() defer e.statusMutex.RUnlock() if e.ctx != nil { if err := e.ctx.Err(); err != nil { return &pluginpb.Status{Code: int32(unhealthy), Results: []string{err.Error()}}, err } } if e.lastError != nil { return &pluginpb.Status{Code: int32(unhealthy), Results: []string{e.lastError.Error()}}, e.lastError } return &pluginpb.Status{Code: int32(healthy)}, nil } func (e *Extension) coreLoop(ctx context.Context) int32 { refresher := &RefresherJob{ mdsClient: metadata.New(), outputOpts: outputOpts{ contentDirPrefix: contentDirPrefix, tempSymlinkPrefix: tempSymlinkPrefix, symlink: symlink, }, } // Watch the metadata server for changes to the identity. mdsChan := refresher.watchIdentity(ctx) // Run the refresher once on start. e.runRefresher(ctx, refresher) // Set up a ticker to refresh the credentials at the configured interval. interval := time.Duration(cfg.Retrieve().MWLID.CredentialRefreshMinutes) * time.Minute ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): galog.Error("MWLID credential refresh core loop exiting due to context cancellation") return healthy case <-ticker.C: galog.V(1).Debugf("Ticker fired, refreshing workload credentials...") e.runRefresher(ctx, refresher) case <-mdsChan: galog.V(1).Debugf("MDS channel fired, refreshing workload credentials...") e.runRefresher(ctx, refresher) // Reset the ticker to prevent a useless refresh. ticker.Reset(interval) } } } func (e *Extension) runRefresher(ctx context.Context, refresher *RefresherJob) { if !refresher.isEnabled(ctx) { galog.Info("MWLID credential refresh is not enabled, skipping run") return } galog.Info("Refreshing workload credentials...") if err := refresher.refreshCreds(ctx, refresher.outputOpts, time.Now().Format(time.RFC3339)); err != nil { galog.Errorf("Failed to refresh workload credentials: %v", err) e.statusMutex.Lock() e.lastError = err e.statusMutex.Unlock() } else { galog.Info("Successfully refreshed workload credentials") e.statusMutex.Lock() e.lastError = nil e.statusMutex.Unlock() } } google-guest-agent-20260819.00/cmd/mwlid_extension/certrefresh_test.go000066400000000000000000000105721524141036400255640ustar00rootroot00000000000000/* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "context" "fmt" "net" "strconv" "testing" "time" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func TestRefresherJobAPI(t *testing.T) { tests := []struct { name string refreshMinutes int }{ { name: "standard_refresh", refreshMinutes: 10, }, { name: "long_refresh", refreshMinutes: 60, }, { name: "short_refresh", refreshMinutes: 1, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { customINI := fmt.Sprintf("[MWLID]\nenabled = true\ncredential_refresh_minutes = %d\n", tc.refreshMinutes) if err := cfg.Load([]byte(customINI)); err != nil { t.Fatalf("cfg.Load() failed to load custom overrides for %q: %v", tc.name, err) } t.Cleanup(func() { cfg.Load(nil) }) interval := 10 * time.Minute if cfg.Retrieve().MWLID.Enabled { interval = time.Duration(cfg.Retrieve().MWLID.CredentialRefreshMinutes) * time.Minute } if interval != time.Duration(tc.refreshMinutes)*time.Minute { t.Errorf("Interval = %v, want %v", interval, time.Duration(tc.refreshMinutes)*time.Minute) } }) } } func TestRun(t *testing.T) { mdsClient := &mdsTestClient{throwErrOn: configStatusKey} if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } testDir := t.TempDir() j := &RefresherJob{mdsClient: mdsClient, outputOpts: outputOpts{testDir, testDir, testDir}} err := j.refreshCreds(context.Background(), j.outputOpts, time.Now().Format(time.RFC3339)) if err != nil { t.Errorf("refreshCreds() = error %v, want nil", err) } } func TestShouldEnable(t *testing.T) { ctx := context.Background() if err := cfg.Load(nil); err != nil { t.Fatalf("cfg.Load() failed unexpectedly with error: %v", err) } config := cfg.Retrieve() tests := []struct { desc string mdsEnabled string mdsErr string grpcEnabled bool grpcErr error want bool }{ { desc: "MDS_Enabled", mdsEnabled: "true", want: true, }, { desc: "MDS_WrongValue", mdsEnabled: "blaah", want: false, }, { desc: "MDS_AttributeNotPresent", mdsErr: enableWorkloadCertsKey, want: false, }, { desc: "GRPC_Enabled_Success", grpcEnabled: true, grpcErr: nil, want: true, }, { desc: "GRPC_Enabled_FailedPrecondition_MDS_Enabled", grpcEnabled: true, grpcErr: status.Error(codes.FailedPrecondition, "test error"), mdsEnabled: "true", want: true, }, { desc: "GRPC_Enabled_FailedPrecondition_MDS_Disabled", grpcEnabled: true, grpcErr: status.Error(codes.FailedPrecondition, "test error"), mdsEnabled: "false", want: false, }, { desc: "GRPC_Enabled_DeadlineExceeded", grpcEnabled: true, grpcErr: status.Error(codes.DeadlineExceeded, "test error"), want: true, }, } for _, test := range tests { t.Run(test.desc, func(t *testing.T) { config.MWLID.Enabled = test.grpcEnabled if test.grpcEnabled { testServer := &mockWorkloadIdentityServer{getWorkloadCertificatesErr: test.grpcErr} addr, stop := startTestGRPCServer(t, testServer) defer stop() host, portStr, err := net.SplitHostPort(addr) if err != nil { t.Fatalf("failed to parse address: %v", err) } port, err := strconv.Atoi(portStr) if err != nil { t.Fatalf("failed to parse port: %v", err) } config.MWLID.ServiceIP = host config.MWLID.ServicePort = port } mdsClient := &mdsTestClient{enabled: test.mdsEnabled, throwErrOn: test.mdsErr} j := &RefresherJob{mdsClient: mdsClient} if got := j.isEnabled(ctx); got != test.want { t.Errorf("isEnabled(ctx) = %t, want %t", got, test.want) } }) } } google-guest-agent-20260819.00/cmd/mwlid_extension/main.go000066400000000000000000000040151524141036400231300ustar00rootroot00000000000000/* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ // Package main serves as the Main entry point for the GCE workload identity cert refresher extension. package main import ( "context" "flag" "net" "os" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "google.golang.org/grpc" ) var ( // required by extensions protocol = flag.String("protocol", "", "protocol to use uds/tcp") address = flag.String("address", "", "address to start server listening on") errorlogfile = flag.String("errorlogfile", "", "extension error log file") ) func main() { flag.Parse() if *errorlogfile != "" { galog.RegisterBackend(context.Background(), galog.NewFileBackend(*errorlogfile)) defer galog.Shutdown(time.Second * 5) } if err := cfg.Load(nil); err != nil { galog.Warnf("Failed to load configuration: %v. Using defaults.", err) } if *protocol == "" { galog.Error("No protocol specified, exiting with an error.") os.Exit(1) } if *address == "" { galog.Error("No address specified, exiting with an error.") os.Exit(1) } listener, err := net.Listen(*protocol, *address) if err != nil { galog.Errorf("Failed to start listening on %q using %q: %v", *address, *protocol, err) os.Exit(1) } defer listener.Close() server := grpc.NewServer() defer server.GracefulStop() Register(server) galog.Info("Starting grpc server") if err = server.Serve(listener); err != nil { galog.Errorf("failed to listen for GRPC messages: %v", err) os.Exit(1) } } google-guest-agent-20260819.00/cmd/mwlid_extension/proto/000077500000000000000000000000001524141036400230205ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/mwlid_extension/proto/mwlid/000077500000000000000000000000001524141036400241345ustar00rootroot00000000000000google-guest-agent-20260819.00/cmd/mwlid_extension/proto/mwlid/workload_identity.pb.go000066400000000000000000000253241524141036400306240ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.12 // protoc v7.35.1 // source: workload_identity.proto package mwlid import ( reflect "reflect" sync "sync" unsafe "unsafe" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type GetWorkloadTrustBundlesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWorkloadTrustBundlesRequest) Reset() { *x = GetWorkloadTrustBundlesRequest{} mi := &file_workload_identity_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWorkloadTrustBundlesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWorkloadTrustBundlesRequest) ProtoMessage() {} func (x *GetWorkloadTrustBundlesRequest) ProtoReflect() protoreflect.Message { mi := &file_workload_identity_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWorkloadTrustBundlesRequest.ProtoReflect.Descriptor instead. func (*GetWorkloadTrustBundlesRequest) Descriptor() ([]byte, []int) { return file_workload_identity_proto_rawDescGZIP(), []int{0} } type GetWorkloadTrustBundlesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The json formatted collection of SPIFFE trust bundles object represented as // a JWK Set and keyed by the trust domain name as described in // https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md SpiffeTrustBundlesMapJson []byte `protobuf:"bytes,1,opt,name=spiffe_trust_bundles_map_json,json=spiffeTrustBundlesMapJson,proto3" json:"spiffe_trust_bundles_map_json,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWorkloadTrustBundlesResponse) Reset() { *x = GetWorkloadTrustBundlesResponse{} mi := &file_workload_identity_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWorkloadTrustBundlesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWorkloadTrustBundlesResponse) ProtoMessage() {} func (x *GetWorkloadTrustBundlesResponse) ProtoReflect() protoreflect.Message { mi := &file_workload_identity_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWorkloadTrustBundlesResponse.ProtoReflect.Descriptor instead. func (*GetWorkloadTrustBundlesResponse) Descriptor() ([]byte, []int) { return file_workload_identity_proto_rawDescGZIP(), []int{1} } func (x *GetWorkloadTrustBundlesResponse) GetSpiffeTrustBundlesMapJson() []byte { if x != nil { return x.SpiffeTrustBundlesMapJson } return nil } type GetWorkloadCertificatesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWorkloadCertificatesRequest) Reset() { *x = GetWorkloadCertificatesRequest{} mi := &file_workload_identity_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWorkloadCertificatesRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWorkloadCertificatesRequest) ProtoMessage() {} func (x *GetWorkloadCertificatesRequest) ProtoReflect() protoreflect.Message { mi := &file_workload_identity_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWorkloadCertificatesRequest.ProtoReflect.Descriptor instead. func (*GetWorkloadCertificatesRequest) Descriptor() ([]byte, []int) { return file_workload_identity_proto_rawDescGZIP(), []int{2} } type GetWorkloadCertificatesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The PEM-encoded certificate chain ordered from leaf to root, // excluding the root. // // The leaf certificate is an SVID that contains a SPIFFE id in the URI SAN. // Each subsequent certificate will be an intermediate certificate that signs // the preceding certificate. CertificateChainPem []byte `protobuf:"bytes,1,opt,name=certificate_chain_pem,json=certificateChainPem,proto3" json:"certificate_chain_pem,omitempty"` // The PEM-encoded private key corresponding to the leaf certificate in the // certificate chain. PrivateKeyPem []byte `protobuf:"bytes,2,opt,name=private_key_pem,json=privateKeyPem,proto3" json:"private_key_pem,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetWorkloadCertificatesResponse) Reset() { *x = GetWorkloadCertificatesResponse{} mi := &file_workload_identity_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *GetWorkloadCertificatesResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*GetWorkloadCertificatesResponse) ProtoMessage() {} func (x *GetWorkloadCertificatesResponse) ProtoReflect() protoreflect.Message { mi := &file_workload_identity_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use GetWorkloadCertificatesResponse.ProtoReflect.Descriptor instead. func (*GetWorkloadCertificatesResponse) Descriptor() ([]byte, []int) { return file_workload_identity_proto_rawDescGZIP(), []int{3} } func (x *GetWorkloadCertificatesResponse) GetCertificateChainPem() []byte { if x != nil { return x.CertificateChainPem } return nil } func (x *GetWorkloadCertificatesResponse) GetPrivateKeyPem() []byte { if x != nil { return x.PrivateKeyPem } return nil } var File_workload_identity_proto protoreflect.FileDescriptor const file_workload_identity_proto_rawDesc = "" + "\n" + "\x17workload_identity.proto\x12\"managed_workload_identity.proto.v1\" \n" + "\x1eGetWorkloadTrustBundlesRequest\"c\n" + "\x1fGetWorkloadTrustBundlesResponse\x12@\n" + "\x1dspiffe_trust_bundles_map_json\x18\x01 \x01(\fR\x19spiffeTrustBundlesMapJson\" \n" + "\x1eGetWorkloadCertificatesRequest\"}\n" + "\x1fGetWorkloadCertificatesResponse\x122\n" + "\x15certificate_chain_pem\x18\x01 \x01(\fR\x13certificateChainPem\x12&\n" + "\x0fprivate_key_pem\x18\x02 \x01(\fR\rprivateKeyPem2\xe0\x02\n" + "\x10WorkloadIdentity\x12\xa4\x01\n" + "\x17GetWorkloadTrustBundles\x12B.managed_workload_identity.proto.v1.GetWorkloadTrustBundlesRequest\x1aC.managed_workload_identity.proto.v1.GetWorkloadTrustBundlesResponse\"\x00\x12\xa4\x01\n" + "\x17GetWorkloadCertificates\x12B.managed_workload_identity.proto.v1.GetWorkloadCertificatesRequest\x1aC.managed_workload_identity.proto.v1.GetWorkloadCertificatesResponse\"\x00B\x02P\x01b\x06proto3" var ( file_workload_identity_proto_rawDescOnce sync.Once file_workload_identity_proto_rawDescData []byte ) func file_workload_identity_proto_rawDescGZIP() []byte { file_workload_identity_proto_rawDescOnce.Do(func() { file_workload_identity_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_workload_identity_proto_rawDesc), len(file_workload_identity_proto_rawDesc))) }) return file_workload_identity_proto_rawDescData } var file_workload_identity_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_workload_identity_proto_goTypes = []any{ (*GetWorkloadTrustBundlesRequest)(nil), // 0: managed_workload_identity.proto.v1.GetWorkloadTrustBundlesRequest (*GetWorkloadTrustBundlesResponse)(nil), // 1: managed_workload_identity.proto.v1.GetWorkloadTrustBundlesResponse (*GetWorkloadCertificatesRequest)(nil), // 2: managed_workload_identity.proto.v1.GetWorkloadCertificatesRequest (*GetWorkloadCertificatesResponse)(nil), // 3: managed_workload_identity.proto.v1.GetWorkloadCertificatesResponse } var file_workload_identity_proto_depIdxs = []int32{ 0, // 0: managed_workload_identity.proto.v1.WorkloadIdentity.GetWorkloadTrustBundles:input_type -> managed_workload_identity.proto.v1.GetWorkloadTrustBundlesRequest 2, // 1: managed_workload_identity.proto.v1.WorkloadIdentity.GetWorkloadCertificates:input_type -> managed_workload_identity.proto.v1.GetWorkloadCertificatesRequest 1, // 2: managed_workload_identity.proto.v1.WorkloadIdentity.GetWorkloadTrustBundles:output_type -> managed_workload_identity.proto.v1.GetWorkloadTrustBundlesResponse 3, // 3: managed_workload_identity.proto.v1.WorkloadIdentity.GetWorkloadCertificates:output_type -> managed_workload_identity.proto.v1.GetWorkloadCertificatesResponse 2, // [2:4] is the sub-list for method output_type 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_workload_identity_proto_init() } func file_workload_identity_proto_init() { if File_workload_identity_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_workload_identity_proto_rawDesc), len(file_workload_identity_proto_rawDesc)), NumEnums: 0, NumMessages: 4, NumExtensions: 0, NumServices: 1, }, GoTypes: file_workload_identity_proto_goTypes, DependencyIndexes: file_workload_identity_proto_depIdxs, MessageInfos: file_workload_identity_proto_msgTypes, }.Build() File_workload_identity_proto = out.File file_workload_identity_proto_goTypes = nil file_workload_identity_proto_depIdxs = nil } google-guest-agent-20260819.00/cmd/mwlid_extension/proto/mwlid/workload_identity_grpc.pb.go000066400000000000000000000200721524141036400316320ustar00rootroot00000000000000// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 // - protoc v7.35.1 // source: workload_identity.proto package mwlid import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( WorkloadIdentity_GetWorkloadTrustBundles_FullMethodName = "/managed_workload_identity.proto.v1.WorkloadIdentity/GetWorkloadTrustBundles" WorkloadIdentity_GetWorkloadCertificates_FullMethodName = "/managed_workload_identity.proto.v1.WorkloadIdentity/GetWorkloadCertificates" ) // WorkloadIdentityClient is the client API for WorkloadIdentity service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type WorkloadIdentityClient interface { // Returns the SPIFFE trust bundles map containing trust domains and their // corresponding cryptographic keys used for validating X.509-SVID(s). GetWorkloadTrustBundles(ctx context.Context, in *GetWorkloadTrustBundlesRequest, opts ...grpc.CallOption) (*GetWorkloadTrustBundlesResponse, error) // Returns the certificate chain containing the X.509-SVID for the SPIFFE // identity assigned to the workload that the client can use for TLS // handshake. GetWorkloadCertificates(ctx context.Context, in *GetWorkloadCertificatesRequest, opts ...grpc.CallOption) (*GetWorkloadCertificatesResponse, error) } type workloadIdentityClient struct { cc grpc.ClientConnInterface } func NewWorkloadIdentityClient(cc grpc.ClientConnInterface) WorkloadIdentityClient { return &workloadIdentityClient{cc} } func (c *workloadIdentityClient) GetWorkloadTrustBundles(ctx context.Context, in *GetWorkloadTrustBundlesRequest, opts ...grpc.CallOption) (*GetWorkloadTrustBundlesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetWorkloadTrustBundlesResponse) err := c.cc.Invoke(ctx, WorkloadIdentity_GetWorkloadTrustBundles_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *workloadIdentityClient) GetWorkloadCertificates(ctx context.Context, in *GetWorkloadCertificatesRequest, opts ...grpc.CallOption) (*GetWorkloadCertificatesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetWorkloadCertificatesResponse) err := c.cc.Invoke(ctx, WorkloadIdentity_GetWorkloadCertificates_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // WorkloadIdentityServer is the server API for WorkloadIdentity service. // All implementations must embed UnimplementedWorkloadIdentityServer // for forward compatibility. type WorkloadIdentityServer interface { // Returns the SPIFFE trust bundles map containing trust domains and their // corresponding cryptographic keys used for validating X.509-SVID(s). GetWorkloadTrustBundles(context.Context, *GetWorkloadTrustBundlesRequest) (*GetWorkloadTrustBundlesResponse, error) // Returns the certificate chain containing the X.509-SVID for the SPIFFE // identity assigned to the workload that the client can use for TLS // handshake. GetWorkloadCertificates(context.Context, *GetWorkloadCertificatesRequest) (*GetWorkloadCertificatesResponse, error) mustEmbedUnimplementedWorkloadIdentityServer() } // UnimplementedWorkloadIdentityServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedWorkloadIdentityServer struct{} func (UnimplementedWorkloadIdentityServer) GetWorkloadTrustBundles(context.Context, *GetWorkloadTrustBundlesRequest) (*GetWorkloadTrustBundlesResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetWorkloadTrustBundles not implemented") } func (UnimplementedWorkloadIdentityServer) GetWorkloadCertificates(context.Context, *GetWorkloadCertificatesRequest) (*GetWorkloadCertificatesResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetWorkloadCertificates not implemented") } func (UnimplementedWorkloadIdentityServer) mustEmbedUnimplementedWorkloadIdentityServer() {} func (UnimplementedWorkloadIdentityServer) testEmbeddedByValue() {} // UnsafeWorkloadIdentityServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to WorkloadIdentityServer will // result in compilation errors. type UnsafeWorkloadIdentityServer interface { mustEmbedUnimplementedWorkloadIdentityServer() } func RegisterWorkloadIdentityServer(s grpc.ServiceRegistrar, srv WorkloadIdentityServer) { // If the following call panics, it indicates UnimplementedWorkloadIdentityServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&WorkloadIdentity_ServiceDesc, srv) } func _WorkloadIdentity_GetWorkloadTrustBundles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetWorkloadTrustBundlesRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WorkloadIdentityServer).GetWorkloadTrustBundles(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: WorkloadIdentity_GetWorkloadTrustBundles_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WorkloadIdentityServer).GetWorkloadTrustBundles(ctx, req.(*GetWorkloadTrustBundlesRequest)) } return interceptor(ctx, in, info, handler) } func _WorkloadIdentity_GetWorkloadCertificates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetWorkloadCertificatesRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WorkloadIdentityServer).GetWorkloadCertificates(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: WorkloadIdentity_GetWorkloadCertificates_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WorkloadIdentityServer).GetWorkloadCertificates(ctx, req.(*GetWorkloadCertificatesRequest)) } return interceptor(ctx, in, info, handler) } // WorkloadIdentity_ServiceDesc is the grpc.ServiceDesc for WorkloadIdentity service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var WorkloadIdentity_ServiceDesc = grpc.ServiceDesc{ ServiceName: "managed_workload_identity.proto.v1.WorkloadIdentity", HandlerType: (*WorkloadIdentityServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "GetWorkloadTrustBundles", Handler: _WorkloadIdentity_GetWorkloadTrustBundles_Handler, }, { MethodName: "GetWorkloadCertificates", Handler: _WorkloadIdentity_GetWorkloadCertificates_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "workload_identity.proto", } google-guest-agent-20260819.00/cmd/mwlid_extension/refresher.go000066400000000000000000000535511524141036400242020ustar00rootroot00000000000000/* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import ( "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/GoogleCloudPlatform/galog" "github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg" "github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata" "github.com/GoogleCloudPlatform/google-guest-agent/internal/retry" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" wipb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/mwlid_extension/proto/mwlid" ) const ( // trustAnchorsKey endpoint contains a set of trusted certificates for peer // X.509 certificate chain validation. trustAnchorsKey = "instance/gce-workload-certificates/trust-anchors" // workloadIdentitiesKey endpoint contains identities managed by the GCE // control plane. This contains the X.509 certificate and the private key for // the VM's trust domain. workloadIdentitiesKey = "instance/gce-workload-certificates/workload-identities" // configStatusKey contains status and any errors in the config values // provided via the VM metadata. configStatusKey = "instance/gce-workload-certificates/config-status" // enableWorkloadCertsKey is set to true as custom metadata to enable // automatic provisioning of credentials. enableWorkloadCertsKey = "instance/attributes/enable-workload-certificate" // defaultGRPCTimeout is the default timeout for grpc calls. defaultGRPCTimeout = 10 * time.Second ) // Status represents the state of gRPC service. type Status int const ( // ServiceUnknown means the gRPC service availability is unknown. This is // used when we've attempted to connect to the service but it failed with a // non-permanent error. ServiceUnknown Status = iota // ServiceUnavailable means the gRPC service is unavailable. This is set when // we successfully connect to the service but it responds with a non-OK // FAILED_PRECONDITION error. ServiceUnavailable // ServiceAvailable means the gRPC service is available. ServiceAvailable ) type grpcServerStatus struct { mutex sync.Mutex // status is the status of the gRPC service. status Status } func (gs *grpcServerStatus) setStatus(s Status) { gs.mutex.Lock() defer gs.mutex.Unlock() gs.status = s } func (gs *grpcServerStatus) serverStatus() Status { gs.mutex.Lock() defer gs.mutex.Unlock() return gs.status } // RefresherJob implements scheduler interface for cert refresher. type RefresherJob struct { // outputOpts is the output directory name and symlink templates. outputOpts outputOpts mdsClient metadata.MDSClientInterface // grpcServerStatus is used to track if the grpc server is available. This // server is not an instance startup dependency and can become available // later. It is used to track if we've already detected server existence // successfully and if not, retry. grpcServerStatus // clientMutex is used to synchronize access to the grpcClient. clientMutex sync.Mutex // grpcClient is the client connection to the grpc server. This is used to // cache the connection so it doesn't need to be recreated every time. grpcClient *grpc.ClientConn // retryPolicy is the retry policy for gRPC calls. retryPolicy retry.Policy } // outputOpts is a struct for output directory name and symlink templates. type outputOpts struct { contentDirPrefix, tempSymlinkPrefix, symlink string } // isEnabled returns true only if enable-workload-certificate metadata attribute // is present and set to true. func (j *RefresherJob) isEnabled(ctx context.Context) bool { // Check if GRPC service is enabled first. // If it's not, check if the fallback MDS service is enabled. return j.isGRPCServiceEnabled(ctx) || j.isMDSServiceEnabled(ctx) } // readMetadata reads metadata value for [key] from MDS. func (j *RefresherJob) readMetadata(ctx context.Context, key string) ([]byte, error) { // GCE Workload Certificate endpoints return 412 Precondition failed if the VM // was never configured with valid config values at least once. Without valid // config values GCE cannot provision the workload certificates. resp, err := j.mdsClient.GetKey(ctx, key, nil) if err != nil { return nil, fmt.Errorf("failed to GET %q from MDS with error: %w", key, err) } return []byte(resp), nil } /* metadata key instance/gce-workload-certificates/workload-identities MANAGED_WORKLOAD_IDENTITY_SPIFFE is of the format: spiffe://POOL_ID.global.PROJECT_NUMBER.workload.id.goog/ns/NAMESPACE_ID/sa/MANAGED_IDENTITY_ID { "status": "OK", // Status of the response, "workloadCredentials": { // Credentials for the VM's trust domains "MANAGED_WORKLOAD_IDENTITY_SPIFFE": { "certificatePem": "-----BEGIN CERTIFICATE-----datahere-----END CERTIFICATE-----", "privateKeyPem": "-----BEGIN PRIVATE KEY-----datahere-----END PRIVATE KEY-----" } } } */ // workloadCredential represents Workload Credentials in metadata. type workloadCredential struct { CertificatePem string `json:"certificatePem"` PrivateKeyPem string `json:"privateKeyPem"` } // workloadIdentities represents Workload Identities in metadata. type workloadIdentities struct { Status string `json:"status"` WorkloadCredentials map[string]workloadCredential `json:"workloadCredentials"` } /* metadata key instance/gce-workload-certificates/trust-anchors { "status": "" // Status of the response, "trustAnchors": { // Trust bundle for the VM's trust domains "PEER_SPIFFE_TRUST_DOMAIN_1": { "trustAnchorsPem" : "", }, "PEER_SPIFFE_TRUST_DOMAIN_2": { "trustAnchorsPem" : "", } } } */ // trustAnchor represents one or more certificates in an arbitrary order in the metadata. type trustAnchor struct { TrustAnchorsPem string `json:"trustAnchorsPem"` } // workloadTrustedAnchors represents Workload Trusted Root Certs in metadata. type workloadTrustedAnchors struct { Status string `json:"status"` TrustAnchors map[string]trustAnchor `json:"trustAnchors"` } // findDomain finds the anchor matching with the domain from spiffeID. // spiffeID is of the form - // spiffe://POOL_ID.global.PROJECT_NUMBER.workload.id.goog/ns/NAMESPACE_ID/sa/MANAGED_IDENTITY_ID // where domain is POOL_ID.global.PROJECT_NUMBER.workload.id.goog and // anchors is a map of various domains and their corresponding trust PEMs. // However, if anchor map contains single entry it returns that without any check. func findDomain(anchors map[string]trustAnchor, spiffeID string) (string, error) { c := len(anchors) for k := range anchors { if c == 1 { return k, nil } if strings.Contains(spiffeID, k) { return k, nil } } return "", fmt.Errorf("no matching trust anchor found") } // writeTrustAnchors parses the input data, finds the domain from spiffeID and // writes ca_certificate.pem in the destDir for that domain. func writeTrustAnchors(wtrcsMd []byte, destDir, spiffeID string) error { wtrcs := workloadTrustedAnchors{} if err := json.Unmarshal(wtrcsMd, &wtrcs); err != nil { return fmt.Errorf("error unmarshaling workload trusted root certs: %w", err) } // Currently there's only one trust anchor but there could be multiple trust // anchors in future. In either case we want the trust anchor with domain // matching with the one in SPIFFE ID. domain, err := findDomain(wtrcs.TrustAnchors, spiffeID) if err != nil { return err } return os.WriteFile(filepath.Join(destDir, "ca_certificates.pem"), []byte(wtrcs.TrustAnchors[domain].TrustAnchorsPem), 0644) } // writeWorkloadIdentities parses the input data, writes the certificates.pem, // private_key.pem files in the destDir, and returns the SPIFFE ID for which it // wrote the certificates. func writeWorkloadIdentities(destDir string, wisMd []byte) (string, error) { var spiffeID string wis := workloadIdentities{} if err := json.Unmarshal(wisMd, &wis); err != nil { return "", fmt.Errorf("error unmarshaling workload identities response: %w", err) } // Its guaranteed to have single entry in workload credentials map. for k := range wis.WorkloadCredentials { spiffeID = k break } if err := os.WriteFile(filepath.Join(destDir, "certificates.pem"), []byte(wis.WorkloadCredentials[spiffeID].CertificatePem), 0644); err != nil { return "", fmt.Errorf("error writing certificates.pem: %w", err) } // There are cases where customers want to hide the private key from the guest // agent. In that case, we want to avoid writing the file at all, since the // key will be empty. privateKeyPem := wis.WorkloadCredentials[spiffeID].PrivateKeyPem if privateKeyPem != "" { if err := os.WriteFile(filepath.Join(destDir, "private_key.pem"), []byte(privateKeyPem), 0644); err != nil { return "", fmt.Errorf("error writing private_key.pem: %w", err) } } else { galog.Debugf("Not writing private_key.pem because it is empty") } return spiffeID, nil } func (j *RefresherJob) writeCredsFromMDS(ctx context.Context, contentDir, symlink string) error { galog.Infof("Refreshing workload credentials from MDS endpoints...") // Get status first so it can be written even when other endpoints are empty. certConfigStatus, err := j.readMetadata(ctx, configStatusKey) if err != nil { // Return success when certs are not configured to avoid unnecessary systemd // failed units. galog.Warnf("Error getting config status, workload certificates may not be configured: %v", err) return nil } galog.Debugf("Creating timestamp contents dir %s", contentDir) if err := os.MkdirAll(contentDir, 0755); err != nil { return fmt.Errorf("error creating contents dir: %w", err) } // Write config_status first even if remaining endpoints are empty. galog.Debugf("Writing config status to %s", contentDir) if err := os.WriteFile(filepath.Join(contentDir, "config_status"), certConfigStatus, 0644); err != nil { return fmt.Errorf("error writing config_status: %w", err) } // Handles the edge case where the config values provided for the first time // may be invalid. This ensures that the symlink directory always exists and // contains the config_status to surface config errors to the VM. if _, err := os.Stat(symlink); os.IsNotExist(err) { galog.Infof("Creating new symlink %s", symlink) if err := os.Symlink(contentDir, symlink); err != nil { return fmt.Errorf("error creating symlink: %w", err) } } // Now get the rest of the content. galog.Debugf("Reading workload identities from MDS") wisMd, err := j.readMetadata(ctx, workloadIdentitiesKey) if err != nil { return fmt.Errorf("error getting workload-identities: %w", err) } galog.Debugf("Writing workload identities to %s", contentDir) spiffeID, err := writeWorkloadIdentities(contentDir, wisMd) if err != nil { return fmt.Errorf("failed to write workload identities with error: %w", err) } galog.Debugf("Reading trust anchors from MDS") wtrcsMd, err := j.readMetadata(ctx, trustAnchorsKey) if err != nil { return fmt.Errorf("error getting workload-trust-anchors: %w", err) } galog.Debugf("Writing trust anchors to %s", contentDir) if err := writeTrustAnchors(wtrcsMd, contentDir, spiffeID); err != nil { return fmt.Errorf("failed to write trust anchors: %w", err) } return nil } func (j *RefresherJob) refreshCreds(ctx context.Context, opts outputOpts, now string) error { contentDir, tempSymlink := j.generateTmpDirNames(opts, now) // Scheduled job [isEnabled] could return true if we did not successfully // determine the service status. In this case we should check the service // status again before proceeding. if j.isGRPCServiceEnabled(ctx) { if err := j.refreshCredsWithGRPC(ctx, contentDir); err != nil { return fmt.Errorf("refresh creds with gRPC error: %w", err) } } else if j.isMDSServiceEnabled(ctx) { if err := j.writeCredsFromMDS(ctx, contentDir, opts.symlink); err != nil { return fmt.Errorf("refresh creds with MDS error: %w", err) } } else { galog.Debugf("Not refreshing workload certificates, service is not enabled") return nil } // We fetched the credentials successfully, now we can rotate the symlink and // remove the previous content dir. galog.Debugf("Creating temporary symlink %s", tempSymlink) if err := os.Symlink(contentDir, tempSymlink); err != nil { return fmt.Errorf("error creating temporary link: %w", err) } oldTarget, err := os.Readlink(opts.symlink) if err != nil { galog.Warnf("Error reading existing symlink %q: %v", opts.symlink, err) oldTarget = "" } // Only rotate on success of all steps above. galog.Infof("Rotating symlink %s", opts.symlink) galog.V(2).Debugf("Attempting to remove existing symlink %q", opts.symlink) if err := os.Remove(opts.symlink); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error removing symlink: %w", err) } galog.V(2).Debugf("Attempting to rename temporary symlink %q to %q", tempSymlink, opts.symlink) if err := os.Rename(tempSymlink, opts.symlink); err != nil { return fmt.Errorf("error rotating target link: %w", err) } // Clean up previous contents dir. newTarget, err := os.Readlink(opts.symlink) if err != nil { return fmt.Errorf("error reading new symlink: %w, unable to remove old symlink target", err) } if oldTarget != newTarget { galog.Infof("Removing old content dir %s", oldTarget) if err := os.RemoveAll(oldTarget); err != nil { return fmt.Errorf("failed to remove old symlink target: %w", err) } } return nil } // newClient returns a cached client if present, otherwise creates a new grpc // client connection to the MWLID service and caches it. func (j *RefresherJob) newClient(ctx context.Context) (*grpc.ClientConn, error) { j.clientMutex.Lock() defer j.clientMutex.Unlock() if j.grpcClient != nil { return j.grpcClient, nil } creds := grpc.WithTransportCredentials(insecure.NewCredentials()) address := fmt.Sprintf("%s:%d", cfg.Retrieve().MWLID.ServiceIP, cfg.Retrieve().MWLID.ServicePort) galog.Debugf("Creating gRPC client for MWLID service at %q", address) conn, err := grpc.NewClient(address, creds) if err != nil { return nil, fmt.Errorf("failed to create gRPC client for MWLID service at %q: %w", address, err) } j.grpcClient = conn return conn, nil } // isMDSServiceEnabled returns true if the preview version of the workload // identity is enabled. func (j *RefresherJob) isMDSServiceEnabled(ctx context.Context) bool { resp, err := j.readMetadata(ctx, enableWorkloadCertsKey) if err != nil { galog.Debugf("Failed to get %q from MDS with error: %v", enableWorkloadCertsKey, err) return false } return bytes.EqualFold(resp, []byte("true")) } // isGRPCServiceEnabled returns true if the MWLID service is enabled and the // server is reachable. func (j *RefresherJob) isGRPCServiceEnabled(ctx context.Context) bool { if !cfg.Retrieve().MWLID.Enabled { galog.Debugf("MWLID credential feature is disabled in config, skipping gRPC server check.") return false } if currStatus := j.serverStatus(); currStatus != ServiceUnknown { return currStatus == ServiceAvailable } conn, err := j.newClient(ctx) if err != nil { galog.Debugf("Failed to create gRPC client for MWLID service: %v", err) return false } c := wipb.NewWorkloadIdentityClient(conn) tCtx, cancel := context.WithTimeout(ctx, defaultGRPCTimeout) defer cancel() // Try calling any RPC to determine if the server is available. _, err = c.GetWorkloadCertificates(tCtx, &wipb.GetWorkloadCertificatesRequest{}, grpc.WaitForReady(true)) if err == nil { galog.Debugf("Successfully connected to MWLID service, gRPC server will be used for cert refresh.") // We successfully connected to the server and it is available. j.setStatus(ServiceAvailable) return true } st, ok := status.FromError(err) if ok && st.Code() == codes.FailedPrecondition { // We got a permanent error, gRPC server is unavailable. galog.Debugf("MWLID gRPC server is unavailable: [%v], MDS will be used for cert refresh.", err) j.setStatus(ServiceUnavailable) return false } galog.Debugf("Error when connecting to MWLID service: [%v], will retry determining server status.", err) // We got an unknown error, this could be a timeout or other error when // connecting to the server. We should retry determining server status. return true } // closeClient closes and removes the cached grpc client connection. func (j *RefresherJob) closeClient() { j.clientMutex.Lock() defer j.clientMutex.Unlock() galog.Debug("Closing gRPC client connection to MWLID service") if j.grpcClient == nil { return } if err := j.grpcClient.Close(); err != nil { galog.Warnf("Failed to close connection to MWLID service: %v", err) } j.grpcClient = nil } // shouldRetryGRPC checks if the gRPC error is retriable. func shouldRetryGRPC(err error) bool { switch status.Code(err) { // gRPC equivalents for HTTP 500 internal server errors. Terminate retry loop. case codes.Internal, codes.DataLoss, codes.Unknown: return false // Permanent client or precondition errors. Terminate retry loop. case codes.FailedPrecondition, codes.InvalidArgument, codes.Unauthenticated, codes.PermissionDenied, codes.Unimplemented: return false // On gRPC equivalents for HTTP 503 (codes.Unavailable) and other transient errors, retry. default: return true } } func (j *RefresherJob) getGRPCRetryPolicy() retry.Policy { p := j.retryPolicy if p.MaxAttempts == 0 { p.MaxAttempts = 5 } if p.BackoffFactor == 0 { p.BackoffFactor = 1 } if p.Jitter == 0 { p.Jitter = time.Second } if p.ShouldRetry == nil { p.ShouldRetry = shouldRetryGRPC } return p } // refreshCredsWithGRPC refreshes the workload certificates using the MWLID // service over gRPC. func (j *RefresherJob) refreshCredsWithGRPC(ctx context.Context, contentDir string) error { galog.Infof("Refreshing workload identity credentials from MWLID server...") conn, err := j.newClient(ctx) if err != nil { return fmt.Errorf("failed to connect to snapshot service: %w", err) } c := wipb.NewWorkloadIdentityClient(conn) // Close the client if we failed to make a successful call to the server. Next // attempt to connect to the server will recreate the client. policy := j.getGRPCRetryPolicy() certs, err := retry.RunWithResponse(ctx, policy, func() (*wipb.GetWorkloadCertificatesResponse, error) { tCtxCerts, cancelCerts := context.WithTimeout(ctx, defaultGRPCTimeout) defer cancelCerts() return c.GetWorkloadCertificates(tCtxCerts, &wipb.GetWorkloadCertificatesRequest{}) }) if err != nil { j.closeClient() return fmt.Errorf("failed to get workload certificates: %w", err) } bundle, err := retry.RunWithResponse(ctx, policy, func() (*wipb.GetWorkloadTrustBundlesResponse, error) { tCtxBundle, cancelBundle := context.WithTimeout(ctx, defaultGRPCTimeout) defer cancelBundle() return c.GetWorkloadTrustBundles(tCtxBundle, &wipb.GetWorkloadTrustBundlesRequest{}) }) if err != nil { j.closeClient() return fmt.Errorf("failed to get workload trust bundles: %w", err) } if err := os.MkdirAll(contentDir, 0755); err != nil { return fmt.Errorf("error creating contents dir: %w", err) } // There are cases where customers want to hide the private key from the guest // agent. In that case, we want to avoid writing the file at all, since the // key will be empty. privateKeyPem := certs.GetPrivateKeyPem() if len(privateKeyPem) > 0 { galog.Debugf("Writing workload certificates private key to %s", contentDir) if err := os.WriteFile(filepath.Join(contentDir, "private_key.pem"), certs.GetPrivateKeyPem(), 0644); err != nil { return fmt.Errorf("error writing private_key.pem: %w", err) } } else { galog.Debugf("Not writing private_key.pem because it is empty") } galog.Debugf("Writing workload certificates certificate chain to %s", contentDir) if err := os.WriteFile(filepath.Join(contentDir, "certificates.pem"), certs.GetCertificateChainPem(), 0644); err != nil { return fmt.Errorf("error writing certificates.pem: %w", err) } galog.Debugf("Writing workload trust bundles to %s", contentDir) if err := os.WriteFile(filepath.Join(contentDir, "trust_bundles.json"), bundle.GetSpiffeTrustBundlesMapJson(), 0644); err != nil { return fmt.Errorf("error writing trust_bundles.json: %w", err) } return nil } // watchIdentity watches the identity in the VM. // // This puts a longpoll on the metadata server for the identity UUID. When the // identity is changed, the hanging request will be returned and will notify the // refresher job to refresh the credentials. func (j *RefresherJob) watchIdentity(ctx context.Context) chan *metadata.Descriptor { mdsChan := make(chan *metadata.Descriptor, 1) go func() { defer close(mdsChan) // Ensure the channel is closed when the go routine exits. var prevUUID string // Keep watching the identity until the context is cancelled. for { desc, err := j.mdsClient.Watch(ctx) if err != nil { galog.Warnf("Failed to watch identity: %v", err) select { case <-ctx.Done(): return case <-time.After(10 * time.Second): } continue } // The refresher would've run once already; skip the first descriptor. if prevUUID == "" { prevUUID = desc.Instance().IdentityConfiguration().IdentityUUID() continue } // Skip if the identity is the same as the previous one. currUUID := desc.Instance().IdentityConfiguration().IdentityUUID() if prevUUID == currUUID { continue } galog.Info("Identity changed, refreshing credentials...") prevUUID = currUUID select { case <-ctx.Done(): return case mdsChan <- desc: } } }() return mdsChan } google-guest-agent-20260819.00/cmd/mwlid_extension/refresher_linux.go000066400000000000000000000026061524141036400254140ustar00rootroot00000000000000//go:build linux /* Copyright 2026 Google LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package main import "fmt" const ( // contentDirPrefix is used as prefix to create certificate directories on // refresh as contentDirPrefix-