mesh/delivery merged: source.main moved to novox/mesh-sdk@1888d1d230b3
mesh/delivery-group group fix/fetch-reads-all merged: every member is merged
mesh/check pass ff81bdf8cc8f on 960d6c34d25e | unit 5815 run, 345 kept, 18 skipped | integration 5603 run, 640 kept, 29 skipped | over bound: unit, in…
The mirror of jschoubben/car-hunter on ace said only "git -C: error:": git writes a bare "error:" before the forge's message, and the reason kept the first non-empty line. forgekey.Said keeps every line that says something, leaves bare prefixes out, joins them and bounds them at 600 bytes; every git and ssh-keygen reason of buildstep and forgekey goes through it, and the mirror's commands are named by their verb, not by -C.
146 lines
5.2 KiB
Go
146 lines
5.2 KiB
Go
package forgekey
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
|
|
type Ran struct {
|
|
Stdout string
|
|
Stderr string
|
|
Status int
|
|
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
|
|
Err string
|
|
}
|
|
|
|
// Runner runs one command with extra environment words, so every tool can be tested without ssh.
|
|
type Runner func(ctx context.Context, env []string, name string, args ...string) Ran
|
|
|
|
// CallTimeout bounds one command of ExecRunner: below the runtime's thirty-second call limit. A variable so a
|
|
// test can lower it.
|
|
var CallTimeout = 20 * time.Second
|
|
|
|
// ExecRunner runs a command on this machine, with no terminal and nothing on its stdin, bounded by
|
|
// CallTimeout.
|
|
func ExecRunner(ctx context.Context, env []string, name string, args ...string) Ran {
|
|
return ExecRunnerFor(CallTimeout)(ctx, env, name, args...)
|
|
}
|
|
|
|
// ExecRunnerFor is ExecRunner with its own bound (issue 1497): a build module's long step passes 0, so only the
|
|
// step's own context ends a command, and a fetch may outlast a tool call's thirty seconds.
|
|
func ExecRunnerFor(bound time.Duration) Runner {
|
|
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
|
return execRun(ctx, bound, false, env, name, args...)
|
|
}
|
|
}
|
|
|
|
// ExecRunnerClean is ExecRunnerFor whose commands get exactly the env argument as their environment, and nothing of
|
|
// this process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller
|
|
// gives PATH and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME
|
|
// it is given, or the machine's temporary directory, never in this process's (stableDir).
|
|
func ExecRunnerClean(bound time.Duration) Runner {
|
|
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
|
return execRun(ctx, bound, true, env, name, args...)
|
|
}
|
|
}
|
|
|
|
func execRun(ctx context.Context, bound time.Duration, clean bool, env []string, name string, args ...string) Ran {
|
|
if bound > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, bound)
|
|
defer cancel()
|
|
}
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
if clean {
|
|
cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment
|
|
} else {
|
|
cmd.Env = append(os.Environ(), env...)
|
|
}
|
|
cmd.Dir = stableDir(clean, env)
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &out, &errb
|
|
err := cmd.Run()
|
|
r := Ran{Stdout: out.String(), Stderr: errb.String()}
|
|
var exitErr *exec.ExitError
|
|
switch {
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
r.Status, r.Err = 124, "ended by its step's deadline"
|
|
if bound > 0 {
|
|
r.Err = fmt.Sprintf("no answer within %d s", int(bound/time.Second))
|
|
}
|
|
case errors.Is(err, exec.ErrNotFound):
|
|
r.Status, r.Err = 127, "ENOENT"
|
|
case errors.As(err, &exitErr):
|
|
r.Status = exitErr.ExitCode()
|
|
case err != nil:
|
|
r.Status, r.Err = 1, err.Error()
|
|
}
|
|
return r
|
|
}
|
|
|
|
// stableDir is the directory a command starts in when it names none (novox/hq issue 734). **Never one that may be
|
|
// deleted under it**: a module runs in the directory its build was unpacked in, which a new build replaces, and a
|
|
// command started there inherits it — `go version` then fails "getwd: no such file or directory" before it reads
|
|
// anything. A clean command starts in the HOME it is given, or the temporary directory; any other keeps this
|
|
// process's directory while it exists, and starts in the temporary directory once it is gone. "" is this process's.
|
|
func stableDir(clean bool, env []string) string {
|
|
if !clean {
|
|
if _, err := os.Getwd(); err == nil {
|
|
return ""
|
|
}
|
|
return os.TempDir()
|
|
}
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
if home, ok := strings.CutPrefix(env[i], "HOME="); ok {
|
|
if info, err := os.Stat(home); err == nil && info.IsDir() {
|
|
return home
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return os.TempDir()
|
|
}
|
|
|
|
// SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log.
|
|
const SaidBound = 600
|
|
|
|
// Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed
|
|
// and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:"
|
|
// alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never
|
|
// cut off behind it: the first line alone of `git fetch` from a repository the account cannot read was "error:".
|
|
func Said(s string) string {
|
|
var keep []string
|
|
for _, l := range strings.Split(s, "\n") {
|
|
l = strings.TrimSpace(l)
|
|
if l == "" || barePrefix(l) {
|
|
continue
|
|
}
|
|
keep = append(keep, l)
|
|
}
|
|
out := strings.Join(keep, "; ")
|
|
if len(out) > SaidBound {
|
|
cut := SaidBound
|
|
for cut > 0 && !utf8.RuneStart(out[cut]) {
|
|
cut--
|
|
}
|
|
out = out[:cut] + " …"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it.
|
|
func barePrefix(l string) bool {
|
|
if !strings.HasSuffix(l, ":") {
|
|
return false
|
|
}
|
|
return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:")
|
|
}
|