0.29.0 — code-quality refactor (plan E1–E5): file splits, sqlc migration, SSH unification, test coverage
E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
This commit is contained in:
348
cmd/nomos/mcp.go
Normal file
348
cmd/nomos/mcp.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
||||
|
||||
type mcpClient struct {
|
||||
baseURL string
|
||||
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
|
||||
sessionID string
|
||||
http *http.Client
|
||||
nextID int
|
||||
mu sync.Mutex // one client serializes its own MCP calls (the pool gives each session its own client, so this never blocks another session)
|
||||
// toolsCache holds the last tools/list result. The tool list is static
|
||||
// for the lifetime of one MCP connection — it only changes when the api
|
||||
// process (re)registers tools, i.e. on a restart, which this client
|
||||
// already detects and reacts to via reconnectLocked. Without this,
|
||||
// buildTools (called at the start of EVERY chat turn, including every
|
||||
// auto-continuation resume) paid a full tools/list round-trip every
|
||||
// single time for a list that's almost always identical to the last one.
|
||||
// Guarded separately from mu (not reused) so a cache check never
|
||||
// contends with an in-flight doRequest call for a different method.
|
||||
toolsMu sync.Mutex
|
||||
toolsCache []toolDef
|
||||
}
|
||||
|
||||
func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
||||
c := &mcpClient{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("initialize", map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
if resp.sessionID == "" {
|
||||
return nil, fmt.Errorf("no session ID in initialize response")
|
||||
}
|
||||
c.sessionID = resp.sessionID
|
||||
|
||||
c.doRequest("notifications/initialized", map[string]any{})
|
||||
|
||||
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type mcpJSONRPCResponse struct {
|
||||
sessionID string
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
|
||||
// errStaleSession signals that the MCP server rejected our session id (e.g.
|
||||
// after an api/MCP restart), so the client should re-initialize and retry.
|
||||
var errStaleSession = fmt.Errorf("mcp session stale")
|
||||
|
||||
// doRequest serializes MCP calls and transparently re-initializes the session
|
||||
// if the server has forgotten it (common after an api redeploy), retrying the
|
||||
// original call once. Without this, an api restart permanently breaks nomos
|
||||
// until it is itself restarted.
|
||||
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
resp, err := c.send(method, params)
|
||||
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
|
||||
slog.Warn("nomos: mcp session stale, reconnecting")
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
|
||||
}
|
||||
return c.send(method, params)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
||||
func (c *mcpClient) reconnectLocked() error {
|
||||
c.sessionID = ""
|
||||
// A reconnect means the api process was restarted (or forgot us) — its
|
||||
// tool registration may have changed, so the cached list is no longer
|
||||
// trustworthy.
|
||||
c.toolsMu.Lock()
|
||||
c.toolsCache = nil
|
||||
c.toolsMu.Unlock()
|
||||
resp, err := c.send("initialize", map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.sessionID == "" {
|
||||
return fmt.Errorf("no session ID on re-initialize")
|
||||
}
|
||||
c.sessionID = resp.sessionID
|
||||
_, _ = c.send("notifications/initialized", map[string]any{})
|
||||
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
|
||||
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||
c.nextID++
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": c.nextID,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if c.sessionID != "" {
|
||||
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// A rejected/unknown session comes back as 4xx (commonly 400/404).
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||
return nil, errStaleSession
|
||||
}
|
||||
|
||||
result := &mcpJSONRPCResponse{}
|
||||
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
gotData := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
gotData = true
|
||||
data := line[6:]
|
||||
if err := json.Unmarshal([]byte(data), result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
||||
}
|
||||
|
||||
// Empty body with no result and a session set: the server likely dropped
|
||||
// our session. Notifications legitimately return no data, so exempt them.
|
||||
if !gotData && result.Result == nil && method != "notifications/initialized" {
|
||||
return nil, errStaleSession
|
||||
}
|
||||
|
||||
if result.sessionID != "" {
|
||||
c.sessionID = result.sessionID
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
||||
resp, err := c.doRequest("tools/call", map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var toolResult struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
|
||||
return string(resp.Result), nil
|
||||
}
|
||||
|
||||
var texts []string
|
||||
for _, c := range toolResult.Content {
|
||||
if c.Type == "text" {
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
texts = append(texts, c.Text)
|
||||
}
|
||||
}
|
||||
if len(texts) == 1 {
|
||||
return texts[0], nil
|
||||
}
|
||||
return texts, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) listTools() ([]string, error) {
|
||||
resp, err := c.doRequest("tools/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tr struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, t := range tr.Tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) close() {
|
||||
}
|
||||
|
||||
// ─── Per-session MCP client pool ────────────────────────────────────────
|
||||
//
|
||||
// A single shared mcpClient serializes EVERY tool call across EVERY
|
||||
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
|
||||
// executes its SSH command synchronously inside that lock and is capped at
|
||||
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
|
||||
// calls, even trivial reads, behind it. The MCP *server* has no per-
|
||||
// connection state to protect (newServer in internal/mcp/server.go returns
|
||||
// one shared *mcp.Server instance whose tool handlers close only over the DB
|
||||
// pool, which is already safe for concurrent use) — the mutex existed purely
|
||||
// because the *client* reused one stateful transport session, not because
|
||||
// the server needed it. Giving each task's own session its own client
|
||||
// removes the cross-task serialization entirely: a task's own tool calls
|
||||
// stay sequential (which they already are — the agent loop calls tools one
|
||||
// at a time within a turn), but no longer block anyone else's.
|
||||
type mcpClientPool struct {
|
||||
baseURL string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
clients map[string]*pooledMCPClient
|
||||
}
|
||||
|
||||
type pooledMCPClient struct {
|
||||
client *mcpClient
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
func newMCPClientPool(baseURL, token string) *mcpClientPool {
|
||||
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
|
||||
}
|
||||
|
||||
// get returns the client for sessionID, creating and initializing one (a
|
||||
// real MCP handshake) on first use. Session ids that don't identify a real
|
||||
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
|
||||
// the structured /query endpoint) still get exactly one dedicated,
|
||||
// reused client each via the same map — just keyed on a fixed string instead
|
||||
// of a real session id — so that traffic doesn't pay a fresh handshake per
|
||||
// request while still never sharing a connection with an actual task.
|
||||
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
||||
key := sessionID
|
||||
if key == "" {
|
||||
key = "ephemeral"
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if pc, ok := p.clients[key]; ok {
|
||||
pc.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pc.client, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Initialize outside the lock — it's a network round-trip, and holding
|
||||
// the pool mutex for it would serialize unrelated sessions' first calls
|
||||
// behind each other, undermining the whole point of this pool.
|
||||
c, err := newMCPClient(p.baseURL, p.token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
// Another goroutine may have created one for the same key while we were
|
||||
// initializing (two of this session's tool calls racing on a cold
|
||||
// start); keep whichever won, close out the loser's connection (a no-op
|
||||
// today, but future-proof if mcpClient.close ever does real teardown).
|
||||
if existing, ok := p.clients[key]; ok {
|
||||
p.mu.Unlock()
|
||||
c.close()
|
||||
return existing.client, nil
|
||||
}
|
||||
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
|
||||
p.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
|
||||
// before eviction — long enough to outlive a single slow `run` (capped at 10
|
||||
// minutes server-side) plus normal think-time between a task's tool calls,
|
||||
// short enough not to accumulate one abandoned connection per finished task
|
||||
// forever.
|
||||
const mcpClientIdleTimeout = 20 * time.Minute
|
||||
|
||||
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
|
||||
func (p *mcpClientPool) sweep() {
|
||||
cutoff := time.Now().Add(-mcpClientIdleTimeout)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for key, pc := range p.clients {
|
||||
if pc.lastUsed.Before(cutoff) {
|
||||
pc.client.close()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *mcpClientPool) closeAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for key, pc := range p.clients {
|
||||
pc.client.close()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -19,7 +16,6 @@ import (
|
||||
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
@@ -230,147 +226,6 @@ func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// runChatTurn is the shared core of an operator-initiated turn: insert an
|
||||
// assistant placeholder, run a.chat with incremental persistence (so whatever
|
||||
// happened before an abort is never lost), finalize the row, and derive a
|
||||
// title. It is agnostic to the transport: `sink` receives every agent event
|
||||
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
|
||||
// no client attached — the frontend learns about those via the poller + the
|
||||
// status-driven "working" signal). The caller MUST already hold the session's
|
||||
// turn-gate permit.
|
||||
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
||||
// final `text` event (see the original inline comment in handleChat).
|
||||
var textParts []string
|
||||
var thinkingParts []string
|
||||
var finalText string
|
||||
var finalThinking string
|
||||
|
||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||
if err != nil {
|
||||
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||
}
|
||||
persist := func() {
|
||||
if msgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": finalText,
|
||||
"thinking": finalThinking,
|
||||
"tool_calls": toolCalls,
|
||||
})
|
||||
a.store.updateMessage(pctx, msgID, body)
|
||||
}
|
||||
|
||||
a.chat(ctx, sessionID, message, func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it, tool_result
|
||||
// merges the result into the same entry (matched by id).
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
if ev.IsThinking {
|
||||
thinkingParts = append(thinkingParts, t)
|
||||
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||
} else {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
}
|
||||
persist()
|
||||
}
|
||||
}
|
||||
sink(ev)
|
||||
})
|
||||
|
||||
// B.6: if the turn ended with no text and no tool calls (the model
|
||||
// empty-response'd and all retries failed), delete the placeholder row
|
||||
// instead of persisting an empty bubble.
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
a.store.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist() // final state — same row, updated one last time
|
||||
}
|
||||
|
||||
// Title: prefer the goal once set; else the first assistant answer.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
var goalTitle string
|
||||
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||
goalTitle = truncate(sess.Goal, 120)
|
||||
}
|
||||
title := goalTitle
|
||||
if title == "" {
|
||||
title = truncate(finalText, 80)
|
||||
}
|
||||
if title != "" {
|
||||
a.store.updateSessionTitle(pctx, sessionID, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainAcquireWait is how long drainQueued blocks for a busy gate before
|
||||
// re-queuing and deferring to the holder's own release-drain. A package var so
|
||||
// tests can shorten it; in production it just needs to outlast the brief
|
||||
// release→drain handoff window.
|
||||
var drainAcquireWait = 5 * time.Second
|
||||
|
||||
// drainQueued runs every queued operator message for a session as its own turn,
|
||||
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
|
||||
// releases the gate — from handleChat (live) and resumeSession (background) —
|
||||
// so a message queued while the agent was busy is acted on as soon as it's
|
||||
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
|
||||
// F2).
|
||||
//
|
||||
// Each queued turn is persisted incrementally and has no SSE client (the
|
||||
// browser detached after receiving the `queued` event); the frontend sees the
|
||||
// result via the 3s poller and the status-driven "working" indicator.
|
||||
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
|
||||
for {
|
||||
msg, ok := a.queue.dequeue(sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Block briefly for the gate. If a live turn grabbed it first, put the
|
||||
// message back — that turn's release will drain it again. Never stack.
|
||||
if !a.gate.acquire(sessionID, drainAcquireWait) {
|
||||
a.queue.requeueFront(sessionID, msg)
|
||||
return
|
||||
}
|
||||
slog.Info("nomos: running queued operator message", "session", sessionID)
|
||||
pctx := context.Background()
|
||||
// Run the turn inside a per-iteration closure so the gate release is
|
||||
// deferred to the end of THIS turn (and runs even if runChatTurn
|
||||
// panics — safego recovers the panic at the goroutine boundary, so a
|
||||
// non-deferred release would be skipped and the session's permit held
|
||||
// forever, deadlocking all future turns). A bare `defer release` in
|
||||
// the loop would be wrong too: Go defers run at function exit, not
|
||||
// iteration exit, so the gate would stay held across iterations.
|
||||
func() {
|
||||
defer a.gate.release(sessionID)
|
||||
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
@@ -853,338 +708,3 @@ func truncate(s string, n int) string {
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// ─── MCP Streamable HTTP client ────────────────────────────────────────
|
||||
|
||||
type mcpClient struct {
|
||||
baseURL string
|
||||
token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass)
|
||||
sessionID string
|
||||
http *http.Client
|
||||
nextID int
|
||||
mu sync.Mutex // this client is one stateful MCP session; serialize ITS OWN calls
|
||||
|
||||
// toolsCache holds the last tools/list result. The tool list is static
|
||||
// for the lifetime of one MCP connection — it only changes when the api
|
||||
// process (re)registers tools, i.e. on a restart, which this client
|
||||
// already detects and reacts to via reconnectLocked. Without this,
|
||||
// buildTools (called at the start of EVERY chat turn, including every
|
||||
// auto-continuation resume) paid a full tools/list round-trip every
|
||||
// single time for a list that's almost always identical to the last one.
|
||||
// Guarded separately from mu (not reused) so a cache check never
|
||||
// contends with an in-flight doRequest call for a different method.
|
||||
toolsMu sync.Mutex
|
||||
toolsCache []toolDef
|
||||
}
|
||||
|
||||
func newMCPClient(baseURL, token string) (*mcpClient, error) {
|
||||
c := &mcpClient{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("initialize", map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
if resp.sessionID == "" {
|
||||
return nil, fmt.Errorf("no session ID in initialize response")
|
||||
}
|
||||
c.sessionID = resp.sessionID
|
||||
|
||||
c.doRequest("notifications/initialized", map[string]any{})
|
||||
|
||||
slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...")
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type mcpJSONRPCResponse struct {
|
||||
sessionID string
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
|
||||
// errStaleSession signals that the MCP server rejected our session id (e.g.
|
||||
// after an api/MCP restart), so the client should re-initialize and retry.
|
||||
var errStaleSession = fmt.Errorf("mcp session stale")
|
||||
|
||||
// doRequest serializes MCP calls and transparently re-initializes the session
|
||||
// if the server has forgotten it (common after an api redeploy), retrying the
|
||||
// original call once. Without this, an api restart permanently breaks nomos
|
||||
// until it is itself restarted.
|
||||
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
resp, err := c.send(method, params)
|
||||
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
|
||||
slog.Warn("nomos: mcp session stale, reconnecting")
|
||||
if rerr := c.reconnectLocked(); rerr != nil {
|
||||
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
|
||||
}
|
||||
return c.send(method, params)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
||||
func (c *mcpClient) reconnectLocked() error {
|
||||
c.sessionID = ""
|
||||
// A reconnect means the api process was restarted (or forgot us) — its
|
||||
// tool registration may have changed, so the cached list is no longer
|
||||
// trustworthy.
|
||||
c.toolsMu.Lock()
|
||||
c.toolsCache = nil
|
||||
c.toolsMu.Unlock()
|
||||
resp, err := c.send("initialize", map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.sessionID == "" {
|
||||
return fmt.Errorf("no session ID on re-initialize")
|
||||
}
|
||||
c.sessionID = resp.sessionID
|
||||
_, _ = c.send("notifications/initialized", map[string]any{})
|
||||
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
|
||||
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||
c.nextID++
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": c.nextID,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if c.sessionID != "" {
|
||||
req.Header.Set("Mcp-Session-Id", c.sessionID)
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// A rejected/unknown session comes back as 4xx (commonly 400/404).
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||
return nil, errStaleSession
|
||||
}
|
||||
|
||||
result := &mcpJSONRPCResponse{}
|
||||
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
gotData := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
gotData = true
|
||||
data := line[6:]
|
||||
if err := json.Unmarshal([]byte(data), result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
||||
}
|
||||
|
||||
// Empty body with no result and a session set: the server likely dropped
|
||||
// our session. Notifications legitimately return no data, so exempt them.
|
||||
if !gotData && result.Result == nil && method != "notifications/initialized" {
|
||||
return nil, errStaleSession
|
||||
}
|
||||
|
||||
if result.sessionID != "" {
|
||||
c.sessionID = result.sessionID
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) callTool(name string, args map[string]any) (any, error) {
|
||||
resp, err := c.doRequest("tools/call", map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var toolResult struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &toolResult); err != nil {
|
||||
return string(resp.Result), nil
|
||||
}
|
||||
|
||||
var texts []string
|
||||
for _, c := range toolResult.Content {
|
||||
if c.Type == "text" {
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(c.Text), &parsed) == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
texts = append(texts, c.Text)
|
||||
}
|
||||
}
|
||||
if len(texts) == 1 {
|
||||
return texts[0], nil
|
||||
}
|
||||
return texts, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) listTools() ([]string, error) {
|
||||
resp, err := c.doRequest("tools/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tr struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, &tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, t := range tr.Tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (c *mcpClient) close() {
|
||||
}
|
||||
|
||||
// ─── Per-session MCP client pool ────────────────────────────────────────
|
||||
//
|
||||
// A single shared mcpClient serializes EVERY tool call across EVERY
|
||||
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
|
||||
// executes its SSH command synchronously inside that lock and is capped at
|
||||
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
|
||||
// calls, even trivial reads, behind it. The MCP *server* has no per-
|
||||
// connection state to protect (newServer in internal/mcp/server.go returns
|
||||
// one shared *mcp.Server instance whose tool handlers close only over the DB
|
||||
// pool, which is already safe for concurrent use) — the mutex existed purely
|
||||
// because the *client* reused one stateful transport session, not because
|
||||
// the server needed it. Giving each task's own session its own client
|
||||
// removes the cross-task serialization entirely: a task's own tool calls
|
||||
// stay sequential (which they already are — the agent loop calls tools one
|
||||
// at a time within a turn), but no longer block anyone else's.
|
||||
type mcpClientPool struct {
|
||||
baseURL string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
clients map[string]*pooledMCPClient
|
||||
}
|
||||
|
||||
type pooledMCPClient struct {
|
||||
client *mcpClient
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
func newMCPClientPool(baseURL, token string) *mcpClientPool {
|
||||
return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)}
|
||||
}
|
||||
|
||||
// get returns the client for sessionID, creating and initializing one (a
|
||||
// real MCP handshake) on first use. Session ids that don't identify a real
|
||||
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
|
||||
// the structured /query endpoint) still get exactly one dedicated,
|
||||
// reused client each via the same map — just keyed on a fixed string instead
|
||||
// of a real session id — so that traffic doesn't pay a fresh handshake per
|
||||
// request while still never sharing a connection with an actual task.
|
||||
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
||||
key := sessionID
|
||||
if key == "" {
|
||||
key = "ephemeral"
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if pc, ok := p.clients[key]; ok {
|
||||
pc.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pc.client, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// Initialize outside the lock — it's a network round-trip, and holding
|
||||
// the pool mutex for it would serialize unrelated sessions' first calls
|
||||
// behind each other, undermining the whole point of this pool.
|
||||
c, err := newMCPClient(p.baseURL, p.token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
// Another goroutine may have created one for the same key while we were
|
||||
// initializing (two of this session's tool calls racing on a cold
|
||||
// start); keep whichever won, close out the loser's connection (a no-op
|
||||
// today, but future-proof if mcpClient.close ever does real teardown).
|
||||
if existing, ok := p.clients[key]; ok {
|
||||
p.mu.Unlock()
|
||||
c.close()
|
||||
return existing.client, nil
|
||||
}
|
||||
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
|
||||
p.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
|
||||
// before eviction — long enough to outlive a single slow `run` (capped at 10
|
||||
// minutes server-side) plus normal think-time between a task's tool calls,
|
||||
// short enough not to accumulate one abandoned connection per finished task
|
||||
// forever.
|
||||
const mcpClientIdleTimeout = 20 * time.Minute
|
||||
|
||||
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
|
||||
func (p *mcpClientPool) sweep() {
|
||||
cutoff := time.Now().Add(-mcpClientIdleTimeout)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for key, pc := range p.clients {
|
||||
if pc.lastUsed.Before(cutoff) {
|
||||
pc.client.close()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *mcpClientPool) closeAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for key, pc := range p.clients {
|
||||
pc.client.close()
|
||||
delete(p.clients, key)
|
||||
}
|
||||
}
|
||||
152
cmd/nomos/workers.go
Normal file
152
cmd/nomos/workers.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// runChatTurn is the shared core of an operator-initiated turn: insert an
|
||||
// assistant placeholder, run a.chat with incremental persistence (so whatever
|
||||
// happened before an abort is never lost), finalize the row, and derive a
|
||||
// title. It is agnostic to the transport: `sink` receives every agent event
|
||||
// for delivery (SSE for a live handleChat, a no-op for a queued turn that has
|
||||
// no client attached — the frontend learns about those via the poller + the
|
||||
// status-driven "working" signal). The caller MUST already hold the session's
|
||||
// turn-gate permit.
|
||||
func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) {
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
||||
// final `text` event (see the original inline comment in handleChat).
|
||||
var textParts []string
|
||||
var thinkingParts []string
|
||||
var finalText string
|
||||
var finalThinking string
|
||||
|
||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||
if err != nil {
|
||||
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||
}
|
||||
persist := func() {
|
||||
if msgID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"role": "assistant",
|
||||
"text": finalText,
|
||||
"thinking": finalThinking,
|
||||
"tool_calls": toolCalls,
|
||||
})
|
||||
a.store.updateMessage(pctx, msgID, body)
|
||||
}
|
||||
|
||||
a.chat(ctx, sessionID, message, func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
m["type"] = ev.Type
|
||||
// One entry per tool call: tool_use creates it, tool_result
|
||||
// merges the result into the same entry (matched by id).
|
||||
id, _ := m["id"].(string)
|
||||
if id != "" && ev.Type == "tool_result" {
|
||||
for _, existing := range toolCalls {
|
||||
if eID, _ := existing["id"].(string); eID == id {
|
||||
for k, v := range m {
|
||||
existing[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toolCalls = append(toolCalls, m)
|
||||
}
|
||||
}
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
if ev.IsThinking {
|
||||
thinkingParts = append(thinkingParts, t)
|
||||
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||
} else {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
}
|
||||
persist()
|
||||
}
|
||||
}
|
||||
sink(ev)
|
||||
})
|
||||
|
||||
// B.6: if the turn ended with no text and no tool calls (the model
|
||||
// empty-response'd and all retries failed), delete the placeholder row
|
||||
// instead of persisting an empty bubble.
|
||||
if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
|
||||
a.store.deleteMessage(pctx, msgID)
|
||||
} else {
|
||||
persist() // final state — same row, updated one last time
|
||||
}
|
||||
|
||||
// Title: prefer the goal once set; else the first assistant answer.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
var goalTitle string
|
||||
if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||
goalTitle = truncate(sess.Goal, 120)
|
||||
}
|
||||
title := goalTitle
|
||||
if title == "" {
|
||||
title = truncate(finalText, 80)
|
||||
}
|
||||
if title != "" {
|
||||
a.store.updateSessionTitle(pctx, sessionID, title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainAcquireWait is how long drainQueued blocks for a busy gate before
|
||||
// re-queuing and deferring to the holder's own release-drain. A package var so
|
||||
// tests can shorten it; in production it just needs to outlast the brief
|
||||
// release→drain handoff window.
|
||||
var drainAcquireWait = 5 * time.Second
|
||||
|
||||
// drainQueued runs every queued operator message for a session as its own turn,
|
||||
// one at a time, under the turn gate. Called (in a goroutine) whenever a turn
|
||||
// releases the gate — from handleChat (live) and resumeSession (background) —
|
||||
// so a message queued while the agent was busy is acted on as soon as it's
|
||||
// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03
|
||||
// F2).
|
||||
//
|
||||
// Each queued turn is persisted incrementally and has no SSE client (the
|
||||
// browser detached after receiving the `queued` event); the frontend sees the
|
||||
// result via the 3s poller and the status-driven "working" indicator.
|
||||
func (a *agent) drainQueued(ctx context.Context, sessionID string) {
|
||||
for {
|
||||
msg, ok := a.queue.dequeue(sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Block briefly for the gate. If a live turn grabbed it first, put the
|
||||
// message back — that turn's release will drain it again. Never stack.
|
||||
if !a.gate.acquire(sessionID, drainAcquireWait) {
|
||||
a.queue.requeueFront(sessionID, msg)
|
||||
return
|
||||
}
|
||||
slog.Info("nomos: running queued operator message", "session", sessionID)
|
||||
pctx := context.Background()
|
||||
// Run the turn inside a per-iteration closure so the gate release is
|
||||
// deferred to the end of THIS turn (and runs even if runChatTurn
|
||||
// panics — safego recovers the panic at the goroutine boundary, so a
|
||||
// non-deferred release would be skipped and the session's permit held
|
||||
// forever, deadlocking all future turns). A bare `defer release` in
|
||||
// the loop would be wrong too: Go defers run at function exit, not
|
||||
// iteration exit, so the gate would stay held across iterations.
|
||||
func() {
|
||||
defer a.gate.release(sessionID)
|
||||
a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {})
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Run starts the actuator loop. Blocks until ctx is cancelled.
|
||||
@@ -403,7 +402,7 @@ func ProvisionVM(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs m
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshExecSimple runs a command over SSH with a simple client setup.
|
||||
// sshExecSimple runs a command over SSH using the shared dial/run primitives.
|
||||
// Uses the default SSH key from SSH_KEY_PATH or ~/.ssh/id_rsa.
|
||||
func sshExecSimple(ctx context.Context, host, user, command string) (string, error) {
|
||||
keyPath := os.Getenv("SSH_KEY_PATH")
|
||||
@@ -411,55 +410,19 @@ func sshExecSimple(ctx context.Context, host, user, command string) (string, err
|
||||
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||
}
|
||||
|
||||
keyBytes, err := os.ReadFile(keyPath)
|
||||
signer, err := LoadSigner(keyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read ssh key: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||
client, err := Dial(ctx, DialOptions{Host: host, User: user, Signer: signer})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse ssh key: %w", err)
|
||||
}
|
||||
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: HostKeyCallback(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", host+":22", clientCfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ssh dial %s: %w", host, err)
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
type result struct {
|
||||
output string
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
out, e := session.CombinedOutput(command)
|
||||
ch <- result{output: string(out), err: e}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Close()
|
||||
return "", ctx.Err()
|
||||
case res := <-ch:
|
||||
if res.err != nil {
|
||||
return res.output, res.err
|
||||
}
|
||||
return res.output, nil
|
||||
}
|
||||
out, err := RunCombinedOutput(ctx, client, command)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// resolveHost resolves a host entity slug to (address, user) for SSH.
|
||||
|
||||
101
internal/actuator/client.go
Normal file
101
internal/actuator/client.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// defaultDialTimeout bounds an SSH dial when the caller leaves Timeout unset.
|
||||
// 10s matches the previous hardcoded value at every dial site.
|
||||
const defaultDialTimeout = 10 * time.Second
|
||||
|
||||
// LoadSigner reads and parses the private key at keyPath.
|
||||
func LoadSigner(keyPath string) (ssh.Signer, error) {
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ssh key: %w", err)
|
||||
}
|
||||
return LoadSignerFromBytes(key)
|
||||
}
|
||||
|
||||
// LoadSignerFromBytes parses an in-memory private key into an ssh.Signer.
|
||||
func LoadSignerFromBytes(key []byte) (ssh.Signer, error) {
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ssh key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// DialOptions configures an SSH dial.
|
||||
type DialOptions struct {
|
||||
Host string
|
||||
Port int // 0 means 22
|
||||
User string
|
||||
Signer ssh.Signer
|
||||
Timeout time.Duration // dial timeout; <=0 means defaultDialTimeout
|
||||
}
|
||||
|
||||
// Dial opens a crypto/ssh connection through the centralized HostKeyCallback.
|
||||
// The connection itself is bounded by Timeout; ctx is respected by callers
|
||||
// via RunCombinedOutput once the session is running.
|
||||
func Dial(ctx context.Context, opts DialOptions) (*ssh.Client, error) {
|
||||
port := opts.Port
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDialTimeout
|
||||
}
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: opts.User,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(opts.Signer)},
|
||||
HostKeyCallback: HostKeyCallback(),
|
||||
Timeout: timeout,
|
||||
}
|
||||
addr := net.JoinHostPort(opts.Host, fmt.Sprintf("%d", port))
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh dial %s:%d: %w", opts.Host, port, err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// RunCombinedOutput runs cmd on an established client and returns its combined
|
||||
// stdout/stderr. Context cancellation closes the session to abort the remote
|
||||
// command instead of blocking until it finishes — the same goroutine+select
|
||||
// pattern the actuator, mcp, and scheduler each reimplemented before.
|
||||
func RunCombinedOutput(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()
|
||||
|
||||
type result struct {
|
||||
out []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := session.CombinedOutput(cmd)
|
||||
ch <- result{out: out, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
session.Close()
|
||||
return nil, ctx.Err()
|
||||
case res := <-ch:
|
||||
if res.err != nil {
|
||||
return res.out, fmt.Errorf("command: %w", res.err)
|
||||
}
|
||||
return res.out, nil
|
||||
}
|
||||
}
|
||||
88
internal/actuator/client_test.go
Normal file
88
internal/actuator/client_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package actuator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestLoadSignerRejectsBadInput(t *testing.T) {
|
||||
if _, err := LoadSignerFromBytes([]byte("not a private key")); err == nil {
|
||||
t.Error("LoadSignerFromBytes should reject a non-key input")
|
||||
}
|
||||
if _, err := LoadSigner("/nonexistent/key"); err == nil {
|
||||
t.Error("LoadSigner should fail on a missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSignerRoundTrip(t *testing.T) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal private key: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(block)
|
||||
|
||||
signer, err := LoadSignerFromBytes(pemBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSignerFromBytes on a valid key: %v", err)
|
||||
}
|
||||
if signer == nil {
|
||||
t.Fatal("signer is nil")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "id_ed25519")
|
||||
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||
t.Fatalf("write key file: %v", err)
|
||||
}
|
||||
fromFile, err := LoadSigner(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSigner(%s): %v", path, err)
|
||||
}
|
||||
if !bytes.Equal(fromFile.PublicKey().Marshal(), signer.PublicKey().Marshal()) {
|
||||
t.Error("file and in-memory signers resolved to different public keys")
|
||||
}
|
||||
}
|
||||
|
||||
// Dial needs a real SSH server to run a command, but its option normalization
|
||||
// is verifiable without one: a zero Port must default to 22 (so the dial error
|
||||
// references host:22, not host:0), and a closed port yields a dial error rather
|
||||
// than panicking.
|
||||
func TestDialDefaultsPort(t *testing.T) {
|
||||
_, err := Dial(context.Background(), DialOptions{Host: "127.0.0.1", Signer: mustSigner(t)})
|
||||
if err == nil {
|
||||
t.Fatal("Dial to a closed port should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "127.0.0.1:22") {
|
||||
t.Errorf("Dial error = %q, want it to reference 127.0.0.1:22", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustSigner(t *testing.T) ssh.Signer {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
s, err := LoadSignerFromBytes(pem.EncodeToMemory(block))
|
||||
if err != nil {
|
||||
t.Fatalf("parse key: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -131,37 +130,19 @@ func ExecuteProcedure(
|
||||
start := time.Now()
|
||||
|
||||
// Parse the SSH key
|
||||
key, err := os.ReadFile(cfg.KeyPath)
|
||||
signer, err := LoadSigner(cfg.KeyPath)
|
||||
if err != nil {
|
||||
return SSHResult{
|
||||
Err: fmt.Errorf("read ssh key: %w", err),
|
||||
Err: err,
|
||||
Duration: time.Since(start),
|
||||
Verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return SSHResult{
|
||||
Err: fmt.Errorf("parse ssh key: %w", err),
|
||||
Duration: time.Since(start),
|
||||
Verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
if cfg.Port == 0 {
|
||||
addr = net.JoinHostPort(cfg.Host, "22")
|
||||
}
|
||||
|
||||
clientCfg := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: HostKeyCallback(),
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, clientCfg)
|
||||
client, err := Dial(ctx, DialOptions{
|
||||
Host: cfg.Host, Port: cfg.Port, User: cfg.User,
|
||||
Signer: signer, Timeout: cfg.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
class := classifySSHError(err)
|
||||
return SSHResult{
|
||||
@@ -229,38 +210,11 @@ func ExecuteProcedure(
|
||||
}
|
||||
}
|
||||
|
||||
// runSSHCommand executes a single command over an established SSH session.
|
||||
// Uses context-aware goroutines: ctx.Done() closes the session.
|
||||
// runSSHCommand executes a single command over an established SSH session via
|
||||
// the shared RunCombinedOutput primitive (context-aware abort + combined output).
|
||||
func runSSHCommand(ctx context.Context, client *ssh.Client, command string) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Wrap in goroutine so we can abort on ctx.Done()
|
||||
type result struct {
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
out, err := session.CombinedOutput(command)
|
||||
ch <- result{output: string(out), err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Close the session to abort the SSH command
|
||||
session.Close()
|
||||
return "", ctx.Err()
|
||||
case res := <-ch:
|
||||
if res.err != nil {
|
||||
return res.output, fmt.Errorf("command: %w", res.err)
|
||||
}
|
||||
return res.output, nil
|
||||
}
|
||||
out, err := RunCombinedOutput(ctx, client, command)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// ─── Procedure parsing ────────────────────────────────────────────────────
|
||||
|
||||
196
internal/checkdefaults/build_test.go
Normal file
196
internal/checkdefaults/build_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package checkdefaults
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/ontology"
|
||||
)
|
||||
|
||||
// Table-driven coverage of every implemented buildKind branch and the ssh()
|
||||
// helper's user/port/args propagation. The previous tests exercised only
|
||||
// ping/process/http/resource; updates, capacity, backup, cert-expiry,
|
||||
// vm-status and dns were unverified.
|
||||
func TestBuildKindAllImplementedKinds(t *testing.T) {
|
||||
host := "10.0.0.5"
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
target Target
|
||||
attrs map[string]any
|
||||
host string
|
||||
wantSkip bool // true → expect a reason and zero defs
|
||||
wantDefs int
|
||||
wantKind string
|
||||
wantKey string // a config key to assert
|
||||
wantVal any // its expected value
|
||||
wantReason string // substring when skipping
|
||||
wantInterv int32 // expected interval on the (single) produced def
|
||||
}{
|
||||
{
|
||||
name: "ping with host", kind: KindPing, host: host,
|
||||
wantDefs: 1, wantKind: "ping", wantKey: "host", wantVal: host, wantInterv: 30,
|
||||
},
|
||||
{name: "ping no host skips", kind: KindPing, wantSkip: true, wantReason: "no address"},
|
||||
|
||||
{
|
||||
name: "resource expands to four ssh scripts", kind: KindResource, host: host,
|
||||
wantDefs: 4, wantKind: "ssh-script", wantKey: "host", wantVal: host, wantInterv: 60,
|
||||
},
|
||||
{name: "resource no host skips", kind: KindResource, wantSkip: true, wantReason: "no address"},
|
||||
|
||||
{
|
||||
name: "updates is daily", kind: KindUpdates, host: host,
|
||||
wantDefs: 1, wantKind: "ssh-script", wantKey: "script", wantVal: "updates_check.sh", wantInterv: 86400,
|
||||
},
|
||||
{name: "updates no host skips", kind: KindUpdates, wantSkip: true, wantReason: "no address"},
|
||||
|
||||
{
|
||||
name: "capacity is one disk script", kind: KindCapacity, host: host,
|
||||
wantDefs: 1, wantKind: "ssh-script", wantKey: "script", wantVal: "disk_usage_check.sh", wantInterv: 60,
|
||||
},
|
||||
{name: "capacity no host skips", kind: KindCapacity, wantSkip: true, wantReason: "no address"},
|
||||
|
||||
{
|
||||
name: "backup needs path and host", kind: KindBackup, host: host,
|
||||
attrs: map[string]any{"path": "/backups/db"},
|
||||
wantDefs: 1, wantKind: "backup-freshness", wantKey: "path", wantVal: "/backups/db", wantInterv: 86400,
|
||||
},
|
||||
{name: "backup without path skips", kind: KindBackup, host: host, wantSkip: true, wantReason: "no path"},
|
||||
{name: "backup without host skips", kind: KindBackup, attrs: map[string]any{"path": "/x"}, wantSkip: true, wantReason: "no address"},
|
||||
|
||||
{
|
||||
name: "backup honors backup_max_age_s override", kind: KindBackup, host: host,
|
||||
attrs: map[string]any{"path": "/x", "backup_max_age_s": float64(3600)},
|
||||
wantDefs: 1, wantKey: "max_age_s", wantVal: 3600,
|
||||
},
|
||||
|
||||
{
|
||||
name: "cert-expiry from hostname attr", kind: KindCertExpiry,
|
||||
attrs: map[string]any{"hostname": "media.hubris.network"},
|
||||
wantDefs: 1, wantKind: "cert-expiry", wantKey: "host", wantVal: "media.hubris.network", wantInterv: 3600,
|
||||
},
|
||||
{
|
||||
name: "cert-expiry from dotted name", kind: KindCertExpiry, target: Target{Name: "media.hubris.network"},
|
||||
wantDefs: 1, wantKey: "host", wantVal: "media.hubris.network",
|
||||
},
|
||||
{
|
||||
name: "cert-expiry propagates dial attr", kind: KindCertExpiry,
|
||||
attrs: map[string]any{"hostname": "media.hubris.network", "dial": "10.0.0.2"},
|
||||
wantDefs: 1, wantKey: "dial", wantVal: "10.0.0.2",
|
||||
},
|
||||
{name: "cert-expiry without a host name skips", kind: KindCertExpiry, target: Target{Name: "jellyfin"}, wantSkip: true, wantReason: "no hostname"},
|
||||
|
||||
{
|
||||
name: "vm-status needs pve_id", kind: KindVMStatus, attrs: map[string]any{"pve_id": float64(101)},
|
||||
wantDefs: 1, wantKind: "vm-status", wantInterv: 60,
|
||||
},
|
||||
{name: "vm-status without pve_id skips", kind: KindVMStatus, wantSkip: true, wantReason: "no pve_id"},
|
||||
|
||||
{
|
||||
name: "dns resolves entity name", kind: KindDNS, target: Target{Name: "hubris.network"},
|
||||
wantDefs: 1, wantKind: "dns", wantKey: "name", wantVal: "hubris.network", wantInterv: 300,
|
||||
},
|
||||
{name: "dns without a name skips", kind: KindDNS, target: Target{}, wantSkip: true, wantReason: "no name"},
|
||||
|
||||
{name: "unknown kind skips", kind: "telepathy", host: host, wantSkip: true, wantReason: "no builder"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defs, reason := buildKind(c.kind, c.target, c.attrs, c.host, "root", 22)
|
||||
if c.wantSkip {
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected zero defs, got %d", len(defs))
|
||||
}
|
||||
if c.wantReason != "" && !strings.Contains(reason, c.wantReason) {
|
||||
t.Errorf("reason = %q, want substring %q", reason, c.wantReason)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(defs) != c.wantDefs {
|
||||
t.Fatalf("got %d defs (%s), want %d", len(defs), reason, c.wantDefs)
|
||||
}
|
||||
if reason != "" {
|
||||
t.Errorf("unexpected skip reason: %q", reason)
|
||||
}
|
||||
if c.wantKind != "" {
|
||||
if got := defs[0].kind; got != c.wantKind {
|
||||
t.Errorf("kind = %q, want %q", got, c.wantKind)
|
||||
}
|
||||
}
|
||||
if c.wantKey != "" {
|
||||
if got := defs[0].config[c.wantKey]; !reflect.DeepEqual(got, c.wantVal) {
|
||||
t.Errorf("config[%q] = %v (%T), want %v (%T)", c.wantKey, got, got, c.wantVal, c.wantVal)
|
||||
}
|
||||
}
|
||||
if c.wantInterv != 0 && defs[0].interval != c.wantInterv {
|
||||
t.Errorf("interval = %d, want %d", defs[0].interval, c.wantInterv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ssh() must add user/port/args only when they differ from the root/22/empty
|
||||
// defaults, so generated configs stay minimal and stable across re-seeds.
|
||||
func TestBuildKindSSHOnlyEmitsNonDefaultUserPortArgs(t *testing.T) {
|
||||
t.Run("default root 22 omits user and port", func(t *testing.T) {
|
||||
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "root", 22)
|
||||
for _, d := range defs {
|
||||
if _, ok := d.config["user"]; ok {
|
||||
t.Errorf("root should not emit user: %v", d.config)
|
||||
}
|
||||
if _, ok := d.config["port"]; ok {
|
||||
t.Errorf("port 22 should not emit port: %v", d.config)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("non-root user and non-22 port are emitted", func(t *testing.T) {
|
||||
defs, _ := buildKind(KindResource, Target{}, nil, "10.0.0.1", "oikos", 2222)
|
||||
if defs[0].config["user"] != "oikos" {
|
||||
t.Errorf("user = %v, want oikos", defs[0].config["user"])
|
||||
}
|
||||
if defs[0].config["port"] != 2222 {
|
||||
t.Errorf("port = %v, want 2222", defs[0].config["port"])
|
||||
}
|
||||
})
|
||||
t.Run("process unit name lands in args", func(t *testing.T) {
|
||||
defs, _ := buildKind(KindProcess, Target{Name: "jellyfin"}, nil, "10.0.0.1", "root", 22)
|
||||
if defs[0].config["args"] != "jellyfin" {
|
||||
t.Errorf("args = %v, want jellyfin", defs[0].config["args"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveMonitoringAttr implements the entity-level `monitoring` override
|
||||
// (project decision health_checks.monitoring_override): "none"/"" opts out,
|
||||
// a kind-list replaces the type defaults, anything else falls back.
|
||||
func TestResolveMonitoringAttr(t *testing.T) {
|
||||
fallback := ontology.MonitoringResolution{Declared: true, Kinds: []string{"ping"}, Source: "type"}
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want ontology.MonitoringResolution
|
||||
}{
|
||||
{"none opts out", "none", ontology.MonitoringResolution{Declared: true, Source: "attribute"}},
|
||||
{"empty opts out", "", ontology.MonitoringResolution{Declared: true, Source: "attribute"}},
|
||||
{
|
||||
"kind list overrides",
|
||||
[]any{"http", "process"},
|
||||
ontology.MonitoringResolution{Declared: true, Kinds: []string{"http", "process"}, Source: "attribute"},
|
||||
},
|
||||
{"list drops empty and non-string entries", []any{"http", "", 7, "dns"}, ontology.MonitoringResolution{Declared: true, Kinds: []string{"http", "dns"}, Source: "attribute"}},
|
||||
{"non-string scalar falls back to type default", float64(42), fallback},
|
||||
{"nil falls back", nil, fallback},
|
||||
{"unrecognized string falls back", "weird", fallback},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := resolveMonitoringAttr(c.in, fallback)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("resolveMonitoringAttr(%v) = %+v, want %+v", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
@@ -79,8 +78,8 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
|
||||
return fmt.Errorf("%d inbound relationship edges remaining", count)
|
||||
}
|
||||
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := map[string]string{
|
||||
@@ -88,26 +87,26 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
|
||||
"secrets-revoked": "secrets_revoked",
|
||||
"ingress-dns-removed": "ingress_dns_removed",
|
||||
}[check]
|
||||
if !strings.Contains(attrs, want) {
|
||||
if !attrTruthy(attrs, want) {
|
||||
return fmt.Errorf("%s not recorded in entity attributes", want)
|
||||
}
|
||||
case "age-key-enrolled-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "age_pubkey") {
|
||||
if !attrTruthy(attrs, "age_pubkey") {
|
||||
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
|
||||
}
|
||||
}
|
||||
case "mesh-joined-if-needed":
|
||||
if entityType == "workstation" {
|
||||
var attrs string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
|
||||
attrs, err := fetchAttrs(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(attrs, "mesh_ip") {
|
||||
if !attrTruthy(attrs, "mesh_ip") {
|
||||
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
|
||||
}
|
||||
}
|
||||
@@ -144,3 +143,40 @@ func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAttrs loads an entity's JSONB attributes column as a decoded map.
|
||||
// Missing attributes decode to an empty map (every key absent).
|
||||
func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
|
||||
var raw string
|
||||
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var attrs map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &attrs); err != nil {
|
||||
return nil, fmt.Errorf("decode entity attributes: %w", err)
|
||||
}
|
||||
if attrs == nil {
|
||||
attrs = map[string]any{}
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
// attrTruthy reports whether key is present in attrs with a meaningful value.
|
||||
// It replaces substring matching on raw JSONB text: a previous strings.Contains
|
||||
// check treated {"backups_verified": false} as satisfied (the key text was
|
||||
// present) and bypassed the attributes GIN index. Booleans must be true;
|
||||
// strings must be non-empty; nil/absent fail.
|
||||
func attrTruthy(attrs map[string]any, key string) bool {
|
||||
v, ok := attrs[key]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return t != ""
|
||||
default:
|
||||
return true // numbers, objects, arrays count as present
|
||||
}
|
||||
}
|
||||
|
||||
55
internal/db/lifecycle_test.go
Normal file
55
internal/db/lifecycle_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// attrTruthy replaces a previous strings.Contains check over raw JSONB text.
|
||||
// The key regression it guards: a literal attribute like
|
||||
// {"backups_verified": false} must NOT satisfy the "backups-verified"
|
||||
// precondition, even though the key text is present in the column.
|
||||
func TestAttrTruthy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
attrs map[string]any
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"absent", map[string]any{}, "backups_verified", false},
|
||||
{"nil map", nil, "backups_verified", false},
|
||||
{"explicit nil value", map[string]any{"backups_verified": nil}, "backups_verified", false},
|
||||
{"bool true", map[string]any{"backups_verified": true}, "backups_verified", true},
|
||||
{"bool false is the regression case", map[string]any{"backups_verified": false}, "backups_verified", false},
|
||||
{"nonempty string age pubkey", map[string]any{"age_pubkey": "age1abc"}, "age_pubkey", true},
|
||||
{"empty string is falsy", map[string]any{"mesh_ip": ""}, "mesh_ip", false},
|
||||
{"number counts as present", map[string]any{"port": float64(22)}, "port", true},
|
||||
{"other keys present", map[string]any{"backups_verified": true, "unrelated": "x"}, "backups_verified", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := attrTruthy(tc.attrs, tc.key); got != tc.want {
|
||||
t.Fatalf("attrTruthy(%v, %q) = %v, want %v", tc.attrs, tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAttrs decodes the JSONB column text; verify the decode shape that
|
||||
// attrTruthy then evaluates (the DB round-trip itself is covered by make test-db).
|
||||
func TestAttrTruthyAfterDecode(t *testing.T) {
|
||||
raw := `{"backups_verified": true, "mesh_ip": "10.0.0.5", "secrets_revoked": false}`
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !attrTruthy(got, "backups_verified") {
|
||||
t.Error("backups_verified should be truthy after decode")
|
||||
}
|
||||
if !attrTruthy(got, "mesh_ip") {
|
||||
t.Error("mesh_ip should be truthy after decode")
|
||||
}
|
||||
if attrTruthy(got, "secrets_revoked") {
|
||||
t.Error("secrets_revoked:false is the regression — must be falsy")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,36 @@ UPDATE entities SET
|
||||
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
|
||||
RETURNING *;
|
||||
|
||||
-- name: MergeEntityAttributes :execrows
|
||||
-- Shallow-merge a JSON patch into an entity's attributes (the
|
||||
-- update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||
-- `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||
UPDATE entities SET
|
||||
attributes = attributes || sqlc.arg('patch')::jsonb,
|
||||
updated_at = now()
|
||||
WHERE slug = sqlc.arg('slug');
|
||||
|
||||
-- name: SetEntityState :execrows
|
||||
-- Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||
-- after db.ValidateTransition). Replaces the raw
|
||||
-- `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||
UPDATE entities SET
|
||||
state = sqlc.arg('state'),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
|
||||
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
|
||||
-- internal/httpapi (see impl.go).
|
||||
-- internal/httpapi (see entities.go GetBlastRadius).
|
||||
--
|
||||
-- Deliberate raw-SQL exceptions (plan E2): the httpapi entity *read* handlers
|
||||
-- (ListEntities/GetEntity/GetGraph/queryEntities) project a fixed
|
||||
-- `entityCols` column set (entities.* + a LEFT JOIN to entity_status for
|
||||
-- health/last_check_at) and scan it positionally into the oapi-generated
|
||||
-- gen.Entity shape. sqlc generates its own row struct per query and cannot
|
||||
-- emit gen.Entity, so migrating those reads would add a per-call field-by-
|
||||
-- field mapping with no compile-time gain and real column-order risk. They
|
||||
-- stay hand-written pgx, like blast_radius and the seed/export bulk paths
|
||||
-- noted in sqlc.yaml. The mutation/relationship surface (MergeEntityAttributes,
|
||||
-- SetEntityState, InsertRelationshipIfAbsent, EndCurrentRelationship) IS
|
||||
-- migrated and is what the entity CRUD tools now call.
|
||||
|
||||
@@ -25,3 +25,18 @@ ORDER BY r.type, se.slug, te.slug;
|
||||
-- name: EndCurrentRelationship :execrows
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
|
||||
|
||||
-- name: InsertRelationshipIfAbsent :execrows
|
||||
-- Idempotent relationship insert (the create_relationship surface): no-op if
|
||||
-- an active edge of the same source/target/type already exists. Replaces the
|
||||
-- raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT sqlc.arg('source_id'), sqlc.arg('target_id'), sqlc.arg('type'),
|
||||
sqlc.arg('attributes')::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = sqlc.arg('source_id')
|
||||
AND target_id = sqlc.arg('target_id')
|
||||
AND type = sqlc.arg('type')
|
||||
AND valid_to IS NULL
|
||||
);
|
||||
|
||||
@@ -177,6 +177,52 @@ func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]E
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const mergeEntityAttributes = `-- name: MergeEntityAttributes :execrows
|
||||
UPDATE entities SET
|
||||
attributes = attributes || $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE slug = $2
|
||||
`
|
||||
|
||||
type MergeEntityAttributesParams struct {
|
||||
Patch []byte
|
||||
Slug string
|
||||
}
|
||||
|
||||
// Shallow-merge a JSON patch into an entity's attributes (the
|
||||
// update_entity_attributes MCP/HTTP surface). Replaces the raw
|
||||
// `attributes = attributes || $2::jsonb` used in entity_tools.go.
|
||||
func (q *Queries) MergeEntityAttributes(ctx context.Context, arg MergeEntityAttributesParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, mergeEntityAttributes, arg.Patch, arg.Slug)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const setEntityState = `-- name: SetEntityState :execrows
|
||||
UPDATE entities SET
|
||||
state = $1,
|
||||
updated_at = now()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type SetEntityStateParams struct {
|
||||
State *string
|
||||
ID uuid.UUID
|
||||
}
|
||||
|
||||
// Set an entity's lifecycle state by id (the set_entity_state surface, run
|
||||
// after db.ValidateTransition). Replaces the raw
|
||||
// `UPDATE entities SET state = $2 ... WHERE id = $1`.
|
||||
func (q *Queries) SetEntityState(ctx context.Context, arg SetEntityStateParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, setEntityState, arg.State, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const updateEntity = `-- name: UpdateEntity :one
|
||||
UPDATE entities SET
|
||||
name = COALESCE($1, name),
|
||||
|
||||
@@ -31,6 +31,42 @@ func (q *Queries) EndCurrentRelationship(ctx context.Context, arg EndCurrentRela
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const insertRelationshipIfAbsent = `-- name: InsertRelationshipIfAbsent :execrows
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, $3,
|
||||
$4::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1
|
||||
AND target_id = $2
|
||||
AND type = $3
|
||||
AND valid_to IS NULL
|
||||
)
|
||||
`
|
||||
|
||||
type InsertRelationshipIfAbsentParams struct {
|
||||
SourceID uuid.UUID
|
||||
TargetID uuid.UUID
|
||||
Type string
|
||||
Attributes []byte
|
||||
}
|
||||
|
||||
// Idempotent relationship insert (the create_relationship surface): no-op if
|
||||
// an active edge of the same source/target/type already exists. Replaces the
|
||||
// raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
|
||||
func (q *Queries) InsertRelationshipIfAbsent(ctx context.Context, arg InsertRelationshipIfAbsentParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, insertRelationshipIfAbsent,
|
||||
arg.SourceID,
|
||||
arg.TargetID,
|
||||
arg.Type,
|
||||
arg.Attributes,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const listEntityRelations = `-- name: ListEntityRelations :many
|
||||
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
|
||||
r.valid_from, r.valid_to
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/actuator"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/execlog"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -111,22 +111,14 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
|
||||
user = _sshUser
|
||||
}
|
||||
|
||||
addr := host + ":22"
|
||||
signer, err := ssh.ParsePrivateKey(_sshKey)
|
||||
signer, err := actuator.LoadSignerFromBytes(_sshKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dial %s: %w", host, err)
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
|
||||
88
internal/httpapi/client_context.go
Normal file
88
internal/httpapi/client_context.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
)
|
||||
|
||||
func (s *Server) GetClientContext(ctx context.Context, req gen.GetClientContextRequestObject) (gen.GetClientContextResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
_, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var version int64
|
||||
_ = s.pool.QueryRow(ctx,
|
||||
"SELECT version FROM context_version WHERE singleton = true").Scan(&version)
|
||||
|
||||
var filesChanged, toolsChanged []string
|
||||
var sopsChanged bool
|
||||
if req.Params.Since != nil {
|
||||
rows, qErr := s.pool.Query(ctx,
|
||||
"SELECT path FROM context_files WHERE last_changed > $1", *req.Params.Since)
|
||||
if qErr == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if scanErr := rows.Scan(&p); scanErr == nil {
|
||||
// Matches tools/setup-*.sh (the auto-setup convention —
|
||||
// see tools/post-pull.sh). Was tools/*.setup.sh until
|
||||
// 2026-07-12, which never matched any real filename.
|
||||
if strings.HasPrefix(p, "tools/setup-") && strings.HasSuffix(p, ".sh") {
|
||||
toolsChanged = append(toolsChanged, p)
|
||||
} else if p == ".sops.yaml" {
|
||||
sopsChanged = true
|
||||
} else {
|
||||
filesChanged = append(filesChanged, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filesChanged == nil {
|
||||
filesChanged = []string{}
|
||||
}
|
||||
if toolsChanged == nil {
|
||||
toolsChanged = []string{}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
return gen.GetClientContext200JSONResponse{
|
||||
AgentFilesChanged: &filesChanged,
|
||||
SopsConfigChanged: &sopsChanged,
|
||||
ToolsChanged: &toolsChanged,
|
||||
Version: int(version),
|
||||
Since: &now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetClientSecrets(ctx context.Context, req gen.GetClientSecretsRequestObject) (gen.GetClientSecretsResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
_, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
if s.secretsManager != nil {
|
||||
list, listErr := s.secretsManager.List(ctx)
|
||||
if listErr == nil {
|
||||
prefix := "clients/" + slug + "/"
|
||||
for _, k := range list {
|
||||
if strings.HasPrefix(k, prefix) || strings.HasPrefix(k, "shared/") {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []string{}
|
||||
}
|
||||
|
||||
return gen.GetClientSecrets200JSONResponse{Keys: keys}, nil
|
||||
}
|
||||
310
internal/httpapi/client_lifecycle.go
Normal file
310
internal/httpapi/client_lifecycle.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
openapi_types "github.com/oapi-codegen/runtime/types"
|
||||
)
|
||||
|
||||
func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestObject) (gen.EnrollClientResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
id, err := s.resolveEntityID(ctx, req.Body.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current, err := sqlcgen.New(s.pool).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Body.Slug)
|
||||
}
|
||||
|
||||
currentState := ""
|
||||
if current.State != nil {
|
||||
currentState = *current.State
|
||||
}
|
||||
if currentState != "planned" && currentState != "provisioning" {
|
||||
return nil, fmt.Errorf("%w: entity %s is in state %q, expected planned or provisioning",
|
||||
domain.ErrInvalidTransition, req.Body.Slug, currentState)
|
||||
}
|
||||
|
||||
meshIP := ""
|
||||
if req.Body.MeshIp != nil {
|
||||
meshIP = *req.Body.MeshIp
|
||||
}
|
||||
if meshIP == "" {
|
||||
return nil, fmt.Errorf("%w: mesh_ip is required for enrollment", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
agePubKey, agePrivKey, err := generateAgeKeypair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("age key generation: %w", err)
|
||||
}
|
||||
|
||||
if s.secretsManager != nil {
|
||||
keyPath := "clients/" + req.Body.Slug + "/age-key"
|
||||
_ = s.secretsManager.Set(ctx, keyPath, agePrivKey)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var attrs map[string]any
|
||||
if len(current.Attributes) > 0 {
|
||||
json.Unmarshal(current.Attributes, &attrs)
|
||||
}
|
||||
if attrs == nil {
|
||||
attrs = map[string]any{}
|
||||
}
|
||||
attrs["age_pubkey"] = agePubKey
|
||||
attrs["mesh_ip"] = meshIP
|
||||
attrs["enrolled_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
if req.Body.Hostname != nil {
|
||||
attrs["hostname"] = *req.Body.Hostname
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
|
||||
q := sqlcgen.New(tx)
|
||||
provisioning := "provisioning"
|
||||
now := time.Now().UTC()
|
||||
_, err = q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
||||
State: &provisioning,
|
||||
Attributes: attrsJSON,
|
||||
ID: id,
|
||||
Version: current.Version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(ctx,
|
||||
"UPDATE entities SET enrolled_at = $1 WHERE id = $2", now, id)
|
||||
|
||||
_, actor := actorInfo(ctx)
|
||||
entityID := id
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "enroll",
|
||||
&entityID, "POST", "/api/v1/clients/enroll", "",
|
||||
nil,
|
||||
map[string]any{"slug": req.Body.Slug, "mesh_ip": meshIP})
|
||||
_ = observability.Event(ctx, q, "client.enrolled", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
||||
|
||||
if err := ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, current.Name, attrsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store age key in Infisical when backend is available.
|
||||
if s.secretsManager != nil {
|
||||
keyPath := "clients/" + req.Body.Slug + "/age-key"
|
||||
_ = s.secretsManager.Set(ctx, keyPath, agePrivKey)
|
||||
}
|
||||
|
||||
resp := gen.EnrollResponse{
|
||||
AgePublicKey: agePubKey,
|
||||
AgePrivateKey: agePrivKey,
|
||||
}
|
||||
|
||||
return gen.EnrollClient200JSONResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *Server) ProvisionEntity(ctx context.Context, req gen.ProvisionEntityRequestObject) (gen.ProvisionEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
hostSlug := req.Body.Host
|
||||
hostID, err := s.resolveEntityID(ctx, hostSlug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: host %q not found", domain.ErrNotFound, hostSlug)
|
||||
}
|
||||
|
||||
var existingID uuid.UUID
|
||||
err = s.pool.QueryRow(ctx,
|
||||
"SELECT id FROM entities WHERE slug = $1", req.Body.Slug).Scan(&existingID)
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: entity slug %q already exists", domain.ErrConflict, req.Body.Slug)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
entityID := uuid.Must(uuid.NewV7())
|
||||
var attrsJSON []byte
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
if len(attrsJSON) == 0 {
|
||||
attrsJSON = []byte("{}")
|
||||
}
|
||||
|
||||
plannedState := "planned"
|
||||
q := sqlcgen.New(tx)
|
||||
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||
ID: entityID,
|
||||
Slug: req.Body.Slug,
|
||||
Type: req.Body.Type,
|
||||
Name: req.Body.Name,
|
||||
State: &plannedState,
|
||||
Attributes: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
execID := uuid.Must(uuid.NewV7())
|
||||
corrID := "provision_" + entityID.String()[:8]
|
||||
if err := q.InsertExecution(ctx, sqlcgen.InsertExecutionParams{
|
||||
EntityID: entityID,
|
||||
Action: "provision",
|
||||
RiskClass: "config_mutation",
|
||||
CorrelationID: corrID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("create execution: %w", err)
|
||||
}
|
||||
|
||||
type stepDef struct {
|
||||
order int
|
||||
name string
|
||||
}
|
||||
steps := []stepDef{
|
||||
{1, "validate-constraints"},
|
||||
{2, "create-container"},
|
||||
{3, "configure-network"},
|
||||
{4, "install-services"},
|
||||
{5, "configure-mounts"},
|
||||
{6, "health-check"},
|
||||
}
|
||||
for _, st := range steps {
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO provisioning_steps (id, entity_id, execution_id, step_order, step_name)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
uuid.Must(uuid.NewV7()), entityID, entityID /* executions PK is entity_id */, st.order, st.name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert provisioning step: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO relationships (source_id, target_id, type)
|
||||
VALUES ($1, $2, 'hosts')`, hostID, entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert relationship: %w", err)
|
||||
}
|
||||
|
||||
_, actor := actorInfo(ctx)
|
||||
_ = observability.Audit(ctx, q, "operator", actor, "provision",
|
||||
&entityID, "POST", "/api/v1/entities/provision", "",
|
||||
nil,
|
||||
map[string]any{"slug": req.Body.Slug, "host": hostSlug})
|
||||
_ = observability.Event(ctx, q, "entity.provisioned", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": req.Body.Slug, "type": req.Body.Type, "host": hostSlug})
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entity := sqlcEntityToGen(inserted)
|
||||
return gen.ProvisionEntity201JSONResponse{
|
||||
Body: gen.ProvisionResponse{
|
||||
Entity: entity,
|
||||
ExecutionId: openapi_types.UUID(execID),
|
||||
},
|
||||
Headers: gen.ProvisionEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(int(inserted.Version)) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetProvisionStatus(ctx context.Context, req gen.GetProvisionStatusRequestObject) (gen.GetProvisionStatusResponseObject, error) {
|
||||
slug := string(req.Slug)
|
||||
id, err := s.resolveEntityID(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
"SELECT state FROM entities WHERE id = $1", id).Scan(&state); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, slug)
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT step_name, status, error_message, started_at, finished_at
|
||||
FROM provisioning_steps WHERE entity_id = $1 ORDER BY step_order`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var provSteps []struct {
|
||||
ErrorMessage *string `json:"error_message"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
Status gen.ProvisionStatusStepsStatus `json:"status"`
|
||||
Step string `json:"step"`
|
||||
}
|
||||
for rows.Next() {
|
||||
var stepName, status string
|
||||
var errMsg *string
|
||||
var started, finished *time.Time
|
||||
if scanErr := rows.Scan(&stepName, &status, &errMsg, &started, &finished); scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
provSteps = append(provSteps, struct {
|
||||
ErrorMessage *string `json:"error_message"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
Status gen.ProvisionStatusStepsStatus `json:"status"`
|
||||
Step string `json:"step"`
|
||||
}{
|
||||
Step: stepName,
|
||||
Status: gen.ProvisionStatusStepsStatus(status),
|
||||
ErrorMessage: errMsg,
|
||||
StartedAt: started,
|
||||
FinishedAt: finished,
|
||||
})
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
return gen.GetProvisionStatus200JSONResponse{
|
||||
Slug: slug,
|
||||
State: state,
|
||||
Steps: provSteps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateAgeKeypair() (pubKey, privKey string, err error) {
|
||||
seed := make([]byte, 32)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
n := new(big.Int).SetBytes(seed)
|
||||
pub := fmt.Sprintf("age1%064x", n)
|
||||
priv := fmt.Sprintf("AGE-SECRET-KEY-1%064x", n)
|
||||
return pub, priv, nil
|
||||
}
|
||||
354
internal/httpapi/entities.go
Normal file
354
internal/httpapi/entities.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) {
|
||||
limit := clampLimit(req.Params.Limit)
|
||||
|
||||
// Type filter includes descendants via the parent hierarchy (R3-1).
|
||||
query := `
|
||||
WITH RECURSIVE tt AS (
|
||||
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
|
||||
UNION
|
||||
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
|
||||
WHERE $1::text IS NOT NULL
|
||||
)
|
||||
SELECT ` + entityCols + ` FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type IN (SELECT name FROM tt)
|
||||
AND ($2::text IS NULL OR e.state = $2)
|
||||
AND ($3::text IS NULL OR et.domain = $3)
|
||||
AND ($4::text IS NULL OR et.layer = $4)
|
||||
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
|
||||
AND ($6::text IS NULL OR e.slug > $6)
|
||||
ORDER BY e.slug
|
||||
LIMIT $7`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query,
|
||||
req.Params.Type, req.Params.State, req.Params.Domain, req.Params.Layer,
|
||||
req.Params.Q, req.Params.Cursor, limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []gen.Entity
|
||||
for rows.Next() {
|
||||
e, err := scanEntity(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, e)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
var next *string
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = &items[len(items)-1].Slug
|
||||
}
|
||||
if items == nil {
|
||||
items = []gen.Entity{}
|
||||
}
|
||||
return gen.ListEntities200JSONResponse{Items: items, NextCursor: next}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, err := scanEntity(s.pool.QueryRow(ctx,
|
||||
"SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.GetEntity200JSONResponse{
|
||||
Body: e,
|
||||
Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := "both"
|
||||
if req.Params.Direction != nil {
|
||||
dir = string(*req.Params.Direction)
|
||||
}
|
||||
relType := req.Params.RelType
|
||||
rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{
|
||||
Direction: dir,
|
||||
ID: id,
|
||||
RelType: relType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []gen.Relationship{}
|
||||
for _, r := range rows {
|
||||
var attrs *map[string]any
|
||||
if len(r.Attributes) > 0 {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
||||
attrs = &m
|
||||
}
|
||||
}
|
||||
validTo := r.ValidTo
|
||||
items = append(items, gen.Relationship{
|
||||
Source: r.SourceSlug,
|
||||
Target: r.TargetSlug,
|
||||
Type: r.Type,
|
||||
Attributes: attrs,
|
||||
ValidFrom: r.ValidFrom,
|
||||
ValidTo: validTo,
|
||||
})
|
||||
}
|
||||
return gen.GetEntityRelations200JSONResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
depth := 3
|
||||
if req.Params.Depth != nil {
|
||||
depth = *req.Params.Depth
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+entityCols+`, b.depth
|
||||
FROM blast_radius($1, $2) b
|
||||
JOIN entities e ON e.id = b.entity_id
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY b.depth, e.slug`, id, depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
resp := gen.GetBlastRadius200JSONResponse{Items: []struct {
|
||||
Depth int `json:"depth"`
|
||||
Entity gen.Entity `json:"entity"`
|
||||
}{}}
|
||||
for rows.Next() {
|
||||
var e gen.Entity
|
||||
var state *string
|
||||
var attrsJSON []byte
|
||||
var maint *time.Time
|
||||
var health *string
|
||||
var lastCheckAt *time.Time
|
||||
var d int
|
||||
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.State = state
|
||||
e.MaintenanceUntil = maint
|
||||
if health != nil {
|
||||
h := gen.EntityHealth(*health)
|
||||
e.Health = &h
|
||||
}
|
||||
e.LastCheckAt = lastCheckAt
|
||||
var attrs map[string]any
|
||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||
e.Attributes = &attrs
|
||||
}
|
||||
resp.Items = append(resp.Items, struct {
|
||||
Depth int `json:"depth"`
|
||||
Entity gen.Entity `json:"entity"`
|
||||
}{Depth: d, Entity: e})
|
||||
}
|
||||
return resp, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) {
|
||||
depth := 2
|
||||
if req.Params.Depth != nil {
|
||||
depth = *req.Params.Depth
|
||||
}
|
||||
|
||||
var nodes []gen.Entity
|
||||
var err error
|
||||
truncated := false
|
||||
|
||||
// pgx can't infer the array element type from a nil *[]string (the
|
||||
// param is absent from the request, not an empty list), so dereference
|
||||
// to a plain []string first — nil there still encodes as SQL NULL, but
|
||||
// pgx has a concrete type to work with.
|
||||
var relTypes []string
|
||||
if req.Params.RelType != nil {
|
||||
relTypes = *req.Params.RelType
|
||||
}
|
||||
|
||||
if req.Params.Root != nil && *req.Params.Root != "" {
|
||||
rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+`
|
||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug`, rootID, depth, relTypes)
|
||||
} else {
|
||||
// Whole-graph view: pick the most-connected entities first so the
|
||||
// graph shows actual topology, not just whatever sorts first
|
||||
// alphabetically. Without this the cap fills with exec:* rows and
|
||||
// drops every host/lxc/service/vm — and every edge those entities
|
||||
// connect — because edges require both endpoints in the node set.
|
||||
// Exclude the cognition transactional types (execution/task): they
|
||||
// are audit records rather than topology, and at ~380 rows they
|
||||
// consumed most of the old 500-node cap.
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+`
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type NOT IN ('execution','task')
|
||||
AND e.id IN (
|
||||
SELECT e2.id FROM entities e2
|
||||
LEFT JOIN relationships r ON r.valid_to IS NULL
|
||||
AND (r.source_id = e2.id OR r.target_id = e2.id)
|
||||
WHERE e2.type NOT IN ('execution','task')
|
||||
GROUP BY e2.id
|
||||
ORDER BY count(r.type) DESC, e2.slug
|
||||
LIMIT $1
|
||||
)
|
||||
ORDER BY e.slug`,
|
||||
graphNodeCap+1)
|
||||
if err == nil && len(nodes) > graphNodeCap {
|
||||
nodes = nodes[:graphNodeCap]
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]uuid.UUID, len(nodes))
|
||||
for i, n := range nodes {
|
||||
ids[i] = uuid.UUID(n.Id)
|
||||
}
|
||||
edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{
|
||||
Ids: ids,
|
||||
RelTypes: relTypes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges := []gen.Relationship{}
|
||||
for _, r := range edgeRows {
|
||||
var attrs *map[string]any
|
||||
if len(r.Attributes) > 0 {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 {
|
||||
attrs = &m
|
||||
}
|
||||
}
|
||||
validTo := r.ValidTo
|
||||
edges = append(edges, gen.Relationship{
|
||||
Source: r.SourceSlug,
|
||||
Target: r.TargetSlug,
|
||||
Type: r.Type,
|
||||
Attributes: attrs,
|
||||
ValidFrom: r.ValidFrom,
|
||||
ValidTo: validTo,
|
||||
})
|
||||
}
|
||||
|
||||
resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges}
|
||||
if truncated {
|
||||
resp.Truncated = &truncated
|
||||
}
|
||||
|
||||
if req.Params.Include != nil {
|
||||
for _, inc := range *req.Params.Include {
|
||||
if inc == gen.Status {
|
||||
health, herr := s.entityHealthByID(ctx, ids)
|
||||
if herr != nil {
|
||||
return nil, herr
|
||||
}
|
||||
resp.Health = &health
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// entityHealthByID returns entity_status.health keyed by entity id, for the
|
||||
// given id set (used by GetGraph's include=status).
|
||||
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
|
||||
health := make(map[string]gen.GraphViewHealth, len(ids))
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var h string
|
||||
if err := rows.Scan(&id, &h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
health[id.String()] = gen.GraphViewHealth(h)
|
||||
}
|
||||
return health, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []gen.Entity{}
|
||||
for rows.Next() {
|
||||
e, err := scanEntity(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, e)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity.
|
||||
func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity {
|
||||
out := gen.Entity{
|
||||
Id: e.ID,
|
||||
Slug: e.Slug,
|
||||
Type: e.Type,
|
||||
Name: e.Name,
|
||||
State: e.State,
|
||||
Version: int(e.Version),
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
if e.MaintenanceUntil != nil {
|
||||
out.MaintenanceUntil = e.MaintenanceUntil
|
||||
}
|
||||
if len(e.Attributes) > 0 {
|
||||
var attrs map[string]any
|
||||
if json.Unmarshal(e.Attributes, &attrs) == nil && len(attrs) > 0 {
|
||||
out.Attributes = &attrs
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
288
internal/httpapi/entity_mutations.go
Normal file
288
internal/httpapi/entity_mutations.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Check idempotency if a key was provided. The idempotency scope is the
|
||||
// calling actor, so replays are per-caller.
|
||||
actorType, actorLabel := actorInfo(ctx)
|
||||
actor := actorLabel
|
||||
var bodyHash string
|
||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||
key := *req.Params.IdempotencyKey
|
||||
q := sqlcgen.New(s.pool)
|
||||
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{
|
||||
Actor: actor,
|
||||
Key: key,
|
||||
})
|
||||
if err == nil {
|
||||
// Verify the request body hasn't changed.
|
||||
bodyJSON, _ := json.Marshal(req.Body)
|
||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
||||
if cached.RequestHash != bodyHash {
|
||||
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
|
||||
}
|
||||
// Replay the cached response.
|
||||
if cached.ResponseCode != nil && *cached.ResponseCode == 201 {
|
||||
var entity gen.Entity
|
||||
if len(cached.ResponseBody) > 0 {
|
||||
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal cached response: %w", err)
|
||||
}
|
||||
}
|
||||
return gen.CreateEntity201JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
// Forward cached error response.
|
||||
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
|
||||
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"},
|
||||
StatusCode: int(*cached.ResponseCode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slug := req.Body.Slug
|
||||
if slug == "" {
|
||||
slug = req.Body.Type + ":" + req.Body.Name
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
q := sqlcgen.New(tx)
|
||||
|
||||
// Validate type exists and is NOT abstract.
|
||||
var isAbstract bool
|
||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if isAbstract {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
|
||||
}
|
||||
|
||||
// Get default state from lifecycle.
|
||||
var defaultState *string
|
||||
var lcDefault string
|
||||
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil {
|
||||
defaultState = &lcDefault
|
||||
}
|
||||
|
||||
state := req.Body.State
|
||||
if state == nil && defaultState != nil {
|
||||
state = defaultState
|
||||
}
|
||||
|
||||
// attributes is NOT NULL; the column default only applies when omitted,
|
||||
// not when an explicit NULL is bound — so default to an empty object.
|
||||
attrsJSON := []byte("{}")
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
// Insert the entity.
|
||||
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||
ID: id,
|
||||
Slug: slug,
|
||||
Type: req.Body.Type,
|
||||
Name: req.Body.Name,
|
||||
State: state,
|
||||
Attributes: attrsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
// Duplicate slug.
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert sqlcgen.Entity → gen.Entity.
|
||||
entity := sqlcEntityToGen(inserted)
|
||||
|
||||
// Cache idempotent response.
|
||||
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
|
||||
respBody, _ := json.Marshal(entity)
|
||||
code := int32(201)
|
||||
if bodyHash == "" {
|
||||
bodyJSON, _ := json.Marshal(req.Body)
|
||||
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
|
||||
}
|
||||
if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
|
||||
Actor: actor,
|
||||
Key: *req.Params.IdempotencyKey,
|
||||
RequestHash: bodyHash,
|
||||
ResponseCode: &code,
|
||||
ResponseBody: respBody,
|
||||
}); putErr != nil {
|
||||
return nil, putErr
|
||||
}
|
||||
}
|
||||
|
||||
// Audit.
|
||||
entityID := inserted.ID
|
||||
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
|
||||
&entityID, "POST", "/api/v1/entities", "",
|
||||
nil,
|
||||
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if eventErr := observability.Event(ctx, q, "entity.created", &entityID,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil {
|
||||
return nil, eventErr
|
||||
}
|
||||
|
||||
if err := ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, inserted.Name, attrsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.CreateEntity201JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) {
|
||||
if req.Body == nil {
|
||||
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
||||
}
|
||||
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse If-Match header (quoted version string).
|
||||
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
|
||||
expectedVersion, err := strconv.Atoi(ifMatch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Get current entity for version check + lifecycle validation.
|
||||
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int(current.Version) != expectedVersion {
|
||||
return nil, fmt.Errorf("%w: expected version %d, current version %d",
|
||||
domain.ErrConflict, expectedVersion, current.Version)
|
||||
}
|
||||
|
||||
// Validate lifecycle transition if state is being changed.
|
||||
if req.Body.State != nil && *req.Body.State != "" {
|
||||
fromState := ""
|
||||
if current.State != nil {
|
||||
fromState = *current.State
|
||||
}
|
||||
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
|
||||
if errors.Is(err, db.ErrTransitionInvalid) {
|
||||
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH,
|
||||
// but we handle it if the generated code ever adds it).
|
||||
// For now, no idempotency check on PATCH.
|
||||
|
||||
// Marshal attributes if provided.
|
||||
var attrsJSON []byte
|
||||
if req.Body.Attributes != nil {
|
||||
attrsJSON, _ = json.Marshal(req.Body.Attributes)
|
||||
}
|
||||
|
||||
// Perform the update via sqlcgen.
|
||||
q := sqlcgen.New(tx)
|
||||
updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
|
||||
Name: req.Body.Name,
|
||||
State: req.Body.State,
|
||||
Attributes: attrsJSON,
|
||||
SetMaintenance: req.Body.MaintenanceUntil != nil,
|
||||
MaintenanceUntil: req.Body.MaintenanceUntil,
|
||||
ID: id,
|
||||
Version: int32(expectedVersion),
|
||||
})
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
// Version mismatch or entity not found.
|
||||
return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entity := sqlcEntityToGen(updated)
|
||||
|
||||
// Audit.
|
||||
patchActorType, patchActor := actorInfo(ctx)
|
||||
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
|
||||
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
|
||||
nil,
|
||||
map[string]any{"version": expectedVersion}); auditErr != nil {
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
if eventErr := observability.Event(ctx, q, "entity.updated", &id,
|
||||
"info", "oikos-api", "",
|
||||
map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil {
|
||||
return nil, eventErr
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gen.PatchEntity200JSONResponse{
|
||||
Body: entity,
|
||||
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
|
||||
}, nil
|
||||
}
|
||||
60
internal/httpapi/events.go
Normal file
60
internal/httpapi/events.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
)
|
||||
|
||||
func (s *Server) QueryEvents(ctx context.Context, req gen.QueryEventsRequestObject) (gen.QueryEventsResponseObject, error) {
|
||||
limit := clampLimit(req.Params.Limit)
|
||||
var eventType, entityID, severity, correlationID *string
|
||||
if req.Params.Type != nil {
|
||||
eventType = req.Params.Type
|
||||
}
|
||||
if req.Params.EntityId != nil {
|
||||
entityID = req.Params.EntityId
|
||||
}
|
||||
if req.Params.Severity != nil {
|
||||
severity = req.Params.Severity
|
||||
}
|
||||
if req.Params.CorrelationId != nil {
|
||||
correlationID = req.Params.CorrelationId
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, ts, type, entity_id::text, severity, source, data, correlation_id
|
||||
FROM events
|
||||
WHERE ($1::text IS NULL OR type = $1)
|
||||
AND ($2::text IS NULL OR entity_id::text = $2)
|
||||
AND ($3::text IS NULL OR severity = $3)
|
||||
AND ($4::text IS NULL OR correlation_id = $4)
|
||||
AND ($5::timestamptz IS NULL OR ts >= $5)
|
||||
AND ($6::timestamptz IS NULL OR ts <= $6)
|
||||
ORDER BY ts DESC
|
||||
LIMIT $7`,
|
||||
eventType, entityID, severity, correlationID, req.Params.From, req.Params.To, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []gen.Event{}
|
||||
for rows.Next() {
|
||||
var e gen.Event
|
||||
var dataBytes []byte
|
||||
var entID, corrID *string
|
||||
if err := rows.Scan(&e.Id, &e.Ts, &e.Type, &entID, &e.Severity, &e.Source, &dataBytes, &corrID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.EntityId = entID
|
||||
e.CorrelationId = corrID
|
||||
var data map[string]any
|
||||
if json.Unmarshal(dataBytes, &data) == nil {
|
||||
e.Data = &data
|
||||
}
|
||||
items = append(items, e)
|
||||
}
|
||||
return gen.QueryEvents200JSONResponse{Items: items}, rows.Err()
|
||||
}
|
||||
80
internal/httpapi/fleet_health.go
Normal file
80
internal/httpapi/fleet_health.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
)
|
||||
|
||||
func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthRequestObject) (gen.GetFleetHealthResponseObject, error) {
|
||||
resp := gen.GetFleetHealth200JSONResponse{}
|
||||
resp.Entities = []struct {
|
||||
Health gen.HealthSummaryEntitiesHealth `json:"health"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
Slug string `json:"slug"`
|
||||
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
|
||||
Type string `json:"type"`
|
||||
}{}
|
||||
|
||||
// Exclude 'check' entities (internal probes) — only entities actually
|
||||
// being monitored should count toward fleet health.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
ORDER BY e.slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
stale := 0
|
||||
for rows.Next() {
|
||||
var slug, typ, health string
|
||||
var lastCheck *time.Time
|
||||
if err := rows.Scan(&slug, &typ, &health, &lastCheck); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch health {
|
||||
case "healthy":
|
||||
resp.Summary.Healthy++
|
||||
case "degraded":
|
||||
resp.Summary.Degraded++
|
||||
case "down":
|
||||
resp.Summary.Down++
|
||||
case "stale":
|
||||
stale++
|
||||
default:
|
||||
resp.Summary.Unknown++
|
||||
}
|
||||
resp.Entities = append(resp.Entities, struct {
|
||||
Health gen.HealthSummaryEntitiesHealth `json:"health"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
Slug string `json:"slug"`
|
||||
Trend *gen.HealthSummaryEntitiesTrend `json:"trend"`
|
||||
Type string `json:"type"`
|
||||
}{
|
||||
Health: gen.HealthSummaryEntitiesHealth(health),
|
||||
LastCheckAt: lastCheck,
|
||||
Slug: slug,
|
||||
Type: typ,
|
||||
})
|
||||
}
|
||||
if stale > 0 {
|
||||
resp.Summary.Stale = &stale
|
||||
}
|
||||
return resp, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) ExportSeeds(ctx context.Context, req gen.ExportSeedsRequestObject) (gen.ExportSeedsResponseObject, error) {
|
||||
exports, err := db.ExportToYAML(ctx, s.pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ExportSeeds200JSONResponse{
|
||||
Ontology: string(exports["ontology.yaml"]),
|
||||
Inventory: string(exports["inventory.yaml"]),
|
||||
Policy: string(exports["policy.yaml"]),
|
||||
}, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
83
internal/httpapi/ontology.go
Normal file
83
internal/httpapi/ontology.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
)
|
||||
|
||||
func (s *Server) GetOntology(ctx context.Context, req gen.GetOntologyRequestObject) (gen.GetOntologyResponseObject, error) {
|
||||
resp := gen.GetOntology200JSONResponse{
|
||||
EntityTypes: []gen.EntityType{},
|
||||
RelationshipTypes: []gen.RelationshipType{},
|
||||
Lifecycles: []gen.LifecycleDef{},
|
||||
}
|
||||
|
||||
q := sqlcgen.New(s.pool)
|
||||
|
||||
etRows, err := q.ListEntityTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, et := range etRows {
|
||||
schemaVersion := int(et.SchemaVersion)
|
||||
var schema *map[string]any
|
||||
if len(et.AttributeSchema) > 0 {
|
||||
var s map[string]any
|
||||
if json.Unmarshal(et.AttributeSchema, &s) == nil && s != nil {
|
||||
schema = &s
|
||||
}
|
||||
}
|
||||
resp.EntityTypes = append(resp.EntityTypes, gen.EntityType{
|
||||
Name: et.Name,
|
||||
ParentType: et.ParentType,
|
||||
IsAbstract: et.IsAbstract,
|
||||
Domain: et.Domain,
|
||||
Layer: gen.EntityTypeLayer(et.Layer),
|
||||
Description: et.Description,
|
||||
LifecycleId: et.LifecycleID,
|
||||
SchemaVersion: &schemaVersion,
|
||||
AttributeSchema: schema,
|
||||
Status: gen.EntityTypeStatus(et.Status),
|
||||
})
|
||||
}
|
||||
|
||||
rtRows, err := q.ListRelationshipTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rt := range rtRows {
|
||||
resp.RelationshipTypes = append(resp.RelationshipTypes, gen.RelationshipType{
|
||||
Name: rt.Name,
|
||||
Inverse: rt.Inverse,
|
||||
SourceType: rt.SourceType,
|
||||
TargetType: rt.TargetType,
|
||||
Cardinality: gen.RelationshipTypeCardinality(rt.Cardinality),
|
||||
Description: rt.Description,
|
||||
})
|
||||
}
|
||||
|
||||
lcRows, err := q.ListLifecycleDefs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lc := range lcRows {
|
||||
terminal := lc.TerminalStates
|
||||
var transitions map[string]any
|
||||
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
|
||||
return nil, fmt.Errorf("lifecycle %s transitions: %w", lc.ID, err)
|
||||
}
|
||||
resp.Lifecycles = append(resp.Lifecycles, gen.LifecycleDef{
|
||||
Id: lc.ID,
|
||||
States: lc.States,
|
||||
DefaultState: lc.DefaultState,
|
||||
TerminalStates: &terminal,
|
||||
Transitions: transitions,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
72
internal/httpapi/query_audit.go
Normal file
72
internal/httpapi/query_audit.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
)
|
||||
|
||||
func (s *Server) QueryAudit(ctx context.Context, req gen.QueryAuditRequestObject) (gen.QueryAuditResponseObject, error) {
|
||||
limit := clampLimit(req.Params.Limit)
|
||||
var actorType, actorID, action, entityID, correlationID *string
|
||||
if req.Params.ActorType != nil {
|
||||
actorType = req.Params.ActorType
|
||||
}
|
||||
if req.Params.ActorId != nil {
|
||||
actorID = req.Params.ActorId
|
||||
}
|
||||
if req.Params.Action != nil {
|
||||
action = req.Params.Action
|
||||
}
|
||||
if req.Params.EntityId != nil {
|
||||
entityID = req.Params.EntityId
|
||||
}
|
||||
if req.Params.CorrelationId != nil {
|
||||
correlationID = req.Params.CorrelationId
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, ts, actor_type, actor_id::text, action, entity_id::text,
|
||||
method, path, status_code, detail, source_ip, correlation_id, session_id::text
|
||||
FROM audit_log
|
||||
WHERE ($1::text IS NULL OR actor_type = $1)
|
||||
AND ($2::text IS NULL OR actor_id::text = $2)
|
||||
AND ($3::text IS NULL OR action = $3)
|
||||
AND ($4::text IS NULL OR entity_id::text = $4)
|
||||
AND ($5::text IS NULL OR correlation_id = $5)
|
||||
AND ($6::timestamptz IS NULL OR ts >= $6)
|
||||
AND ($7::timestamptz IS NULL OR ts <= $7)
|
||||
ORDER BY ts DESC
|
||||
LIMIT $8`,
|
||||
actorType, actorID, action, entityID, correlationID, req.Params.From, req.Params.To, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []gen.AuditEntry{}
|
||||
for rows.Next() {
|
||||
var a gen.AuditEntry
|
||||
var detailBytes []byte
|
||||
var actID, entID, method, path, sourceIP, corrID, sessionID *string
|
||||
var statusCode *int
|
||||
if err := rows.Scan(&a.Id, &a.Ts, &a.ActorType, &actID, &a.Action, &entID,
|
||||
&method, &path, &statusCode, &detailBytes, &sourceIP, &corrID, &sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ActorId = actID
|
||||
a.EntityId = entID
|
||||
a.Method = method
|
||||
a.Path = path
|
||||
a.StatusCode = statusCode
|
||||
a.SourceIp = sourceIP
|
||||
a.CorrelationId = corrID
|
||||
var detail map[string]any
|
||||
if json.Unmarshal(detailBytes, &detail) == nil {
|
||||
a.Detail = &detail
|
||||
}
|
||||
items = append(items, a)
|
||||
}
|
||||
return gen.QueryAudit200JSONResponse{Items: items}, rows.Err()
|
||||
}
|
||||
170
internal/httpapi/signals.go
Normal file
170
internal/httpapi/signals.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) ListSignals(ctx context.Context, req gen.ListSignalsRequestObject) (gen.ListSignalsResponseObject, error) {
|
||||
limit := clampLimit(req.Params.Limit)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
|
||||
te.slug, sig.check_id::text, sig.evidence, sig.likely_cause,
|
||||
sig.occurrence_count, sig.flap_count, sig.hold_down_until,
|
||||
sig.mute_until, sig.first_seen_at, sig.last_seen_at
|
||||
FROM signals sig
|
||||
JOIN entities se ON se.id = sig.entity_id
|
||||
LEFT JOIN entities te ON te.id = sig.target_entity_id
|
||||
WHERE ($1::text IS NULL OR sig.state = $1)
|
||||
AND ($2::text IS NULL OR sig.severity = $2)
|
||||
AND ($3::text IS NULL OR te.slug = $3)
|
||||
AND ($4::text IS NULL OR sig.kind = $4)
|
||||
AND ($5::text IS NULL OR se.slug > $5)
|
||||
ORDER BY se.slug
|
||||
LIMIT $6`,
|
||||
req.Params.State, (*string)(req.Params.Severity), req.Params.EntityId,
|
||||
req.Params.Kind, req.Params.Cursor, limit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []gen.Signal{}
|
||||
for rows.Next() {
|
||||
var sig gen.Signal
|
||||
var flap int
|
||||
if err := rows.Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
||||
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
||||
&sig.OccurrenceCount, &flap, &sig.HoldDownUntil,
|
||||
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sig.FlapCount = &flap
|
||||
items = append(items, sig)
|
||||
}
|
||||
if rows.Err() != nil {
|
||||
return nil, rows.Err()
|
||||
}
|
||||
|
||||
var next *string
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = &items[len(items)-1].Slug
|
||||
}
|
||||
return gen.ListSignals200JSONResponse{Items: items, NextCursor: next}, nil
|
||||
}
|
||||
|
||||
func (s *Server) AckSignal(ctx context.Context, req gen.AckSignalRequestObject) (gen.AckSignalResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var sig gen.Signal
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE signals SET state = 'acknowledged', updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','failed')
|
||||
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
||||
kind, severity, 'acknowledged',
|
||||
(SELECT slug FROM entities WHERE id = target_entity_id),
|
||||
check_id::text, evidence, likely_cause,
|
||||
occurrence_count, flap_count, hold_down_until,
|
||||
mute_until, first_seen_at, last_seen_at`,
|
||||
id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
||||
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
||||
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
||||
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: signal %s not in a state that can be acknowledged", domain.ErrInvalidTransition, req.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.AckSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var sig gen.Signal
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed')
|
||||
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
||||
kind, severity, 'resolved',
|
||||
(SELECT slug FROM entities WHERE id = target_entity_id),
|
||||
check_id::text, evidence, likely_cause,
|
||||
occurrence_count, flap_count, hold_down_until,
|
||||
mute_until, first_seen_at, last_seen_at`,
|
||||
id).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
||||
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
||||
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
||||
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: signal %s not in a state that can be resolved", domain.ErrInvalidTransition, req.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ResolveSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) {
|
||||
id, err := s.resolveEntityID(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var sig gen.Signal
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','acknowledged')
|
||||
RETURNING entity_id, (SELECT slug FROM entities WHERE id = $1),
|
||||
kind, severity, 'muted',
|
||||
(SELECT slug FROM entities WHERE id = target_entity_id),
|
||||
check_id::text, evidence, likely_cause,
|
||||
occurrence_count, flap_count, hold_down_until,
|
||||
mute_until, first_seen_at, last_seen_at`,
|
||||
id, req.Body.MuteUntil).Scan(&sig.Id, &sig.Slug, &sig.Kind, &sig.Severity, &sig.State,
|
||||
&sig.Target, &sig.CheckId, &sig.Evidence, &sig.LikelyCause,
|
||||
&sig.OccurrenceCount, &sig.FlapCount, &sig.HoldDownUntil,
|
||||
&sig.MuteUntil, &sig.FirstSeenAt, &sig.LastSeenAt)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("%w: signal %s not in a state that can be muted", domain.ErrInvalidTransition, req.Id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.MuteSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
|
||||
}
|
||||
242
internal/mcp/analysis_tools.go
Normal file
242
internal/mcp/analysis_tools.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func AnalysisTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "get_health_summary", Description: "Fleet health per entity — optionally filter by health state(s)",
|
||||
InputSchema: objSchema(
|
||||
prop{"health", "string", "Comma-separated health states to include (e.g. 'down,stale'). Omit for all."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
healthStr, _ := args["health"].(string)
|
||||
query := `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check' AND e.state <> 'destroyed'`
|
||||
if healthStr != "" {
|
||||
query += ` AND st.health = ANY(string_to_array($1, ','))`
|
||||
return queryRows(ctx, pool, query, healthStr), nil
|
||||
}
|
||||
query += ` ORDER BY e.slug`
|
||||
return queryRows(ctx, pool, query), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log",
|
||||
InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id, session_id::text
|
||||
FROM audit_log
|
||||
WHERE ($1::text IS NULL OR entity_id::text = $1)
|
||||
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "query_metrics", Description: "Time-series metrics with bucketed avg/min/max over N hours",
|
||||
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
hours := int(getFloat(args, "hours", 24))
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||
entity_id::text, metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg,
|
||||
ROUND(min(value)::numeric, 2) AS min,
|
||||
ROUND(max(value)::numeric, 2) AS max
|
||||
FROM metric_samples
|
||||
WHERE ts > now() - make_interval(hours => $1)
|
||||
GROUP BY bucket, entity_id, metric
|
||||
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
||||
}},
|
||||
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||
|
||||
{tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||
prop{"state", "string", "Filter by signal state (raised, resolved)"},
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT s.entity_id::text, s.kind, s.severity, s.state,
|
||||
s.occurrence_count, e.slug AS target_slug,
|
||||
s.first_seen_at, s.last_seen_at
|
||||
FROM signals s
|
||||
LEFT JOIN entities e ON e.id = s.target_entity_id
|
||||
WHERE ($1::text IS NULL OR e.slug = $1)
|
||||
AND ($2::text IS NULL OR s.state = $2)
|
||||
ORDER BY s.last_seen_at DESC LIMIT $3`,
|
||||
nStr(args["entity_slug"]), nStr(args["state"]), limit), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns",
|
||||
InputSchema: objSchema(
|
||||
prop{"status", "string", "Filter by status (hypothesized, validated, active)"},
|
||||
prop{"entity_type", "string", "Filter by applies_type"},
|
||||
prop{"action", "string", "Filter by action"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT p.entity_id::text, p.applies_type, p.action, p.pattern,
|
||||
p.confidence, p.evidence_count, p.success_count, p.failure_count,
|
||||
p.status, p.quarantined, p.version, p.last_validated_at
|
||||
FROM patterns p
|
||||
WHERE ($1::text IS NULL OR p.status = $1)
|
||||
AND ($2::text IS NULL OR p.applies_type = $2)
|
||||
AND ($3::text IS NULL OR p.action = $3)
|
||||
ORDER BY p.applies_type, p.action`,
|
||||
nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills",
|
||||
InputSchema: objSchema(
|
||||
prop{"status", "string", "Filter by status (active, inactive, deprecated)"},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview,
|
||||
s.applies_type, s.action, s.status, s.success_rate,
|
||||
s.changed_by::text, s.change_reason, s.last_used_at
|
||||
FROM skills s
|
||||
WHERE ($1::text IS NULL OR s.status = $1)
|
||||
ORDER BY s.name, s.version DESC`,
|
||||
nStr(args["status"])), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_trend", Description: "Metric slope, variance, and averages for an entity over N days",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_id", "string", "Entity slug"},
|
||||
prop{"days", "integer", "Look-back window in days (default 7)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_id"].(string)
|
||||
days := int(getFloat(args, "days", 7))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT metric,
|
||||
ROUND(avg(value)::numeric, 2) AS avg_val,
|
||||
ROUND(stddev(value)::numeric, 2) AS std_val,
|
||||
count(*) AS sample_count,
|
||||
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
|
||||
FROM metric_samples ms
|
||||
JOIN entities e ON e.id = ms.entity_id
|
||||
WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2)
|
||||
GROUP BY metric
|
||||
ORDER BY metric`, slug, days), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_event_timeline", Description: "Recent events filtered by severity and entity slug",
|
||||
InputSchema: objSchema(
|
||||
prop{"severity", "string", "Filter by severity (info, warn, error)"},
|
||||
prop{"entity_slug", "string", "Filter by entity slug"},
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug,
|
||||
ev.data::text AS message, ev.correlation_id
|
||||
FROM events ev
|
||||
LEFT JOIN entities e ON e.id = ev.entity_id
|
||||
WHERE ($1::text IS NULL OR ev.severity = $1)
|
||||
AND ($2::text IS NULL OR e.slug = $2)
|
||||
ORDER BY ev.ts DESC LIMIT $3`,
|
||||
nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log",
|
||||
InputSchema: objSchema(
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||
left(output_summary, 200) AS output_summary,
|
||||
duration_ms, token_count, success, correlation_id
|
||||
FROM agent_activity
|
||||
WHERE agent_id = $1
|
||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
||||
}},
|
||||
// classify_command is the command-scoped preflight from
|
||||
// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
|
||||
// existing `preflight` tool is entity/action-scoped — useless when
|
||||
// the agent is composing a `run` command and needs to know whether
|
||||
// the classifier will accept it before submitting. Without this,
|
||||
// the agent has to retry with cosmetic variations until it finds
|
||||
// one that passes (see sessions a51e2086, 8acea2e3 — three
|
||||
// duplicate rclone sessions, all bouncing off the classifier).
|
||||
// Call this BEFORE `run` whenever the classification is uncertain.
|
||||
{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
|
||||
InputSchema: objSchema(
|
||||
prop{"command", "string", "The exact shell command you intend to pass to run."},
|
||||
prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
command, _ := args["command"].(string)
|
||||
declaredRisk, _ := args["declared_risk"].(string)
|
||||
if command == "" {
|
||||
return textResult("error: command is required"), nil
|
||||
}
|
||||
risk := policy.ClassifyCommand(command, declaredRisk)
|
||||
note := ""
|
||||
switch risk {
|
||||
case policy.RiskReadOnly:
|
||||
note = "auto-acts on `run` (no approval needed)."
|
||||
case policy.RiskReversibleLow:
|
||||
note = "auto-acts on `run` (no approval needed)."
|
||||
case policy.RiskConfigMutation:
|
||||
note = "requires operator approval on `run` (or loose assent window active)."
|
||||
case policy.RiskDestructive:
|
||||
note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"command": command,
|
||||
"declared_risk": declaredRisk,
|
||||
"risk_class": risk,
|
||||
"note": note,
|
||||
})
|
||||
return textResult(string(out)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_ontology", Description: "Entity types, relationship types, and lifecycle definitions. Use this to understand the schema — what entity types exist, what relationships connect them, and what lifecycle states each type supports.",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
etResult := queryRowsJSONSingle(ctx, pool, `
|
||||
SELECT name, parent_type, is_abstract, domain, layer,
|
||||
description, lifecycle_id, schema_version, status
|
||||
FROM entity_types ORDER BY name`)
|
||||
|
||||
rtResult := queryRowsJSONSingle(ctx, pool, `
|
||||
SELECT name, inverse, source_type, target_type,
|
||||
cardinality, description
|
||||
FROM relationship_types ORDER BY name`)
|
||||
|
||||
lcResult := queryRowsJSONSingle(ctx, pool, `
|
||||
SELECT id, name, states, transitions::text
|
||||
FROM lifecycles ORDER BY name`)
|
||||
|
||||
result := map[string]any{
|
||||
"entity_types": etResult,
|
||||
"relationship_types": rtResult,
|
||||
"lifecycles": lcResult,
|
||||
}
|
||||
b, _ := json.MarshalIndent(result, "", " ")
|
||||
return textResult(string(b)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||
InputSchema: objSchema(
|
||||
prop{"url", "string", "Absolute http(s) URL to fetch"},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
rawURL, _ := args["url"].(string)
|
||||
return httpGet(ctx, rawURL), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
501
internal/mcp/entity_tools.go
Normal file
501
internal/mcp/entity_tools.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/audit"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return textResult(`{"ok":true,"server":"oikos","version":"dev"}`), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID",
|
||||
InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
idOrSlug, _ := args["slug_or_id"].(string)
|
||||
return queryEntity(ctx, pool, idOrSlug), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search",
|
||||
InputSchema: objSchema(
|
||||
prop{"type", "string", "Filter by entity type"},
|
||||
prop{"state", "string", "Filter by lifecycle state"},
|
||||
prop{"q", "string", "Substring match on slug or name"},
|
||||
prop{"limit", "integer", "Max rows (default 50)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 50))
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||
FROM entities e
|
||||
WHERE ($1::text IS NULL OR e.type = $1)
|
||||
AND ($2::text IS NULL OR e.state = $2)
|
||||
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||
ORDER BY e.slug LIMIT $4`,
|
||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_relations", Description: "List inbound/outbound edges for one entity, optionally filtered by relationship type",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_id", "string", "Entity slug"},
|
||||
prop{"types", "string", "Comma-separated relationship types to include (e.g. 'hosts,provides,depends-on'). Omit for all."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_id"].(string)
|
||||
typesStr, _ := args["types"].(string)
|
||||
if slug == "" {
|
||||
return textResult("entity_id is required"), nil
|
||||
}
|
||||
query := `
|
||||
SELECT r.type, src.slug AS source, tgt.slug AS target
|
||||
FROM relationships r
|
||||
JOIN entities src ON src.id = r.source_id
|
||||
JOIN entities tgt ON tgt.id = r.target_id
|
||||
WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL`
|
||||
if typesStr != "" {
|
||||
query += ` AND r.type = ANY(string_to_array($2, ','))`
|
||||
return queryRows(ctx, pool, query, slug, typesStr), nil
|
||||
}
|
||||
query += ` ORDER BY r.type`
|
||||
return queryRows(ctx, pool, query, slug), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_id", "string", "Entity slug"},
|
||||
prop{"depth", "integer", "Traversal depth (default 3)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_id"].(string)
|
||||
depth := int(getFloat(args, "depth", 3))
|
||||
return queryRows(ctx, pool,
|
||||
"SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id",
|
||||
slug, depth), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "create_entity", Description: "Create a new entity in the knowledge graph. Use it when a task needs an entity that does not exist yet: a service, a host/LXC/VM, an ingress, a cert, etc. After inserting, it derives default checks from the entity type's monitoring spec, so creating a checkable entity wires its monitoring in one call. Does NOT require approval. If the slug already exists it returns 'already exists' — then use update_entity_attributes to change it. FOOTGUN: creating a type=check entity creates a bare entity row but does NOT wire a check_def — the scheduler will never probe it. To add monitoring, set `monitoring: [\"http\"]` + `url` on the target via update_entity_attributes.",
|
||||
InputSchema: objSchema(
|
||||
prop{"type", "string", "Entity type — must already exist in the ontology and not be abstract (e.g. service, lxc, host, vm, check, ingress, cert, dns)."},
|
||||
prop{"name", "string", "Human-readable name (e.g. 'HAOS http service check')."},
|
||||
prop{"slug", "string", "Entity slug (e.g. check:http:service:haos:0, ingress:home.hubris.network). If omitted, defaults to <type>:<name>."},
|
||||
prop{"attributes", "string", "JSON object string of attributes, e.g. {\"check_type\":\"http:service\",\"target\":\"service:haos\",\"port\":\"8123\"}. Optional."},
|
||||
prop{"state", "string", "Lifecycle state. Optional; defaults to the type's lifecycle default_state."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
entityType, _ := args["type"].(string)
|
||||
name, _ := args["name"].(string)
|
||||
slug, _ := args["slug"].(string)
|
||||
if slug == "" && entityType != "" && name != "" {
|
||||
slug = entityType + ":" + name
|
||||
}
|
||||
if entityType == "" || name == "" || slug == "" {
|
||||
return textResult("error: type and name are required (slug defaults to <type>:<name>)"), nil
|
||||
}
|
||||
attrsStr, _ := args["attributes"].(string)
|
||||
attrs := map[string]any{}
|
||||
if attrsStr != "" {
|
||||
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||
}
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
stateStr, _ := args["state"].(string)
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
|
||||
var isAbstract bool
|
||||
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
|
||||
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
|
||||
}
|
||||
if isAbstract {
|
||||
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
|
||||
}
|
||||
|
||||
// Default state from the type's lifecycle unless the caller
|
||||
// supplied one. Caller-supplied states are validated against
|
||||
// the lifecycle's declared states — a create_entity bypass of
|
||||
// lifecycle guardrails would let an agent create in a terminal
|
||||
// state (destroyed) without satisfying the preconditions that
|
||||
// set_entity_state enforces for the same transition.
|
||||
var state *string
|
||||
var lsDefault, statesRaw string
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
|
||||
FROM lifecycle_defs ld
|
||||
JOIN entity_types et ON et.lifecycle_id = ld.id
|
||||
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
|
||||
var validStates []string
|
||||
json.Unmarshal([]byte(statesRaw), &validStates)
|
||||
if stateStr != "" {
|
||||
found := false
|
||||
for _, s := range validStates {
|
||||
if s == stateStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(validStates) > 0 {
|
||||
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
|
||||
}
|
||||
state = &stateStr
|
||||
} else if lsDefault != "" {
|
||||
state = &lsDefault
|
||||
}
|
||||
}
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
|
||||
}
|
||||
|
||||
var createdName string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, state, attributes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING name`,
|
||||
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||||
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
|
||||
}
|
||||
|
||||
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
|
||||
if derr != nil {
|
||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
|
||||
}
|
||||
if cerr := tx.Commit(ctx); cerr != nil {
|
||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
||||
}
|
||||
|
||||
return textResult(formatCreateResult(slug, entityType, res)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||
InputSchema: objSchema(
|
||||
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
|
||||
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["slug"].(string)
|
||||
attrsStr, _ := args["attributes"].(string)
|
||||
if slug == "" || attrsStr == "" {
|
||||
return textResult("error: slug and attributes are required"), nil
|
||||
}
|
||||
var attrs map[string]any
|
||||
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||
}
|
||||
|
||||
// Strip scheduler-owned keys: health is computed by the scheduler
|
||||
// from probe results (spotted live 2026-08-05: an agent set
|
||||
// health:"healthy" on lxc:nfs-export, which derived 4 spurious checks).
|
||||
// Agents can observe health via get_health_summary / list_checks.
|
||||
var blocked []string
|
||||
for _, key := range []string{"health", "last_check_at", "last_check"} {
|
||||
if _, ok := attrs[key]; ok {
|
||||
delete(attrs, key)
|
||||
blocked = append(blocked, key)
|
||||
}
|
||||
}
|
||||
if len(blocked) > 0 {
|
||||
// Re-marshal the filtered attrs
|
||||
filtered, _ := json.Marshal(attrs)
|
||||
attrsStr = string(filtered)
|
||||
if len(attrs) == 0 {
|
||||
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
||||
}
|
||||
}
|
||||
attrsJSON, _ := json.Marshal(attrs)
|
||||
|
||||
// Run the merge + check regeneration in one transaction so the
|
||||
// derived checks always see the post-merge attributes. Mirrors
|
||||
// httpapi.PatchEntity; without this, setting an entity's
|
||||
// `monitoring` attribute via MCP silently produced no checks.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ra, err := sqlcgen.New(tx).MergeEntityAttributes(ctx, sqlcgen.MergeEntityAttributesParams{Slug: slug, Patch: attrsJSON})
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||
}
|
||||
if ra == 0 {
|
||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||
}
|
||||
|
||||
merged, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
|
||||
}
|
||||
id := merged.ID
|
||||
entityType := merged.Type
|
||||
name := merged.Name
|
||||
mergedAttrs := merged.Attributes
|
||||
|
||||
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
|
||||
if cerr != nil {
|
||||
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
|
||||
}
|
||||
if cerr := tx.Commit(ctx); cerr != nil {
|
||||
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
|
||||
}
|
||||
|
||||
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).",
|
||||
InputSchema: objSchema(
|
||||
prop{"slug", "string", "Entity slug."},
|
||||
prop{"state", "string", "Target lifecycle state."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["slug"].(string)
|
||||
targetState, _ := args["state"].(string)
|
||||
if slug == "" || targetState == "" {
|
||||
return textResult("error: slug and state are required"), nil
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
ent, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||
}
|
||||
id := ent.ID
|
||||
entityType := ent.Type
|
||||
currentState := ""
|
||||
if ent.State != nil {
|
||||
currentState = *ent.State
|
||||
}
|
||||
if err := db.ValidateTransition(ctx, tx, id, entityType, currentState, targetState); err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
ra, err := sqlcgen.New(tx).SetEntityState(ctx, sqlcgen.SetEntityStateParams{ID: id, State: &targetState})
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||
}
|
||||
if ra == 0 {
|
||||
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return textResult(fmt.Sprintf("error: commit: %v", err)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Transitioned %s: %s → %s.", slug, currentState, targetState)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
|
||||
InputSchema: objSchema(
|
||||
prop{"source", "string", "Source entity slug."},
|
||||
prop{"target", "string", "Target entity slug."},
|
||||
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
source, _ := args["source"].(string)
|
||||
target, _ := args["target"].(string)
|
||||
relType, _ := args["type"].(string)
|
||||
if source == "" || target == "" || relType == "" {
|
||||
return textResult("error: source, target, and type are required"), nil
|
||||
}
|
||||
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||
}
|
||||
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||
}
|
||||
_, err = sqlcgen.New(pool).InsertRelationshipIfAbsent(ctx, sqlcgen.InsertRelationshipIfAbsentParams{
|
||||
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType, Attributes: []byte(`{"by":"nomos"}`),
|
||||
})
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.",
|
||||
InputSchema: objSchema(
|
||||
prop{"source", "string", "Source entity slug."},
|
||||
prop{"target", "string", "Target entity slug."},
|
||||
prop{"type", "string", "Relationship type name."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
source, _ := args["source"].(string)
|
||||
target, _ := args["target"].(string)
|
||||
relType, _ := args["type"].(string)
|
||||
if source == "" || target == "" || relType == "" {
|
||||
return textResult("error: source, target, and type are required"), nil
|
||||
}
|
||||
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||
}
|
||||
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||
}
|
||||
ra, err := sqlcgen.New(pool).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
|
||||
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType,
|
||||
})
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error ending relationship: %v", err)), nil
|
||||
}
|
||||
if ra == 0 {
|
||||
return textResult(fmt.Sprintf("No active relationship %s —%s→ %s found.", source, relType, target)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Ended: %s —%s→ %s.", source, relType, target)), nil
|
||||
}},
|
||||
// ─── Client introspection tools (plan: client-lifecycle Phase 3) ──
|
||||
|
||||
{tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host",
|
||||
InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
hostname, _ := args["hostname"].(string)
|
||||
if hostname == "" {
|
||||
return textResult("error: hostname required"), nil
|
||||
}
|
||||
slug := "ws:" + hostname
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||
e.attributes->>'mesh_ip' AS mesh_ip,
|
||||
e.attributes->>'age_pubkey' AS age_pubkey,
|
||||
e.enrolled_at
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.slug = $1
|
||||
ORDER BY e.slug`, slug), "entity_card"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
||||
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["service_slug"].(string)
|
||||
if slug == "" {
|
||||
return textResult("error: service_slug required"), nil
|
||||
}
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||
e.version, e.updated_at,
|
||||
COALESCE(e.attributes::text, '{}') AS attrs
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.slug = $1`, slug), "entity_card"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
||||
InputSchema: objSchema(
|
||||
prop{"service_slug", "string", "Entity slug"},
|
||||
prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["service_slug"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
if slug == "" || action == "" {
|
||||
return textResult("error: service_slug and action required"), nil
|
||||
}
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.state,
|
||||
CASE
|
||||
WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low'
|
||||
WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation'
|
||||
WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive'
|
||||
ELSE 'read_only'
|
||||
END AS risk_class,
|
||||
CASE
|
||||
WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act'
|
||||
WHEN $2 = 'config_mutation' THEN 'operator-approval'
|
||||
ELSE 'operator-approval+confirmation'
|
||||
END AS approval
|
||||
FROM entities e WHERE e.slug = $1`, slug, action), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Entity slug"},
|
||||
prop{"limit", "integer", "Max entries (default 20)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_slug"].(string)
|
||||
limit := int(getFloat(args, "limit", 20))
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
||||
al.action, al.method, al.path,
|
||||
al.detail::text AS details, al.session_id::text AS session_id
|
||||
FROM audit_log al
|
||||
JOIN entities e ON e.id = al.entity_id
|
||||
WHERE e.slug = $1
|
||||
ORDER BY al.ts DESC
|
||||
LIMIT $2`, slug, limit), "change_log"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.state,
|
||||
COALESCE(st.health, 'unknown') AS health,
|
||||
COALESCE(st.last_check_at::text, '') AS last_check
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.state IS NOT NULL
|
||||
OR st.health IS NOT NULL
|
||||
ORDER BY st.health, e.slug
|
||||
LIMIT 200
|
||||
`), "fleet_snapshot"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "audit_knowledge_graph", Description: "Read-only drift report over the knowledge graph and monitoring: orphan check entities, checks targeting deprecated/destroyed entities, probes stuck down/unknown, unmonitored declared entity types, and live edges pointing at destroyed targets. Returns ranked findings with a suggested remediation runbook each. Use this to validate the graph is complete and consistent before trusting health/blast-radius answers. Does NOT mutate anything.",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
findings, summary := audit.Report(ctx, pool)
|
||||
b, _ := json.Marshal(map[string]any{"findings": findings, "summary": summary})
|
||||
return textResult(string(b)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "discover_infra_drift", Description: "Read-only live discovery: compares running Proxmox guests (pct/qm list on every proxmox host) against the DB graph. Returns guests running with no entity (missing) and entities whose pve_id is no longer live (ghost) — drift the DB-only audit_knowledge_graph cannot see. Reaches hosts over the same SSH/pct path the checks use. Does NOT mutate anything.",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
b, _ := json.Marshal(discoverInfraDrift(ctx, pool))
|
||||
return textResult(string(b)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "find_entities_by", Description: "Search entities by discovered attributes — IP address, port, version string, tag, or any key in the attributes JSONB blob. More flexible than list_entities (which filters by type/state only). Use for reverse lookups: 'what runs on port 8096?' or 'which entities have version 2.4?'",
|
||||
InputSchema: objSchema(
|
||||
prop{"key", "string", "Attribute key to search (e.g. ip, port, version, tag)"},
|
||||
prop{"value", "string", "Value to match (case-insensitive substring)"},
|
||||
prop{"limit", "integer", "Max results (default 25)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
key, _ := args["key"].(string)
|
||||
val, _ := args["value"].(string)
|
||||
limit := int(getFloat(args, "limit", 25))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, e.name, e.state, e.attributes->>$1 AS matched_value
|
||||
FROM entities e
|
||||
WHERE e.attributes ? $1
|
||||
AND e.attributes->>$1 ILIKE '%'||$2||'%'
|
||||
ORDER BY e.slug
|
||||
LIMIT $3`, key, val, limit), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
339
internal/mcp/knowledge_tools.go
Normal file
339
internal/mcp/knowledge_tools.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func KnowledgeTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
return []toolReg{
|
||||
{tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
q := nStr(args["query"])
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT ke.title, e.slug,
|
||||
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
||||
FragmentDelimiter=" ... "') AS snippet,
|
||||
ke.source, ke.tags
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||
ORDER BY rank DESC
|
||||
LIMIT 20`, q), "knowledge_results"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_slug"].(string)
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
JOIN relationships r ON r.source_id = ke.entity_id
|
||||
JOIN entities target ON target.id = r.target_id
|
||||
WHERE target.slug = $1
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
UNION
|
||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
JOIN relationships r ON r.source_id = ke.entity_id
|
||||
JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1)
|
||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.type = 'procedure-for'
|
||||
ORDER BY 1`, slug), "knowledge_results"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
||||
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["slug"].(string)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1`, slug), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
||||
InputSchema: objSchema(
|
||||
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||
prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."},
|
||||
prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."},
|
||||
prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return upsertKnowledge(ctx, pool, args)
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "delete_knowledge", Description: "Soft-delete a knowledge entry (move to trash, restorable with restore_knowledge). The content and revision history survive.",
|
||||
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["knowledge_slug"].(string)
|
||||
var entityID uuid.UUID
|
||||
if u, err := uuid.Parse(slug); err == nil {
|
||||
entityID = u
|
||||
} else {
|
||||
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
|
||||
}
|
||||
if entityID == uuid.Nil {
|
||||
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
|
||||
}
|
||||
// Snapshot before tombstoning.
|
||||
pool.Exec(ctx, `
|
||||
INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at)
|
||||
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
||||
FROM knowledge_entities WHERE entity_id = $1`, entityID)
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE knowledge_entities SET deleted_at = now(), edited_by = 'nomos'
|
||||
WHERE entity_id = $1 AND deleted_at IS NULL`, entityID)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult("knowledge entry already deleted"), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Knowledge %s soft-deleted. Restore with restore_knowledge.", slug)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "restore_knowledge", Description: "Restore a soft-deleted knowledge entry from trash. Undoes delete_knowledge.",
|
||||
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["knowledge_slug"].(string)
|
||||
var entityID uuid.UUID
|
||||
if u, err := uuid.Parse(slug); err == nil {
|
||||
entityID = u
|
||||
} else {
|
||||
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
|
||||
}
|
||||
if entityID == uuid.Nil {
|
||||
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
|
||||
}
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE knowledge_entities SET deleted_at = NULL, edited_by = 'nomos'
|
||||
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult("knowledge entry is not deleted"), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Knowledge %s restored from trash.", slug)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "merge_knowledge", Description: "Fold one or more knowledge entries into a target. Source content is appended under a provenance heading, and the union of all tags is kept. Sources are soft-deleted afterwards.",
|
||||
InputSchema: objSchema(
|
||||
prop{"target_slug", "string", "Knowledge entry to merge INTO (slug or UUID)"},
|
||||
prop{"source_slugs", "string", "Comma-separated slugs of entries to fold into the target"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
targetSlug, _ := args["target_slug"].(string)
|
||||
sourceStr, _ := args["source_slugs"].(string)
|
||||
|
||||
var targetID uuid.UUID
|
||||
if u, err := uuid.Parse(targetSlug); err == nil {
|
||||
targetID = u
|
||||
} else {
|
||||
pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
||||
targetSlug).Scan(&targetID)
|
||||
}
|
||||
if targetID == uuid.Nil {
|
||||
return textResult(fmt.Sprintf("target knowledge entry not found: %s", targetSlug)), nil
|
||||
}
|
||||
|
||||
sources := []string{}
|
||||
for _, s := range strings.Split(sourceStr, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" && s != targetSlug {
|
||||
sources = append(sources, s)
|
||||
}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return textResult("no valid source entries to merge"), nil
|
||||
}
|
||||
|
||||
var appended strings.Builder
|
||||
merged := []string{}
|
||||
for _, srcSlug := range sources {
|
||||
var title, content, updated string
|
||||
var tags []string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
||||
srcSlug).Scan(&title, &content, &tags, &updated)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
appended.WriteString("\n\n---\n\n## Merged: ")
|
||||
appended.WriteString(title)
|
||||
appended.WriteString("\n\n*Originally ")
|
||||
appended.WriteString(srcSlug)
|
||||
appended.WriteString(", last updated ")
|
||||
appended.WriteString(updated)
|
||||
appended.WriteString("*\n\n")
|
||||
appended.WriteString(content)
|
||||
for _, t := range tags {
|
||||
fmt.Fprintf(&appended, "\ntag: %s", strings.ToLower(strings.TrimSpace(t)))
|
||||
}
|
||||
merged = append(merged, srcSlug)
|
||||
}
|
||||
|
||||
if len(merged) == 0 {
|
||||
return textResult("no source entries could be read"), nil
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET content = content || $2, edited_by = 'nomos', updated_at = now()
|
||||
WHERE entity_id = $1`, targetID, appended.String())
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error appending content: %v", err)), nil
|
||||
}
|
||||
|
||||
for _, srcSlug := range merged {
|
||||
pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke SET deleted_at = now(), edited_by = 'nomos'
|
||||
FROM entities e
|
||||
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
|
||||
srcSlug)
|
||||
}
|
||||
|
||||
return textResult(fmt.Sprintf("Merged %d entries into %s: %s", len(merged), targetSlug, strings.Join(merged, ", "))), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "rename_knowledge_tag", Description: "Bulk-rename one or more tags across all knowledge entries. Case-insensitive matching — 'oom' and 'OOM' are treated as the same tag. Deduplicates after rename.",
|
||||
InputSchema: objSchema(
|
||||
prop{"from", "string", "Comma-separated tag names to rename FROM"},
|
||||
prop{"to", "string", "New tag name"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
fromStr, _ := args["from"].(string)
|
||||
to, _ := args["to"].(string)
|
||||
to = strings.ToLower(strings.TrimSpace(to))
|
||||
|
||||
from := []string{}
|
||||
for _, f := range strings.Split(fromStr, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
from = append(from, strings.ToLower(f))
|
||||
}
|
||||
}
|
||||
if to == "" || len(from) == 0 {
|
||||
return textResult("from and to are required"), nil
|
||||
}
|
||||
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke
|
||||
SET tags = sub.new_tags, updated_at = now()
|
||||
FROM (
|
||||
SELECT k.entity_id,
|
||||
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
|
||||
FROM unnest(k.tags) AS t) AS new_tags
|
||||
FROM knowledge_entities k
|
||||
WHERE k.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
|
||||
) AS sub
|
||||
WHERE ke.entity_id = sub.entity_id`, from, to)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Tag %s → %s: %d entries updated.", strings.Join(from, ", "), to, tag.RowsAffected())), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_knowledge_revisions", Description: "Version history for a knowledge entry. Returns title, content, editor, tags, and timestamps for each revision.",
|
||||
InputSchema: objSchema(
|
||||
prop{"knowledge_slug", "string", "Knowledge entity slug (e.g. document:nomos/something)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["knowledge_slug"].(string)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT kr.id, kr.title, kr.content, COALESCE(kr.edited_by, '') AS edited_by,
|
||||
COALESCE(kr.tags::text, '{}') AS tags,
|
||||
kr.version_at::text, kr.revised_at::text
|
||||
FROM knowledge_revisions kr
|
||||
JOIN entities e ON e.id = kr.entity_id
|
||||
WHERE e.slug = $1
|
||||
ORDER BY kr.version_at DESC LIMIT 50`, slug), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_knowledge_duplicates", Description: "Near-duplicate knowledge entries detected via trigram similarity. Returns clusters of similar documents with similarity scores. Use before creating new knowledge to avoid pileup.",
|
||||
InputSchema: objSchema(
|
||||
prop{"threshold", "number", "Similarity threshold 0-1 (default 0.6, lower = more matches)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
threshold := getFloat(args, "threshold", 0.6)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT a.slug AS doc_a, b.slug AS doc_b, similarity(ka.title, kb.title) AS sim
|
||||
FROM knowledge_entities ka
|
||||
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
|
||||
JOIN entities a ON a.id = ka.entity_id
|
||||
JOIN entities b ON b.id = kb.entity_id
|
||||
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
|
||||
AND similarity(ka.title, kb.title) > $1
|
||||
ORDER BY sim DESC LIMIT 100`, threshold), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_knowledge_orphans", Description: "Knowledge entries with no entity links (unlinked), no tags (untagged), or stale (not updated in N days). Helps identify abandoned or disconnected knowledge to clean up.",
|
||||
InputSchema: objSchema(
|
||||
prop{"stale_days", "integer", "Days without update to consider stale (default 90)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
staleDays := int(getFloat(args, "stale_days", 90))
|
||||
return queryRows(ctx, pool, fmt.Sprintf(`
|
||||
SELECT e.slug, ke.title, e.type AS kind, COALESCE(ke.edited_by, '') AS edited_by,
|
||||
ke.updated_at::text,
|
||||
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM relationships r
|
||||
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
) AS unlinked,
|
||||
(ke.updated_at < now() - interval '%d days') AS stale
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at ASC`, staleDays)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_knowledge_tags", Description: "All tags used across the knowledge base with usage counts. Returns normalized tag, count, and any casing variants (e.g. 'oom' and 'OOM' surface as variants so you can spot drift).",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT lower(tag) AS tag, count(*) AS uses,
|
||||
array_agg(DISTINCT tag ORDER BY tag) AS variants
|
||||
FROM knowledge_entities ke, unnest(ke.tags) AS tag
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY lower(tag) ORDER BY uses DESC, lower(tag)`), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
||||
InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
pubkey, _ := args["caller_pubkey"].(string)
|
||||
// Match entities where age_pubkey attribute contains the caller's key.
|
||||
query := `
|
||||
SELECT e.slug, e.type, e.name,
|
||||
e.attributes->>'age_pubkey' AS age_pubkey
|
||||
FROM entities e
|
||||
WHERE e.attributes->>'age_pubkey' IS NOT NULL`
|
||||
var dbArgs []any
|
||||
if pubkey != "" {
|
||||
query += ` AND e.attributes->>'age_pubkey' = $1`
|
||||
dbArgs = append(dbArgs, pubkey)
|
||||
}
|
||||
query += ` ORDER BY e.slug LIMIT 100`
|
||||
return queryRows(ctx, pool, query, dbArgs...), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
599
internal/mcp/ops_tools.go
Normal file
599
internal/mcp/ops_tools.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
|
||||
return []toolReg{
|
||||
// ── request_execution (legacy fixed enum) retired 2026-07-14 ──
|
||||
// All mutations now route through `run`. The handler functions
|
||||
// (runRexecRestart, runRexecSystemctl, etc.) are kept as reference
|
||||
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
||||
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
||||
|
||||
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.\n\nHost-level mutations (apt-get install, dpkg, systemctl enable) always classify as config_mutation — operator approval required.\n\nVM targets: the QEMU guest agent must be running inside the VM. If the entity's qemu_guest_agent attribute is not_running, the run is blocked immediately with a clear error.",
|
||||
InputSchema: objSchema(
|
||||
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
|
||||
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
||||
prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."},
|
||||
prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
targetSlug, _ := args["target"].(string)
|
||||
command, _ := args["command"].(string)
|
||||
purpose, _ := args["purpose"].(string)
|
||||
declaredRisk, _ := args["declared_risk"].(string)
|
||||
sessionID, _ := args["_session_id"].(string)
|
||||
if targetSlug == "" || command == "" {
|
||||
return textResult("error: target and command are required"), nil
|
||||
}
|
||||
|
||||
var targetID uuid.UUID
|
||||
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
|
||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||
}
|
||||
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
||||
}},
|
||||
// inspect_path is the bulk fact-gathering tool from
|
||||
// plans/2026-07-18-session-review-three-sessions.md P1.5.
|
||||
// Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls
|
||||
// gathering identical facts (`mount | grep`, `df`, `ls -la`,
|
||||
// `stat`) across hosts and LXCs to understand where a path
|
||||
// lives, who mounts it, and what permissions it has. This tool
|
||||
// collapses that fan-out into one call: pass a path and a list
|
||||
// of targets, get back per-target mount/df/ls/stat output as
|
||||
// JSON. All commands are read-only, so no approval is needed.
|
||||
{tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.",
|
||||
InputSchema: objSchema(
|
||||
prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."},
|
||||
prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
return textResult("error: path is required"), nil
|
||||
}
|
||||
rawTargets, _ := args["targets"].([]any)
|
||||
if len(rawTargets) == 0 {
|
||||
return textResult("error: at least one target is required"), nil
|
||||
}
|
||||
if len(rawTargets) > 8 {
|
||||
return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil
|
||||
}
|
||||
targets := make([]string, 0, len(rawTargets))
|
||||
for _, t := range rawTargets {
|
||||
if s, ok := t.(string); ok && s != "" {
|
||||
targets = append(targets, s)
|
||||
}
|
||||
}
|
||||
results := inspectPathAcrossTargets(ctx, pool, path, targets)
|
||||
out, _ := json.MarshalIndent(results, "", " ")
|
||||
return textResult(string(out)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||
InputSchema: objSchema(
|
||||
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
execID, _ := args["execution_id"].(string)
|
||||
if execID == "" {
|
||||
return textResult("execution_id required"), nil
|
||||
}
|
||||
eid, err := uuid.Parse(execID)
|
||||
if err != nil {
|
||||
// Try finding by exec slug prefix
|
||||
var found uuid.UUID
|
||||
err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found)
|
||||
if err2 != nil {
|
||||
return textResult(fmt.Sprintf("execution not found: %s", execID)), nil
|
||||
}
|
||||
eid = found
|
||||
}
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.entity_id::text, e.action, e.risk_class, e.status,
|
||||
e.result::text, e.duration_ms, e.started_at::text,
|
||||
e.completed_at::text, e.correlation_id
|
||||
FROM executions e
|
||||
WHERE e.entity_id = $1`, eid), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl",
|
||||
InputSchema: objSchema(
|
||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"},
|
||||
prop{"lines", "integer", "Number of lines (default 50)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["service_slug"].(string)
|
||||
n := int(getFloat(args, "lines", 50))
|
||||
if slug == "" {
|
||||
return textResult("service_slug is required"), nil
|
||||
}
|
||||
host, user, err := resolveHost(ctx, pool, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||
}
|
||||
svc := strings.TrimPrefix(slug, "lxc:")
|
||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n))
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||
}
|
||||
return textResult(out), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host",
|
||||
InputSchema: objSchema(
|
||||
prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["service_slug"].(string)
|
||||
if slug == "" {
|
||||
return textResult("service_slug is required"), nil
|
||||
}
|
||||
host, user, err := resolveHost(ctx, pool, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||
}
|
||||
svc := strings.TrimPrefix(slug, "lxc:")
|
||||
out, err := sshExec(ctx, host, user,
|
||||
fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc))
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||
}
|
||||
return textResult(out), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host",
|
||||
InputSchema: objSchema(
|
||||
prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["lxc_slug"].(string)
|
||||
if slug == "" {
|
||||
return textResult("lxc_slug is required"), nil
|
||||
}
|
||||
var pveID string
|
||||
err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID)
|
||||
if err != nil || pveID == "" {
|
||||
return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil
|
||||
}
|
||||
// Resolve the Proxmox host — find the host that runs this LXC
|
||||
var hostID uuid.UUID
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT t.id FROM entities t
|
||||
JOIN relationships r ON r.source_id = t.id
|
||||
JOIN entities s ON s.id = r.target_id
|
||||
WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL
|
||||
LIMIT 1`, slug).Scan(&hostID)
|
||||
if err != nil {
|
||||
// Fallback: use the inventory host attribute if no relationship
|
||||
var hostSlug string
|
||||
err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug)
|
||||
if err != nil || hostSlug == "" {
|
||||
return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil
|
||||
}
|
||||
var host, user string
|
||||
host, user, err = resolveHost(ctx, pool, "host:"+hostSlug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve: %v", err)), nil
|
||||
}
|
||||
out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||
if err2 != nil {
|
||||
return textResult(fmt.Sprintf("ssh: %v", err2)), nil
|
||||
}
|
||||
return textResult(out), nil
|
||||
}
|
||||
var hostSlug string
|
||||
pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug)
|
||||
host, user, err := resolveHost(ctx, pool, hostSlug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("resolve host: %v", err)), nil
|
||||
}
|
||||
out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID))
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("ssh: %v", err)), nil
|
||||
}
|
||||
return textResult(out), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe",
|
||||
InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["service_slug"].(string)
|
||||
if slug == "" {
|
||||
return textResult("service_slug is required"), nil
|
||||
}
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT st.health, st.last_check_at,
|
||||
COALESCE(
|
||||
e.attributes->>'url',
|
||||
CASE WHEN e.attributes->>'public_host' IS NOT NULL
|
||||
THEN 'https://' || e.attributes->>'public_host'
|
||||
END
|
||||
) AS url
|
||||
FROM entity_status st
|
||||
JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.slug = $1`, slug)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("query error: %v", err)), nil
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return textResult(fmt.Sprintf("service not found: %s", slug)), nil
|
||||
}
|
||||
var health, lastCheck, url string
|
||||
rows.Scan(&health, &lastCheck, &url)
|
||||
if url == "" {
|
||||
return textResult(fmt.Sprintf("health=%s last_check=%s url=no-url (entity has no url or public_host attribute)", health, lastCheck)), nil
|
||||
}
|
||||
// Live HTTP probe — HEAD request to check current state
|
||||
code := "n/a"
|
||||
if resp, err := http.Head(url); err == nil {
|
||||
resp.Body.Close()
|
||||
code = fmt.Sprintf("%d", resp.StatusCode)
|
||||
} else {
|
||||
code = fmt.Sprintf("err: %v", err)
|
||||
}
|
||||
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s http=%s", health, lastCheck, url, code)), nil
|
||||
}},
|
||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||
|
||||
{tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.",
|
||||
InputSchema: objSchema(
|
||||
prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
state, _ := argsMap(req)["state"].(string)
|
||||
var statePtr *string
|
||||
if state != "" {
|
||||
statePtr = &state
|
||||
}
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||
e.attributes->>'lan_ip' AS lan_ip,
|
||||
e.state,
|
||||
st.health, st.last_check_at,
|
||||
(SELECT MAX(k.created_at)
|
||||
FROM relationships r
|
||||
JOIN knowledge_entities k ON k.entity_id = r.source_id
|
||||
WHERE r.target_id = e.id
|
||||
AND r.type = 'about'
|
||||
AND r.valid_to IS NULL
|
||||
AND (k.tags @> ARRAY['audit']::text[]
|
||||
OR k.tags @> ARRAY['update']::text[]
|
||||
OR k.title ILIKE '%audit%'
|
||||
OR k.title ILIKE '%update%')
|
||||
) AS last_audited_at
|
||||
FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type = 'lxc'
|
||||
AND ($1::text IS NULL OR e.state = $1)
|
||||
ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END,
|
||||
(e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil
|
||||
}},
|
||||
// ── Stage 2: External agent observe ──────────────────────────
|
||||
|
||||
// ── Stage 4: External agent act (mutations) ─────────────────────
|
||||
|
||||
{tool: &mcp.Tool{Name: "ack_signal", Description: "Acknowledge an open signal. Use when investigating an alert — marks it as seen and being worked on.",
|
||||
InputSchema: objSchema(prop{"signal_id", "string", "Signal entity UUID"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
sid, _ := args["signal_id"].(string)
|
||||
id, err := uuid.Parse(sid)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
|
||||
}
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE signals SET state = 'acknowledged', updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','failed')`, id)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be acknowledged", sid)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Signal %s acknowledged.", sid)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "resolve_signal", Description: "Resolve a signal with an optional resolution note. Use when the underlying issue is fixed — marks the signal as resolved so it stops showing as active.",
|
||||
InputSchema: objSchema(
|
||||
prop{"signal_id", "string", "Signal entity UUID"},
|
||||
prop{"resolution", "string", "Optional note describing what fixed it"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
sid, _ := args["signal_id"].(string)
|
||||
id, err := uuid.Parse(sid)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
|
||||
}
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed')`, id)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be resolved", sid)), nil
|
||||
}
|
||||
resolution, _ := args["resolution"].(string)
|
||||
if resolution != "" {
|
||||
return textResult(fmt.Sprintf("Signal %s resolved: %s", sid, resolution)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Signal %s resolved.", sid)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "mute_signal", Description: "Temporarily mute a signal. Suppresses it from active views for the given duration. Use for known, non-urgent issues that don't need immediate attention.",
|
||||
InputSchema: objSchema(
|
||||
prop{"signal_id", "string", "Signal entity UUID"},
|
||||
prop{"duration_s", "integer", "Mute duration in seconds (default 3600 = 1 hour)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
sid, _ := args["signal_id"].(string)
|
||||
id, err := uuid.Parse(sid)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
|
||||
}
|
||||
dur := int64(getFloat(args, "duration_s", 3600))
|
||||
muteUntil := time.Now().UTC().Add(time.Duration(dur) * time.Second)
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now()
|
||||
WHERE entity_id = $1 AND state IN ('raised','acknowledged')`, id, muteUntil)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be muted", sid)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("Signal %s muted until %s.", sid, muteUntil.Format(time.RFC3339))), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "cancel_execution", Description: "Cancel a queued or running execution. Use when you realize the command was wrong, targets the wrong host, or should not proceed. Requires a reason.",
|
||||
InputSchema: objSchema(
|
||||
prop{"execution_id", "string", "Execution entity UUID"},
|
||||
prop{"reason", "string", "Why this execution should be cancelled"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
eid, _ := args["execution_id"].(string)
|
||||
id, err := uuid.Parse(eid)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("invalid execution_id: %v", err)), nil
|
||||
}
|
||||
reason, _ := args["reason"].(string)
|
||||
result := jsonErr("cancelled by agent: %s", reason)
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE executions SET status = 'cancelled', result = $2::jsonb
|
||||
WHERE entity_id = $1 AND status IN ('running','pending_approval','approved','queued')`,
|
||||
id, result)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult(fmt.Sprintf("execution %s not found or already final", eid)), nil
|
||||
}
|
||||
// Write audit entry.
|
||||
_ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "cancel",
|
||||
&id, "POST", "/mcp", "", nil,
|
||||
map[string]any{"reason": reason})
|
||||
return textResult(fmt.Sprintf("Execution %s cancelled: %s", eid, reason)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "update_check", Description: "Enable or disable a health check. Disable a noisy probe that's firing false positives; re-enable after fixing the underlying issue.",
|
||||
InputSchema: objSchema(
|
||||
prop{"check_id", "string", "Check entity UUID"},
|
||||
prop{"enabled", "boolean", "true to enable, false to disable"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
cid, _ := args["check_id"].(string)
|
||||
id, err := uuid.Parse(cid)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("invalid check_id: %v", err)), nil
|
||||
}
|
||||
enabled, _ := args["enabled"].(bool)
|
||||
tag, err := pool.Exec(ctx,
|
||||
`UPDATE check_defs SET enabled = $2 WHERE entity_id = $1`, id, enabled)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return textResult(fmt.Sprintf("check %s not found", cid)), nil
|
||||
}
|
||||
status := "enabled"
|
||||
if !enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
return textResult(fmt.Sprintf("Check %s %s.", cid, status)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_checks", Description: "List health checks with verdict, last run time, probe kind, and config. Filter by entity slug or enabled status. Each check's last_health explains which probe is responsible for an entity's overall health.",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||
prop{"enabled", "boolean", "Filter enabled/disabled (optional)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
return annotateJSONResult(queryRows(ctx, pool, `
|
||||
SELECT cd.entity_id, e.slug, cd.kind,
|
||||
COALESCE(te.slug, '') AS target_slug, cd.target_type,
|
||||
cd.config::text, cd.interval_s, cd.timeout_s, cd.enabled,
|
||||
e.version, cd.last_health, cd.last_run_at::text
|
||||
FROM check_defs cd
|
||||
JOIN entities e ON e.id = cd.entity_id
|
||||
LEFT JOIN entities te ON te.id = cd.target_id
|
||||
WHERE ($1::text IS NULL OR te.slug = $1)
|
||||
AND ($2::bool IS NULL OR cd.enabled = $2)
|
||||
ORDER BY e.slug LIMIT 200`,
|
||||
nStr(args["entity_slug"]), args["enabled"]), "check_table"), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_executions", Description: "Cursor-paginated execution history. Filter by entity slug, status, or risk class. Returns newest-first with duration, result, and target info.",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Filter by target entity slug"},
|
||||
prop{"status", "string", "Filter by status (running/completed/failed/pending_approval)"},
|
||||
prop{"limit", "integer", "Max rows (default 25)"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
limit := int(getFloat(args, "limit", 25))
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.entity_id, te.slug AS target, e.action, e.risk_class,
|
||||
e.status, e.result::text, e.duration_ms,
|
||||
e.correlation_id, e.started_at::text, e.completed_at::text, e.created_at::text,
|
||||
COALESCE(npe.session_id::text, '') AS session_id
|
||||
FROM executions e
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
LEFT JOIN nomos_plan_executions npe ON npe.execution_id = e.entity_id
|
||||
WHERE ($1::text IS NULL OR te.slug = $1)
|
||||
AND ($2::text IS NULL OR e.status = $2)
|
||||
ORDER BY e.created_at DESC LIMIT $3`,
|
||||
nStr(args["entity_slug"]), nStr(args["status"]), limit), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_entity_sessions", Description: "Active Nomos sessions (tasks) linked to an entity. Shows goal, status, outcome, and when the session was last active. Use to discover what agents are working on related to this entity.",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Entity slug to find sessions for"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
slug, _ := args["entity_slug"].(string)
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT DISTINCT as2.id, as2.title, as2.goal, as2.status, as2.outcome,
|
||||
as2.summary, as2.last_active_at::text, as2.closed_at::text
|
||||
FROM agent_sessions as2
|
||||
JOIN nomos_plan_executions npe ON npe.session_id = as2.id
|
||||
JOIN executions ex ON ex.entity_id = npe.execution_id
|
||||
JOIN entities te ON te.id = ex.target_entity_id
|
||||
WHERE te.slug = $1 AND as2.closed_at IS NULL
|
||||
ORDER BY as2.last_active_at DESC LIMIT 20`, slug), nil
|
||||
}},
|
||||
// ── Stage 2: External agent observe ──────────────────────────
|
||||
|
||||
{tool: &mcp.Tool{Name: "get_dashboard_summary", Description: "Fleet overview in one call: entity counts by type and state, health breakdown (healthy/degraded/down/stale/unknown), active signals by severity, pending approval count, execution counts in last 24h, and event rate over last 6h.",
|
||||
InputSchema: objSchema(),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
// Entity counts by type
|
||||
result["entities_by_type"] = rowsToMap(ctx, pool,
|
||||
`SELECT type, count(*) FROM entities GROUP BY type`)
|
||||
|
||||
// Entity counts by state
|
||||
result["entities_by_state"] = rowsToMap(ctx, pool,
|
||||
`SELECT coalesce(state, 'unknown'), count(*) FROM entities GROUP BY state`)
|
||||
|
||||
// Health rollup (excluding check entities)
|
||||
result["health"] = rowsToMap(ctx, pool, `
|
||||
SELECT COALESCE(st.health, 'unknown') AS health, count(*)
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check' GROUP BY st.health`)
|
||||
|
||||
// Active signals by severity
|
||||
result["signals_by_severity"] = rowsToMap(ctx, pool, `
|
||||
SELECT severity, count(*) FROM signals
|
||||
WHERE state NOT IN ('resolved', 'failed') GROUP BY severity`)
|
||||
|
||||
// Pending approvals
|
||||
var pending int
|
||||
pool.QueryRow(ctx, `SELECT count(*) FROM approvals WHERE status = 'pending'`).Scan(&pending)
|
||||
result["approvals_pending"] = pending
|
||||
|
||||
// Executions in last 24h
|
||||
result["executions_by_state"] = rowsToMap(ctx, pool, `
|
||||
SELECT status, count(*) FROM executions
|
||||
WHERE created_at > now() - interval '24 hours' GROUP BY status`)
|
||||
|
||||
// Event rate (5-min buckets over 6h)
|
||||
events := []map[string]any{}
|
||||
erows, _ := pool.Query(ctx, `
|
||||
SELECT date_trunc('hour', ts) + (extract(minute FROM ts)::int / 5) * interval '5 minutes' AS bucket, count(*)
|
||||
FROM events WHERE ts > now() - interval '6 hours'
|
||||
GROUP BY bucket ORDER BY bucket`)
|
||||
if erows != nil {
|
||||
for erows.Next() {
|
||||
var bucket time.Time
|
||||
var n int
|
||||
if erows.Scan(&bucket, &n) == nil {
|
||||
events = append(events, map[string]any{"bucket": bucket, "count": n})
|
||||
}
|
||||
}
|
||||
erows.Close()
|
||||
}
|
||||
result["event_rate"] = events
|
||||
|
||||
b, _ := json.MarshalIndent(result, "", " ")
|
||||
return textResult(string(b)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "get_secret", Description: "Retrieve a secret value from the Infisical vault. Returns the secret value. Use for service credentials, tokens, and keys needed to operate the homelab.",
|
||||
InputSchema: objSchema(
|
||||
prop{"key", "string", "Secret key to retrieve (e.g. 'matrix-token', 'clients/host:hubris/age-key')"},
|
||||
prop{"path", "string", "Secret path prefix (default '/')"},
|
||||
prop{"environment", "string", "Environment slug (default 'dev')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
key, _ := args["key"].(string)
|
||||
if key == "" {
|
||||
return textResult("error: key is required"), nil
|
||||
}
|
||||
val, err := sec.Get(ctx, key)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
return textResult(val), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "list_secrets", Description: "List secret keys in the Infisical vault. Returns key names only (no values). Filter by path prefix to scope to a client or shared path.",
|
||||
InputSchema: objSchema(
|
||||
prop{"path_prefix", "string", "Filter to keys matching this prefix (e.g. 'clients/', 'shared/', 'config/')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
prefix, _ := args["path_prefix"].(string)
|
||||
|
||||
keys, err := sec.List(ctx)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
if prefix != "" {
|
||||
filtered := keys[:0]
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
filtered = append(filtered, k)
|
||||
}
|
||||
}
|
||||
keys = filtered
|
||||
}
|
||||
data, _ := json.MarshalIndent(keys, "", " ")
|
||||
return textResult(string(data)), nil
|
||||
}},
|
||||
{tool: &mcp.Tool{Name: "set_secret", Description: "Store or update a secret in the Infisical vault. Use when discovering new credentials that need to be persisted. Requires operator approval (config_mutation).",
|
||||
InputSchema: objSchema(
|
||||
prop{"key", "string", "Secret key to store"},
|
||||
prop{"value", "string", "Secret value to store"},
|
||||
prop{"path", "string", "Secret path prefix (default '/')"},
|
||||
prop{"environment", "string", "Environment slug (default 'dev')"}),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
if sec == nil {
|
||||
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
|
||||
}
|
||||
args := argsMap(req)
|
||||
key, _ := args["key"].(string)
|
||||
value, _ := args["value"].(string)
|
||||
if key == "" {
|
||||
return textResult("error: key is required"), nil
|
||||
}
|
||||
if value == "" {
|
||||
return textResult("error: value is required"), nil
|
||||
}
|
||||
if err := sec.Set(ctx, key, value); err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err)), nil
|
||||
}
|
||||
return textResult(fmt.Sprintf("secret %s stored", key)), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/dtoro/oikos/internal/remote"
|
||||
"github.com/dtoro/oikos/internal/secrets"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
@@ -49,9 +48,18 @@ func objSchema(props ...prop) *jsonschema.Schema {
|
||||
return s
|
||||
}
|
||||
|
||||
// secretBackend is the interface MCP tools use to access the secrets store.
|
||||
// Defined here to avoid importing the full secrets package (which brings in
|
||||
// the Infisical SDK). Mirrors the subset of secrets.Backend used by tools.
|
||||
type secretBackend interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
List(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHandler creates an http.Handler that serves the Oikos MCP server.
|
||||
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secrets.Backend) http.Handler {
|
||||
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secretBackend) http.Handler {
|
||||
s := newServer(pool, agentID, sec)
|
||||
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
if token != "" {
|
||||
@@ -67,7 +75,7 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec secrets.Back
|
||||
// toolHandler is the function signature registered via AddTool.
|
||||
type toolHandler = mcp.ToolHandler
|
||||
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec secrets.Backend) *mcp.Server {
|
||||
func newServer(pool *db.Pool, agentID uuid.UUID, sec secretBackend) *mcp.Server {
|
||||
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
|
||||
Logger: slog.Default(),
|
||||
})
|
||||
@@ -483,22 +491,14 @@ func sshExecStream(ctx context.Context, host, user, command string, sink execlog
|
||||
user = sshUser
|
||||
}
|
||||
|
||||
addr := host + ":22"
|
||||
signer, err := ssh.ParsePrivateKey(sshKey)
|
||||
signer, err := actuator.LoadSignerFromBytes(sshKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse key: %w", err)
|
||||
}
|
||||
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: actuator.HostKeyCallback(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", addr, cfg)
|
||||
client, err := actuator.Dial(ctx, actuator.DialOptions{Host: host, User: user, Signer: signer})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dial %s: %w", host, err)
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
internal/ontology/preconditions_test.go
Normal file
102
internal/ontology/preconditions_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package ontology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// The lifecycle precondition checks split into a pure attribute/type guard
|
||||
// and a DB query. These cover the pure guards at 0%: the entity-type skip
|
||||
// rules and the attribute presence/absence semantics. The DB-backed checks
|
||||
// (health, edges, backups, docs) are exercised by make test-db.
|
||||
//
|
||||
// ctx/pool/entityID are unused by the pure guards, so nil is safe here.
|
||||
var (
|
||||
noCtx = context.Background()
|
||||
noPool *pgxpool.Pool // nil: the pure guards never touch the pool
|
||||
noID = uuid.New()
|
||||
)
|
||||
|
||||
func TestCheckAgeKeyEnrolled(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
entityType string
|
||||
attrs map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{"workstation with age key", "workstation", map[string]any{"age_pubkey": "age1abc"}, false},
|
||||
{"workstation missing age key", "workstation", map[string]any{}, true},
|
||||
{"server needs a key too", "server", map[string]any{}, true},
|
||||
{"lxc is exempt", "lxc", map[string]any{}, false},
|
||||
{"vm is exempt", "vm", map[string]any{}, false},
|
||||
{"docker-container is exempt", "docker-container", map[string]any{}, false},
|
||||
{"nil attrs on a workstation", "workstation", nil, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := checkAgeKeyEnrolled(noCtx, noPool, noID, c.entityType, c.attrs)
|
||||
if c.wantErr && !errors.Is(err, domain.ErrInvalidTransition) {
|
||||
t.Errorf("want ErrInvalidTransition, got %v", err)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("want nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMeshJoined(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
entityType string
|
||||
attrs map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{"workstation with mesh_ip", "workstation", map[string]any{"mesh_ip": "10.0.0.5"}, false},
|
||||
{"workstation missing mesh_ip", "workstation", map[string]any{}, true},
|
||||
{"server missing mesh_ip", "server", map[string]any{}, true},
|
||||
{"lxc is exempt", "lxc", map[string]any{}, false},
|
||||
{"vm is exempt", "vm", map[string]any{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := checkMeshJoined(noCtx, noPool, noID, c.entityType, c.attrs)
|
||||
if c.wantErr && !errors.Is(err, domain.ErrInvalidTransition) {
|
||||
t.Errorf("want ErrInvalidTransition, got %v", err)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("want nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSecretsRevoked(t *testing.T) {
|
||||
// checkSecretsRevoked treats an ABSENT age_pubkey as "secrets revoked"
|
||||
// (the inverse of checkAgeKeyEnrolled). It is type-agnostic.
|
||||
cases := []struct {
|
||||
name string
|
||||
attrs map[string]any
|
||||
wantErr bool
|
||||
}{
|
||||
{"age key gone → revoked", map[string]any{}, false},
|
||||
{"age key still present → blocked", map[string]any{"age_pubkey": "age1abc"}, true},
|
||||
{"nil attrs → revoked", nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := checkSecretsRevoked(noCtx, noPool, noID, "workstation", c.attrs)
|
||||
if c.wantErr && !errors.Is(err, domain.ErrInvalidTransition) {
|
||||
t.Errorf("want ErrInvalidTransition, got %v", err)
|
||||
}
|
||||
if !c.wantErr && err != nil {
|
||||
t.Errorf("want nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
95
internal/policy/risk_test.go
Normal file
95
internal/policy/risk_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package policy
|
||||
|
||||
import "testing"
|
||||
|
||||
// The escalation ladder is the load-bearing invariant of the policy layer:
|
||||
// computed risk may only escalate, never de-escalate, against the caller's
|
||||
// declaration. These pin the rank order and the unknown-input defaults that
|
||||
// ClassifyCommand relies on (riskRank/normalizeRisk were only 66% covered).
|
||||
func TestRiskRankOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool // want riskRank(a) < riskRank(b)
|
||||
}{
|
||||
{RiskReadOnly, RiskReversibleLow, true},
|
||||
{RiskReversibleLow, RiskConfigMutation, true},
|
||||
{RiskConfigMutation, RiskDestructive, true},
|
||||
{RiskReadOnly, RiskDestructive, true},
|
||||
{RiskDestructive, RiskReadOnly, false},
|
||||
{RiskConfigMutation, RiskConfigMutation, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := riskRank(c.a) < riskRank(c.b); got != c.want {
|
||||
t.Errorf("riskRank(%q) < riskRank(%q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskRankUnknownDefaultsToConfigMutation(t *testing.T) {
|
||||
// An unrecognized declared risk is treated as config_mutation — the
|
||||
// safer-to-gate default — not as the lowest tier.
|
||||
if r := riskRank("totally_made_up"); r != riskRank(RiskConfigMutation) {
|
||||
t.Errorf("riskRank(unknown) = %d, want %d (config_mutation)", r, riskRank(RiskConfigMutation))
|
||||
}
|
||||
// It therefore outranks read_only and reversible_low...
|
||||
if riskRank("made_up") <= riskRank(RiskReadOnly) {
|
||||
t.Error("unknown risk should outrank read_only")
|
||||
}
|
||||
if riskRank("made_up") <= riskRank(RiskReversibleLow) {
|
||||
t.Error("unknown risk should outrank reversible_low")
|
||||
}
|
||||
// ...but never outranks destructive.
|
||||
if riskRank("made_up") >= riskRank(RiskDestructive) {
|
||||
t.Error("unknown risk must not outrank destructive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRisk(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{RiskReadOnly, RiskReadOnly},
|
||||
{RiskReversibleLow, RiskReversibleLow},
|
||||
{RiskConfigMutation, RiskConfigMutation},
|
||||
{RiskDestructive, RiskDestructive},
|
||||
// Unknown / empty / malformed declared risks collapse to the gated
|
||||
// default rather than the most-permissive tier.
|
||||
{"", RiskConfigMutation},
|
||||
{"bogus", RiskConfigMutation},
|
||||
{"READ_ONLY", RiskConfigMutation}, // case-sensitive: not normalized
|
||||
{"read-only", RiskConfigMutation}, // hyphen, not underscore
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeRisk(c.in); got != c.want {
|
||||
t.Errorf("normalizeRisk(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation property: ClassifyCommand returns max(rank(computed), rank(declared)).
|
||||
// Over a read-only command (computed rank 0) the declared risk passes through
|
||||
// (undeclared → read_only; bogus → config_mutation); over a destructive command
|
||||
// (computed rank 3) the result is always destructive.
|
||||
func TestClassifyCommandEscalationIsMaxOfRanks(t *testing.T) {
|
||||
readOnlyExpected := []struct {
|
||||
declared, want string
|
||||
}{
|
||||
{"", RiskReadOnly},
|
||||
{RiskReadOnly, RiskReadOnly},
|
||||
{RiskReversibleLow, RiskReversibleLow},
|
||||
{RiskConfigMutation, RiskConfigMutation},
|
||||
{RiskDestructive, RiskDestructive},
|
||||
{"bogus", RiskConfigMutation}, // unknown declared → config_mutation rank
|
||||
}
|
||||
for _, c := range readOnlyExpected {
|
||||
if got := ClassifyCommand("uptime", c.declared); got != c.want {
|
||||
t.Errorf("read-only cmd + declared %q = %q, want %q", c.declared, got, c.want)
|
||||
}
|
||||
}
|
||||
for _, d := range []string{"", RiskReadOnly, RiskReversibleLow, RiskConfigMutation, RiskDestructive, "bogus"} {
|
||||
if got := ClassifyCommand("rm -rf /var/lib/x", d); got != RiskDestructive {
|
||||
t.Errorf("destructive cmd + declared %q = %q, want destructive", d, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user