tailscale/tool/gocross/toolchain.go
Aaron Klotz 02f6030dbd tool, tool/gocross: update gocross to support building natively on Windows and add a PowerShell Core wrapper script
gocross-wrapper.ps1 is a PowerShell core script that is essentially a
straight port of gocross-wrapper.sh. It requires PowerShell 7.4, which
is the latest LTS release of PSCore.

Why use PowerShell Core instead of Windows PowerShell? Essentially
because the former is much better to script with and is the edition
that is currently maintained.

Because we're using PowerShell Core, but many people will be running
scripts from a machine that only has Windows PowerShell, go.cmd has
been updated to prompt the user for PowerShell core installation if
necessary.

gocross-wrapper.sh has also been updated to utilize the PSCore script
when running under cygwin or msys.

gocross itself required a couple of updates:

We update gocross to output the PowerShell Core wrapper alongside the
bash wrapper, which will propagate the revised scripts to other repos
as necessary.

We also fix a couple of things in gocross that didn't work on Windows:
we change the toolchain resolution code to use os.UserHomeDir instead
of directly referencing the HOME environment variable, and we fix a
bug in the way arguments were being passed into exec.Command on
non-Unix systems.

Updates https://github.com/tailscale/corp/issues/29940

Signed-off-by: Aaron Klotz <aaron@tailscale.com>
2025-08-18 09:49:24 -06:00

203 lines
5.5 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
)
func toolchainRev() (string, error) {
// gocross gets built in the root of the repo that has toolchain
// information, so we can use os.Args[0] to locate toolchain info.
//
// We might be getting invoked via the synthetic goroot that we create, so
// walk symlinks to find the true location of gocross.
start, err := os.Executable()
if err != nil {
return "", err
}
start, err = filepath.EvalSymlinks(start)
if err != nil {
return "", fmt.Errorf("evaluating symlinks in %q: %v", os.Args[0], err)
}
start = filepath.Dir(start)
d := start
findTopLevel:
for {
if _, err := os.Lstat(filepath.Join(d, ".git")); err == nil {
break findTopLevel
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("finding .git: %v", err)
}
d = filepath.Dir(d)
if d == "/" {
return "", fmt.Errorf("couldn't find .git starting from %q, cannot manage toolchain", start)
}
}
return readRevFile(filepath.Join(d, "go.toolchain.rev"))
}
func readRevFile(path string) (string, error) {
bs, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(bytes.TrimSpace(bs)), nil
}
func getToolchain() (toolchainDir, gorootDir string, err error) {
rev, err := toolchainRev()
if err != nil {
return "", "", err
}
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)
// You might wonder why getting the toolchain also provisions and returns a
// path suitable for use as GOROOT. Wonder no longer!
//
// A bunch of our tests and build processes involve re-invoking 'go build'
// or other build-ish commands (install, run, ...). These typically use
// runtime.GOROOT + "bin/go" to get at the Go binary. Even more edge case-y,
// tailscale.com/cmd/tsconnect needs to fish a javascript glue file out of
// GOROOT in order to build the javascript bundle for serving.
//
// Gocross always does a -trimpath on builds for reproducibility, which
// wipes out the burned-in runtime.GOROOT value from the binary. This means
// that using gocross on these various test and build processes ends up
// breaking with mysterious path errors.
//
// We don't want to stop using -trimpath, or otherwise make GOROOT work in
// "normal" builds, because that is a footgun that lets people accidentally
// create assumptions that the build toolchain is still around at runtime.
// Instead, we want to make 'go test' and 'go run' have access to GOROOT,
// while still removing it from standalone binaries.
//
// So, construct and pass a GOROOT to the actual 'go' invocation, which lets
// tests and build processes locate and use GOROOT. For consistency, the
// GOROOT that's passed in is a symlink farm that mostly points to the
// toolchain's underlying GOROOT, but 'bin/go' points back to gocross. This
// means that if you invoke 'go test' via gocross, and that test tries to
// build code, that build will also end up using gocross.
if err := ensureToolchain(cache, toolchainDir); err != nil {
return "", "", err
}
if err := ensureGoroot(toolchainDir, gorootDir); err != nil {
return "", "", err
}
return toolchainDir, gorootDir, nil
}
func ensureToolchain(cacheDir, toolchainDir string) error {
stampFile := toolchainDir + ".extracted"
wantRev, err := toolchainRev()
if err != nil {
return err
}
gotRev, err := readRevFile(stampFile)
if err != nil {
return fmt.Errorf("reading stamp file %q: %v", stampFile, err)
}
if gotRev == wantRev {
// Toolchain already good.
return nil
}
if err := os.RemoveAll(toolchainDir); err != nil {
return err
}
if err := os.RemoveAll(stampFile); err != nil {
return err
}
if filepath.IsAbs(wantRev) {
// Local dev toolchain.
if err := os.Symlink(wantRev, toolchainDir); err != nil {
return err
}
return nil
} else {
if err := downloadCachedgo(toolchainDir, wantRev); err != nil {
return err
}
}
if err := os.WriteFile(stampFile, []byte(wantRev), 0644); err != nil {
return err
}
return nil
}
func ensureGoroot(toolchainDir, gorootDir string) error {
if _, err := os.Stat(gorootDir); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
return makeGoroot(toolchainDir, gorootDir)
}
func downloadCachedgo(toolchainDir, toolchainRev string) error {
url := fmt.Sprintf("https://github.com/tailscale/go/releases/download/build-%s/%s-%s.tar.gz", toolchainRev, runtime.GOOS, runtime.GOARCH)
archivePath := toolchainDir + ".tar.gz"
f, err := os.Create(archivePath)
if err != nil {
return err
}
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failed to get %q: %v", url, resp.Status)
}
if _, err := io.Copy(f, resp.Body); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.MkdirAll(toolchainDir, 0755); err != nil {
return err
}
cmd := exec.Command("tar", "--strip-components=1", "-xf", archivePath)
cmd.Dir = toolchainDir
if err := cmd.Run(); err != nil {
return err
}
if err := os.RemoveAll(archivePath); err != nil {
return err
}
return nil
}