diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ccb39869..fe7849af6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -177,7 +177,7 @@ jobs: run: ./tool/go build -o /tmp/testwrapper ./cmd/testwrapper - name: test all working-directory: src - run: NOBASHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} + run: NOBASHDEBUG=true NOPWSHDEBUG=true PATH=$PWD/tool:$PATH /tmp/testwrapper ./... ${{matrix.buildflags}} env: GOARCH: ${{ matrix.goarch }} TS_TEST_SHARD: ${{ matrix.shard }} diff --git a/tool/go.cmd b/tool/go.cmd index 04172a28d..b7b5d0483 100644 --- a/tool/go.cmd +++ b/tool/go.cmd @@ -1,2 +1,36 @@ @echo off -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0go-win.ps1" %* +rem Checking for PowerShell Core using PowerShell for Windows... +powershell -NoProfile -NonInteractive -Command "& {Get-Command -Name pwsh -ErrorAction Stop}" > NUL +if ERRORLEVEL 1 ( + rem Ask the user whether they should install the dependencies. Note that this + rem code path never runs in CI because pwsh is always explicitly installed. + + rem Time out after 5 minutes, defaulting to 'N' + choice /c yn /t 300 /d n /m "PowerShell Core is required. Install now" + if ERRORLEVEL 2 ( + echo Aborting due to unmet dependencies. + exit /b 1 + ) + + rem Check for a .NET Core runtime using PowerShell for Windows... + powershell -NoProfile -NonInteractive -Command "& {if (-not (dotnet --list-runtimes | Select-String 'Microsoft\.NETCore\.App' -Quiet)) {exit 1}}" > NUL + rem Install .NET Core if missing to provide PowerShell Core's runtime library. + if ERRORLEVEL 1 ( + rem Time out after 5 minutes, defaulting to 'N' + choice /c yn /t 300 /d n /m "PowerShell Core requires .NET Core for its runtime library. Install now" + if ERRORLEVEL 2 ( + echo Aborting due to unmet dependencies. + exit /b 1 + ) + + winget install --accept-package-agreements --id Microsoft.DotNet.Runtime.8 -e --source winget + ) + + rem Now install PowerShell Core. + winget install --accept-package-agreements --id Microsoft.PowerShell -e --source winget + if ERRORLEVEL 0 echo Please re-run this script within a new console session to pick up PATH changes. + rem Either way we didn't build, so return 1. + exit /b 1 +) + +pwsh -NoProfile -ExecutionPolicy Bypass "%~dp0..\tool\gocross\gocross-wrapper.ps1" %* diff --git a/tool/gocross/exec_other.go b/tool/gocross/exec_other.go index 8d4df0db3..7bce0c099 100644 --- a/tool/gocross/exec_other.go +++ b/tool/gocross/exec_other.go @@ -11,7 +11,7 @@ import ( ) func doExec(cmd string, args []string, env []string) error { - c := exec.Command(cmd, args...) + c := exec.Command(cmd, args[1:]...) c.Env = env c.Stdin = os.Stdin c.Stdout = os.Stdout diff --git a/tool/gocross/gocross-wrapper.ps1 b/tool/gocross/gocross-wrapper.ps1 new file mode 100644 index 000000000..fcc010dce --- /dev/null +++ b/tool/gocross/gocross-wrapper.ps1 @@ -0,0 +1,220 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +#Requires -Version 7.4 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3.0 + +if (($Env:CI -eq 'true') -and ($Env:NOPWSHDEBUG -ne 'true')) { + Set-PSDebug -Trace 1 +} + +<# + .DESCRIPTION + Copies the script's $args variable into an array, which is easier to work with + when preparing to start child processes. +#> +function Copy-ScriptArgs { + $list = [System.Collections.Generic.List[string]]::new($Script:args.Count) + foreach ($arg in $Script:args) { + $list.Add($arg) + } + return $list.ToArray() +} + +<# + .DESCRIPTION + Copies the current environment into a hashtable, which is easier to work with + when preparing to start child processes. +#> +function Copy-Environment { + $result = @{} + foreach ($pair in (Get-Item -Path Env:)) { + $result[$pair.Key] = $pair.Value + } + return $result +} + +<# + .DESCRIPTION + Outputs the fully-qualified path to the repository's root directory. This + function expects to be run from somewhere within a git repository. + The directory containing the git executable must be somewhere in the PATH. +#> +function Get-RepoRoot { + Get-Command -Name 'git' | Out-Null + $repoRoot = & git rev-parse --show-toplevel + if ($LASTEXITCODE -ne 0) { + throw "failed obtaining repo root: git failed with code $LASTEXITCODE" + } + + # Git outputs a path containing forward slashes. Canonicalize. + return [System.IO.Path]::GetFullPath($repoRoot) +} + +<# + .DESCRIPTION + Runs the provided ScriptBlock in a child scope, restoring any changes to the + current working directory once the script block completes. +#> +function Start-ChildScope { + param ( + [Parameter(Mandatory = $true)] + [ScriptBlock]$ScriptBlock + ) + + $initialLocation = Get-Location + try { + Invoke-Command -ScriptBlock $ScriptBlock + } + finally { + Set-Location -Path $initialLocation + } +} + +<# + .SYNOPSIS + Write-Output with timestamps prepended to each line. +#> +function Write-Log { + param ($message) + $timestamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + Write-Output "$timestamp - $message" +} + +$bootstrapScriptBlock = { + + $repoRoot = Get-RepoRoot + + Set-Location -LiteralPath $repoRoot + + switch -Wildcard -File .\go.toolchain.rev { + "/*" { $toolchain = $_ } + default { + $rev = $_ + $tsgo = Join-Path $Env:USERPROFILE '.cache' 'tsgo' + $toolchain = Join-Path $tsgo $rev + if (-not (Test-Path -LiteralPath "$toolchain.extracted" -PathType Leaf -ErrorAction SilentlyContinue)) { + New-Item -Force -Path $tsgo -ItemType Directory | Out-Null + Remove-Item -Force -Recurse -LiteralPath $toolchain -ErrorAction SilentlyContinue + Write-Log "Downloading Go toolchain $rev" + + # Values from https://web.archive.org/web/20250227081443/https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.architecture?view=net-9.0 + $cpuArch = ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture | Out-String -NoNewline) + # Comparison in switch is case-insensitive by default. + switch ($cpuArch) { + 'x86' { $goArch = '386' } + 'x64' { $goArch = 'amd64' } + default { $goArch = $cpuArch } + } + + Invoke-WebRequest -Uri "https://github.com/tailscale/go/releases/download/build-$rev/windows-$goArch.tar.gz" -OutFile "$toolchain.tar.gz" + try { + New-Item -Force -Path $toolchain -ItemType Directory | Out-Null + Start-ChildScope -ScriptBlock { + Set-Location -LiteralPath $toolchain + tar --strip-components=1 -xf "$toolchain.tar.gz" + if ($LASTEXITCODE -ne 0) { + throw "tar failed with exit code $LASTEXITCODE" + } + } + $rev | Out-File -FilePath "$toolchain.extracted" + } + finally { + Remove-Item -Force "$toolchain.tar.gz" -ErrorAction Continue + } + + # Cleanup old toolchains. + $maxDays = 90 + $oldFiles = Get-ChildItem -Path $tsgo -Filter '*.extracted' -File -Recurse -Depth 1 | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$maxDays) } + foreach ($file in $oldFiles) { + Write-Log "Cleaning up old Go toolchain $($file.Basename)" + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Continue + $dirName = Join-Path $file.DirectoryName $file.Basename -Resolve -ErrorAction Continue + if ($dirName -and (Test-Path -LiteralPath $dirName -PathType Container -ErrorAction Continue)) { + Remove-Item -LiteralPath $dirName -Recurse -Force -ErrorAction Continue + } + } + } + } + } + + if ($Env:TS_USE_GOCROSS -ne '1') { + return + } + + if (Test-Path -LiteralPath $toolchain -PathType Container -ErrorAction SilentlyContinue) { + $goMod = Join-Path $repoRoot 'go.mod' -Resolve + $goLine = Get-Content -LiteralPath $goMod | Select-String -Pattern '^go (.*)$' -List + $wantGoMinor = $goLine.Matches.Groups[1].Value.split('.')[1] + $versionFile = Join-Path $toolchain 'VERSION' + if (Test-Path -LiteralPath $versionFile -PathType Leaf -ErrorAction SilentlyContinue) { + try { + $haveGoMinor = ((Get-Content -LiteralPath $versionFile -TotalCount 1).split('.')[1]) -replace 'rc.*', '' + } + catch { + } + } + + if ([string]::IsNullOrEmpty($haveGoMinor) -or ($haveGoMinor -lt $wantGoMinor)) { + Remove-Item -Force -Recurse -LiteralPath $toolchain -ErrorAction Continue + Remove-Item -Force -LiteralPath "$toolchain.extracted" -ErrorAction Continue + } + } + + $wantVer = & git rev-parse HEAD + $gocrossOk = $false + $gocrossPath = '.\gocross.exe' + if (Get-Command -Name $gocrossPath -CommandType Application -ErrorAction SilentlyContinue) { + $gotVer = & $gocrossPath gocross-version 2> $null + if ($gotVer -eq $wantVer) { + $gocrossOk = $true + } + } + + if (-not $gocrossOk) { + $goBuildEnv = Copy-Environment + $goBuildEnv['CGO_ENABLED'] = '0' + $goBuildEnv.Remove('GOOS') + $goBuildEnv.Remove('GOARCH') + $goBuildEnv.Remove('GO111MODULE') + $goBuildEnv.Remove('GOROOT') + + $procExe = Join-Path $toolchain 'bin' 'go.exe' -Resolve + $proc = Start-Process -FilePath $procExe -WorkingDirectory $repoRoot -Environment $goBuildEnv -ArgumentList 'build', '-o', $gocrossPath, "-ldflags=-X=tailscale.com/version.gitCommitStamp=$wantVer", 'tailscale.com/tool/gocross' -NoNewWindow -Wait -PassThru + if ($proc.ExitCode -ne 0) { + throw 'error building gocross' + } + } + +} # bootstrapScriptBlock + +Start-ChildScope -ScriptBlock $bootstrapScriptBlock + +$repoRoot = Get-RepoRoot + +$execEnv = Copy-Environment +$execEnv.Remove('GOROOT') + +$argList = Copy-ScriptArgs + +if ($Env:TS_USE_GOCROSS -ne '1') { + $revFile = Join-Path $repoRoot 'go.toolchain.rev' -Resolve + switch -Wildcard -File $revFile { + "/*" { $toolchain = $_ } + default { + $rev = $_ + $tsgo = Join-Path $Env:USERPROFILE '.cache' 'tsgo' + $toolchain = Join-Path $tsgo $rev -Resolve + } + } + + $procExe = Join-Path $toolchain 'bin' 'go.exe' -Resolve + $proc = Start-Process -FilePath $procExe -WorkingDirectory $repoRoot -Environment $execEnv -ArgumentList $argList -NoNewWindow -Wait -PassThru + exit $proc.ExitCode +} + +$procExe = Join-Path $repoRoot 'gocross.exe' -Resolve +$proc = Start-Process -FilePath $procExe -WorkingDirectory $repoRoot -Environment $execEnv -ArgumentList $argList -NoNewWindow -Wait -PassThru +exit $proc.ExitCode diff --git a/tool/gocross/gocross-wrapper.sh b/tool/gocross/gocross-wrapper.sh index 90485d31b..d93b137aa 100755 --- a/tool/gocross/gocross-wrapper.sh +++ b/tool/gocross/gocross-wrapper.sh @@ -15,6 +15,12 @@ if [[ "${CI:-}" == "true" && "${NOBASHDEBUG:-}" != "true" ]]; then set -x fi +if [[ "${OSTYPE:-}" == "cygwin" || "${OSTYPE:-}" == "msys" ]]; then + hash pwsh 2>/dev/null || { echo >&2 "This operation requires PowerShell Core."; exit 1; } + pwsh -NoProfile -ExecutionPolicy Bypass "${BASH_SOURCE%/*}/gocross-wrapper.ps1" "$@" + exit +fi + # Locate a bootstrap toolchain and (re)build gocross if necessary. We run all of # this in a subshell because posix shell semantics make it very easy to # accidentally mutate the input environment that will get passed to gocross at diff --git a/tool/gocross/gocross.go b/tool/gocross/gocross.go index d14ea0388..6d5d06aeb 100644 --- a/tool/gocross/gocross.go +++ b/tool/gocross/gocross.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "runtime/debug" + "strings" "tailscale.com/atomicfile" ) @@ -68,8 +69,13 @@ func main() { fmt.Fprintf(os.Stderr, "usage: gocross write-wrapper-script \n") os.Exit(1) } - if err := atomicfile.WriteFile(os.Args[2], wrapperScript, 0755); err != nil { - fmt.Fprintf(os.Stderr, "writing wrapper script: %v\n", err) + if err := atomicfile.WriteFile(os.Args[2], wrapperScriptBash, 0755); err != nil { + fmt.Fprintf(os.Stderr, "writing bash wrapper script: %v\n", err) + os.Exit(1) + } + psFileName := strings.TrimSuffix(os.Args[2], filepath.Ext(os.Args[2])) + ".ps1" + if err := atomicfile.WriteFile(psFileName, wrapperScriptPowerShell, 0755); err != nil { + fmt.Fprintf(os.Stderr, "writing PowerShell wrapper script: %v\n", err) os.Exit(1) } os.Exit(0) @@ -112,7 +118,10 @@ func main() { } //go:embed gocross-wrapper.sh -var wrapperScript []byte +var wrapperScriptBash []byte + +//go:embed gocross-wrapper.ps1 +var wrapperScriptPowerShell []byte func debugf(format string, args ...any) { debug := os.Getenv("GOCROSS_DEBUG") diff --git a/tool/gocross/gocross_wrapper_test.go b/tool/gocross/gocross_wrapper_test.go index f4dcec429..6937ccec7 100644 --- a/tool/gocross/gocross_wrapper_test.go +++ b/tool/gocross/gocross_wrapper_test.go @@ -21,7 +21,7 @@ func TestGocrossWrapper(t *testing.T) { t.Fatalf("gocross-wrapper.sh failed: %v\n%s", err, out) } if i > 0 && !strings.Contains(string(out), "gocross_ok=1\n") { - t.Errorf("expected to find 'gocross-ok=1'; got output:\n%s", out) + t.Errorf("expected to find 'gocross_ok=1'; got output:\n%s", out) } } } diff --git a/tool/gocross/gocross_wrapper_windows_test.go b/tool/gocross/gocross_wrapper_windows_test.go new file mode 100644 index 000000000..aa4277425 --- /dev/null +++ b/tool/gocross/gocross_wrapper_windows_test.go @@ -0,0 +1,25 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestGocrossWrapper(t *testing.T) { + for i := range 2 { // once to build gocross; second to test it's cached + cmd := exec.Command("pwsh", "-NoProfile", "-ExecutionPolicy", "Bypass", ".\\gocross-wrapper.ps1", "version") + cmd.Env = append(os.Environ(), "CI=true", "NOPWSHDEBUG=false", "TS_USE_GOCROSS=1") // for Set-PSDebug verbosity + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("gocross-wrapper.ps1 failed: %v\n%s", err, out) + } + if i > 0 && !strings.Contains(string(out), "$gocrossOk = $true\r\n") { + t.Errorf("expected to find '$gocrossOk = $true'; got output:\n%s", out) + } + } +} diff --git a/tool/gocross/toolchain.go b/tool/gocross/toolchain.go index f422e289e..9cf7f892b 100644 --- a/tool/gocross/toolchain.go +++ b/tool/gocross/toolchain.go @@ -60,7 +60,15 @@ func getToolchain() (toolchainDir, gorootDir string, err error) { return "", "", err } - cache := filepath.Join(os.Getenv("HOME"), ".cache") + homeDir, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + + // We use ".cache" instead of os.UserCacheDir for legacy reasons and we + // don't want to break that on platforms where the latter returns a different + // result. + cache := filepath.Join(homeDir, ".cache") toolchainDir = filepath.Join(cache, "tsgo", rev) gorootDir = filepath.Join(cache, "tsgoroot", rev)