155 lines
5.0 KiB
Go
155 lines
5.0 KiB
Go
package gotool
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/mod/modfile"
|
|
|
|
"go.maximumdirect.net/releasekit/internal/process"
|
|
)
|
|
|
|
const releaseKitModule = "go.maximumdirect.net/releasekit"
|
|
const releaseKitCommand = releaseKitModule + "/cmd/releasekit"
|
|
|
|
// TemporaryExecutable recognizes executables in Go's temporary build layout,
|
|
// <work parent>/go-build<nonce>/b<action>/exe/<name>. Those paths disappear when
|
|
// the Go driver finishes and cannot provide a later native-command fallback.
|
|
func TemporaryExecutable(path string) bool {
|
|
exeDir := filepath.Dir(path)
|
|
if filepath.Base(exeDir) != "exe" {
|
|
return false
|
|
}
|
|
actionDir := filepath.Dir(exeDir)
|
|
action, ok := strings.CutPrefix(filepath.Base(actionDir), "b")
|
|
if !ok || action == "" {
|
|
return false
|
|
}
|
|
if _, err := strconv.ParseUint(action, 10, 64); err != nil {
|
|
return false
|
|
}
|
|
nonce, ok := strings.CutPrefix(filepath.Base(filepath.Dir(actionDir)), "go-build")
|
|
if !ok || nonce == "" {
|
|
return false
|
|
}
|
|
_, err := strconv.ParseUint(nonce, 10, 64)
|
|
return err == nil
|
|
}
|
|
|
|
// RecoveryTool returns module-selected Go arguments for a later invocation.
|
|
// An empty result means this module does not select ReleaseKit. API-only modules
|
|
// receive a private module root that outlives launcher-owned bootstraps.
|
|
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
|
|
path := filepath.Join(moduleDir, "go.mod")
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
module, err := modfile.Parse(path, data, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, tool := range module.Tool {
|
|
if tool.Path == releaseKitCommand {
|
|
return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil
|
|
}
|
|
}
|
|
// Ask Go about the selected module graph, including transitive dependencies.
|
|
// This reads real module metadata, independently of a launcher's -modfile.
|
|
var selection bytes.Buffer
|
|
err = process.Run(ctx, process.Invocation{
|
|
Args: []string{"go", "list", "-e", "-json", "-mod=readonly", "-modfile=", "-m", releaseKitModule},
|
|
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
|
Stdout: &selection, Stderr: io.Discard,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("identify recovery tool selection: %w", err)
|
|
}
|
|
var selected struct {
|
|
Main bool
|
|
Version string
|
|
Error *struct{ Err string }
|
|
}
|
|
if err := json.Unmarshal(selection.Bytes(), &selected); err != nil {
|
|
return nil, err
|
|
}
|
|
if !selected.Main && selected.Version == "" {
|
|
// A source-built CLI also supports consumers without a module dependency.
|
|
return nil, nil
|
|
}
|
|
if selected.Error != nil {
|
|
return nil, fmt.Errorf("identify recovery tool selection: %s", selected.Error.Err)
|
|
}
|
|
if err := module.AddTool(releaseKitCommand); err != nil {
|
|
return nil, err
|
|
}
|
|
// Relocating the root avoids the consumer's vendor tree. Preserve local
|
|
// replacements relative to the directory that originally owned go.mod.
|
|
for _, replacement := range module.Replace {
|
|
if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) {
|
|
if err := module.AddReplace(replacement.Old.Path, replacement.Old.Version, filepath.Join(moduleDir, replacement.New.Path), ""); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
// The original main module's source remains local if a dependency refers
|
|
// to it. This also permits recovery when releasing ReleaseKit itself.
|
|
if err := module.AddReplace(module.Module.Mod.Path, "", moduleDir, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
if selected.Main {
|
|
if err := module.AddRequire(releaseKitModule, "v0.0.0"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := module.AddModuleStmt("releasekit.local/recovery"); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err = module.Format()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sums, err := os.ReadFile(filepath.Join(moduleDir, "go.sum"))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(recordDir, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
directory, err := os.MkdirTemp(recordDir, "recovery-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create durable recovery module directory: %w", err)
|
|
}
|
|
complete := false
|
|
defer func() {
|
|
if !complete {
|
|
_ = os.RemoveAll(directory)
|
|
}
|
|
}()
|
|
if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, "go.sum"), sums, 0600); err != nil {
|
|
return nil, err
|
|
}
|
|
// Prepare the private graph and sums before the later tool invocation.
|
|
captured := &combinedWriter{mode: captureStatus}
|
|
err = process.Run(ctx, process.Invocation{
|
|
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
|
|
Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
|
Stdout: captured, Stderr: captured,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
|
|
}
|
|
complete = true
|
|
return []string{"-C", directory, "tool", "-modfile=", releaseKitCommand}, nil
|
|
}
|