0.29.1 — review-fix round on E3: RunOutput, sshKeyPath fallback, RunStreaming consolidation, signer cache, stderr in errors
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

This commit is contained in:
2026-08-08 23:01:10 +02:00
parent 75c0848a6f
commit 7236c46e5c
8 changed files with 88 additions and 249 deletions

View File

@@ -452,30 +452,6 @@ func initSSH() {
// goroutine forever with no way for the caller to ever get an answer.
const sshExecTimeout = 10 * time.Minute
// streamWriter buffers everything it is given while forwarding each write to a
// sink. Assigning one to session.Stdout and another (sharing the same buffer)
// to session.Stderr reproduces CombinedOutput's interleaving exactly, in the
// order the remote end actually produced it — which reading from StdoutPipe
// and StderrPipe separately would not guarantee.
type streamWriter struct {
mu *sync.Mutex
buf *bytes.Buffer
stream string
sink execlog.Sink
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
w.mu.Unlock()
if w.sink != nil {
// Copy: the ssh library reuses p after Write returns, and the sink
// hands the bytes to a DB call that may outlive this frame.
w.sink(w.stream, append([]byte(nil), p...))
}
return len(p), nil
}
func sshExec(ctx context.Context, host, user, command string) (string, error) {
return sshExecStream(ctx, host, user, command, nil)
}
@@ -502,77 +478,7 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
defer session.Close()
var (
mu sync.Mutex
buf bytes.Buffer
)
session.Stdout = &streamWriter{mu: &mu, buf: &buf, stream: "stdout", sink: sink}
session.Stderr = &streamWriter{mu: &mu, buf: &buf, stream: "stderr", sink: sink}
// collected returns whatever output has arrived so far. Callable while the
// command is still running, which is what makes partial output on timeout
// possible.
collected := func() string {
mu.Lock()
defer mu.Unlock()
return strings.TrimSpace(buf.String())
}
done := make(chan error, 1)
go func() {
// Recovers a panic in the SSH library internals (rare but not
// impossible) and reports it as a failed command instead of crashing
// the whole api process — every gated action runs through this
// function, so an unrecovered panic here would take down every
// concurrently-running task's execution, not just this one. Without
// this, a panic would ALSO silently degrade to "wait out the full
// timeout" (done never receives, the select below falls through to
// its time.After case) rather than crashing outright — recovering
// and sending an immediate result is strictly better: the caller
// finds out now, not after sshExecTimeout.
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic in ssh exec: %v", r)
}
}()
// Run rather than CombinedOutput so the assigned writers are used;
// Run returns only after both streams have been fully drained.
done <- session.Run(command)
}()
select {
case err := <-done:
text := collected()
// A non-zero exit MUST surface as an error — matching the fix
// applied to httpapi's sshExec (this copy still had the original
// bug: only erroring when there was no output at all, so a command
// that failed but printed something was silently reported as
// success).
if err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", err, text)
}
return text, fmt.Errorf("exec: %w", err)
}
return text, nil
case <-time.After(sshExecTimeout):
session.Close()
client.Close()
// Return what the command managed to print before it hung. This used
// to return "", discarding everything — so a hung command, the case
// where the output matters most, was the one case that left no trace.
return collected(), fmt.Errorf("timed out after %s waiting for command to finish on %s", sshExecTimeout, host)
case <-ctx.Done():
session.Close()
client.Close()
return collected(), ctx.Err()
}
return actuator.RunStreaming(ctx, client, command, sink, sshExecTimeout)
}
// resolveHost resolves a host:<slug> to its reachable IP and SSH user. A thin

View File

@@ -43,8 +43,6 @@ func formatCreateResult(slug, entityType string, res checkdefaults.Result) strin
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
}
// rowsToMap runs a SELECT key, value query and returns the result as a
// map[string]any. Used by get_dashboard_summary to aggregate count queries.
func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) map[string]any {
m := map[string]any{}
rows, err := pool.Query(ctx, query, args...)
@@ -62,8 +60,6 @@ func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) ma
return m
}
// queryRowsJSONSingle runs a query and returns the rows as a parsed JSON array
// of maps. Used by get_ontology to embed sub-queries into a structured result.
func queryRowsJSONSingle(ctx context.Context, pool *db.Pool, query string, args ...any) []map[string]any {
rows, err := pool.Query(ctx, query, args...)
if err != nil {