tool/gocross: a tool for building Tailscale binaries

Signed-off-by: David Anderson <danderson@tailscale.com>
pull/7344/head
David Anderson 1 year ago committed by Dave Anderson
parent 0b8f89c79c
commit 860734aed9

2
.gitignore vendored

@ -34,3 +34,5 @@ cmd/tailscaled/tailscaled
# Ignore direnv nix-shell environment cache
.direnv/
/gocross

@ -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}" "$@"
exec "$(dirname "$0")/../tool/gocross/gocross-wrapper.sh" "$@"

@ -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()
}

@ -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: "<hostos>_<hostarch>_to_<targetos>_<targetarch>_<anything else?>"
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=riscv64 (was riscv64)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=freebsd (was freebsd)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=0 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=windows (was windows)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=darwin (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was amd64)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=darwin (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=arm64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=ios (was ios)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=1 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS=-mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
GOARCH=amd64 (was amd64)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=darwin (was darwin)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/special/toolchain/path (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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 <nil>)
CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
CGO_ENABLED=1 (was <nil>)
CGO_LDFLAGS= (was <nil>)
GOARCH=amd64 (was <nil>)
GOARM=5 (was <nil>)
GOMIPS=softfloat (was <nil>)
GOOS=linux (was <nil>)
GOROOT=/goroot (was <nil>)
TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
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))
}
})
}
}

@ -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 <nil>)", k, v))
}
}
for k := range e.unset {
old, ok := e.init[k]
if ok {
lines = append(lines, fmt.Sprintf("%s=<nil> (was %s)", k, old))
} else {
lines = append(lines, fmt.Sprintf("%s=<nil> (was <nil>)", k))
}
}
sort.Strings(lines)
return strings.Join(lines, "\n")
}

@ -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 <nil>)"
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 <nil>)
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 <nil>)
FOO=<nil> (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)
}
}

@ -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()
}

@ -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)
}

@ -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" "$@"

@ -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")
}

@ -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
}

@ -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
}
Loading…
Cancel
Save