Files
ReleaseKit/internal/gotool/gotool.go

268 lines
8.2 KiB
Go

// 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
}
// PrepareBuildDependencies downloads module dependencies with the available
// local Go toolchain. It preserves caller module-network settings and caches,
// while disabling workspace discovery and automatic toolchain downloads.
func PrepareBuildDependencies(ctx context.Context, directory string) error {
captured := &combinedWriter{}
err := process.Run(ctx, process.Invocation{
Args: []string{"go", "mod", "download"},
Dir: directory,
Env: map[string]string{
"GOWORK": "off",
"GOTOOLCHAIN": "local",
},
Stdout: captured,
Stderr: captured,
})
if err != nil {
if output := strings.TrimSpace(string(captured.take())); output != "" {
return fmt.Errorf("go mod download: %w: %s", err, output)
}
return fmt.Errorf("go mod download: %w", err)
}
return 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.
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) error {
_, err := tool.execute(ctx, directory, mode, target, output, false, args...)
return err
}
// Output executes a Go command and returns its combined stdout and stderr.
// Output, when supplied, also receives the bytes as the command runs.
func (tool *Tool) Output(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
return tool.execute(ctx, directory, mode, target, output, true, args...)
}
func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, keepOutput bool, 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,
})
if forwardErr := captured.forwardingError(); forwardErr != nil {
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
}
contents := captured.take()
if err != nil {
if len(contents) != 0 {
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
}
return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
}
return outputBytes(contents, keepOutput), nil
}
func outputBytes(contents []byte, keep bool) []byte {
if keep {
return contents
}
return 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) {
var configuration, diagnostics bytes.Buffer
err := process.Run(ctx, process.Invocation{
Args: args,
Dir: directory,
Env: environment,
Stdout: &configuration,
Stderr: &diagnostics,
})
contents := configuration.Bytes()
if err != nil {
if output := strings.TrimSpace(configuration.String() + diagnostics.String()); output != "" {
return contents, fmt.Errorf("%s: %w: %s", strings.Join(args, " "), err, output)
}
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
forwardErr error
}
func (writer *combinedWriter) Write(contents []byte) (int, error) {
writer.mu.Lock()
defer writer.mu.Unlock()
if writer.output == nil {
return writer.buffer.Write(contents)
}
if _, err := writer.buffer.Write(contents); err != nil {
return 0, err
}
written, err := writer.output.Write(contents)
if err == nil && written != len(contents) {
err = io.ErrShortWrite
}
if err != nil && writer.forwardErr == nil {
writer.forwardErr = err
}
return written, err
}
func (writer *combinedWriter) forwardingError() error {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.forwardErr
}
// take transfers the completed capture to the caller. It must only be used
// after the child process has stopped writing.
func (writer *combinedWriter) take() []byte {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.buffer.Bytes()
}