Files
mesh-delivery/sdk/forgekey/forgekey.go
T

108 lines
3.8 KiB
Go

// Package forgekey holds the Delivery domain's names beside mesh-sdk's forgekey (novox/hq ADR 0635 §4): the kind of
// key a build module keeps, the runner whose commands get exactly the environment they are given (a fetch holds no
// registry password), and the words a failed command is reported in. mesh-sdk's forgekey keeps what every module's
// key keeper shares: KeyKeeper, KeyPutter, Runner, ExecRunner and Ran.
package forgekey
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"unicode/utf8"
sdk "git.novox.be/novox/mesh-sdk/go/forgekey"
)
// KindBuilder is the kind of key a build module's account keeps (ADR 0466 §2); sdk.KindMachine is a machine's own.
const KindBuilder = "builder"
// ExecRunnerClean is a runner 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. bound 0 leaves only the step's own context.
func ExecRunnerClean(bound time.Duration) sdk.Runner {
return func(ctx context.Context, env []string, name string, args ...string) sdk.Ran {
if bound > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, bound)
defer cancel()
}
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment
cmd.Dir = homeOrTemp(env)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := sdk.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
}
}
// homeOrTemp is the directory a clean command starts in: the HOME it is given, or the temporary directory (novox/hq
// issue 734: never one that may be deleted under it, as a module's build directory is when a new build replaces it).
func homeOrTemp(env []string) string {
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.
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:")
}