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

@@ -1,7 +1,6 @@
package httpapi
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -10,7 +9,6 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/dtoro/oikos/internal/actuator"
@@ -77,24 +75,8 @@ const sshExecTimeout = 10 * time.Minute
// streamWriter buffers everything it is given while forwarding each write to a
// sink. One on session.Stdout and another sharing the same buffer on
// session.Stderr reproduces CombinedOutput's interleaving in the order the
// remote end produced it. Mirrors the twin in internal/mcp/server.go.
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 once Write returns.
w.sink(w.stream, append([]byte(nil), p...))
}
return len(p), nil
}
// remote end produced it. Shared implementation lives in internal/actuator
// (actuator.streamWriter / actuator.RunStreaming).
func sshExec(ctx context.Context, host, user, command string) (string, error) {
return sshExecStream(ctx, host, user, command, nil)
@@ -122,69 +104,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 := func() string {
mu.Lock()
defer mu.Unlock()
return strings.TrimSpace(buf.String())
}
done := make(chan error, 1)
go func() {
// See internal/mcp/server.go's sshExec for why this recovers rather
// than letting a rare SSH-library panic crash the whole api process.
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 are fully drained.
done <- session.Run(command)
}()
select {
case err := <-done:
text := collected()
// A non-zero exit MUST surface as an error. The previous guard only
// errored when there was no output, so a `pct create` that printed
// "CT 132 already exists" and exited non-zero was reported as
// success — the execution was marked completed though nothing was
// provisioned.
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):
// Close the session/client to hang up the remote side; the
// goroutine above will eventually exit once that unblocks Run, but we
// don't wait for it — the caller needs an answer now, not an
// indefinite hang.
session.Close()
client.Close()
// Return what arrived before it hung, rather than "". A provisioning
// command that stalls halfway is precisely when its output matters.
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)
}
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {