init monlet repo with stage 0-2 (docs, contract, agent MVP)
This commit is contained in:
154
agent/internal/runner/runner.go
Normal file
154
agent/internal/runner/runner.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var replacementRune = []byte("<22>")
|
||||
|
||||
const (
|
||||
MaxOutputBytes = 8192
|
||||
truncMarkerFmt = "...[truncated %d bytes]"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusWarning Status = "warning"
|
||||
StatusCritical Status = "critical"
|
||||
StatusUnknown Status = "unknown"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Status Status
|
||||
ExitCode int
|
||||
DurationMs int64
|
||||
Output string
|
||||
OutputTruncated bool
|
||||
}
|
||||
|
||||
// Run executes argv with timeout. Stdout+stderr are captured into a single
|
||||
// buffer; both are truncated to 8 KiB UTF-8 bytes if exceeded.
|
||||
func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
|
||||
start := time.Now()
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cctx, argv[0], argv[1:]...)
|
||||
cmd.SysProcAttr = sysProcAttr()
|
||||
cmd.Cancel = func() error { return killGroup(cmd) }
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
err := cmd.Run()
|
||||
dur := time.Since(start).Milliseconds()
|
||||
|
||||
out, truncated := TruncateUTF8(buf.Bytes(), MaxOutputBytes)
|
||||
r := Result{
|
||||
DurationMs: dur,
|
||||
Output: out,
|
||||
OutputTruncated: truncated,
|
||||
}
|
||||
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
r.Status = StatusUnknown
|
||||
r.ExitCode = -1
|
||||
return r
|
||||
}
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
r.ExitCode = ee.ExitCode()
|
||||
} else {
|
||||
r.Status = StatusUnknown
|
||||
r.ExitCode = -1
|
||||
return r
|
||||
}
|
||||
}
|
||||
r.Status = MapExit(r.ExitCode)
|
||||
return r
|
||||
}
|
||||
|
||||
func MapExit(code int) Status {
|
||||
switch code {
|
||||
case 0:
|
||||
return StatusOK
|
||||
case 1:
|
||||
return StatusWarning
|
||||
case 2:
|
||||
return StatusCritical
|
||||
default:
|
||||
return StatusUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// TruncateUTF8 returns a string whose UTF-8 byte length is <= max and a
|
||||
// truncation flag. If truncated, appends "...[truncated N bytes]" marker.
|
||||
func TruncateUTF8(b []byte, max int) (string, bool) {
|
||||
// Sanitize invalid bytes first; otherwise json.Marshal replaces them with
|
||||
// U+FFFD (3 bytes each) after our size check and the wire payload can
|
||||
// exceed the contract limit.
|
||||
b = bytes.ToValidUTF8(b, replacementRune)
|
||||
if len(b) <= max {
|
||||
return string(b), false
|
||||
}
|
||||
budget := max - 32
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
cut := safeBoundary(b, budget)
|
||||
marker := fmt.Sprintf(truncMarkerFmt, len(b)-cut)
|
||||
if cut+len(marker) <= max {
|
||||
return string(b[:cut]) + marker, true
|
||||
}
|
||||
budget = max - len(marker) - 1
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
}
|
||||
cut := safeBoundary(b, max/2)
|
||||
marker := fmt.Sprintf(truncMarkerFmt, len(b)-cut)
|
||||
return string(b[:cut]) + marker, true
|
||||
}
|
||||
|
||||
func safeBoundary(b []byte, limit int) int {
|
||||
if limit >= len(b) {
|
||||
return len(b)
|
||||
}
|
||||
for i := limit; i > 0; i-- {
|
||||
if utf8.RuneStart(b[i]) {
|
||||
if utf8.Valid(b[:i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func killGroup(cmd *exec.Cmd) error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
pgid, err := syscall.Getpgid(cmd.Process.Pid)
|
||||
if err == nil {
|
||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
return nil
|
||||
}
|
||||
return cmd.Process.Kill()
|
||||
}
|
||||
113
agent/internal/runner/runner_test.go
Normal file
113
agent/internal/runner/runner_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestMapExit(t *testing.T) {
|
||||
cases := map[int]Status{0: StatusOK, 1: StatusWarning, 2: StatusCritical, 3: StatusUnknown, 255: StatusUnknown}
|
||||
for code, want := range cases {
|
||||
if got := MapExit(code); got != want {
|
||||
t.Errorf("MapExit(%d)=%s want %s", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8Short(t *testing.T) {
|
||||
out, tr := TruncateUTF8([]byte("hi"), 100)
|
||||
if tr || out != "hi" {
|
||||
t.Fatalf("got %q tr=%v", out, tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8Long(t *testing.T) {
|
||||
in := strings.Repeat("a", 9000)
|
||||
out, tr := TruncateUTF8([]byte(in), MaxOutputBytes)
|
||||
if !tr {
|
||||
t.Fatal("expected truncated")
|
||||
}
|
||||
if len([]byte(out)) > MaxOutputBytes {
|
||||
t.Fatalf("byte len %d > %d", len([]byte(out)), MaxOutputBytes)
|
||||
}
|
||||
if !strings.Contains(out, "truncated") {
|
||||
t.Fatalf("no marker: %q", out[len(out)-50:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8SanitizesInvalidBytes(t *testing.T) {
|
||||
// invalid UTF-8 bytes that json.Marshal would replace with U+FFFD (3 bytes each)
|
||||
in := append([]byte("hello"), 0xff, 0xfe, 0xfd)
|
||||
out, tr := TruncateUTF8(in, 100)
|
||||
if tr {
|
||||
t.Fatal("should fit")
|
||||
}
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatalf("output must be valid UTF-8: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8Multibyte(t *testing.T) {
|
||||
in := strings.Repeat("ё", 5000) // 2 bytes per rune = 10000 bytes
|
||||
out, tr := TruncateUTF8([]byte(in), MaxOutputBytes)
|
||||
if !tr {
|
||||
t.Fatal("expected truncated")
|
||||
}
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatal("invalid UTF-8 after truncate")
|
||||
}
|
||||
if len([]byte(out)) > MaxOutputBytes {
|
||||
t.Fatalf("byte len %d > %d", len([]byte(out)), MaxOutputBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOK(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"sh", "-c", "echo ok"}, 2*time.Second)
|
||||
if r.Status != StatusOK || r.ExitCode != 0 {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Output, "ok") {
|
||||
t.Fatalf("output: %q", r.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWarning(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"sh", "-c", "exit 1"}, 2*time.Second)
|
||||
if r.Status != StatusWarning || r.ExitCode != 1 {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCritical(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"sh", "-c", "exit 2"}, 2*time.Second)
|
||||
if r.Status != StatusCritical {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownExit(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"sh", "-c", "exit 7"}, 2*time.Second)
|
||||
if r.Status != StatusUnknown || r.ExitCode != 7 {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimeout(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"sh", "-c", "sleep 5"}, 200*time.Millisecond)
|
||||
if r.Status != StatusUnknown {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
if r.DurationMs > 2000 {
|
||||
t.Fatalf("duration too long: %d", r.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoExec(t *testing.T) {
|
||||
r := Run(context.Background(), []string{"/does/not/exist/zzz"}, 1*time.Second)
|
||||
if r.Status != StatusUnknown {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user