// 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()...) }