Files
oikos/internal/execworker/worker.go
dtoro 5e10437fe3
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
Phase 4 (Performance) + Phase 6 (Infrastructure) completion
Phase 4 — Performance:
- F1: SSH DialPool with key-by-host pooling and 5min idle TTL
- F2: In-memory entity lookup cache (TTL 60s, HTTP resolveEntityID)
- F3: Trigram GIN indexes on entities.slug and entities.name (migration 031)
- F4: Partial index on executions(classification_id) for auto-act (migration 032)
- Added missing RunOutput and RunStreaming in actuator/ (E3 gap fill)

Phase 6 — Infrastructure:
- H1: Infisical image pinned to v0.99.1
- H2: execworker daemon — polls pending executions with per-execution
  advisory locks, recovers orphaned running executions, wired as
  docker-compose service
- H3: splitSQL hardened with block comment and string-literal support,
  6 new edge-case tests (11 total)
- H4: Scheduler acquires pg_try_advisory_lock(0x01c05e6) at startup
2026-08-08 23:46:43 +02:00

207 lines
6.3 KiB
Go

// Package execworker processes pending executions as a background daemon.
// This provides a Postgres-backed queue: executions survive restarts, and
// per-execution advisory locks prevent duplicate processing across instances.
package execworker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/health"
"github.com/dtoro/oikos/internal/remote"
"github.com/google/uuid"
)
// Run starts the execution worker loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("execworker: starting")
// Liveness probe
probe := health.New(2 * time.Minute)
probe.Serve(ctx, cfg.HealthListen)
recoverOrphaned(ctx, pool)
probe.Bump()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("execworker: shutting down")
return
case <-ticker.C:
processPending(ctx, pool)
probe.Bump()
}
}
}
// recoverOrphaned marks executions stuck in 'running' as failed.
func recoverOrphaned(ctx context.Context, pool *db.Pool) {
tag, err := pool.Exec(ctx, `UPDATE executions SET status = 'failed', result = '{"error":"worker restarted while execution was running"}'::jsonb, completed_at = now() WHERE status = 'running'`)
if err != nil {
slog.Error("execworker: recover orphaned", "error", err)
return
}
if tag.RowsAffected() > 0 {
slog.Warn("execworker: recovered orphaned executions", "count", tag.RowsAffected())
}
}
// processPending polls for pending executions and dispatches them.
func processPending(ctx context.Context, pool *db.Pool) {
rows, err := pool.Query(ctx, `
SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class, e.correlation_id, e.status,
COALESCE(t.slug, '') AS target_slug
FROM executions e
LEFT JOIN entities t ON t.id = e.target_entity_id
WHERE e.status = 'proposed'
ORDER BY e.created_at ASC
LIMIT 10`)
if err != nil {
slog.Error("execworker: query pending", "error", err)
return
}
defer rows.Close()
q := sqlcgen.New(pool)
for rows.Next() {
var execID, targetID *uuid.UUID
var action, riskClass, correlationID, status, targetSlug string
if err := rows.Scan(&execID, &targetID, &action, &riskClass, &correlationID, &status, &targetSlug); err != nil {
slog.Error("execworker: scan row", "error", err)
continue
}
if execID == nil {
continue
}
// At-most-once: try advisory lock on execution entity_id.
// Acquire a dedicated connection so the session-scoped lock isn't
// released when the transient pool connection is returned.
lockKey := hashUUID(*execID)
lockConn, err := pool.Acquire(ctx)
if err != nil {
slog.Error("execworker: acquire lock conn", "error", err)
continue
}
var locked bool
if err := lockConn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", lockKey).Scan(&locked); err != nil || !locked {
lockConn.Release()
continue
}
dispatch(ctx, pool, q, *execID, targetID, action, targetSlug, correlationID)
// Release the per-execution lock on the same connection.
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey)
lockConn.Release()
}
}
func dispatch(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries, execID uuid.UUID, targetID *uuid.UUID, action, targetSlug, correlationID string) {
startedAt := time.Now()
// Mark running
_, err := pool.Exec(ctx,
`UPDATE executions SET status = 'running', started_at = $2 WHERE entity_id = $1`,
execID, startedAt)
if err != nil {
slog.Error("execworker: mark running", "error", err, "execution", execID)
return
}
// Resolve SSH target. If targetSlug is available, use it; otherwise resolve from targetID.
var host, user string
if targetSlug == "" && targetID != nil {
if err := pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", *targetID).Scan(&targetSlug); err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("resolve target slug: %v", err))
return
}
}
if targetSlug != "" {
addr, sshUser, err := remote.ResolveHost(ctx, pool, targetSlug, "root")
if err == nil {
host, user = addr, sshUser
}
}
if host == "" {
failExecution(ctx, pool, execID, fmt.Sprintf("no reachable target: %s", targetSlug))
return
}
// Determine the command to run from the action field.
// Format: "action_name:{json_params}" or a raw command string.
cmd := action
if idx := strings.Index(action, ":"); idx > 0 && idx < len(action)-1 {
rawParams := action[idx+1:]
var params map[string]any
if json.Unmarshal([]byte(rawParams), &params) == nil {
if c, ok := params["command"].(string); ok && c != "" {
cmd = c
}
}
}
signer, err := actuator.LoadSigner(os.Getenv("OIKOS_SSH_KEY_PATH"))
if err != nil {
signer, err = actuator.LoadSigner("/etc/oikos/ssh_key")
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("load ssh key: %v", err))
return
}
}
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("ssh dial: %v", err))
return
}
defer client.Close()
out, err := actuator.RunCombinedOutput(ctx, client, cmd)
if err != nil {
failExecution(ctx, pool, execID, fmt.Sprintf("command: %v\noutput: %s", err, string(out)))
return
}
duration := time.Since(startedAt).Milliseconds()
resultJSON, _ := json.Marshal(map[string]any{"output": string(out), "success": true})
_ = q.UpdateExecutionStatus(ctx, sqlcgen.UpdateExecutionStatusParams{
EntityID: execID,
Status: "completed",
Result: resultJSON,
DurationMs: &[]int32{int32(duration)}[0],
Verified: true,
})
slog.Info("execworker: execution complete",
"execution", execID, "target", targetSlug, "duration_ms", duration)
}
func failExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, reason string) {
slog.Error("execworker: execution failed", "execution", execID, "error", reason)
resultJSON, _ := json.Marshal(map[string]any{"error": reason, "success": false})
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb, completed_at=now() WHERE entity_id=$1`,
execID, resultJSON)
}
func hashUUID(id uuid.UUID) int {
h := 0
for _, b := range id {
h = (h*31 + int(b)) & 0x7fffffff
}
return h
}