feat: add mcp read tools for fleet, health and workflow data
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// serverSummary is what a list returns: enough for a model to decide which
|
||||
// server to ask about next, and nothing else. The full document is an order of
|
||||
// magnitude larger and listing thirty of them would dominate a context window.
|
||||
type serverSummary struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Online bool `json:"online"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// models.Server has no Online bool: it stores Status as one of "pending",
|
||||
// "online" or "offline" (see internal/services/servers.go). Online here
|
||||
// mirrors that string the same way the REST layer treats it.
|
||||
func summariseServer(s models.Server) serverSummary {
|
||||
return serverSummary{
|
||||
ID: s.ServerID,
|
||||
Hostname: s.Hostname,
|
||||
OS: s.OSInfo,
|
||||
Online: s.Status == "online",
|
||||
Tags: s.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultLimit and maxLimit bound every listing. A model asking for everything
|
||||
// gets a page and is told the total, which is more useful than a truncated blob
|
||||
// it cannot tell is truncated.
|
||||
const (
|
||||
defaultLimit = 50
|
||||
maxLimit = 200
|
||||
)
|
||||
|
||||
func pageLimit(args map[string]any) int {
|
||||
n, ok := args["limit"].(float64)
|
||||
if !ok || int(n) <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if int(n) > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func stringArg(args map[string]any, key string) string {
|
||||
s, _ := args[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func tagArg(args map[string]any) map[string]string {
|
||||
raw, ok := args["tags"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type listServersResult struct {
|
||||
Servers []serverSummary `json:"servers"`
|
||||
Total int `json:"total"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
All().Register(Tool{
|
||||
Name: "list_servers",
|
||||
Scope: "servers:read",
|
||||
Description: "List the servers in this Vantage fleet, optionally filtered by tags. " +
|
||||
"Returns a compact summary per server; use get_server for full detail on one.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
|
||||
if !ok {
|
||||
// The requested tags and the token's restriction can never both
|
||||
// hold, so the honest answer is an empty fleet.
|
||||
return listServersResult{Servers: []serverSummary{}}, nil
|
||||
}
|
||||
|
||||
servers, err := services.ListServersFiltered(c.InstanceID, sel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list servers: %w", err)
|
||||
}
|
||||
|
||||
limit := pageLimit(args)
|
||||
out := make([]serverSummary, 0, limit)
|
||||
for _, s := range servers {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, summariseServer(s))
|
||||
}
|
||||
return listServersResult{Servers: out, Total: len(servers), Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_server",
|
||||
Scope: "servers:read",
|
||||
Description: "Get detail for one server by ID: OS, online state and tags. " +
|
||||
"Use list_pending_updates for that server's outstanding package updates.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
id := stringArg(args, "server_id")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("server_id is required")
|
||||
}
|
||||
srv, err := services.GetServerScoped(c.InstanceID, id, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no server %q is visible to this token", id)
|
||||
}
|
||||
return summariseServer(*srv), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// monitorSummary carries state and identity. A model asking "what is broken"
|
||||
// needs the state and the name; the target URL, expected status, keyword,
|
||||
// runner and channel list are configuration it did not ask for.
|
||||
type monitorSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
State string `json:"state"`
|
||||
Interval int `json:"interval_sec"`
|
||||
}
|
||||
|
||||
// models.Monitor.State is a MonitorState struct whose status field is
|
||||
// Status (a plain string: models.StatusUp/StatusDown/StatusPending), not the
|
||||
// ".Status" field-of-a-field the brief guessed at.
|
||||
func summariseMonitor(m models.Monitor) monitorSummary {
|
||||
return monitorSummary{
|
||||
ID: m.MonitorID,
|
||||
Name: m.Name,
|
||||
Group: m.Group,
|
||||
Type: m.Type,
|
||||
Enabled: m.Enabled,
|
||||
State: m.State.Status,
|
||||
Interval: m.IntervalSec,
|
||||
}
|
||||
}
|
||||
|
||||
type listMonitorsResult struct {
|
||||
Monitors []monitorSummary `json:"monitors"`
|
||||
Total int `json:"total"`
|
||||
Shown int `json:"shown"`
|
||||
Down int `json:"down"`
|
||||
}
|
||||
|
||||
// monitorStatusDetail is get_monitor_status's projection: enough to tell a
|
||||
// model what a monitor is currently doing, without its target configuration.
|
||||
type monitorStatusDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
State string `json:"state"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type incidentSummary struct {
|
||||
ID string `json:"id"`
|
||||
MonitorName string `json:"monitor_name"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
type listIncidentsResult struct {
|
||||
Incidents []incidentSummary `json:"incidents"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
type monitorSample struct {
|
||||
At time.Time `json:"at"`
|
||||
Ok bool `json:"ok"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
}
|
||||
|
||||
type listSamplesResult struct {
|
||||
Samples []monitorSample `json:"samples"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
const defaultSampleLimit = 100
|
||||
const maxSampleLimit = 500
|
||||
|
||||
func init() {
|
||||
All().Register(Tool{
|
||||
Name: "list_monitors",
|
||||
Scope: "monitors:read",
|
||||
Description: "List the monitors on this instance with their current state. " +
|
||||
"Pass state:\"down\" to see only what is currently failing.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
monitors, err := services.ListMonitors(c.InstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list monitors: %w", err)
|
||||
}
|
||||
|
||||
wantState := stringArg(args, "state")
|
||||
limit := pageLimit(args)
|
||||
|
||||
out := make([]monitorSummary, 0, limit)
|
||||
down, total := 0, 0
|
||||
for _, m := range monitors {
|
||||
summary := summariseMonitor(m)
|
||||
if summary.State == "down" {
|
||||
down++
|
||||
}
|
||||
if wantState != "" && summary.State != wantState {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if len(out) < limit {
|
||||
out = append(out, summary)
|
||||
}
|
||||
}
|
||||
return listMonitorsResult{Monitors: out, Total: total, Shown: len(out), Down: down}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_monitor_status",
|
||||
Scope: "monitors:read",
|
||||
Description: "Get one monitor's current state: up, down or pending, the last check " +
|
||||
"time, and the last error message if it is failing.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
id := stringArg(args, "monitor_id")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("monitor_id is required")
|
||||
}
|
||||
m, err := services.GetMonitor(c.InstanceID, id)
|
||||
if err != nil || m == nil {
|
||||
return nil, fmt.Errorf("no monitor %q found", id)
|
||||
}
|
||||
return monitorStatusDetail{
|
||||
ID: m.MonitorID,
|
||||
Name: m.Name,
|
||||
Type: m.Type,
|
||||
State: m.State.Status,
|
||||
LastCheckAt: m.State.LastCheckAt,
|
||||
LastError: m.State.Message,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "list_incidents",
|
||||
Scope: "monitors:read",
|
||||
Description: "List monitor incidents (outages), most recent first. Pass monitor_id to " +
|
||||
"scope to one monitor, or omit it to see incidents across every monitor.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
limit := int64(pageLimit(args))
|
||||
monitorID := stringArg(args, "monitor_id")
|
||||
|
||||
var monitorNames map[string]string
|
||||
var monitorIDs []string
|
||||
if monitorID != "" {
|
||||
monitorIDs = []string{monitorID}
|
||||
} else {
|
||||
monitors, err := services.ListMonitors(c.InstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list monitors: %w", err)
|
||||
}
|
||||
monitorNames = make(map[string]string, len(monitors))
|
||||
for _, m := range monitors {
|
||||
monitorNames[m.MonitorID] = m.Name
|
||||
monitorIDs = append(monitorIDs, m.MonitorID)
|
||||
}
|
||||
}
|
||||
|
||||
out := []incidentSummary{}
|
||||
for _, mid := range monitorIDs {
|
||||
if len(out) >= int(limit) {
|
||||
break
|
||||
}
|
||||
incidents, err := services.ListIncidents(c.InstanceID, mid, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list incidents: %w", err)
|
||||
}
|
||||
name := mid
|
||||
if monitorNames != nil {
|
||||
if n, ok := monitorNames[mid]; ok {
|
||||
name = n
|
||||
}
|
||||
} else {
|
||||
if m, err := services.GetMonitor(c.InstanceID, mid); err == nil && m != nil {
|
||||
name = m.Name
|
||||
}
|
||||
}
|
||||
for _, inc := range incidents {
|
||||
if len(out) >= int(limit) {
|
||||
break
|
||||
}
|
||||
out = append(out, incidentSummary{
|
||||
ID: inc.IncidentID,
|
||||
MonitorName: name,
|
||||
StartedAt: inc.StartedAt,
|
||||
ResolvedAt: inc.ResolvedAt,
|
||||
Cause: inc.Cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
return listIncidentsResult{Incidents: out, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_monitor_samples",
|
||||
Scope: "monitors:read",
|
||||
Description: "Get one monitor's recent raw check results (timestamp, ok/fail, latency). " +
|
||||
"Samples are numerous and expire after 48 hours; use list_incidents for a longer view.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
id := stringArg(args, "monitor_id")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("monitor_id is required")
|
||||
}
|
||||
limit := int(pageLimit(args))
|
||||
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
|
||||
limit = int(raw)
|
||||
} else {
|
||||
limit = defaultSampleLimit
|
||||
}
|
||||
if limit > maxSampleLimit {
|
||||
limit = maxSampleLimit
|
||||
}
|
||||
|
||||
samples, err := services.MonitorSamples(c.InstanceID, id, time.Now().Add(-services.MonitorSampleTTL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get samples: %w", err)
|
||||
}
|
||||
|
||||
out := make([]monitorSample, 0, limit)
|
||||
for _, s := range samples {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, monitorSample{At: s.At, Ok: s.Up, LatencyMs: s.LatencyMs})
|
||||
}
|
||||
return listSamplesResult{Samples: out, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func TestReadToolsAreRegistered(t *testing.T) {
|
||||
want := []string{
|
||||
"list_servers", "get_server", "search_fleet",
|
||||
"list_monitors", "get_monitor_status", "list_incidents", "get_monitor_samples",
|
||||
"list_pending_updates", "list_vulnerabilities", "get_server_packages",
|
||||
"list_workflows", "get_workflow", "get_run", "get_run_logs",
|
||||
"list_audit_events", "list_secret_names",
|
||||
}
|
||||
for _, name := range want {
|
||||
tool, ok := All().Lookup(name)
|
||||
if !ok {
|
||||
t.Errorf("tool %q is not registered", name)
|
||||
continue
|
||||
}
|
||||
if tool.Write {
|
||||
t.Errorf("tool %q is marked as a write", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secret plaintext must never be reachable, at any scope. This is the one
|
||||
// deliberate refusal in the read set and it is worth a test of its own.
|
||||
func TestNoSecretRevealTool(t *testing.T) {
|
||||
for _, tool := range All().Tools() {
|
||||
if tool.Name == "reveal_secret" || tool.Name == "get_secret" {
|
||||
t.Errorf("tool %q exposes secret plaintext to a model", tool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fleet listing that costs thousands of tokens degrades every interaction
|
||||
// and is otherwise invisible until someone reads a bill.
|
||||
func TestServerSummaryStaysSmall(t *testing.T) {
|
||||
fleet := make([]serverSummary, 30)
|
||||
for i := range fleet {
|
||||
fleet[i] = summariseServer(models.Server{
|
||||
ServerID: "srv-000000000000000000000000",
|
||||
Hostname: "web-server-with-a-longish-name",
|
||||
OSInfo: "Ubuntu 24.04.1 LTS",
|
||||
Tags: map[string]string{"env": "prod", "team": "core"},
|
||||
})
|
||||
}
|
||||
|
||||
out, err := json.Marshal(fleet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) > 8000 {
|
||||
t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// ---- workflows ----
|
||||
|
||||
type workflowSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Steps int `json:"steps"`
|
||||
Targets int `json:"targets"`
|
||||
Scheduled bool `json:"scheduled"`
|
||||
}
|
||||
|
||||
type listWorkflowsResult struct {
|
||||
Workflows []workflowSummary `json:"workflows"`
|
||||
Total int `json:"total"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
type workflowStepRef struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type workflowDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Steps []workflowStepRef `json:"steps"`
|
||||
Targets []string `json:"target_server_ids,omitempty"`
|
||||
Tags map[string]string `json:"target_tags,omitempty"`
|
||||
Schedule string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// ---- runs ----
|
||||
|
||||
type runStatusCounts struct {
|
||||
Pending int `json:"pending,omitempty"`
|
||||
Running int `json:"running,omitempty"`
|
||||
Success int `json:"success,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
type runDetail struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
ServerCounts runStatusCounts `json:"server_status_counts"`
|
||||
}
|
||||
|
||||
type runLogsResult struct {
|
||||
Lines []string `json:"lines"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
const defaultLogLimit = 200
|
||||
|
||||
// ---- pending updates ----
|
||||
|
||||
type pendingUpdate struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Package string `json:"package"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
}
|
||||
|
||||
type listPendingUpdatesResult struct {
|
||||
Updates []pendingUpdate `json:"updates"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
// ---- vulnerabilities ----
|
||||
|
||||
type vulnSummary struct {
|
||||
CVEID string `json:"cve_id"`
|
||||
Severity string `json:"severity"`
|
||||
Package string `json:"package"`
|
||||
AffectedNum int `json:"affected_servers"`
|
||||
FixedIn string `json:"fixed_in,omitempty"`
|
||||
}
|
||||
|
||||
type listVulnsResult struct {
|
||||
Vulnerabilities []vulnSummary `json:"vulnerabilities"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
// ---- packages ----
|
||||
|
||||
type packageEntry struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type serverPackagesResult struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Packages []packageEntry `json:"packages"`
|
||||
Total int `json:"total"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
// ---- search_fleet ----
|
||||
|
||||
type packageMatch struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Package string `json:"package"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type searchFleetResult struct {
|
||||
Matches []packageMatch `json:"matches"`
|
||||
Shown int `json:"shown"`
|
||||
}
|
||||
|
||||
// ---- audit ----
|
||||
|
||||
type auditEventSummary struct {
|
||||
At time.Time `json:"at"`
|
||||
Type string `json:"type"`
|
||||
Actor string `json:"actor"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type listAuditResult struct {
|
||||
Events []auditEventSummary `json:"events"`
|
||||
Total int `json:"shown"`
|
||||
}
|
||||
|
||||
// ---- secrets ----
|
||||
|
||||
type secretGroupNames struct {
|
||||
Group string `json:"group"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type listSecretNamesResult struct {
|
||||
Groups []secretGroupNames `json:"groups"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
All().Register(Tool{
|
||||
Name: "list_workflows",
|
||||
Scope: "workflows:read",
|
||||
Description: "List the workflows defined on this instance: step count, target count, " +
|
||||
"and whether each is on a schedule. Use get_workflow for the ordered step list.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
workflows, err := services.ListWorkflows(c.InstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list workflows: %w", err)
|
||||
}
|
||||
limit := pageLimit(args)
|
||||
out := make([]workflowSummary, 0, limit)
|
||||
for _, w := range workflows {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
targets := len(w.TargetServerIDs)
|
||||
if len(w.TargetTags) > 0 {
|
||||
targets = len(w.TargetTags)
|
||||
}
|
||||
out = append(out, workflowSummary{
|
||||
ID: w.WorkflowID,
|
||||
Name: w.Name,
|
||||
Steps: len(w.Steps),
|
||||
Targets: targets,
|
||||
Scheduled: w.Schedule != nil && w.Schedule.Enabled,
|
||||
})
|
||||
}
|
||||
return listWorkflowsResult{Workflows: out, Total: len(workflows), Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_workflow",
|
||||
Scope: "workflows:read",
|
||||
Description: "Get one workflow's full definition: ordered steps, targets and schedule. " +
|
||||
"Use get_run for what happened the last time it ran.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
id := stringArg(args, "workflow_id")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("workflow_id is required")
|
||||
}
|
||||
w, err := services.GetWorkflow(c.InstanceID, id)
|
||||
if err != nil || w == nil {
|
||||
return nil, fmt.Errorf("no workflow %q found", id)
|
||||
}
|
||||
steps := make([]workflowStepRef, 0, len(w.Steps))
|
||||
for _, s := range w.Steps {
|
||||
name := s.StepID
|
||||
if s.Inline != nil {
|
||||
name = s.Inline.Name
|
||||
}
|
||||
steps = append(steps, workflowStepRef{ID: s.StepID, Name: name})
|
||||
}
|
||||
schedule := ""
|
||||
if w.Schedule != nil && w.Schedule.Enabled {
|
||||
schedule = w.Schedule.Cron
|
||||
}
|
||||
return workflowDetail{
|
||||
ID: w.WorkflowID,
|
||||
Name: w.Name,
|
||||
Steps: steps,
|
||||
Targets: w.TargetServerIDs,
|
||||
Tags: w.TargetTags,
|
||||
Schedule: schedule,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_run",
|
||||
Scope: "workflows:read",
|
||||
Description: "Get one workflow run's status: overall state, start/finish time, and a " +
|
||||
"count of servers by their per-server status. Use get_run_logs for the output of one server.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
id := stringArg(args, "run_id")
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("run_id is required")
|
||||
}
|
||||
r, err := services.GetRun(c.InstanceID, id)
|
||||
if err != nil || r == nil {
|
||||
return nil, fmt.Errorf("no run %q found", id)
|
||||
}
|
||||
var counts runStatusCounts
|
||||
for _, sr := range r.ServerRuns {
|
||||
switch sr.Status {
|
||||
case "pending":
|
||||
counts.Pending++
|
||||
case "running":
|
||||
counts.Running++
|
||||
case "success":
|
||||
counts.Success++
|
||||
case "failed":
|
||||
counts.Failed++
|
||||
case "skipped":
|
||||
counts.Skipped++
|
||||
}
|
||||
}
|
||||
return runDetail{
|
||||
ID: r.RunID,
|
||||
WorkflowName: r.Name,
|
||||
Status: r.Status,
|
||||
StartedAt: r.StartedAt,
|
||||
FinishedAt: r.FinishedAt,
|
||||
ServerCounts: counts,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_run_logs",
|
||||
Scope: "workflows:read",
|
||||
Description: "Get the ordered log lines for one server within one workflow run. " +
|
||||
"Capped at 200 lines by default; ask for a higher limit if you need more.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
runID := stringArg(args, "run_id")
|
||||
serverID := stringArg(args, "server_id")
|
||||
if runID == "" || serverID == "" {
|
||||
return nil, fmt.Errorf("run_id and server_id are required")
|
||||
}
|
||||
|
||||
// The run's own instance must be checked before any line is
|
||||
// returned: ReadServerRunLog takes no instance ID and will read
|
||||
// any run on the process, so GetRun is what proves this run
|
||||
// belongs to the caller.
|
||||
r, err := services.GetRun(c.InstanceID, runID)
|
||||
if err != nil || r == nil {
|
||||
return nil, fmt.Errorf("no run %q found", runID)
|
||||
}
|
||||
found := false
|
||||
for _, sr := range r.ServerRuns {
|
||||
if sr.ServerID == serverID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
|
||||
}
|
||||
|
||||
limit := defaultLogLimit
|
||||
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
|
||||
limit = int(raw)
|
||||
}
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
|
||||
lines, _, err := services.ReadServerRunLog(runID, serverID, 0, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read run log: %w", err)
|
||||
}
|
||||
return runLogsResult{Lines: lines, Shown: len(lines)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "list_pending_updates",
|
||||
Scope: "servers:read",
|
||||
Description: "List outstanding package updates across the fleet, or for one server. " +
|
||||
"Pass server_id for one server, or tags to filter by, respecting the token's own scope.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
limit := pageLimit(args)
|
||||
out := []pendingUpdate{}
|
||||
|
||||
serverID := stringArg(args, "server_id")
|
||||
if serverID != "" {
|
||||
srv, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
|
||||
}
|
||||
for _, u := range srv.AvailableUpdates {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, pendingUpdate{
|
||||
ServerID: srv.ServerID, Hostname: srv.Hostname,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
|
||||
})
|
||||
}
|
||||
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
|
||||
}
|
||||
|
||||
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
|
||||
if !ok {
|
||||
return listPendingUpdatesResult{Updates: out}, nil
|
||||
}
|
||||
servers, err := services.ListServersFiltered(c.InstanceID, sel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list servers: %w", err)
|
||||
}
|
||||
for _, srv := range servers {
|
||||
for _, u := range srv.AvailableUpdates {
|
||||
if len(out) == limit {
|
||||
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
|
||||
}
|
||||
out = append(out, pendingUpdate{
|
||||
ServerID: srv.ServerID, Hostname: srv.Hostname,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
|
||||
})
|
||||
}
|
||||
}
|
||||
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "list_vulnerabilities",
|
||||
Scope: "vulns:read",
|
||||
Description: "List known CVEs affecting this fleet, one row per CVE/package pair with " +
|
||||
"how many servers are affected. Filter by severity or status (open/accepted).",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
f := services.FindingFilter{
|
||||
Severity: stringArg(args, "severity"),
|
||||
State: stringArg(args, "status"),
|
||||
}
|
||||
findings, err := services.ListInstanceFindings(c.InstanceID, f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list vulnerabilities: %w", err)
|
||||
}
|
||||
|
||||
type key struct{ cve, pkg string }
|
||||
counts := map[key]int{}
|
||||
meta := map[key]vulnSummary{}
|
||||
for _, fnd := range findings {
|
||||
k := key{fnd.CVEID, fnd.PackageName}
|
||||
counts[k]++
|
||||
if _, seen := meta[k]; !seen {
|
||||
meta[k] = vulnSummary{CVEID: fnd.CVEID, Severity: fnd.Severity, Package: fnd.PackageName, FixedIn: fnd.FixedIn}
|
||||
}
|
||||
}
|
||||
|
||||
limit := pageLimit(args)
|
||||
out := make([]vulnSummary, 0, limit)
|
||||
for k, v := range meta {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
v.AffectedNum = counts[k]
|
||||
out = append(out, v)
|
||||
}
|
||||
return listVulnsResult{Vulnerabilities: out, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "get_server_packages",
|
||||
Scope: "vulns:read",
|
||||
Description: "List installed packages on one server, optionally filtered by name. " +
|
||||
"A server can carry ~2000 packages, so pass name to search rather than listing them all.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
serverID := stringArg(args, "server_id")
|
||||
if serverID == "" {
|
||||
return nil, fmt.Errorf("server_id is required")
|
||||
}
|
||||
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
|
||||
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
|
||||
}
|
||||
|
||||
pkgs, err := services.ListPackages(c.InstanceID, serverID)
|
||||
if err != nil || pkgs == nil {
|
||||
return nil, fmt.Errorf("no package data for server %q", serverID)
|
||||
}
|
||||
|
||||
nameFilter := strings.ToLower(stringArg(args, "name"))
|
||||
limit := pageLimit(args)
|
||||
out := make([]packageEntry, 0, limit)
|
||||
total := 0
|
||||
for _, p := range pkgs.Packages {
|
||||
if nameFilter != "" && !strings.Contains(strings.ToLower(p.Name), nameFilter) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if len(out) < limit {
|
||||
out = append(out, packageEntry{Name: p.Name, Version: p.Version})
|
||||
}
|
||||
}
|
||||
return serverPackagesResult{ServerID: serverID, Packages: out, Total: total, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "search_fleet",
|
||||
Scope: "vulns:read",
|
||||
Description: "Search every server's installed packages by name across the whole fleet — " +
|
||||
"answers questions like \"which hosts still run OpenSSL 1.1\". Pass version_below to " +
|
||||
"further narrow to versions that sort earlier than the given string.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
name := stringArg(args, "name")
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
versionBelow := stringArg(args, "version_below")
|
||||
|
||||
hits, err := services.SearchPackages(c.InstanceID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not search packages: %w", err)
|
||||
}
|
||||
|
||||
limit := pageLimit(args)
|
||||
out := make([]packageMatch, 0, limit)
|
||||
for _, h := range hits {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
if versionBelow != "" && h.Version >= versionBelow {
|
||||
continue
|
||||
}
|
||||
// A hit's server must be resolved through the token's own
|
||||
// scope: SearchPackages runs unscoped across the instance,
|
||||
// so a server outside the token's tag restriction is
|
||||
// dropped here rather than named to the caller.
|
||||
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, packageMatch{Hostname: srv.Hostname, Package: h.Name, Version: h.Version})
|
||||
}
|
||||
return searchFleetResult{Matches: out, Shown: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "list_audit_events",
|
||||
Scope: "settings:read",
|
||||
Description: "List recent audit log events on this instance: who did what, and when. " +
|
||||
"Filter by event_type prefix (e.g. \"workflow\", \"key\", \"server\").",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
limit := int64(pageLimit(args))
|
||||
events, _, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
|
||||
Category: stringArg(args, "event_type"),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list audit events: %w", err)
|
||||
}
|
||||
out := make([]auditEventSummary, 0, len(events))
|
||||
for _, e := range events {
|
||||
out = append(out, auditEventSummary{At: e.CreatedAt, Type: e.EventType, Actor: e.Actor, Detail: e.Details})
|
||||
}
|
||||
return listAuditResult{Events: out, Total: len(out)}, nil
|
||||
},
|
||||
})
|
||||
|
||||
All().Register(Tool{
|
||||
Name: "list_secret_names",
|
||||
Scope: "secrets:read",
|
||||
Description: "List secret group and key names on this instance. Metadata only — no " +
|
||||
"tool ever returns a secret's plaintext value to a model.",
|
||||
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
|
||||
groups, err := services.ListSecretGroups(c.InstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not list secret groups: %w", err)
|
||||
}
|
||||
out := make([]secretGroupNames, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
// GetSecretGroup returns key metadata only (models.Secret's
|
||||
// EncryptedValue is json:"-"); the plaintext reveal path
|
||||
// (services.RevealSecret) is never called from this tool.
|
||||
secrets, err := services.GetSecretGroup(c.InstanceID, g.Group)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read secret group %q: %w", g.Group, err)
|
||||
}
|
||||
keys := make([]string, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
keys = append(keys, s.Key)
|
||||
}
|
||||
out = append(out, secretGroupNames{Group: g.Group, Keys: keys})
|
||||
}
|
||||
return listSecretNamesResult{Groups: out}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user