// Package gotool owns Go configuration discovery and ReleaseKit's Go command // environments. package gotool import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "strings" "sync" "go.maximumdirect.net/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 settingsFile 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"` SettingsFile string `json:"GOENV"` } output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH", "GOENV") 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}, settingsFile: host.SettingsFile} 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{mode: captureStatus} err := process.Run(ctx, process.Invocation{ Args: []string{"go", "mod", "download", "-modfile="}, Dir: directory, Env: map[string]string{ "GOWORK": "off", "GOTOOLCHAIN": "local", }, Stdout: captured, Stderr: captured, }) if err != nil { omitted := captured.omitted() if output := diagnosticText(captured.take(), omitted); 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) modeCapture := captureStatus if keepOutput { modeCapture = captureFull } captured := &combinedWriter{output: output, mode: modeCapture} 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)) } if err == nil && !keepOutput { return nil, nil } omitted := captured.omitted() contents := captured.take() if err != nil { if diagnostic := diagnosticText(contents, omitted); diagnostic != "" { return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, diagnostic) } return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err) } return outputBytes(contents, keepOutput), nil } func diagnosticText(contents []byte, omitted bool) string { text := strings.TrimSpace(string(contents)) if omitted { return "[earlier Go output omitted]\n" + text } return text } 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 mode captureMode tail []byte head int truncated bool forwardErr error } type captureMode uint8 const ( captureFull captureMode = iota captureStatus statusDiagnosticLimit = 1 << 20 ) func (writer *combinedWriter) capture(contents []byte) { if writer.mode == captureFull { _, _ = writer.buffer.Write(contents) return } if len(contents) >= statusDiagnosticLimit { writer.truncated = writer.truncated || len(writer.tail) != 0 || len(contents) > statusDiagnosticLimit if cap(writer.tail) < statusDiagnosticLimit { writer.tail = make([]byte, statusDiagnosticLimit) } else { writer.tail = writer.tail[:statusDiagnosticLimit] } copy(writer.tail, contents[len(contents)-statusDiagnosticLimit:]) writer.head = 0 return } if available := statusDiagnosticLimit - len(writer.tail); available > 0 { n := min(available, len(contents)) if needed := len(writer.tail) + n; needed > cap(writer.tail) { capacity := min(statusDiagnosticLimit, max(needed, 2*cap(writer.tail))) grown := make([]byte, len(writer.tail), capacity) copy(grown, writer.tail) writer.tail = grown } writer.tail = append(writer.tail, contents[:n]...) contents = contents[n:] } if len(contents) == 0 { return } writer.truncated = true n := copy(writer.tail[writer.head:], contents) copy(writer.tail, contents[n:]) writer.head = (writer.head + len(contents)) % statusDiagnosticLimit } func (writer *combinedWriter) Write(contents []byte) (int, error) { writer.mu.Lock() defer writer.mu.Unlock() writer.capture(contents) if writer.output == nil { return len(contents), nil } 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) omitted() bool { writer.mu.Lock() defer writer.mu.Unlock() return writer.truncated } 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() if writer.mode == captureFull { contents := writer.buffer.Bytes() writer.buffer = bytes.Buffer{} return contents } contents := writer.tail if writer.head != 0 { contents = make([]byte, 0, len(writer.tail)) contents = append(contents, writer.tail[writer.head:]...) contents = append(contents, writer.tail[:writer.head]...) } writer.tail = nil writer.head = 0 return contents }