From 860734aed90a93d520f76c8c1c532a338e936d1f Mon Sep 17 00:00:00 2001 From: David Anderson Date: Tue, 21 Feb 2023 16:07:33 -0800 Subject: [PATCH] tool/gocross: a tool for building Tailscale binaries Signed-off-by: David Anderson --- .gitignore | 2 + tool/go | 79 +----- tool/gocross/autoflags.go | 183 ++++++++++++++ tool/gocross/autoflags_test.go | 409 ++++++++++++++++++++++++++++++++ tool/gocross/env.go | 131 ++++++++++ tool/gocross/env_test.go | 99 ++++++++ tool/gocross/exec_other.go | 20 ++ tool/gocross/exec_unix.go | 12 + tool/gocross/gocross-wrapper.sh | 72 ++++++ tool/gocross/gocross.go | 132 +++++++++++ tool/gocross/goroot.go | 90 +++++++ tool/gocross/toolchain.go | 173 ++++++++++++++ 12 files changed, 1324 insertions(+), 78 deletions(-) create mode 100644 tool/gocross/autoflags.go create mode 100644 tool/gocross/autoflags_test.go create mode 100644 tool/gocross/env.go create mode 100644 tool/gocross/env_test.go create mode 100644 tool/gocross/exec_other.go create mode 100644 tool/gocross/exec_unix.go create mode 100755 tool/gocross/gocross-wrapper.sh create mode 100644 tool/gocross/gocross.go create mode 100644 tool/gocross/goroot.go create mode 100644 tool/gocross/toolchain.go diff --git a/.gitignore b/.gitignore index 4cd6d3a01..c824cdf10 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ cmd/tailscaled/tailscaled # Ignore direnv nix-shell environment cache .direnv/ + +/gocross diff --git a/tool/go b/tool/go index 776852023..1c53683d5 100755 --- a/tool/go +++ b/tool/go @@ -4,81 +4,4 @@ # currently-desired version from https://github.com/tailscale/go, # downloading it first if necessary. -set -eu - -log() { - echo "$@" >&2 -} - -DEFAULT_TOOLCHAIN_DIR="${HOME}/.cache/tailscale-go" -TOOLCHAIN="${TOOLCHAIN-${DEFAULT_TOOLCHAIN_DIR}}" -TOOLCHAIN_GO="${TOOLCHAIN}/bin/go" -read -r REV < "$(dirname "$0")/../go.toolchain.rev" - -# Fast, quiet path, when Tailscale is already current. -if [ -e "${TOOLCHAIN_GO}" ]; then - short_hash=$("${TOOLCHAIN_GO}" version | sed 's/.*-ts//; s/ .*//') - case $REV in - "$short_hash"*) - unset GOROOT - exec "${TOOLCHAIN_GO}" "$@" - esac -fi - -# This works for linux and darwin, which is sufficient -# (we do not build tailscale-go for other targets). -host_os=$(uname -s | tr A-Z a-z) -host_arch="$(uname -m)" -if [ "$host_arch" = "aarch64" ]; then - # Go uses the name "arm64". - host_arch="arm64" -elif [ "$host_arch" = "x86_64" ]; then - # Go uses the name "amd64". - host_arch="amd64" -fi - -get_cached() { - if [ ! -d "$TOOLCHAIN" ]; then - mkdir -p "$TOOLCHAIN" - fi - - archive="$TOOLCHAIN-$REV.tar.gz" - mark="$TOOLCHAIN.extracted" - extracted= - - # Ignore the error from read, which may error if the mark file does not contain a line end. - read -r extracted < "$mark" || true - - if [ "$extracted" = "$REV" ] && [ -e "${TOOLCHAIN_GO}" ]; then - # already ok - log "Go toolchain '$REV' already extracted." - return 0 - fi - - rm -f "$archive.new" "$TOOLCHAIN.extracted" - if [ ! -e "$archive" ]; then - log "Need to download go '$REV'." - curl -f -L -o "$archive.new" "https://github.com/tailscale/go/releases/download/build-${REV}/${host_os}-${host_arch}.tar.gz" - rm -f "$archive" - mv "$archive.new" "$archive" - fi - - log "Extracting tailscale/go rev '$REV'" >&2 - log " into '$TOOLCHAIN'." >&2 - rm -rf "$TOOLCHAIN" - mkdir -p "$TOOLCHAIN" - (cd "$TOOLCHAIN" && tar --strip-components=1 -xf "$archive") - echo "$REV" >$mark -} - -if [ "${REV}" = "SKIP" ] || - [ "${host_os}" != "darwin" -a "${host_os}" != "linux" ] || - [ "${host_arch}" != "amd64" -a "${host_arch}" != "arm64" ]; then - # Use whichever go is available - exec go "$@" -else - get_cached -fi - -unset GOROOT -exec "${TOOLCHAIN_GO}" "$@" \ No newline at end of file +exec "$(dirname "$0")/../tool/gocross/gocross-wrapper.sh" "$@" diff --git a/tool/gocross/autoflags.go b/tool/gocross/autoflags.go new file mode 100644 index 000000000..07a553e32 --- /dev/null +++ b/tool/gocross/autoflags.go @@ -0,0 +1,183 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "runtime" + "strings" + + "tailscale.com/version/mkversion" +) + +// Autoflags adjusts the commandline argv into a new commandline +// newArgv and envvar alterations in env. +func Autoflags(argv []string, goroot string) (newArgv []string, env *Environment, err error) { + return autoflagsForTest(argv, NewEnvironment(), goroot, runtime.GOOS, runtime.GOARCH, mkversion.Info) +} + +func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativeGOARCH string, getVersion func() mkversion.VersionInfo) (newArgv []string, newEnv *Environment, err error) { + // This is where all our "automatic flag injection" decisions get + // made. Modifying this code will modify the environment variables + // and commandline flags that the final `go` tool invocation will + // receive. + // + // When choosing between making this code concise or readable, + // please err on the side of being readable. Our build + // environments are relatively complicated by Go standards, and we + // want to keep it intelligible and malleable for our future + // selves. + var ( + subcommand = "" + + targetOS = env.Get("GOOS", nativeGOOS) + targetArch = env.Get("GOARCH", nativeGOARCH) + buildFlags = []string{"-trimpath"} + cgoCflags = []string{"-O3", "-std=gnu11"} + cgoLdflags []string + ldflags []string + tags = []string{"tailscale_go"} + cgo = false + failReflect = false + ) + if len(argv) > 1 { + subcommand = argv[1] + } + + switch subcommand { + case "build", "env", "install", "run", "test", "list": + default: + return argv, env, nil + } + + vi := getVersion() + ldflags = []string{ + "-X", "tailscale.com/version.longStamp=" + vi.Long, + "-X", "tailscale.com/version.shortStamp=" + vi.Short, + "-X", "tailscale.com/version.gitCommitStamp=" + vi.GitHash, + "-X", "tailscale.com/version.extraGitCommitStamp=" + vi.OtherHash, + } + + switch targetOS { + case "linux": + // Getting Go to build a static binary with cgo enabled is a + // minor ordeal. The incantations you apparently need are + // documented at: https://github.com/golang/go/issues/26492 + tags = append(tags, "osusergo", "netgo") + cgo = targetOS == nativeGOOS && targetArch == nativeGOARCH + // When in a Nix environment, the gcc package is built with only dynamic + // versions of glibc. You can get a static version of glibc via + // pkgs.glibc.static, but then you are reliant on Nix's gcc wrapper + // magic to inject that as a -L path to linker invocations. + // + // We can't rely on that magic linker flag injection, because that + // injection breaks redo's go machinery for dynamic go+cgo linking due + // to flag ordering issues that we can't easily fix (since the nix + // machinery controls the flag ordering, not us). + // + // So, instead, we unset NIX_LDFLAGS in our nix shell, which disables + // the magic linker flag passing; and we have shell.nix drop the path to + // the static glibc files in GOCROSS_GLIBC_DIR. Finally, we reinject it + // into the build process here, so that the linker can find static glibc + // and complete a static-with-cgo linkage. + extldflags := []string{"-static"} + if glibcDir := env.Get("GOCROSS_GLIBC_DIR", ""); glibcDir != "" { + extldflags = append(extldflags, "-L", glibcDir) + } + // -extldflags, when it contains multiple external linker flags, must be + // quoted in its entirety as a member of -ldflags. Source: + // https://github.com/golang/go/issues/6234 + ldflags = append(ldflags, fmt.Sprintf("'-extldflags=%s'", strings.Join(extldflags, " "))) + case "windowsgui": + // Fake GOOS that translates to "windows, but building GUI .exes not console .exes" + targetOS = "windows" + ldflags = append(ldflags, "-H", "windowsgui", "-s") + case "windows": + ldflags = append(ldflags, "-H", "windows", "-s") + case "ios": + failReflect = true + fallthrough + case "darwin": + cgo = nativeGOOS == "darwin" + tags = append(tags, "omitidna", "omitpemdecrypt") + if env.IsSet("XCODE_VERSION_ACTUAL") { + var xcodeFlags []string + // Minimum OS version being targeted, results in + // e.g. -mmacosx-version-min=11.3 + minOSKey := env.Get("DEPLOYMENT_TARGET_CLANG_FLAG_NAME", "") + minOSVal := env.Get(env.Get("DEPLOYMENT_TARGET_CLANG_ENV_NAME", ""), "") + xcodeFlags = append(xcodeFlags, fmt.Sprintf("-%s=%s", minOSKey, minOSVal)) + + // Target-specific SDK directory. Must be passed as two + // words ("-isysroot PATH", not "-isysroot=PATH"). + xcodeFlags = append(xcodeFlags, "-isysroot", env.Get("SDKROOT", "")) + + // What does clang call the target GOARCH? + var clangArch string + switch targetArch { + case "amd64": + clangArch = "x86_64" + case "arm64": + clangArch = "arm64" + default: + return nil, nil, fmt.Errorf("unsupported GOARCH=%q when building from Xcode", targetArch) + } + xcodeFlags = append(xcodeFlags, "-arch", clangArch) + cgoCflags = append(cgoCflags, xcodeFlags...) + cgoLdflags = append(cgoLdflags, xcodeFlags...) + ldflags = append(ldflags, "-w") + } + } + + // Finished computing the settings we want. Generate the modified + // commandline and environment modifications. + newArgv = append(newArgv, argv[:2]...) // Program name and `go` tool subcommand + newArgv = append(newArgv, buildFlags...) + if len(tags) > 0 { + newArgv = append(newArgv, fmt.Sprintf("-tags=%s", strings.Join(tags, ","))) + } + if len(ldflags) > 0 { + newArgv = append(newArgv, "-ldflags", strings.Join(ldflags, " ")) + } + newArgv = append(newArgv, argv[2:]...) + + env.Set("GOOS", targetOS) + env.Set("GOARCH", targetArch) + env.Set("GOARM", "5") // TODO: fix, see go/internal-bug/3092 + env.Set("GOMIPS", "softfloat") + env.Set("CGO_ENABLED", boolStr(cgo)) + env.Set("CGO_CFLAGS", strings.Join(cgoCflags, " ")) + env.Set("CGO_LDFLAGS", strings.Join(cgoLdflags, " ")) + env.Set("CC", "cc") + env.Set("TS_LINK_FAIL_REFLECT", boolStr(failReflect)) + env.Set("GOROOT", goroot) + + if subcommand == "env" { + return argv, env, nil + } + + return newArgv, env, nil +} + +// boolStr formats v as a string 0 or 1. +// Used because CGO_ENABLED doesn't strconv.ParseBool, so +// strconv.FormatBool breaks. +func boolStr(v bool) string { + if v { + return "1" + } + return "0" +} + +// formatArgv formats a []string similarly to %v, but quotes each +// string so that the reader can clearly see each array element. +func formatArgv(v []string) string { + var ret strings.Builder + ret.WriteByte('[') + for _, s := range v { + fmt.Fprintf(&ret, "%q ", s) + } + ret.WriteByte(']') + return ret.String() +} diff --git a/tool/gocross/autoflags_test.go b/tool/gocross/autoflags_test.go new file mode 100644 index 000000000..b4712dc3d --- /dev/null +++ b/tool/gocross/autoflags_test.go @@ -0,0 +1,409 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "reflect" + "testing" + + "tailscale.com/version/mkversion" +) + +var fakeVersion = mkversion.VersionInfo{ + Short: "1.2.3", + Long: "1.2.3-long", + GitHash: "abcd", + OtherHash: "defg", + Xcode: "100.2.3", + Winres: "1,2,3,0", +} + +func TestAutoflags(t *testing.T) { + tests := []struct { + // name convention: "__to___" + name string + env map[string]string + argv []string + goroot string + nativeGOOS string + nativeGOARCH string + + wantEnv map[string]string + envDiff string + wantArgv []string + }{ + { + name: "linux_amd64_to_linux_amd64", + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "./cmd/tailcontrol", + }, + }, + { + name: "install_linux_amd64_to_linux_amd64", + argv: []string{"gocross", "install", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "install", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_linux_riscv64", + env: map[string]string{ + "GOARCH": "riscv64", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=0 (was ) +CGO_LDFLAGS= (was ) +GOARCH=riscv64 (was riscv64) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_freebsd_amd64", + env: map[string]string{ + "GOOS": "freebsd", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=0 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=freebsd (was freebsd) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_linux_amd64_race", + argv: []string{"gocross", "test", "-race", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "test", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "-race", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_windows_amd64", + env: map[string]string{ + "GOOS": "windows", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=0 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=windows (was windows) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -H windows -s", + "./cmd/tailcontrol", + }, + }, + { + name: "darwin_arm64_to_darwin_arm64", + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "darwin", + nativeGOARCH: "arm64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=arm64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=darwin (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,omitidna,omitpemdecrypt", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", + "./cmd/tailcontrol", + }, + }, + { + name: "darwin_arm64_to_darwin_amd64", + env: map[string]string{ + "GOARCH": "amd64", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "darwin", + nativeGOARCH: "arm64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was amd64) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=darwin (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,omitidna,omitpemdecrypt", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", + "./cmd/tailcontrol", + }, + }, + { + name: "darwin_arm64_to_ios_arm64", + env: map[string]string{ + "GOOS": "ios", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "darwin", + nativeGOARCH: "arm64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=arm64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=ios (was ios) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=1 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,omitidna,omitpemdecrypt", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg", + "./cmd/tailcontrol", + }, + }, + { + name: "darwin_arm64_to_darwin_amd64_xcode", + env: map[string]string{ + "GOOS": "darwin", + "GOARCH": "amd64", + "XCODE_VERSION_ACTUAL": "1300", + "DEPLOYMENT_TARGET_CLANG_FLAG_NAME": "mmacosx-version-min", + "MACOSX_DEPLOYMENT_TARGET": "11.3", + "DEPLOYMENT_TARGET_CLANG_ENV_NAME": "MACOSX_DEPLOYMENT_TARGET", + "SDKROOT": "/my/sdk/root", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "darwin", + nativeGOARCH: "arm64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS=-mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was ) +GOARCH=amd64 (was amd64) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=darwin (was darwin) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,omitidna,omitpemdecrypt", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -w", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_linux_amd64_in_goroot", + argv: []string{"go", "build", "./cmd/tailcontrol"}, + goroot: "/special/toolchain/path", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/special/toolchain/path (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "go", "build", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_list_amd64_to_linux_amd64", + argv: []string{"gocross", "list", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "list", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'", + "./cmd/tailcontrol", + }, + }, + { + name: "linux_amd64_to_linux_amd64_with_extra_glibc_path", + env: map[string]string{ + "GOCROSS_GLIBC_DIR": "/my/glibc/path", + }, + argv: []string{"gocross", "build", "./cmd/tailcontrol"}, + goroot: "/goroot", + nativeGOOS: "linux", + nativeGOARCH: "amd64", + + envDiff: `CC=cc (was ) +CGO_CFLAGS=-O3 -std=gnu11 (was ) +CGO_ENABLED=1 (was ) +CGO_LDFLAGS= (was ) +GOARCH=amd64 (was ) +GOARM=5 (was ) +GOMIPS=softfloat (was ) +GOOS=linux (was ) +GOROOT=/goroot (was ) +TS_LINK_FAIL_REFLECT=0 (was )`, + wantArgv: []string{ + "gocross", "build", + "-trimpath", + "-tags=tailscale_go,osusergo,netgo", + "-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static -L /my/glibc/path'", + "./cmd/tailcontrol", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + getver := func() mkversion.VersionInfo { return fakeVersion } + env := newEnvironmentForTest(test.env, nil, nil) + + gotArgv, env, err := autoflagsForTest(test.argv, env, test.goroot, test.nativeGOOS, test.nativeGOARCH, getver) + if err != nil { + t.Fatalf("newAutoflagsForTest failed: %v", err) + } + + if diff := env.Diff(); diff != test.envDiff { + t.Errorf("wrong environment diff, got:\n%s\n\nwant:\n%s", diff, test.envDiff) + } + if !reflect.DeepEqual(gotArgv, test.wantArgv) { + t.Errorf("wrong argv:\n got : %s\n want: %s", formatArgv(gotArgv), formatArgv(test.wantArgv)) + } + }) + } +} diff --git a/tool/gocross/env.go b/tool/gocross/env.go new file mode 100644 index 000000000..9d8a4f1b3 --- /dev/null +++ b/tool/gocross/env.go @@ -0,0 +1,131 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// Environment starts from an initial set of environment variables, and tracks +// mutations to the environment. It can then apply those mutations to the +// environment, or produce debugging output that illustrates the changes it +// would make. +type Environment struct { + init map[string]string + set map[string]string + unset map[string]bool + + setenv func(string, string) error + unsetenv func(string) error +} + +// NewEnvironment returns an Environment initialized from os.Environ. +func NewEnvironment() *Environment { + init := map[string]string{} + for _, env := range os.Environ() { + fs := strings.SplitN(env, "=", 2) + if len(fs) != 2 { + panic("bad environ provided") + } + init[fs[0]] = fs[1] + } + + return newEnvironmentForTest(init, os.Setenv, os.Unsetenv) +} + +func newEnvironmentForTest(init map[string]string, setenv func(string, string) error, unsetenv func(string) error) *Environment { + return &Environment{ + init: init, + set: map[string]string{}, + unset: map[string]bool{}, + setenv: setenv, + unsetenv: unsetenv, + } +} + +// Set sets the environment variable k to v. +func (e *Environment) Set(k, v string) { + e.set[k] = v + delete(e.unset, k) +} + +// Unset removes the environment variable k. +func (e *Environment) Unset(k string) { + delete(e.set, k) + e.unset[k] = true +} + +// IsSet reports whether the environment variable k is set. +func (e *Environment) IsSet(k string) bool { + if e.unset[k] { + return false + } + if _, ok := e.init[k]; ok { + return true + } + if _, ok := e.set[k]; ok { + return true + } + return false +} + +// Get returns the value of the environment variable k, or defaultVal if it is +// not set. +func (e *Environment) Get(k, defaultVal string) string { + if e.unset[k] { + return defaultVal + } + if v, ok := e.set[k]; ok { + return v + } + if v, ok := e.init[k]; ok { + return v + } + return defaultVal +} + +// Apply applies all pending mutations to the environment. +func (e *Environment) Apply() error { + for k, v := range e.set { + if err := e.setenv(k, v); err != nil { + return fmt.Errorf("setting %q: %v", k, err) + } + e.init[k] = v + delete(e.set, k) + } + for k := range e.unset { + if err := e.unsetenv(k); err != nil { + return fmt.Errorf("unsetting %q: %v", k, err) + } + delete(e.init, k) + delete(e.unset, k) + } + return nil +} + +// Diff returns a string describing the pending mutations to the environment. +func (e *Environment) Diff() string { + lines := make([]string, 0, len(e.set)+len(e.unset)) + for k, v := range e.set { + old, ok := e.init[k] + if ok { + lines = append(lines, fmt.Sprintf("%s=%s (was %s)", k, v, old)) + } else { + lines = append(lines, fmt.Sprintf("%s=%s (was )", k, v)) + } + } + for k := range e.unset { + old, ok := e.init[k] + if ok { + lines = append(lines, fmt.Sprintf("%s= (was %s)", k, old)) + } else { + lines = append(lines, fmt.Sprintf("%s= (was )", k)) + } + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} diff --git a/tool/gocross/env_test.go b/tool/gocross/env_test.go new file mode 100644 index 000000000..001487bb8 --- /dev/null +++ b/tool/gocross/env_test.go @@ -0,0 +1,99 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestEnv(t *testing.T) { + + var ( + init = map[string]string{ + "FOO": "bar", + } + + wasSet = map[string]string{} + wasUnset = map[string]bool{} + + setenv = func(k, v string) error { + wasSet[k] = v + return nil + } + unsetenv = func(k string) error { + wasUnset[k] = true + return nil + } + ) + + env := newEnvironmentForTest(init, setenv, unsetenv) + + if got, want := env.Get("FOO", ""), "bar"; got != want { + t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) + } + if got, want := env.IsSet("FOO"), true; got != want { + t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) + } + + if got, want := env.Get("BAR", "defaultVal"), "defaultVal"; got != want { + t.Errorf(`env.Get("BAR") = %q, want %q`, got, want) + } + if got, want := env.IsSet("BAR"), false; got != want { + t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want) + } + + env.Set("BAR", "quux") + if got, want := env.Get("BAR", ""), "quux"; got != want { + t.Errorf(`env.Get("BAR") = %q, want %q`, got, want) + } + if got, want := env.IsSet("BAR"), true; got != want { + t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want) + } + diff := "BAR=quux (was )" + if got := env.Diff(); got != diff { + t.Errorf("env.Diff() = %q, want %q", got, diff) + } + + env.Set("FOO", "foo2") + if got, want := env.Get("FOO", ""), "foo2"; got != want { + t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) + } + if got, want := env.IsSet("FOO"), true; got != want { + t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) + } + diff = `BAR=quux (was ) +FOO=foo2 (was bar)` + if got := env.Diff(); got != diff { + t.Errorf("env.Diff() = %q, want %q", got, diff) + } + + env.Unset("FOO") + if got, want := env.Get("FOO", "default"), "default"; got != want { + t.Errorf(`env.Get("FOO") = %q, want %q`, got, want) + } + if got, want := env.IsSet("FOO"), false; got != want { + t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want) + } + diff = `BAR=quux (was ) +FOO= (was bar)` + if got := env.Diff(); got != diff { + t.Errorf("env.Diff() = %q, want %q", got, diff) + } + + if err := env.Apply(); err != nil { + t.Fatalf("env.Apply() failed: %v", err) + } + + wantSet := map[string]string{"BAR": "quux"} + wantUnset := map[string]bool{"FOO": true} + + if diff := cmp.Diff(wasSet, wantSet); diff != "" { + t.Errorf("env.Apply didn't set as expected (-got+want):\n%s", diff) + } + if diff := cmp.Diff(wasUnset, wantUnset); diff != "" { + t.Errorf("env.Apply didn't unset as expected (-got+want):\n%s", diff) + } +} diff --git a/tool/gocross/exec_other.go b/tool/gocross/exec_other.go new file mode 100644 index 000000000..8d4df0db3 --- /dev/null +++ b/tool/gocross/exec_other.go @@ -0,0 +1,20 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !unix + +package main + +import ( + "os" + "os/exec" +) + +func doExec(cmd string, args []string, env []string) error { + c := exec.Command(cmd, args...) + c.Env = env + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} diff --git a/tool/gocross/exec_unix.go b/tool/gocross/exec_unix.go new file mode 100644 index 000000000..79cbf764a --- /dev/null +++ b/tool/gocross/exec_unix.go @@ -0,0 +1,12 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build unix + +package main + +import "golang.org/x/sys/unix" + +func doExec(cmd string, args []string, env []string) error { + return unix.Exec(cmd, args, env) +} diff --git a/tool/gocross/gocross-wrapper.sh b/tool/gocross/gocross-wrapper.sh new file mode 100755 index 000000000..9ebfb9610 --- /dev/null +++ b/tool/gocross/gocross-wrapper.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env sh +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause +# +# gocross-wrapper.sh is a wrapper that can be aliased to 'go', which +# transparently builds gocross using a "bootstrap" Go toolchain, and +# then invokes gocross. + +set -eu + +if [ "${CI:-}" = "true" ]; then + set -x +fi + +repo_root="$(dirname $0)/../.." + +toolchain="$HOME/.cache/tailscale-go" + +if [ ! -d "$toolchain" ]; then + mkdir -p "$HOME/.cache" + + # We need any Go toolchain to build gocross, but the toolchain also has to + # be reasonably recent because we upgrade eagerly and gocross might not + # build with Go N-1. So, if we have no cached tailscale toolchain at all, + # fetch the initial one in shell. Once gocross is built, it'll manage + # updates. + read -r REV <$repo_root/go.toolchain.rev + + # This works for linux and darwin, which is sufficient + # (we do not build tailscale-go for other targets). + HOST_OS=$(uname -s | tr A-Z a-z) + HOST_ARCH="$(uname -m)" + if [ "$HOST_ARCH" = "aarch64" ]; then + # Go uses the name "arm64". + HOST_ARCH="arm64" + elif [ "$HOST_ARCH" = "x86_64" ]; then + # Go uses the name "amd64". + HOST_ARCH="amd64" + fi + + rm -rf "$toolchain" "$toolchain.extracted" + curl -f -L -o "$toolchain.tar.gz" "https://github.com/tailscale/go/releases/download/build-${REV}/${HOST_OS}-${HOST_ARCH}.tar.gz" + mkdir -p "$toolchain" + (cd "$toolchain" && tar --strip-components=1 -xf "$toolchain.tar.gz") + echo "$REV" >"$toolchain.extracted" +fi + +# Binaries run with `gocross run` can reinvoke gocross, resulting in a +# potentially fancy build that invokes external linkers, might be +# cross-building for other targets, and so forth. In one hilarious +# case, cmd/cloner invokes go with GO111MODULE=off at some stage. +# +# Anyway, build gocross in a stripped down universe. +gocross_path="$repo_root/gocross" +gocross_ok=0 +if [ -x "$gocross_path" ]; then + gotver="$($gocross_path gocross-version 2>/dev/null || echo '')" + wantver="$(git rev-parse HEAD)" + if [ "$gotver" = "$wantver" ]; then + gocross_ok=1 + fi +fi +if [ "$gocross_ok" = "0" ]; then + ( + unset GOOS + unset GOARCH + unset GO111MODULE + export CGO_ENABLED=0 + "$toolchain/bin/go" build -o "$gocross_path" tailscale.com/tool/gocross + ) +fi +exec "$gocross_path" "$@" diff --git a/tool/gocross/gocross.go b/tool/gocross/gocross.go new file mode 100644 index 000000000..b036b6be0 --- /dev/null +++ b/tool/gocross/gocross.go @@ -0,0 +1,132 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// gocross is a wrapper around the `go` tool that invokes `go` from Tailscale's +// custom toolchain, with the right build parameters injected based on the +// native+target GOOS/GOARCH. +// +// In short, when aliased to `go`, using `go build`, `go test` behave like the +// upstream Go tools, but produce correctly configured, correctly linked +// binaries stamped with version information. + +package main + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + runtimeDebug "runtime/debug" +) + +func main() { + if len(os.Args) > 1 { + // These additional subcommands are various support commands to handle + // integration with Tailscale's existing build system. Unless otherwise + // specified, these are not stable APIs, and may change or go away at + // any time. + switch os.Args[1] { + case "gocross-version": + hash, err := embeddedCommit() + if err != nil { + fmt.Fprintf(os.Stderr, "getting commit hash: %v", err) + os.Exit(1) + } + fmt.Println(hash) + os.Exit(0) + case "is-gocross": + // This subcommand exits with an error code when called on a + // regular go binary, so it can be used to detect when `go` is + // actually gocross. + os.Exit(0) + case "make-goroot": + _, gorootDir, err := getToolchain() + if err != nil { + fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err) + os.Exit(1) + } + + fmt.Println(gorootDir) + os.Exit(0) + case "gocross-get-toolchain-go": + toolchain, _, err := getToolchain() + if err != nil { + fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err) + os.Exit(1) + } + fmt.Println(filepath.Join(toolchain, "bin/go")) + os.Exit(0) + } + } + + toolchain, goroot, err := getToolchain() + if err != nil { + fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err) + os.Exit(1) + } + + args := os.Args + if os.Getenv("GOCROSS_BYPASS") == "" { + newArgv, env, err := Autoflags(os.Args, goroot) + if err != nil { + fmt.Fprintf(os.Stderr, "computing flags: %v\n", err) + os.Exit(1) + } + + // Make sure the right version of cmd/go is the first thing in the PATH + // for tests that execute `go build` or `go test`. + // TODO: if we really need to do this, do it inside Autoflags, not here. + path := filepath.Join(toolchain, "bin") + string(os.PathListSeparator) + os.Getenv("PATH") + env.Set("PATH", path) + + debug("Input: %s\n", formatArgv(os.Args)) + debug("Command: %s\n", formatArgv(newArgv)) + debug("Set the following flags/envvars:\n%s\n", env.Diff()) + + args = newArgv + if err := env.Apply(); err != nil { + fmt.Fprintf(os.Stderr, "modifying environment: %v\n", err) + os.Exit(1) + } + + } + + doExec(filepath.Join(toolchain, "bin/go"), args, os.Environ()) +} + +func debug(format string, args ...interface{}) { + debug := os.Getenv("GOCROSS_DEBUG") + var ( + out *os.File + err error + ) + switch debug { + case "0", "": + return + case "1": + out = os.Stderr + default: + out, err = os.OpenFile(debug, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0640) + if err != nil { + fmt.Fprintf(os.Stderr, "opening debug file %q: %v", debug, err) + out = os.Stderr + } else { + defer out.Close() // May lose some write errors, but we don't care. + } + } + + fmt.Fprintf(out, format, args...) +} + +func embeddedCommit() (string, error) { + bi, ok := runtimeDebug.ReadBuildInfo() + if !ok { + return "", fmt.Errorf("no build info") + } + for _, s := range bi.Settings { + if s.Key == "vcs.revision" { + return s.Value, nil + } + } + return "", fmt.Errorf("no git commit found") +} diff --git a/tool/gocross/goroot.go b/tool/gocross/goroot.go new file mode 100644 index 000000000..58d025da5 --- /dev/null +++ b/tool/gocross/goroot.go @@ -0,0 +1,90 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// makeGoroot constructs a GOROOT-like file structure in outPath, +// which consists of toolchainRoot except for the `go` binary, which +// points to gocross. +// +// It's useful for integrating with tooling that expects to be handed +// a GOROOT, like the Goland IDE or depaware. +func makeGoroot(toolchainRoot, outPath string) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("getting gocross's path: %v", err) + } + + os.RemoveAll(outPath) + if err := os.MkdirAll(filepath.Join(outPath, "bin"), 0750); err != nil { + return fmt.Errorf("making %q: %v", outPath, err) + } + if err := os.Symlink(self, filepath.Join(outPath, "bin/go")); err != nil { + return fmt.Errorf("linking gocross into outpath: %v", err) + } + + if err := linkFarm(toolchainRoot, outPath); err != nil { + return fmt.Errorf("creating GOROOT link farm: %v", err) + } + if err := linkFarm(filepath.Join(toolchainRoot, "bin"), filepath.Join(outPath, "bin")); err != nil { + return fmt.Errorf("creating GOROOT/bin link farm: %v", err) + } + + return nil +} + +func copyFile(src, dst string) error { + s, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening %q: %v", src, err) + } + defer s.Close() + + d, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE, 0755) + if err != nil { + return fmt.Errorf("opening %q: %v", dst, err) + } + + if _, err := io.Copy(d, s); err != nil { + d.Close() + return fmt.Errorf("copying %q to %q: %v", src, dst, err) + } + + if err := d.Close(); err != nil { + return fmt.Errorf("closing %q: %v", dst, err) + } + + return nil +} + +// linkFarm symlinks every entry in srcDir into outDir, unless that +// directory entry already exists. +func linkFarm(srcDir, outDir string) error { + ents, err := os.ReadDir(srcDir) + if err != nil { + return fmt.Errorf("reading %q: %v", srcDir, err) + } + + for _, ent := range ents { + dst := filepath.Join(outDir, ent.Name()) + _, err := os.Lstat(dst) + if errors.Is(err, fs.ErrNotExist) { + if err := os.Symlink(filepath.Join(srcDir, ent.Name()), dst); err != nil { + return fmt.Errorf("symlinking %q to %q: %v", ent.Name(), outDir, err) + } + } else if err != nil { + return fmt.Errorf("stat-ing %q: %v", dst, err) + } + } + + return nil +} diff --git a/tool/gocross/toolchain.go b/tool/gocross/toolchain.go new file mode 100644 index 000000000..67ea7c5c5 --- /dev/null +++ b/tool/gocross/toolchain.go @@ -0,0 +1,173 @@ +// 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) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting CWD: %v", err) + } + d := cwd +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", cwd) + } + } + + return readRevFile(filepath.Join(d, "go.toolchain.rev")) +} + +func readRevFile(path string) (string, error) { + bs, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(bytes.TrimSpace(bs)), nil +} + +func getToolchain() (toolchainDir, gorootDir string, err error) { + cache := filepath.Join(os.Getenv("HOME"), ".cache") + toolchainDir = filepath.Join(cache, "tailscale-go") + gorootDir = filepath.Join(toolchainDir, "gocross-goroot") + + // 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 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 +}