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
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -99,3 +101,41 @@ func RunCombinedOutput(ctx context.Context, client *ssh.Client, cmd string) ([]b
|
||||
return res.out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// RunOutput runs cmd on an established client and returns stdout only.
|
||||
// Stderr is folded into the returned error so callers that parse stdout
|
||||
// as JSON (e.g. the scheduler's check scripts) don't get interleaved
|
||||
// stderr in the output stream.
|
||||
func RunOutput(ctx context.Context, client *ssh.Client, cmd string) ([]byte, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
session.Stdout = &outBuf
|
||||
session.Stderr = &errBuf
|
||||
|
||||
type result struct {
|
||||
runErr error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
ch <- result{runErr: session.Run(cmd)}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Close()
|
||||
return nil, ctx.Err()
|
||||
case res := <-ch:
|
||||
if res.runErr != nil {
|
||||
if errBuf.Len() > 0 {
|
||||
return outBuf.Bytes(), fmt.Errorf("command: %w\nstderr: %s", res.runErr, strings.TrimSpace(errBuf.String()))
|
||||
}
|
||||
return outBuf.Bytes(), fmt.Errorf("command: %w", res.runErr)
|
||||
}
|
||||
return outBuf.Bytes(), nil
|
||||
}
|
||||
}
|
||||
122
internal/actuator/pool.go
Normal file
122
internal/actuator/pool.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type poolEntry struct {
|
||||
client *ssh.Client
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
type DialPool struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*poolEntry
|
||||
ttl time.Duration
|
||||
done chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func NewDialPool(ttl time.Duration) *DialPool {
|
||||
p := &DialPool{
|
||||
entries: make(map[string]*poolEntry),
|
||||
ttl: ttl,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if ttl > 0 {
|
||||
go p.evictLoop()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *DialPool) key(opts DialOptions) string {
|
||||
port := opts.Port
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", opts.Host, port)
|
||||
}
|
||||
|
||||
func (p *DialPool) Get(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
|
||||
k := p.key(opts)
|
||||
p.mu.RLock()
|
||||
entry, ok := p.entries[k]
|
||||
p.mu.RUnlock()
|
||||
if ok {
|
||||
// Quick health check: a session can be created without running a
|
||||
// command — if it fails, the connection is dead and we evict it.
|
||||
testSession, err := entry.client.NewSession()
|
||||
if err == nil {
|
||||
testSession.Close()
|
||||
return entry.client, nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.entries[k] == entry {
|
||||
entry.client.Close()
|
||||
delete(p.entries, k)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
// Fall through to dial below
|
||||
}
|
||||
client, err := Dial(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.stopped {
|
||||
p.mu.Unlock()
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("ssh dial pool: closed")
|
||||
}
|
||||
if existing, ok2 := p.entries[k]; ok2 {
|
||||
p.mu.Unlock()
|
||||
client.Close()
|
||||
return existing.client, nil
|
||||
}
|
||||
p.entries[k] = &poolEntry{client: client, createdAt: time.Now()}
|
||||
p.mu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (p *DialPool) Close() {
|
||||
p.mu.Lock()
|
||||
p.stopped = true
|
||||
for k, entry := range p.entries {
|
||||
entry.client.Close()
|
||||
delete(p.entries, k)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if p.ttl > 0 {
|
||||
close(p.done)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DialPool) evictLoop() {
|
||||
ticker := time.NewTicker(p.ttl / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.evict()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DialPool) evict() {
|
||||
deadline := time.Now().Add(-p.ttl)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for k, entry := range p.entries {
|
||||
if entry.createdAt.Before(deadline) {
|
||||
entry.client.Close()
|
||||
delete(p.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
86
internal/actuator/stream.go
Normal file
86
internal/actuator/stream.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// RunStreaming runs a command on an established SSH client and forwards output
|
||||
// chunks to sink as they arrive. A nil sink collects output silently. Returns
|
||||
// the full combined output and any command error.
|
||||
func RunStreaming(ctx context.Context, client *ssh.Client, command string, sink func(stream string, chunk []byte), timeout time.Duration) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
outPipe, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
errPipe, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
type streamResult struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
resultCh := make(chan streamResult, 1)
|
||||
go func() {
|
||||
var combined []byte
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
readStream := func(stream string, r io.Reader) {
|
||||
sc := bufio.NewScanner(r)
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
chunk := make([]byte, len(line))
|
||||
copy(chunk, line)
|
||||
if sink != nil {
|
||||
sink(stream, chunk)
|
||||
}
|
||||
if stream == "stdout" || stream == "" {
|
||||
if len(combined) > 0 {
|
||||
combined = append(combined, '\n')
|
||||
}
|
||||
combined = append(combined, chunk...)
|
||||
}
|
||||
}
|
||||
done <- struct{}{}
|
||||
}
|
||||
|
||||
go readStream("stdout", outPipe)
|
||||
go readStream("stderr", errPipe)
|
||||
|
||||
runErr := session.Run(command)
|
||||
<-done
|
||||
<-done
|
||||
resultCh <- streamResult{out: string(combined), err: runErr}
|
||||
}()
|
||||
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Close()
|
||||
return "", ctx.Err()
|
||||
case res := <-resultCh:
|
||||
if res.err != nil {
|
||||
return res.out, fmt.Errorf("command: %w", res.err)
|
||||
}
|
||||
return res.out, nil
|
||||
}
|
||||
}
|
||||
61
internal/db/entity_cache.go
Normal file
61
internal/db/entity_cache.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entityCacheEntry struct {
|
||||
slug string
|
||||
id string
|
||||
attrs string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
type EntityCache struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]entityCacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewEntityCache(ttl time.Duration) *EntityCache {
|
||||
return &EntityCache{
|
||||
m: make(map[string]entityCacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EntityCache) GetSlug(id string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[id]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
return "", false
|
||||
}
|
||||
return e.slug, true
|
||||
}
|
||||
|
||||
func (c *EntityCache) GetID(slug string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[slug]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.exp) {
|
||||
return "", false
|
||||
}
|
||||
return e.id, true
|
||||
}
|
||||
|
||||
func (c *EntityCache) Set(slug, id, attrs string) {
|
||||
exp := time.Now().Add(c.ttl)
|
||||
c.mu.Lock()
|
||||
c.m[slug] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||
c.m[id] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *EntityCache) Invalidate(slug, id string) {
|
||||
c.mu.Lock()
|
||||
delete(c.m, slug)
|
||||
delete(c.m, id)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -190,7 +190,10 @@ func hasSuffix(s, suffix string) bool {
|
||||
}
|
||||
|
||||
// splitSQL splits a SQL string into individual statements.
|
||||
// Handles $$ ... $$ dollar-quoted blocks and -- line comments.
|
||||
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
|
||||
// -- line comments, /* ... */ block comments, and '...' string literals
|
||||
// so that semicolons inside any of these constructs are not treated as
|
||||
// statement boundaries.
|
||||
func splitSQL(sql string) []string {
|
||||
var statements []string
|
||||
var current strings.Builder
|
||||
@@ -201,7 +204,6 @@ func splitSQL(sql string) []string {
|
||||
for i < len(sql) {
|
||||
// Handle line comments (-- to end of line)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||
// Skip to end of line
|
||||
for i < len(sql) && sql[i] != '\n' {
|
||||
current.WriteByte(sql[i])
|
||||
i++
|
||||
@@ -209,6 +211,34 @@ func splitSQL(sql string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle block comments (/* ... */)
|
||||
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
|
||||
end := strings.Index(sql[i+2:], "*/")
|
||||
if end >= 0 {
|
||||
current.WriteString(sql[i : i+end+4])
|
||||
i += end + 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle single-quoted string literals ('...')
|
||||
if !inDollarQuote && sql[i] == '\'' {
|
||||
j := i + 1
|
||||
for j < len(sql) {
|
||||
if sql[j] == '\'' {
|
||||
if j+1 < len(sql) && sql[j+1] == '\'' {
|
||||
j += 2 // skip doubled quote ''
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
current.WriteString(sql[i : j+1])
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for dollar-quote start/end
|
||||
if !inDollarQuote && sql[i] == '$' {
|
||||
j := i + 1
|
||||
|
||||
@@ -51,3 +51,57 @@ func TestSplitSQLSemicolonInComment(t *testing.T) {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
|
||||
sql := `SELECT '$100'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockComment(t *testing.T) {
|
||||
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
|
||||
sql := `/* $$ not a dollar quote */ SELECT 1;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLDoubledQuoteInString(t *testing.T) {
|
||||
sql := `SELECT 'O''Brien'; SELECT 2;`
|
||||
stmts := nonEmpty(splitSQL(sql))
|
||||
if len(stmts) != 2 {
|
||||
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLEmptyInput(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL(""))
|
||||
if len(stmts) != 0 {
|
||||
t.Fatalf("got %d statements, want 0", len(stmts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitSQLNoSemicolon(t *testing.T) {
|
||||
stmts := nonEmpty(splitSQL("SELECT 1"))
|
||||
if len(stmts) != 1 {
|
||||
t.Fatalf("got %d statements, want 1", len(stmts))
|
||||
}
|
||||
}
|
||||
|
||||
13
internal/execworker/init.go
Normal file
13
internal/execworker/init.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package execworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
)
|
||||
|
||||
// RunnerForMain provides the run function for registration in main.
|
||||
func RunnerForMain() func(context.Context, *db.Pool, config.Config) {
|
||||
return Run
|
||||
}
|
||||
207
internal/execworker/worker.go
Normal file
207
internal/execworker/worker.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// 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), ¶ms) == 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
|
||||
}
|
||||
@@ -281,6 +281,8 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.entityCache.Invalidate(entity.Slug, entity.Id.String())
|
||||
|
||||
return gen.PatchEntity200JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
|
||||
@@ -57,16 +57,27 @@ func clampLimit(l *int) int {
|
||||
// resolveEntityID resolves a UUID-or-slug path/query value to the entity UUID.
|
||||
func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
if id, err := uuid.Parse(idOrSlug); err == nil {
|
||||
if _, ok := s.entityCache.GetSlug(id.String()); ok {
|
||||
return id, nil
|
||||
}
|
||||
entity, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
||||
}
|
||||
s.entityCache.Set(entity.Slug, entity.ID.String(), "")
|
||||
return entity.ID, nil
|
||||
}
|
||||
if cachedID, ok := s.entityCache.GetID(idOrSlug); ok {
|
||||
id, parseErr := uuid.Parse(cachedID)
|
||||
if parseErr == nil {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
entity, err := sqlcgen.New(s.pool).GetEntityBySlug(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: %s", domain.ErrNotFound, idOrSlug)
|
||||
}
|
||||
s.entityCache.Set(entity.Slug, entity.ID.String(), "")
|
||||
return entity.ID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ type Server struct {
|
||||
pool *db.Pool
|
||||
cfg config.Config
|
||||
secretsManager secrets.Backend
|
||||
entityCache *db.EntityCache
|
||||
sseBroker *sseBroker
|
||||
sseSubs map[*sseSubscriber]struct{}
|
||||
sseMu sync.Mutex
|
||||
@@ -69,10 +70,11 @@ type Server struct {
|
||||
// and pool.Close() deadlocks.
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
|
||||
s := &Server{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
sseBroker: newSSEBroker(10000),
|
||||
sseSubs: make(map[*sseSubscriber]struct{}),
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
entityCache: db.NewEntityCache(60 * time.Second),
|
||||
sseBroker: newSSEBroker(10000),
|
||||
sseSubs: make(map[*sseSubscriber]struct{}),
|
||||
}
|
||||
|
||||
// Wire secrets backend: Infisical primary with SOPS DR fallback.
|
||||
|
||||
@@ -34,8 +34,13 @@ import (
|
||||
var (
|
||||
sshKeyPath string
|
||||
sshUser string
|
||||
sshPool *actuator.DialPool
|
||||
)
|
||||
|
||||
// schedulerLockKey is the advisory-lock key preventing duplicate scheduler
|
||||
// instances. Must differ from db.migrationLockKey (0x01c05e5).
|
||||
const schedulerLockKey = 0x01c05e6
|
||||
|
||||
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
||||
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
|
||||
@@ -49,6 +54,32 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
if sshUser == "" {
|
||||
sshUser = "root"
|
||||
}
|
||||
sshPool = actuator.NewDialPool(5 * time.Minute)
|
||||
defer sshPool.Close()
|
||||
|
||||
// Acquire a session-level advisory lock so only one scheduler instance
|
||||
// runs at a time. If another instance holds the lock, we exit — duplicate
|
||||
// schedulers would duplicate health checks, signals, metrics, and events.
|
||||
lockConn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
slog.Error("scheduler: acquire connection for lock", "error", err)
|
||||
return
|
||||
}
|
||||
var locked bool
|
||||
if err := lockConn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", schedulerLockKey).Scan(&locked); err != nil {
|
||||
lockConn.Release()
|
||||
slog.Error("scheduler: advisory lock error", "error", err)
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
lockConn.Release()
|
||||
slog.Warn("scheduler: advisory lock held by another instance, exiting")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", schedulerLockKey)
|
||||
lockConn.Release()
|
||||
}()
|
||||
|
||||
// Liveness probe (plan D5): staleness is 3x the interval so a single
|
||||
// slow check pass (one host hung on SSH) doesn't flap the container
|
||||
@@ -1010,13 +1041,12 @@ func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Dur
|
||||
p = n
|
||||
}
|
||||
}
|
||||
client, err := actuator.Dial(ctx, actuator.DialOptions{
|
||||
client, err := sshPool.Get(ctx, actuator.DialOptions{
|
||||
Host: host, Port: p, User: user, Signer: signer, Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh %s: %v", host, err)
|
||||
}
|
||||
defer client.Close()
|
||||
// RunOutput (stdout-only) — the scheduler parses check output as JSON or
|
||||
// matches it literally, so stderr must not be merged in (RunCombinedOutput
|
||||
// is for the live-run display path in mcp/httpapi).
|
||||
|
||||
Reference in New Issue
Block a user