Centralize Go validation and build environments
This commit is contained in:
203
internal/gotool/gotool.go
Normal file
203
internal/gotool/gotool.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Package gotool owns Go configuration discovery and ReleaseKit's Go command
|
||||
// environments.
|
||||
package gotool
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
|
||||
)
|
||||
|
||||
// Mode selects the target environment for a Go command.
|
||||
type Mode uint8
|
||||
|
||||
const (
|
||||
// Native runs Go for the toolchain host and keeps its effective cgo settings.
|
||||
Native Mode = iota
|
||||
// Static runs Go for the selected target with cgo disabled.
|
||||
Static
|
||||
)
|
||||
|
||||
// Target selects a Go operating system and architecture.
|
||||
type Target struct {
|
||||
OS string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// Tool holds the host target and effective operational settings resolved from
|
||||
// the process environment and Go's persistent environment file.
|
||||
type Tool struct {
|
||||
host Target
|
||||
settings map[string]string
|
||||
}
|
||||
|
||||
var operationalSettings = []string{
|
||||
"CC", "CXX", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_CXXFLAGS", "CGO_ENABLED", "CGO_LDFLAGS",
|
||||
"GOCACHE", "GOCACHEPROG", "GOMODCACHE", "GOPATH", "GOROOT", "GOTMPDIR", "PKG_CONFIG",
|
||||
}
|
||||
|
||||
var architectureFeatures = []string{
|
||||
"GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM",
|
||||
}
|
||||
|
||||
// New discovers the Go toolchain host and its effective cache, tool, and cgo
|
||||
// settings. Discovery uses the selected local Go command and neutralizes
|
||||
// ambient flags, experiments, and architecture tuning so they cannot prevent
|
||||
// configuration from being read.
|
||||
func New(ctx context.Context, directory string) (*Tool, error) {
|
||||
var host struct {
|
||||
OS string `json:"GOHOSTOS"`
|
||||
Arch string `json:"GOHOSTARCH"`
|
||||
}
|
||||
output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("identify Go host: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(output, &host); err != nil {
|
||||
return nil, fmt.Errorf("decode Go host settings: %w", err)
|
||||
}
|
||||
if host.OS == "" || host.Arch == "" {
|
||||
return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}}
|
||||
|
||||
environment := discoveryEnvironment()
|
||||
environment["GOOS"] = tool.host.OS
|
||||
environment["GOARCH"] = tool.host.Arch
|
||||
args := append([]string{"go", "env", "-json"}, operationalSettings...)
|
||||
output, err = discover(ctx, directory, environment, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve effective Go settings: %w", err)
|
||||
}
|
||||
var settings map[string]string
|
||||
if err := json.Unmarshal(output, &settings); err != nil {
|
||||
return nil, fmt.Errorf("decode effective Go settings: %w", err)
|
||||
}
|
||||
for _, key := range operationalSettings {
|
||||
if _, ok := settings[key]; !ok {
|
||||
return nil, fmt.Errorf("Go did not report effective setting %s", key)
|
||||
}
|
||||
}
|
||||
tool.settings = settings
|
||||
return tool, nil
|
||||
}
|
||||
|
||||
// HostTarget reports the Go command's host operating system and architecture.
|
||||
func (tool *Tool) HostTarget() Target {
|
||||
return tool.host
|
||||
}
|
||||
|
||||
// Run executes a Go command with ReleaseKit's shared offline baseline. Native
|
||||
// commands use the host target and effective cgo configuration. Static commands
|
||||
// use target and disable cgo. Output, when supplied, receives stdout and stderr
|
||||
// as the command runs; the returned bytes retain both streams for diagnostics.
|
||||
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("Go command requires arguments")
|
||||
}
|
||||
environment := tool.environment(mode, target)
|
||||
captured := &combinedWriter{output: output}
|
||||
commandArgs := append([]string{"go"}, args...)
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: commandArgs,
|
||||
Dir: directory,
|
||||
Env: environment,
|
||||
Stdout: captured,
|
||||
Stderr: captured,
|
||||
})
|
||||
contents := captured.Bytes()
|
||||
if err != nil {
|
||||
if len(contents) != 0 {
|
||||
return contents, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
}
|
||||
return contents, fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func (tool *Tool) environment(mode Mode, target Target) map[string]string {
|
||||
if mode == Native || target.OS == "" || target.Arch == "" {
|
||||
target = tool.host
|
||||
}
|
||||
values := make(map[string]string, len(tool.settings)+len(architectureFeatures)+12)
|
||||
for key, value := range tool.settings {
|
||||
values[key] = value
|
||||
}
|
||||
values["GOENV"] = "off"
|
||||
values["GOFLAGS"] = "-mod=readonly"
|
||||
values["GOWORK"] = "off"
|
||||
values["GOTOOLCHAIN"] = "local"
|
||||
values["GOPROXY"] = "off"
|
||||
values["GONOPROXY"] = "none"
|
||||
values["GOSUMDB"] = "off"
|
||||
values["GOOS"] = target.OS
|
||||
values["GOARCH"] = target.Arch
|
||||
values["GOEXPERIMENT"] = ""
|
||||
for _, key := range architectureFeatures {
|
||||
values[key] = ""
|
||||
}
|
||||
if mode == Static {
|
||||
values["CGO_ENABLED"] = "0"
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func discoveryEnvironment() map[string]string {
|
||||
environment := map[string]string{
|
||||
"GOFLAGS": "-mod=readonly",
|
||||
"GOTOOLCHAIN": "local",
|
||||
"GOWORK": "off",
|
||||
"GOEXPERIMENT": "none",
|
||||
}
|
||||
for _, key := range architectureFeatures {
|
||||
environment[key] = ""
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
func discover(ctx context.Context, directory string, environment map[string]string, args ...string) ([]byte, error) {
|
||||
captured := &combinedWriter{}
|
||||
err := process.Run(ctx, process.Invocation{
|
||||
Args: args,
|
||||
Dir: directory,
|
||||
Env: environment,
|
||||
Stdout: captured,
|
||||
Stderr: captured,
|
||||
})
|
||||
contents := captured.Bytes()
|
||||
if err != nil {
|
||||
if len(contents) != 0 {
|
||||
return contents, fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
|
||||
}
|
||||
return contents, fmt.Errorf("%s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
type combinedWriter struct {
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
buffer bytes.Buffer
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Write(contents []byte) (int, error) {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
if writer.output == nil {
|
||||
return writer.buffer.Write(contents)
|
||||
}
|
||||
return io.MultiWriter(&writer.buffer, writer.output).Write(contents)
|
||||
}
|
||||
|
||||
func (writer *combinedWriter) Bytes() []byte {
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
return append([]byte(nil), writer.buffer.Bytes()...)
|
||||
}
|
||||
138
internal/gotool/gotool_test.go
Normal file
138
internal/gotool/gotool_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package gotool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNativeAndStaticCommandsUseOwnedBaseline(t *testing.T) {
|
||||
clearGoSettings(t)
|
||||
t.Setenv("GOENV", "off")
|
||||
t.Setenv("GOFLAGS", "-run=^$")
|
||||
t.Setenv("GOOS", "windows")
|
||||
t.Setenv("GOARCH", "arm64")
|
||||
t.Setenv("GOAMD64", "v2")
|
||||
t.Setenv("GOEXPERIMENT", "loopvar")
|
||||
|
||||
tool, err := New(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
|
||||
t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED")
|
||||
if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH {
|
||||
t.Fatalf("native environment = %#v", native)
|
||||
}
|
||||
if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" {
|
||||
t.Fatalf("native Go settings = %#v", native)
|
||||
}
|
||||
if native["CGO_ENABLED"] == "" {
|
||||
t.Fatalf("native cgo setting was not resolved: %#v", native)
|
||||
}
|
||||
|
||||
static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT")
|
||||
if static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" {
|
||||
t.Fatalf("static environment = %#v", static)
|
||||
}
|
||||
if static["GOFLAGS"] != "-mod=readonly" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" {
|
||||
t.Fatalf("static baseline settings = %#v", static)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
|
||||
clearGoSettings(t)
|
||||
cache := filepath.Join(t.TempDir(), "configured-cache")
|
||||
moduleCache := filepath.Join(t.TempDir(), "configured-module-cache")
|
||||
temporary := filepath.Join(t.TempDir(), "configured-temporary")
|
||||
if err := os.Mkdir(temporary, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goEnv := filepath.Join(t.TempDir(), "go-env")
|
||||
contents := strings.Join([]string{
|
||||
"GOFLAGS=-run=^$",
|
||||
"GOOS=windows",
|
||||
"GOARCH=arm64",
|
||||
"GOAMD64=v2",
|
||||
"GOEXPERIMENT=loopvar",
|
||||
"GOCACHE=" + cache,
|
||||
"GOMODCACHE=" + moduleCache,
|
||||
"GOTMPDIR=" + temporary,
|
||||
"CGO_ENABLED=1",
|
||||
"CC=releasekit-test-cc",
|
||||
"CXX=releasekit-test-cxx",
|
||||
"CGO_CFLAGS=-O0 -g",
|
||||
"PKG_CONFIG=releasekit-test-pkg-config",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(goEnv, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GOENV", goEnv)
|
||||
|
||||
tool, err := New(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool.HostTarget() != (Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
|
||||
t.Fatalf("Go host = %#v, want runtime host %s/%s", tool.HostTarget(), runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
native := readSettings(t, tool, Native, Target{}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "GOAMD64", "GOEXPERIMENT", "CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "GOCACHE", "GOMODCACHE", "GOTMPDIR", "PKG_CONFIG")
|
||||
if native["GOENV"] != "" || native["GOOS"] != runtime.GOOS || native["GOARCH"] != runtime.GOARCH {
|
||||
t.Fatalf("native environment = %#v", native)
|
||||
}
|
||||
if native["GOFLAGS"] != "-mod=readonly" || native["GOAMD64"] == "v2" || native["GOEXPERIMENT"] != "" {
|
||||
t.Fatalf("persisted overrides were not cleared: %#v", native)
|
||||
}
|
||||
if native["CGO_ENABLED"] != "1" || native["CC"] != "releasekit-test-cc" || native["CXX"] != "releasekit-test-cxx" || native["CGO_CFLAGS"] != "-O0 -g" || native["PKG_CONFIG"] != "releasekit-test-pkg-config" {
|
||||
t.Fatalf("persistent native C settings = %#v", native)
|
||||
}
|
||||
if native["GOCACHE"] != cache || native["GOMODCACHE"] != moduleCache || native["GOTMPDIR"] != temporary {
|
||||
t.Fatalf("persistent cache settings = %#v", native)
|
||||
}
|
||||
static := readSettings(t, tool, Static, Target{OS: "linux", Arch: "amd64"}, "GOENV", "GOOS", "GOARCH", "GOFLAGS", "CGO_ENABLED", "GOAMD64", "GOEXPERIMENT", "GOCACHE")
|
||||
if static["GOENV"] != "" || static["GOOS"] != "linux" || static["GOARCH"] != "amd64" || static["CGO_ENABLED"] != "0" || static["GOAMD64"] != "v1" || static["GOEXPERIMENT"] != "" || static["GOCACHE"] != cache {
|
||||
t.Fatalf("persistent static environment = %#v", static)
|
||||
}
|
||||
}
|
||||
|
||||
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
|
||||
t.Helper()
|
||||
args := append([]string{"env", "-json"}, names...)
|
||||
output, err := tool.Run(context.Background(), t.TempDir(), mode, target, nil, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var settings map[string]string
|
||||
if err := json.Unmarshal(output, &settings); err != nil {
|
||||
t.Fatalf("decode Go environment %q: %v", output, err)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func clearGoSettings(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{
|
||||
"GOENV", "GOFLAGS", "GOWORK", "GOTOOLCHAIN", "GOPROXY", "GONOPROXY", "GOSUMDB",
|
||||
"GOOS", "GOARCH", "GOEXPERIMENT", "GOAMD64", "GOARM", "GOARM64", "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM",
|
||||
"CGO_ENABLED", "CC", "CXX", "CGO_CFLAGS", "CGO_CPPFLAGS", "CGO_CXXFLAGS", "CGO_LDFLAGS", "GOCACHE", "GOCACHEPROG", "GOMODCACHE", "GOPATH", "GOROOT", "GOTMPDIR", "PKG_CONFIG",
|
||||
} {
|
||||
value, exists := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if exists {
|
||||
_ = os.Setenv(key, value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user