From 26aea3823c679e30a4d3b7524e74cd9273330203 Mon Sep 17 00:00:00 2001 From: Davis Goodin Date: Wed, 13 Nov 2024 09:36:23 -0800 Subject: [PATCH] Add eng/install-scripts/microsoft-go-install.ps1 (#182) * Add .NET install script * Make script install Microsoft Go, test, util script-placer cmd * Update .github/workflows/install-script-pwsh-test.yml Co-authored-by: Martijn Verburg * Doc: prereleases not supported * Move script, new module, smarter utility * Simplify using "go run" --------- Co-authored-by: Martijn Verburg --- .../workflows/install-script-pwsh-test.yml | 39 + goinstallscript/README.md | 122 +++ goinstallscript/go.mod | 3 + goinstallscript/main.go | 68 ++ goinstallscript/powershell/.gitattributes | 2 + goinstallscript/powershell/go-install.ps1 | 899 ++++++++++++++++++ goinstallscript/powershell/powershell.go | 14 + goinstallscript/powershell/powershell_test.go | 197 ++++ 8 files changed, 1344 insertions(+) create mode 100644 .github/workflows/install-script-pwsh-test.yml create mode 100644 goinstallscript/README.md create mode 100644 goinstallscript/go.mod create mode 100644 goinstallscript/main.go create mode 100644 goinstallscript/powershell/.gitattributes create mode 100644 goinstallscript/powershell/go-install.ps1 create mode 100644 goinstallscript/powershell/powershell.go create mode 100644 goinstallscript/powershell/powershell_test.go diff --git a/.github/workflows/install-script-pwsh-test.yml b/.github/workflows/install-script-pwsh-test.yml new file mode 100644 index 0000000..7d71afe --- /dev/null +++ b/.github/workflows/install-script-pwsh-test.yml @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# This job tests that the install script in PowerShell works as expected when +# actually downloading Microsoft Go, not only just running the unit tests. + +name: install-script-pwsh-test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - windows-2019 + - ubuntu-latest + - ubuntu-20.04 + runs-on: ${{ matrix.os }} + steps: + # Intentionally get upstream Go. This allows us to run install script + # tests and see results even if the Microsoft Go install process breaks. + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: stable + + - name: Checkout repository + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - name: Test with download + run: | + cd goinstallscript + go test -v ./... -download diff --git a/goinstallscript/README.md b/goinstallscript/README.md new file mode 100644 index 0000000..d7223d8 --- /dev/null +++ b/goinstallscript/README.md @@ -0,0 +1,122 @@ +# go-install.ps1 + +[`go-install.ps1`](powershell/go-install.ps1) is a PowerShell script that installs the [Microsoft Go](https://github.com/microsoft/go) toolset. +The script works with Windows PowerShell and PowerShell (`pwsh`) and can install all [supported prebuilt Microsoft Go toolset platforms](https://github.com/microsoft/go?tab=readme-ov-file#download-and-install). +It installs the Microsoft Go toolset into a directory of your choice, or defaults to a directory in the user-specific data directory. + +See `go-install.ps1 -h` for more information about the parameters and defaults. + +The script is intended for use in CI/CD pipelines or to reproduce the results of those CI/CD pipelines locally. + +There is a utility command [`goinstallscript`, documented later in this readme](#githubcommicrosoftgo-infragoinstallscript), that helps install `go-install.ps1` and keep it up to date. + +## Prerequisites + +On non-Windows platforms, install [PowerShell (`pwsh`)](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell). + +On Windows, either Windows PowerShell or PowerShell can be used. + +> [!NOTE] +> PowerShell was formerly known as PowerShell Core. +> Now [Windows PowerShell and PowerShell](https://learn.microsoft.com/en-us/powershell/scripting/what-is-windows-powershell) are the names used by Microsoft for these products. + +## Usage + +Run the script using the `pwsh` command: + +```bash +pwsh ./go-install.ps1 +``` + +If you use a PowerShell terminal, you can also choose to run the script directly: + +``` +.\go-install.ps1 +``` + +Running the script directly allows the script to change the terminal's `PATH` so the installed Go binary is then available in the current session as `go`. + +Note that in typical CI/CD pipelines, each step is run in a fresh process and the `PATH` change will not be preserved. +If you're using Azure Pipelines, see the help message for `-AzurePipelinePath`. + +Pass `-h` to show help. + +## Where to put the script + +The script can be placed in the root of your repository or in a subdirectory. +It can be run from a different directory with no effect on the results. +The script can be renamed and will still function properly. + +To copy the script and set up a mechanism to keep it up to date, use the `goinstallscript` command. + +# github.com/microsoft/go-infra/goinstallscript + +The `goinstallscript` command helps install `go-install.ps1` and keep it up to date. + +### Set up `goinstallscript` + +In your Go module, run: + +``` +go get github.com/microsoft/go-infra/goinstallscript@latest +``` + +Then, in the directory of your choice inside your module, run: + +``` +go run github.com/microsoft/go-infra/goinstallscript +``` + +Pass `-h` for more information about the parameters and defaults. + +> [!NOTE] +> It is not recommended to use `go install` to install the `goinstallscript` command. +> The PowerShell script's content is embedded in the binary, so running an old build of `goinstallscript` may create a file with an unexpected version of the script. +> +> By using `go run`, you ensure the script always matches the expected version in the `go.mod` dependency. + +One more step is needed to prevent `go mod tidy` from removing the new dependency from your `go.mod` file. +Add a `tools/tools.go` file to your module with the following content: + +```go +//go:build tools + +package tools + +import ( + _ "github.com/microsoft/go-infra/goinstallscript/powershell" +) +``` + +> [!NOTE] +> This is a well-known workaround to pin the version of a tool in a Go module. +> See the [Go wiki](https://go.dev/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module) for more information. +> +> If you already have a file that serves this purpose for other tools, you can add the import to that file instead. + +### Updating the script using `goinstallscript` + +> [!NOTE] +> It isn't necessary to update the script to get new builds of the Microsoft Go toolset. +> Updates to the script are rare, and only occur when the lookup or download processes themselves change. + +To update the script, run the `go get` and `go run` commands again in the directory where the script is stored. + +### Set up a CI test to ensure the script is up to date + +First, make sure dependabot is working. +It will submit PRs that update the microsoft/go-infra dependency automatically. + +The script isn't integrated directly with dependabot, so it's necessary to add a test case that alerts a developer when a manual update is necessary. +This is done by adding a CI step that runs the following command in the directory where the script is stored: + +``` +go run github.com/microsoft/go-infra/goinstallscript -check +``` + +The CI step will fail if the script is not up to date because the command returns a nonzero exit code. + +### Reacting to a `-check` failure + +If the CI step fails, the script is out of date. +Check out the dependabot branch and use the `go run` command again to overwrite the script content with the updated version. diff --git a/goinstallscript/go.mod b/goinstallscript/go.mod new file mode 100644 index 0000000..238678c --- /dev/null +++ b/goinstallscript/go.mod @@ -0,0 +1,3 @@ +module github.com/microsoft/go-infra/goinstallscript + +go 1.22.0 diff --git a/goinstallscript/main.go b/goinstallscript/main.go new file mode 100644 index 0000000..0a535dc --- /dev/null +++ b/goinstallscript/main.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/microsoft/go-infra/goinstallscript/powershell" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + h := flag.Bool("h", false, "Print this help message") + + name := flag.String("name", powershell.Name, "Name of the script file to create.") + check := flag.Bool("check", false, + "Do not write the file, just check if any change would be made.\n"+ + "Exit code 2 if there's a text difference, 1 if there's an error, 0 if the file matches this command's payload.") + + flag.Parse() + + if *h { + fmt.Println( + "This command creates (by default) a PowerShell (pwsh) script named 'go-install.ps1' in the current directory.", + "The script contains its own documentation.") + flag.PrintDefaults() + return nil + } + + if *check { + return runCheck(*name) + } + + if err := os.WriteFile(*name, []byte(powershell.Content), 0o777); err != nil { + return err + } + fmt.Println("Created " + *name) + return nil +} + +func runCheck(name string) error { + existing, err := os.ReadFile(name) + if err != nil { + return err + } + if string(existing) == powershell.Content { + fmt.Println("Check ok: " + name + " file matches expected content.") + return nil + } + // Accept CRLF as well. The user might be using autocrlf on Windows. + if strings.ReplaceAll(string(existing), "\r\n", "\n") == powershell.Content { + fmt.Println("Check ok: " + name + " file contains CRLF line endings but otherwise matches expected content.") + return nil + } + fmt.Println("Check failed: " + name + " file differs from expected content.") + os.Exit(2) + panic("unreachable: command should have exited with status 2") +} diff --git a/goinstallscript/powershell/.gitattributes b/goinstallscript/powershell/.gitattributes new file mode 100644 index 0000000..b796944 --- /dev/null +++ b/goinstallscript/powershell/.gitattributes @@ -0,0 +1,2 @@ +# Always use LF when checking out the install script to match "go install" behavior. +go-install.ps1 text eol=lf diff --git a/goinstallscript/powershell/go-install.ps1 b/goinstallscript/powershell/go-install.ps1 new file mode 100644 index 0000000..29cc7fc --- /dev/null +++ b/goinstallscript/powershell/go-install.ps1 @@ -0,0 +1,899 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Installs Microsoft Go +.DESCRIPTION + Installs the Microsoft Go toolset. + + Note that the intended use of this script is for Continuous Integration (CI) scenarios, where: + - The toolset needs to be installed without user interaction and without admin rights. + - The toolset installation doesn't need to persist across multiple CI runs. + Visit https://github.com/microsoft/go for a list of other ways to install Microsoft Go. + +.PARAMETER Version + Default: Latest + Download the specified version. Supports some aliases. Possible values: + - Latest - the most recent major version. + - Previous - the second most recent major version. + - 2-part version in format go1.A - represents a specific major version. + examples: go1.18, go1.23 + - 3-part version in format go1.A.B - latest revision of a specific release. + examples: go1.18.0, go1.23.1 + - 4-part version in format go1.A.B-C - a specific revision of Microsoft Go, immutable. + examples: go1.18.0-1, go1.23.1-3 + Microsoft Go doesn't publish prereleases, so they are not available. +.PARAMETER InstallDir + Path to where to install Microsoft Go. Note that if a directory is given, GOROOT is placed + directly in that directory. + Default: - a folder automatically selected inside LocalApplicationData as evaluated by PowerShell. + Example auto on Windows: C:\Users\myself\AppData\Local\microsoft-go\ + Example auto on Linux: /home/myself/.local/share/microsoft-go/ + If OS or Architecture are not , the path includes OS and Architecture. This avoids + overlapping installations but still allows for a shorter path for ordinary situations. +.PARAMETER OS + Default: - this value represents currently running OS + Operating system of prebuilt toolset binaries to be installed. + Possible values are: , windows, linux, darwin +.PARAMETER Architecture + Default: - this value represents currently running OS architecture + Architecture of prebuilt toolset binaries to be installed. + Possible values are: , amd64, x64, 386, x86, arm64, arm +.PARAMETER DryRun + If set, it will not perform installation. Instead, it displays what command line to use to + consistently install currently requested version of Microsoft Go. For example, if you specify + Version 'Latest', it will print a command with the specific 4-part version so this command can + be used deterministicly in a build script. + It also prints the location the binaries would have been installed to. +.PARAMETER NoPath + By default, this script will update the environment variable PATH for the current process to + include the binaries folder inside installation folder. + If set, it will print the binaries location but not set any environment variable. +.PARAMETER AzurePipelinePath + If set, it will print an Azure DevOps logging command that causes the Azure DevOps to update the + PATH environment variable of subsequent build steps to include the binaries folder. +.PARAMETER ProxyAddress + If set, it will use the proxy when making web requests +.PARAMETER ProxyUseDefaultCredentials + Default: false + Use default credentials when using ProxyAddress. +.PARAMETER ProxyBypassList + If set, when using ProxyAddress, this comma separated url list is passed to the underlying + HttpClientHandler. +.PARAMETER DownloadTimeout + Determines timeout duration in seconds for downloading the toolset file. + Default: 1200 seconds (20 minutes) +.PARAMETER KeepArchive + If set, the downloaded file is kept. +.PARAMETER ArchivePath + A path to use to store the toolset archive file, a zip or tar.gz. + Default: a generated random filename in the system's temporary directory. +.PARAMETER Help + Displays this help message. +.PARAMETER Verbose + Displays diagnostics information. +.EXAMPLE + go-install.ps1 + Installs the latest released Microsoft Go version. +.EXAMPLE + go-install.ps1 -Version Previous + Installs the latest version of the previous major (1.X) version of Microsoft Go. +#> +[cmdletbinding()] +param( + [string]$Version="Latest", + [Alias('i')][string]$InstallDir="", + [string]$OS="", + [string]$Architecture="", + [switch]$DryRun, + [switch]$NoPath, + [switch]$AzurePipelinePath, + [string]$ProxyAddress, + [switch]$ProxyUseDefaultCredentials, + [string[]]$ProxyBypassList=@(), + [int]$DownloadTimeout=1200, + [switch]$KeepArchive, + [string]$ArchivePath, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" +$ProgressPreference="SilentlyContinue" + +$MicrosoftGoInstallScriptVersion = "0.0.1" + +function Say($str) { + try { + Write-Host "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Host (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: $str" + } +} + +function Say-Warning($str) { + try { + Write-Warning "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Warning (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: Warning: $str" + } +} + +# Writes a line with error style settings. +# Use this function to show a human-readable comment along with an exception. +function Say-Error($str) { + try { + # Write-Error is quite oververbose for the purpose of the function, let's write one line with error style settings. + $Host.UI.WriteErrorLine("go-install: $str") + } + catch { + Write-Output "go-install: Error: $str" + } +} + +function Say-Verbose($str) { + try { + Write-Verbose "go-install: $str" + } + catch { + # Some platforms cannot utilize Write-Verbose (Azure Functions, for instance). Fall back to Write-Output + Write-Output "go-install: $str" + } +} + +function Measure-Action($name, $block) { + $time = Measure-Command $block + $totalSeconds = $time.TotalSeconds + Say-Verbose "⏱ Action '$name' took $totalSeconds seconds" +} + +function Get-Remote-File-Size($zipUri) { + try { + $response = Invoke-WebRequest -Uri $zipUri -Method Head + $fileSize = $response.Headers["Content-Length"] + if ((![string]::IsNullOrEmpty($fileSize))) { + Say "Remote file $zipUri size is $fileSize bytes." + + return $fileSize + } + } + catch { + Say-Verbose "Content-Length header was not extracted for $zipUri." + } + + return $null +} + +function Say-Invocation($Invocation) { + $command = $Invocation.MyCommand; + $args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ") + Say-Verbose "$command $args" +} + +function Invoke-With-Retry([ScriptBlock]$ScriptBlock, [System.Threading.CancellationToken]$cancellationToken = [System.Threading.CancellationToken]::None, [int]$MaxAttempts = 3, [int]$SecondsBetweenAttempts = 1) { + $Attempts = 0 + $local:startTime = $(get-date) + + while ($true) { + try { + return & $ScriptBlock + } + catch { + $Attempts++ + if (($Attempts -lt $MaxAttempts) -and -not $cancellationToken.IsCancellationRequested) { + Start-Sleep $SecondsBetweenAttempts + } + else { + $local:elapsedTime = $(get-date) - $local:startTime + if (($local:elapsedTime.TotalSeconds - $DownloadTimeout) -gt 0 -and -not $cancellationToken.IsCancellationRequested) { + throw New-Object System.TimeoutException("Failed to reach the server: connection timeout: default timeout is $DownloadTimeout second(s)"); + } + throw; + } + } + } +} + +function Get-Machine-Architecture() { + Say-Invocation $MyInvocation + + # Try the .NET API. If we don't get anything, this is probably PowerShell on Windows. + try { + $Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + if ($Architecture) { + # Possible values: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.architecture + return $Architecture.ToString().ToLowerInvariant() + } + } + catch { + Say-Verbose "Failed to get the machine architecture using .NET API. Falling back to environment variables." + } + + # On PS x86, PROCESSOR_ARCHITECTURE reports x86 even on x64 systems. + # To get the correct architecture, we need to use PROCESSOR_ARCHITEW6432. + # PS x64 doesn't define this, so we fall back to PROCESSOR_ARCHITECTURE. + # Possible values: amd64, x64, x86, arm64, arm + if( $ENV:PROCESSOR_ARCHITEW6432 -ne $null ) { + return $ENV:PROCESSOR_ARCHITEW6432 + } + + return $ENV:PROCESSOR_ARCHITECTURE +} + +function Get-CLIArchitecture-From-Architecture([string]$Architecture) { + Say-Invocation $MyInvocation + + if ($Architecture -eq "") { + $Architecture = Get-Machine-Architecture + } + + switch ($Architecture.ToLowerInvariant()) { + { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "amd64" } + { ($_ -eq "386") -or ($_ -eq "x86") } { return "386" } + { $_ -eq "arm" } { return "armv6l" } + { $_ -eq "arm64" } { return "arm64" } + default { throw "Architecture '$Architecture' not supported. If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Get-CLIOS-From-OS([string]$OS) { + Say-Invocation $MyInvocation + + if (!(Test-Path -LiteralPath 'variable:IsWindows')) { + # If we don't have IsWindows, this is Windows PowerShell (powershell), not PowerShell Core (pwsh). + # So, we can't use the variable, but we know we're on Windows. + $IsWindows = $true + } + + if ($OS -eq "") { + if ($IsWindows -or [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { + $OS = "windows" + } + elseif ($IsLinux) { + $OS = "linux" + } + elseif ($IsMacOS) { + $OS = "darwin" + } + else { + throw "Unable to automatically determine the OS." + } + } + + switch ($OS.ToLowerInvariant()) { + { $_ -eq "windows" } { return "windows" } + { $_ -eq "linux" } { return "linux" } + { $_ -eq "darwin" } { return "darwin" } + default { throw "OS '$OS' not supported. If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Get-GeneratedArchivePath([string]$CLIOS) { + Say-Invocation $MyInvocation + + $Extension = switch ($CLIOS) { + "windows" { ".zip" } + default { ".tar.gz" } + } + + return [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + $Extension +} + +function Fetch-SupportedVersion([string]$StableKey) { + # Figure out what's latest by querying the list of release branches. + $ReleaseBranchData = DownloadJson "https://aka.ms/golang/release/latest/release-branch-links.json" + + # Find first thing in the array of objects where the key by name is true. + foreach ($branch in $ReleaseBranchData) { + if (Get-OrNull $branch $StableKey) { + return $branch.version + } + } + + throw "Failed to find a branch where '$StableKey' is true." +} + +function Get-NormalizedVersion([string]$Version) { + Say-Invocation $MyInvocation + + if ([string]::IsNullOrEmpty($Version)) { + return "" + } + switch ($Version.ToLowerInvariant()) { + { $_ -eq "latest" } { return Fetch-SupportedVersion -StableKey "latestStable" } + { $_ -eq "previous" } { return Fetch-SupportedVersion -StableKey "previousStable" } + { $_ -like "go1.*" } { return $_ } + default { throw "Version '$Version' not recognized. Missing 'go' prefix? If you think this is a bug, report it at https://github.com/microsoft/go/issues" } + } +} + +function Load-Assembly([string] $Assembly) { + try { + Add-Type -Assembly $Assembly | Out-Null + } + catch { + # On Nano Server, Powershell Core Edition is used. Add-Type is unable to resolve base class assemblies because they are not GAC'd. + # Loading the base class assemblies is not unnecessary as the types will automatically get resolved. + } +} + +function GetHTTPResponse([Uri] $Uri, [bool]$HeaderOnly, [bool]$DisableRedirect) +{ + $cts = New-Object System.Threading.CancellationTokenSource + + $downloadScript = { + + $HttpClient = $null + + try { + # HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet. + Load-Assembly -Assembly System.Net.Http + + if (-not $ProxyAddress) { + try { + # Despite no proxy being explicitly specified, we may still be behind a default proxy + $DefaultProxy = [System.Net.WebRequest]::DefaultWebProxy; + if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) { + if ($null -ne $DefaultProxy.GetProxy($Uri)) { + $ProxyAddress = $DefaultProxy.GetProxy($Uri).OriginalString + } else { + $ProxyAddress = $null + } + $ProxyUseDefaultCredentials = $true + } + } + catch { + # Eat the exception and move forward as the above code is an attempt + # at resolving the DefaultProxy that may not have been a problem. + $ProxyAddress = $null + Say-Verbose("Exception ignored: $_.Exception.Message - moving forward...") + } + } + + $HttpClientHandler = New-Object System.Net.Http.HttpClientHandler + if ($ProxyAddress) { + $HttpClientHandler.Proxy = New-Object System.Net.WebProxy -Property @{ + Address=$ProxyAddress; + UseDefaultCredentials=$ProxyUseDefaultCredentials; + BypassList = $ProxyBypassList; + } + } + if ($DisableRedirect) { + $HttpClientHandler.AllowAutoRedirect = $false + } + $HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler + + # Default timeout for HttpClient is 100s. For a 50 MB download this assumes 500 KB/s average, any less will time out + # Defaulting to 20 minutes allows it to work over much slower connections. + $HttpClient.Timeout = New-TimeSpan -Seconds $DownloadTimeout + + if ($HeaderOnly){ + $completionOption = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + } + else { + $completionOption = [System.Net.Http.HttpCompletionOption]::ResponseContentRead + } + + $Task = $HttpClient.GetAsync("$Uri", $completionOption).ConfigureAwait("false"); + $Response = $Task.GetAwaiter().GetResult(); + + if (($null -eq $Response) -or ((-not $HeaderOnly) -and (-not ($Response.IsSuccessStatusCode)))) { + # The feed credential is potentially sensitive info. Do not log FeedCredential to console output. + $DownloadException = [System.Exception] "Unable to download $Uri." + + if ($null -ne $Response) { + $DownloadException.Data["StatusCode"] = [int] $Response.StatusCode + $DownloadException.Data["ErrorMessage"] = "Unable to download $Uri. Returned HTTP status code: " + $DownloadException.Data["StatusCode"] + + if (404 -eq [int] $Response.StatusCode) { + $cts.Cancel() + } + } + + throw $DownloadException + } + + return $Response + } + catch [System.Net.Http.HttpRequestException] { + $DownloadException = [System.Exception] "Unable to download $Uri." + + # Pick up the exception message and inner exceptions' messages if they exist + $CurrentException = $PSItem.Exception + $ErrorMsg = $CurrentException.Message + "`r`n" + while ($CurrentException.InnerException) { + $CurrentException = $CurrentException.InnerException + $ErrorMsg += $CurrentException.Message + "`r`n" + } + + # Check if there is an issue concerning TLS. + if ($ErrorMsg -like "*SSL/TLS*") { + $ErrorMsg += "Ensure that TLS 1.2 or higher is enabled to use this script.`r`n" + } + + $DownloadException.Data["ErrorMessage"] = $ErrorMsg + throw $DownloadException + } + finally { + if ($null -ne $HttpClient) { + $HttpClient.Dispose() + } + } + } + + try { + return Invoke-With-Retry $downloadScript $cts.Token + } + finally { + if ($null -ne $cts) { + $cts.Dispose() + } + } +} + +function Resolve-Installation-Path([string]$InstallDir) { + Say-Invocation $MyInvocation + + if ($InstallDir -eq "") { + $Dir = Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath "microsoft-go" + if ($OS -ne "" -or $Architecture -ne "") { + $Dir = Join-Path -Path $Dir -ChildPath "$($CLIOS)_$CLIArchitecture" + } + return $Dir + } + return $InstallDir +} + +function Resolve-Versioned-Installation-Path([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + return Join-Path -Path $InstallRoot -ChildPath "go$SpecificVersion" +} + +function Is-ToolsetInstalled([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoToolsetPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + $GoBinPath = (Join-Path $GoToolsetPath "bin") + Say-Verbose "Is-ToolsetInstalled: GoToolsetPath=$GoToolsetPath" + # A few basic checks to see if a likely usable toolset is installed. + # If these fail, it will be reinstalled. + return (Test-Path $GoToolsetPath -PathType Container) -and + ( + (Test-Path (Join-Path $GoBinPath "go") -PathType Leaf) -or + (Test-Path (Join-Path $GoBinPath "go.exe") -PathType Leaf) + ) +} + +function Get-Absolute-Path([string]$RelativeOrAbsolutePath) { + # Too much spam + # Say-Invocation $MyInvocation + + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath) +} + +function Extract-Zip([string]$ArchivePath, [string]$OutPath) { + Say-Invocation $MyInvocation + + Load-Assembly -Assembly System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $OutPath) +} + +function Extract-TarGz([string]$ArchivePath, [string]$OutPath) { + Say-Invocation $MyInvocation + + if (-not (Test-Path $OutPath)) { + New-Item -ItemType Directory -Force -Path $OutPath + } + + try { + & tar -C $OutPath -xzf $ArchivePath + if ($LASTEXITCODE -ne 0) { + throw "tar exit code: $LASTEXITCODE" + } + } + catch { + throw "Failed to extract the tar.gz archive `"$ArchivePath`". Error: $_" + } +} + +function Extract-ToolsetArchive([string]$ArchivePath, [string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoRootPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + # First extract to a temporary directory to avoid partial extraction to the final location. This + # makes it so rerunning the script fixes a problem in case of an interruption. Don't use + # systemwide temp directory because Move-Item from there has been observed to fail on Linux. + $TempGoExtractDir = Join-Path $InstallRoot ".tmp-extract" + $TempGoRootPath = Resolve-Versioned-Installation-Path $TempGoExtractDir $SpecificVersion + + # Clean up directories from a previous attempt. + if (Test-Path $GoRootPath) { + Remove-Item $GoRootPath -Recurse -Force + } + if (Test-Path $TempGoExtractDir) { + Remove-Item $TempGoExtractDir -Recurse -Force + } + if (Test-Path $TempGoRootPath) { + Remove-Item $TempGoRootPath -Recurse -Force + } + + try { + switch ([System.IO.Path]::GetExtension($ArchivePath).ToLowerInvariant()) { + ".zip" { Extract-Zip $ArchivePath $TempGoRootPath } + ".gz" { Extract-TarGz $ArchivePath $TempGoRootPath } + default { throw "Unsupported archive type: $ArchivePath" } + } + + # Move contents of inner "go" dir to the output path to avoid unwanted extra dir. + Move-Item (Join-Path $TempGoRootPath "go") $GoRootPath + + $GoRootPath = "" + } + finally { + if ($GoRootPath -ne "" -and (Test-Path $GoRootPath)) { + Remove-Item $GoRootPath -Recurse -Force + } + if (Test-Path $TempGoExtractDir) { + Remove-Item $TempGoExtractDir -Recurse -Force + } + if (Test-Path $TempGoRootPath) { + Remove-Item $TempGoRootPath -Recurse -Force + } + } +} + +function DownloadJson([string]$Source) { + $Text = DownloadString $Source + + try { + return ConvertFrom-Json $Text + } + catch { + Say-Verbose "Failed to parse the JSON response from '$Source': $Text" + throw $_ + } +} + +function DownloadString([string]$Source) { + $Stream = $null + $Reader = $null + + # To make sure errors are accurate and useful, attempt to get the target first. This prevents a + # situation where we succesfully download bing.com after a failed redirect, try to parse the + # HTML as JSON, and present a confusing error message. + if ($Source -like "https://aka.ms/*") { + $DirectSource = Get-AkaMSRedirectTarget $Source + if (!$DirectSource) { + throw "Failed to aka.ms redirect for URL: $Source" + } + $Source = $DirectSource + } + + try { + $Response = GetHTTPResponse -Uri $Source + $Stream = $Response.Content.ReadAsStreamAsync().Result + $Reader = New-Object System.IO.StreamReader($Stream) + return $Reader.ReadToEnd() + } + finally { + if ($null -ne $Stream) { + $Stream.Dispose() + } + if ($null -ne $Reader) { + $Reader.Dispose() + } + } +} + +function DownloadFile($Source, [string]$OutPath) { + if ($Source -notlike "http*") { + # Using System.IO.Path.GetFullPath to get the current directory + # does not work in this context - $pwd gives the current directory + if (![System.IO.Path]::IsPathRooted($Source)) { + $Source = $(Join-Path -Path $pwd -ChildPath $Source) + } + $Source = Get-Absolute-Path $Source + Say "Copying file from $Source to $OutPath" + Copy-Item $Source $OutPath + return + } + + $Stream = $null + + try { + $Response = GetHTTPResponse -Uri $Source + $Stream = $Response.Content.ReadAsStreamAsync().Result + $File = [System.IO.File]::Create($OutPath) + $Stream.CopyTo($File) + $File.Close() + + ValidateRemoteLocalFileSizes -LocalFileOutPath $OutPath -SourceUri $Source + } + finally { + if ($null -ne $Stream) { + $Stream.Dispose() + } + } +} + +function ValidateRemoteLocalFileSizes([string]$LocalFileOutPath, $SourceUri) { + try { + $remoteFileSize = Get-Remote-File-Size -zipUri $SourceUri + $fileSize = [long](Get-Item $LocalFileOutPath).Length + Say "Downloaded file $SourceUri size is $fileSize bytes." + + if ((![string]::IsNullOrEmpty($remoteFileSize)) -and !([string]::IsNullOrEmpty($fileSize)) ) { + if ($remoteFileSize -ne $fileSize) { + Say "The remote and local file sizes are not equal. Remote file size is $remoteFileSize bytes and local size is $fileSize bytes. The local package may be corrupted." + } + else { + Say "The remote and local file sizes are equal." + } + } + else { + Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + } + } + catch { + Say "Either downloaded or local package size can not be measured. One of them may be corrupted." + } +} + +function Remove-FileSafely($Path) { + try { + if (Test-Path $Path) { + Remove-Item $Path + Say-Verbose "The temporary file `"$Path`" was removed." + } + else { + Say-Verbose "The temporary file `"$Path`" does not exist, therefore is not removed." + } + } + catch { + Say-Warning "Failed to remove the temporary file: `"$Path`", remove it manually." + } +} + +function Prepend-ToolsetPathEnv([string]$InstallRoot, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $GoRootPath = Resolve-Versioned-Installation-Path $InstallRoot $SpecificVersion + $BinPath = Get-Absolute-Path (Join-Path -Path $GoRootPath -ChildPath "bin") + + if (-Not $NoPath) { + $SuffixedBinPath = $BinPath + [System.IO.Path]::PathSeparator + if (-Not $env:PATH.Contains($SuffixedBinPath)) { + Say "Adding to current process PATH: $BinPath" + Say "Note: This change will not be visible if PowerShell was run as a child process." + $env:PATH = $SuffixedBinPath + $env:PATH + Say-Verbose "The current process PATH is now `"$env:PATH`"." + } + else { + Say "Current process PATH already contains `"$BinPath`"" + } + } + else { + Say "Binaries can be found in $BinPath" + } + + if ($AzurePipelinePath) { + Say "Running an Azure Pipelines logging command to prepend `"$BinPath`" to the PATH." + Say "##vso[task.prependpath]$BinPath" + } +} + +function PrintDryRunOutput($Invocation) { + $RepeatableCommand = ".\$ScriptName -Version `"go$SpecificVersion`" -InstallDir `"$InstallRoot`" -OS `"$CLIOS`" -Architecture `"$CLIArchitecture`"" + + foreach ($key in $Invocation.BoundParameters.Keys) { + if (-not (@("Version","InstallDir","OS","Architecture","DryRun") -contains $key)) { + $RepeatableCommand+=" -$key `"$($Invocation.BoundParameters[$key])`"" + } + } + Say "Repeatable invocation: $RepeatableCommand" +} + +function Get-AkaMSRedirectTarget([string] $akaMsLink) { + $akaMsDownloadLink=$null + + for ($maxRedirections = 9; $maxRedirections -ge 0; $maxRedirections--) + { + #get HTTP response + #do not pass credentials as a part of the $akaMsLink and do not apply credentials in the GetHTTPResponse function + #otherwise the redirect link would have credentials as well + #it would result in applying credentials twice to the resulting link and thus breaking it, and in echoing credentials to the output as a part of redirect link + $Response= GetHTTPResponse -Uri $akaMsLink -HeaderOnly $true -DisableRedirect $true -DisableFeedCredential $true + Say-Verbose "Received response:`n$Response" + + if ([string]::IsNullOrEmpty($Response)) { + Say-Verbose "The link '$akaMsLink' is not valid: failed to get redirect location. The resource is not available." + return $null + } + + #if HTTP code is 301 (Moved Permanently), the redirect link exists + if ($Response.StatusCode -eq 301) + { + try { + $akaMsDownloadLink = $Response.Headers.GetValues("Location")[0] + + if ([string]::IsNullOrEmpty($akaMsDownloadLink)) { + Say-Verbose "The link '$akaMsLink' is not valid: server returned 301 (Moved Permanently), but the headers do not contain the redirect location." + return $null + } + + Say-Verbose "The redirect location retrieved: '$akaMsDownloadLink'." + # This may yet be a link to another redirection. Attempt to retrieve the page again. + $akaMsLink = $akaMsDownloadLink + continue + } + catch { + Say-Verbose "The link '$akaMsLink' is not valid: failed to get redirect location." + return $null + } + } + elseif ((($Response.StatusCode -lt 300) -or ($Response.StatusCode -ge 400)) -and (-not [string]::IsNullOrEmpty($akaMsDownloadLink))) + { + # Redirections have ended. + return $akaMsDownloadLink + } + + Say-Verbose "The link '$akaMsLink' is not valid: failed to retrieve the redirection location." + return $null + } + + Say-Verbose "Aka.ms links have redirected more than the maximum allowed redirections. This may be caused by a cyclic redirection of aka.ms links." + return $null +} + +# Strict mode means attempting to access a JSON key that doesn't exist fails harshly. +# This utility helps make JSON access a bit more concise under those rules. +# https://github.com/PowerShell/PowerShell/issues/10875 +function Get-OrNull($Target, $Property) { + if ($Target -and $Target.PSObject.Properties[$Property]) { + return $Target.PSObject.Properties[$Property].Value + } + return $null +} + +function Get-AssetInformation([string]$NormalizedVersion, [string]$OS, [string]$Architecture) { + Say-Invocation $MyInvocation + + #construct aka.ms link like "https://aka.ms/golang/release/latest/go1.23.assets.json" + $AkaMsLink = "https://aka.ms/golang/release/latest" + $AkaMsLink +="/$NormalizedVersion.assets.json" + Say-Verbose "Constructed assets.json aka.ms link: '$AkaMsLink'." + + $Assets = DownloadJson $AkaMsLink + $MatchingArches = @($Assets.arches | Where-Object { + $Env = Get-OrNull $_ 'env' + return (Get-OrNull $Env 'GOOS') -eq $OS -and + (Get-OrNull $Env 'GOARCH') -eq $Architecture + }) + + foreach ($arch in $MatchingArches) { + Say-Verbose "Matching env '$($arch.env)'." + } + + if ($MatchingArches.Count -ne 1) { + throw "Failed to find exactly one matching asset for OS '$OS' and architecture '$Architecture'." + } + + return ($MatchingArches[0], $Assets.version) +} + +function Prepare-Install-Directory { + New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null +} + +# The following marker is used by microsoft/go-infra tests to insert more logic that runs before any +# installation happens and may stop the script before installation. This allows unit testing without +# adding additional inputs and complexity only used by tests. + +# [END OF FUNCTIONS] + +if ($Help) { + Get-Help $PSCommandPath -Examples + exit +} + +Say "Microsoft Go Install Script version $MicrosoftGoInstallScriptVersion" + +Say-Verbose "Note that the intended use of this script is for Continuous Integration (CI) scenarios, where:" +Say-Verbose "- The toolset needs to be installed without user interaction and without admin rights." +Say-Verbose "- The toolset installation doesn't need to persist across multiple CI runs." +Say-Verbose "Visit https://github.com/microsoft/go for a list of other ways to install Microsoft Go.`r`n" + +Measure-Action "Product discovery" { + $script:CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture + $script:CLIOS = Get-CLIOS-From-OS $OS + $script:NormalizedVersion = Get-NormalizedVersion $Version + Say-Verbose "Normalized version: '$NormalizedVersion'" +} + +if ($ArchivePath -eq "") { + $ArchivePath = Get-GeneratedArchivePath $CLIOS + Say-Verbose "Generated archive path: $ArchivePath" +} + +$InstallRoot = Resolve-Installation-Path $InstallDir +Say-Verbose "InstallRoot: $InstallRoot" + +$ScriptName = $MyInvocation.MyCommand.Name + +Say "Fetching information for version '$Version'." +($Arch, $SpecificVersion) = Get-AssetInformation $NormalizedVersion $CLIOS $CLIArchitecture + +$DownloadLink = $Arch.url +Say-Verbose "Found download link $DownloadLink with version $SpecificVersion" + +if (-Not $DryRun) { + Say-Verbose "Checking if the version $SpecificVersion is already installed" + if (Is-ToolsetInstalled -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion) { + Say "Microsoft Go version '$SpecificVersion' is already installed." + Measure-Action "Setting up shell environment" { Prepend-ToolsetPathEnv -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + return + } +} + +if ($DryRun) { + PrintDryRunOutput $MyInvocation + return +} + +Measure-Action "Installation directory preparation" { Prepare-Install-Directory } + +Say-Verbose "Zip path: $ArchivePath" + +Say-Verbose "Downloading link $DownloadLink" + +try { + Measure-Action "Package download" { DownloadFile -Source $DownloadLink -OutPath $ArchivePath } + Say-Verbose "Download succeeded." +} +catch { + $StatusCode = $null + $ErrorMessage = $null + + if ($PSItem.Exception.Data.Contains("StatusCode")) { + $StatusCode = $PSItem.Exception.Data["StatusCode"] + } + + if ($PSItem.Exception.Data.Contains("ErrorMessage")) { + $ErrorMessage = $PSItem.Exception.Data["ErrorMessage"] + } else { + $ErrorMessage = $PSItem.Exception.Message + } + + if (-not $KeepArchive) { + Remove-FileSafely -Path $ArchivePath + } + + throw "Downloading has failed with error:`nUri: $DownloadLink`nStatusCode: $StatusCode`nError: $ErrorMessage" +} + +Say "Extracting the archive." +Measure-Action "Archive extraction" { Extract-ToolsetArchive -ArchivePath $ArchivePath -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + +Say-Verbose "Checking installation: version = $SpecificVersion" +$isAssetInstalled = Is-ToolsetInstalled -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion + +# Version verification failed. More likely something is wrong either with the downloaded content or with the verification algorithm. +if (!$isAssetInstalled) { + Say-Error "Failed to verify that the toolset was installed.`nInstallation source: $DownloadLink.`nInstallation location: $InstallRoot.`nReport the bug at https://github.com/microsoft/go/issues." + throw "Toolset with version $SpecificVersion failed to install with an unknown error." +} + +if (-not $KeepArchive) { + Remove-FileSafely -Path $ArchivePath +} + +Measure-Action "Setting up environment PATH to find 'go' command" { Prepend-ToolsetPathEnv -InstallRoot $InstallRoot -SpecificVersion $SpecificVersion } + +Say "Installed version is $SpecificVersion" +Say "Installation finished" diff --git a/goinstallscript/powershell/powershell.go b/goinstallscript/powershell/powershell.go new file mode 100644 index 0000000..75d208b --- /dev/null +++ b/goinstallscript/powershell/powershell.go @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package powershell embeds the PowerShell script for installing Go. +package powershell + +import ( + _ "embed" +) + +//go:embed go-install.ps1 +var Content string + +const Name = "go-install.ps1" diff --git a/goinstallscript/powershell/powershell_test.go b/goinstallscript/powershell/powershell_test.go new file mode 100644 index 0000000..cf03aa1 --- /dev/null +++ b/goinstallscript/powershell/powershell_test.go @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package powershell_test + +import ( + "encoding/json" + "flag" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/microsoft/go-infra/goinstallscript/powershell" +) + +var download = flag.Bool( + "download", false, + "Run tests that include downloading Microsoft Go from the internet. "+ + "These may be very slow and not totally reproducible.") + +const endOfFunctionsMarker = "# [END OF FUNCTIONS]" + +// makeTestFile creates a file in a temporary directory where some content has been inserted after the "end of functions" marker. +func makeTestFile(t *testing.T, postFuncContent string) string { + t.Helper() + before, after, ok := strings.Cut(powershell.Content, endOfFunctionsMarker) + if !ok { + t.Fatal("missing # [END OF FUNCTIONS] in powershellscript.Content") + } + content := before + "\n" + endOfFunctionsMarker + "\n" + postFuncContent + "\n" + after + p := filepath.Join(t.TempDir(), powershell.Name) + if err := os.WriteFile(p, []byte(content), 0o777); err != nil { + t.Fatal(err) + } + return p +} + +func runTestFile(t *testing.T, interpreter, path string, args ...string) string { + t.Helper() + cmd := exec.Command(interpreter, append([]string{"-NoProfile", path}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("error running %v: %v; output:\n---\n%s\n---", cmd, err, out) + } + return strings.TrimSpace(string(out)) +} + +func currentOSInterpreters(t *testing.T) []string { + t.Helper() + if runtime.GOOS == "windows" { + // Windows has both pwsh (PowerShell Core) and powershell (Windows PowerShell). + return []string{"pwsh", "powershell"} + } + // Other platforms only have pwsh (PowerShell Core). + return []string{"pwsh"} +} + +func TestDetectOS(t *testing.T) { + for _, interpreter := range currentOSInterpreters(t) { + t.Run(interpreter, func(t *testing.T) { + t.Parallel() + + out := runTestFile(t, interpreter, makeTestFile(t, ` + @{ + Arch = Get-CLIArchitecture-From-Architecture $Architecture + OS = Get-CLIOS-From-OS $OS + } | ConvertTo-Json | Write-Output + exit + `)) + + var result struct { + Arch string + OS string + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("error unmarshalling JSON: %v; output:\n---\n%s\n---", err, out) + } + + if result.Arch != runtime.GOARCH { + t.Errorf("expected architecture %q, got %q", runtime.GOARCH, result.Arch) + } + if result.OS != runtime.GOOS { + t.Errorf("expected OS %q, got %q", runtime.GOOS, result.OS) + } + }) + } +} + +func TestGenerateArchivePath(t *testing.T) { + for _, interpreter := range currentOSInterpreters(t) { + t.Run(interpreter, func(t *testing.T) { + for _, os := range []string{"windows", "linux", "darwin"} { + t.Run(os, func(t *testing.T) { + t.Parallel() + + out := runTestFile(t, interpreter, makeTestFile(t, ` + Write-Output (Get-GeneratedArchivePath -CLIOS "`+os+`") + exit + `)) + + t.Logf("Generated path:\n%s\n", out) + + expectExt := ".tar.gz" + if os == "windows" { + expectExt = ".zip" + } + if !strings.HasSuffix(out, expectExt) { + t.Errorf("expected path to end with %q, got %q", expectExt, out) + } + }) + } + }) + } +} + +func TestInstallPath(t *testing.T) { + // Figure out some prefix that we expect the install path to start with. + // Do the best we can without getting complicated. + installDirPrefix, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + installDirPrefix = localAppData + } + if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { + installDirPrefix = xdgDataHome + } + + for _, interpreter := range currentOSInterpreters(t) { + t.Run(interpreter, func(t *testing.T) { + t.Parallel() + + out := runTestFile(t, interpreter, makeTestFile(t, ` + Write-Output (Resolve-Installation-Path "") + exit + `)) + + if !strings.HasPrefix(out, installDirPrefix) { + t.Errorf("expected path to start with %q, got %q", installDirPrefix, out) + } + t.Logf("Install path:\n%s\n", out) + }) + } +} + +func TestInstall(t *testing.T) { + if !*download { + t.Skip("skipping test that downloads Microsoft Go from the internet; use -download to run it") + } + for _, interpreter := range currentOSInterpreters(t) { + t.Run(interpreter, func(t *testing.T) { + // Test a few version strings that have interesting handling. Note that this test isn't + // necessarily reproducible because some of these versions are floating versions and + // will change over time. + for _, version := range []string{ + "latest", + "previous", + "go1.21.2-1", + } { + t.Run(version, func(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + + cmd := exec.Command( + interpreter, "-NoProfile", + makeTestFile(t, ``), + "-InstallDir", installDir, + "-Version", version, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("error running %v: %v; output:\n---\n%s\n---", cmd, err, out) + } + t.Logf("Output:\n---\n%s\n---\n", out) + + // Check that there is an installed go or go.exe binary. + goBlob := filepath.Join(installDir, "go*", "bin", "go") + if runtime.GOOS == "windows" { + goBlob += ".exe" + } + results, err := filepath.Glob(goBlob) + if err != nil { + t.Fatalf("error globbing %q: %v", goBlob, err) + } + if len(results) != 1 { + t.Fatalf("expected exactly one result for %q, got %v", goBlob, results) + } + }) + } + }) + } +}