mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Consolidate and extend AI tool suite
Major tools refactoring for better organization and capabilities: New consolidated tools: - pulse_query: Unified resource search, get, config, topology operations - pulse_read: Safe read-only command execution with NonInteractiveOnly - pulse_control: Guest lifecycle control (start/stop/restart) - pulse_docker: Docker container operations - pulse_file: Safe file read/write operations - pulse_kubernetes: K8s resource management - pulse_metrics: Performance metrics retrieval - pulse_alerts: Alert management - pulse_storage: Storage pool operations - pulse_knowledge: Note-taking and recall - pulse_pmg: Proxmox Mail Gateway integration Executor improvements: - Cleaner tool registration pattern - Better error handling and recovery - Protocol layer for result formatting - Enhanced adapter interfaces Includes comprehensive tests for: - File and Docker operations - Kubernetes control operations - Command execution safety
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
)
|
||||
|
||||
// StateGetter provides access to the current infrastructure state
|
||||
@@ -88,6 +92,128 @@ func (a *StorageMCPAdapter) GetCephClusters() []models.CephCluster {
|
||||
return state.CephClusters
|
||||
}
|
||||
|
||||
// StorageConfigSource provides storage configuration data with context.
|
||||
type StorageConfigSource interface {
|
||||
GetStorageConfig(ctx context.Context, instance string) (map[string][]proxmox.Storage, error)
|
||||
}
|
||||
|
||||
// StorageConfigMCPAdapter adapts monitoring storage config access to MCP StorageConfigProvider interface.
|
||||
type StorageConfigMCPAdapter struct {
|
||||
source StorageConfigSource
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewStorageConfigMCPAdapter creates a new adapter for storage config data.
|
||||
func NewStorageConfigMCPAdapter(source StorageConfigSource) *StorageConfigMCPAdapter {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
return &StorageConfigMCPAdapter{
|
||||
source: source,
|
||||
timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStorageConfig implements mcp.StorageConfigProvider.
|
||||
func (a *StorageConfigMCPAdapter) GetStorageConfig(instance string) ([]StorageConfigSummary, error) {
|
||||
if a == nil || a.source == nil {
|
||||
return nil, fmt.Errorf("storage config source not available")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.timeout)
|
||||
defer cancel()
|
||||
|
||||
storageByInstance, err := a.source.GetStorageConfig(ctx, instance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]StorageConfigSummary, 0)
|
||||
seen := make(map[string]bool)
|
||||
for inst, storages := range storageByInstance {
|
||||
for _, storage := range storages {
|
||||
key := inst + ":" + storage.Storage
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
entry := StorageConfigSummary{
|
||||
ID: storage.Storage,
|
||||
Name: storage.Storage,
|
||||
Instance: inst,
|
||||
Type: storage.Type,
|
||||
Content: storage.Content,
|
||||
Nodes: parseStorageConfigNodes(storage.Nodes),
|
||||
Path: storage.Path,
|
||||
Shared: storage.Shared == 1,
|
||||
Enabled: storage.Enabled == 1,
|
||||
Active: storage.Active == 1,
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Instance != result[j].Instance {
|
||||
return result[i].Instance < result[j].Instance
|
||||
}
|
||||
return result[i].ID < result[j].ID
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseStorageConfigNodes(nodes string) []string {
|
||||
nodes = strings.TrimSpace(nodes)
|
||||
if nodes == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(nodes, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
node := strings.TrimSpace(part)
|
||||
if node == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, node)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GuestConfigSource provides guest configuration data with context.
|
||||
type GuestConfigSource interface {
|
||||
GetGuestConfig(ctx context.Context, guestType, instance, node string, vmid int) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
// GuestConfigMCPAdapter adapts monitoring config access to MCP GuestConfigProvider interface.
|
||||
type GuestConfigMCPAdapter struct {
|
||||
source GuestConfigSource
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewGuestConfigMCPAdapter creates a new adapter for guest config data.
|
||||
func NewGuestConfigMCPAdapter(source GuestConfigSource) *GuestConfigMCPAdapter {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
return &GuestConfigMCPAdapter{
|
||||
source: source,
|
||||
timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGuestConfig implements mcp.GuestConfigProvider.
|
||||
func (a *GuestConfigMCPAdapter) GetGuestConfig(guestType, instance, node string, vmid int) (map[string]interface{}, error) {
|
||||
if a == nil || a.source == nil {
|
||||
return nil, fmt.Errorf("guest config source not available")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.timeout)
|
||||
defer cancel()
|
||||
return a.source.GetGuestConfig(ctx, guestType, instance, node, vmid)
|
||||
}
|
||||
|
||||
// BackupMCPAdapter adapts the monitor state to MCP BackupProvider interface
|
||||
type BackupMCPAdapter struct {
|
||||
stateGetter StateGetter
|
||||
@@ -746,6 +872,8 @@ type DiscoverySource interface {
|
||||
ListDiscoveriesByType(resourceType string) ([]DiscoverySourceData, error)
|
||||
ListDiscoveriesByHost(hostID string) ([]DiscoverySourceData, error)
|
||||
FormatForAIContext(discoveries []DiscoverySourceData) string
|
||||
// TriggerDiscovery initiates discovery for a resource, returning discovered data
|
||||
TriggerDiscovery(ctx context.Context, resourceType, hostID, resourceID string) (DiscoverySourceData, error)
|
||||
}
|
||||
|
||||
// DiscoverySourceData represents discovery data from the source
|
||||
@@ -763,6 +891,9 @@ type DiscoverySourceData struct {
|
||||
Facts []DiscoverySourceFact
|
||||
ConfigPaths []string
|
||||
DataPaths []string
|
||||
LogPaths []string
|
||||
Ports []DiscoverySourcePort
|
||||
DockerMounts []DiscoverySourceDockerMount // Docker bind mounts (for LXCs/VMs running Docker)
|
||||
UserNotes string
|
||||
Confidence float64
|
||||
AIReasoning string
|
||||
@@ -770,15 +901,33 @@ type DiscoverySourceData struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DiscoverySourceFact represents a fact from the source
|
||||
type DiscoverySourceFact struct {
|
||||
Category string
|
||||
Key string
|
||||
Value string
|
||||
Source string
|
||||
// DiscoverySourceDockerMount represents a Docker bind mount from the source
|
||||
type DiscoverySourceDockerMount struct {
|
||||
ContainerName string // Docker container name
|
||||
Source string // Host path (where to actually write files)
|
||||
Destination string // Container path (what the service sees)
|
||||
Type string // Mount type: bind, volume, tmpfs
|
||||
ReadOnly bool // Whether mount is read-only
|
||||
}
|
||||
|
||||
// DiscoveryMCPAdapter adapts aidiscovery.Service to MCP DiscoveryProvider interface
|
||||
// DiscoverySourcePort represents a port from the source
|
||||
type DiscoverySourcePort struct {
|
||||
Port int
|
||||
Protocol string
|
||||
Process string
|
||||
Address string
|
||||
}
|
||||
|
||||
// DiscoverySourceFact represents a fact from the source
|
||||
type DiscoverySourceFact struct {
|
||||
Category string
|
||||
Key string
|
||||
Value string
|
||||
Source string
|
||||
Confidence float64 // 0-1 confidence for this fact
|
||||
}
|
||||
|
||||
// DiscoveryMCPAdapter adapts servicediscovery.Service to MCP DiscoveryProvider interface
|
||||
type DiscoveryMCPAdapter struct {
|
||||
source DiscoverySource
|
||||
}
|
||||
@@ -876,10 +1025,30 @@ func (a *DiscoveryMCPAdapter) FormatForAIContext(discoveries []*ResourceDiscover
|
||||
facts := make([]DiscoverySourceFact, 0, len(d.Facts))
|
||||
for _, f := range d.Facts {
|
||||
facts = append(facts, DiscoverySourceFact{
|
||||
Category: f.Category,
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Category: f.Category,
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Confidence: f.Confidence,
|
||||
})
|
||||
}
|
||||
ports := make([]DiscoverySourcePort, 0, len(d.Ports))
|
||||
for _, p := range d.Ports {
|
||||
ports = append(ports, DiscoverySourcePort{
|
||||
Port: p.Port,
|
||||
Protocol: p.Protocol,
|
||||
Process: p.Process,
|
||||
Address: p.Address,
|
||||
})
|
||||
}
|
||||
dockerMounts := make([]DiscoverySourceDockerMount, 0, len(d.BindMounts))
|
||||
for _, m := range d.BindMounts {
|
||||
dockerMounts = append(dockerMounts, DiscoverySourceDockerMount{
|
||||
ContainerName: m.ContainerName,
|
||||
Source: m.Source,
|
||||
Destination: m.Destination,
|
||||
Type: m.Type,
|
||||
ReadOnly: m.ReadOnly,
|
||||
})
|
||||
}
|
||||
sourceData = append(sourceData, DiscoverySourceData{
|
||||
@@ -896,6 +1065,8 @@ func (a *DiscoveryMCPAdapter) FormatForAIContext(discoveries []*ResourceDiscover
|
||||
Facts: facts,
|
||||
ConfigPaths: d.ConfigPaths,
|
||||
DataPaths: d.DataPaths,
|
||||
Ports: ports,
|
||||
DockerMounts: dockerMounts,
|
||||
UserNotes: d.UserNotes,
|
||||
Confidence: d.Confidence,
|
||||
AIReasoning: d.AIReasoning,
|
||||
@@ -907,6 +1078,20 @@ func (a *DiscoveryMCPAdapter) FormatForAIContext(discoveries []*ResourceDiscover
|
||||
return a.source.FormatForAIContext(sourceData)
|
||||
}
|
||||
|
||||
// TriggerDiscovery implements tools.DiscoveryProvider
|
||||
func (a *DiscoveryMCPAdapter) TriggerDiscovery(ctx context.Context, resourceType, hostID, resourceID string) (*ResourceDiscoveryInfo, error) {
|
||||
if a.source == nil {
|
||||
return nil, fmt.Errorf("discovery source not available")
|
||||
}
|
||||
|
||||
data, err := a.source.TriggerDiscovery(ctx, resourceType, hostID, resourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return a.convertToInfo(data), nil
|
||||
}
|
||||
|
||||
func (a *DiscoveryMCPAdapter) convertToInfo(data DiscoverySourceData) *ResourceDiscoveryInfo {
|
||||
if data.ID == "" {
|
||||
return nil
|
||||
@@ -915,10 +1100,33 @@ func (a *DiscoveryMCPAdapter) convertToInfo(data DiscoverySourceData) *ResourceD
|
||||
facts := make([]DiscoveryFact, 0, len(data.Facts))
|
||||
for _, f := range data.Facts {
|
||||
facts = append(facts, DiscoveryFact{
|
||||
Category: f.Category,
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Category: f.Category,
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Confidence: f.Confidence,
|
||||
})
|
||||
}
|
||||
|
||||
ports := make([]DiscoveryPortInfo, 0, len(data.Ports))
|
||||
for _, p := range data.Ports {
|
||||
ports = append(ports, DiscoveryPortInfo{
|
||||
Port: p.Port,
|
||||
Protocol: p.Protocol,
|
||||
Process: p.Process,
|
||||
Address: p.Address,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert DockerMounts to BindMounts
|
||||
bindMounts := make([]DiscoveryMount, 0, len(data.DockerMounts))
|
||||
for _, m := range data.DockerMounts {
|
||||
bindMounts = append(bindMounts, DiscoveryMount{
|
||||
ContainerName: m.ContainerName,
|
||||
Source: m.Source,
|
||||
Destination: m.Destination,
|
||||
Type: m.Type,
|
||||
ReadOnly: m.ReadOnly,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -936,6 +1144,9 @@ func (a *DiscoveryMCPAdapter) convertToInfo(data DiscoverySourceData) *ResourceD
|
||||
Facts: facts,
|
||||
ConfigPaths: data.ConfigPaths,
|
||||
DataPaths: data.DataPaths,
|
||||
LogPaths: data.LogPaths,
|
||||
Ports: ports,
|
||||
BindMounts: bindMounts,
|
||||
UserNotes: data.UserNotes,
|
||||
Confidence: data.Confidence,
|
||||
AIReasoning: data.AIReasoning,
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
|
||||
@@ -65,7 +67,8 @@ func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
|
||||
{AgentID: "agent1", Hostname: "node1"},
|
||||
}).Twice()
|
||||
agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool {
|
||||
return payload.Command == "uptime" && payload.TargetType == "host" && payload.TargetID == "host1"
|
||||
// For direct host targets, TargetID is empty - resolveTargetForCommand returns "" for host type
|
||||
return payload.Command == "uptime" && payload.TargetType == "host" && payload.TargetID == ""
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
Stdout: "ok",
|
||||
ExitCode: 0,
|
||||
@@ -85,6 +88,107 @@ func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPulseToolExecutor_RunCommandLXCRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("LXCCommandRoutedCorrectly", func(t *testing.T) {
|
||||
// Test that commands targeting LXCs are routed with correct target type/ID
|
||||
// The agent handles sh -c wrapping, so tool just sends raw command
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "delly"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Tool sends raw command, agent will wrap in sh -c
|
||||
return cmd.TargetType == "container" &&
|
||||
cmd.TargetID == "108" &&
|
||||
cmd.Command == "grep pattern /var/log/*.log"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "matched line",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
Containers: []models.Container{
|
||||
{VMID: 108, Name: "jellyfin", Node: "delly"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeRunCommand(ctx, map[string]interface{}{
|
||||
"command": "grep pattern /var/log/*.log",
|
||||
"target_host": "jellyfin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Command completed successfully")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("VMCommandRoutedCorrectly", func(t *testing.T) {
|
||||
// Test that commands targeting VMs are routed with correct target type/ID
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "delly"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.TargetType == "vm" &&
|
||||
cmd.TargetID == "100" &&
|
||||
cmd.Command == "ls /tmp/*.txt"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "result",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
VMs: []models.VM{
|
||||
{VMID: 100, Name: "test-vm", Node: "delly"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeRunCommand(ctx, map[string]interface{}{
|
||||
"command": "ls /tmp/*.txt",
|
||||
"target_host": "test-vm",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Command completed successfully")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DirectHostRoutedCorrectly", func(t *testing.T) {
|
||||
// Direct host commands have target type "host"
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "host-agent", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "host-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.TargetType == "host" &&
|
||||
cmd.Command == "ls /tmp/*.txt"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "files",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeRunCommand(ctx, map[string]interface{}{
|
||||
"command": "ls /tmp/*.txt",
|
||||
"target_host": "tower",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Command completed successfully")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPulseToolExecutor_FindAgentForCommand(t *testing.T) {
|
||||
t.Run("NoAgentServer", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
|
||||
@@ -34,7 +34,10 @@ func TestPulseToolExecutor_ExecuteListBackups(t *testing.T) {
|
||||
backupProv.On("GetBackups").Return(expectedBackups)
|
||||
backupProv.On("GetPBSInstances").Return([]models.PBSInstance{})
|
||||
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_list_backups", map[string]interface{}{})
|
||||
// Use consolidated pulse_storage tool with type: "backups"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_storage", map[string]interface{}{
|
||||
"type": "backups",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
}
|
||||
@@ -66,7 +69,9 @@ func TestPulseToolExecutor_ExecuteControlGuest(t *testing.T) {
|
||||
ExitCode: 0,
|
||||
}, nil)
|
||||
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_control_guest", map[string]interface{}{
|
||||
// Use consolidated pulse_control tool with type: "guest"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_control", map[string]interface{}{
|
||||
"type": "guest",
|
||||
"guest_id": "100",
|
||||
"action": "stop",
|
||||
})
|
||||
@@ -107,9 +112,11 @@ func TestPulseToolExecutor_ExecuteControlDocker(t *testing.T) {
|
||||
ExitCode: 0,
|
||||
}, nil)
|
||||
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_control_docker", map[string]interface{}{
|
||||
// Use consolidated pulse_docker tool with action: "control"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_docker", map[string]interface{}{
|
||||
"action": "control",
|
||||
"container": "nginx",
|
||||
"action": "restart",
|
||||
"operation": "restart",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Successfully executed 'docker restart'")
|
||||
|
||||
@@ -147,14 +147,16 @@ type ResourceSearchResponse struct {
|
||||
|
||||
// ResourceMatch is a compact match result for pulse_search_resources
|
||||
type ResourceMatch struct {
|
||||
Type string `json:"type"` // "node", "vm", "container", "docker", "docker_host"
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Type string `json:"type"` // "node", "vm", "container", "docker", "docker_host"
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Node string `json:"node,omitempty"` // Proxmox node this resource is on
|
||||
NodeHasAgent bool `json:"node_has_agent,omitempty"` // True if the Proxmox node has a connected agent
|
||||
Host string `json:"host,omitempty"` // Docker host for docker containers
|
||||
VMID int `json:"vmid,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
AgentConnected bool `json:"agent_connected,omitempty"` // True if this specific resource has a connected agent
|
||||
}
|
||||
|
||||
// NodeSummary is a summarized node for list responses
|
||||
@@ -324,6 +326,35 @@ type ResourceResponse struct {
|
||||
UpdateAvailable bool `json:"update_available,omitempty"`
|
||||
}
|
||||
|
||||
// GuestConfigResponse is returned by pulse_get_guest_config.
|
||||
type GuestConfigResponse struct {
|
||||
GuestType string `json:"guest_type"`
|
||||
VMID int `json:"vmid"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
OSType string `json:"os_type,omitempty"`
|
||||
Onboot *bool `json:"onboot,omitempty"`
|
||||
RootFS string `json:"rootfs,omitempty"`
|
||||
Mounts []GuestMountConfig `json:"mounts,omitempty"`
|
||||
Disks []GuestDiskConfig `json:"disks,omitempty"`
|
||||
Raw map[string]string `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// GuestMountConfig summarizes a container mount.
|
||||
type GuestMountConfig struct {
|
||||
Key string `json:"key"`
|
||||
Source string `json:"source"`
|
||||
Mountpoint string `json:"mountpoint,omitempty"`
|
||||
}
|
||||
|
||||
// GuestDiskConfig summarizes a VM disk definition.
|
||||
type GuestDiskConfig struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ResourceCPU describes CPU usage
|
||||
type ResourceCPU struct {
|
||||
Percent float64 `json:"percent"`
|
||||
@@ -476,12 +507,37 @@ type StorageResponse struct {
|
||||
Pagination *PaginationInfo `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
// StorageConfigResponse is returned by pulse_get_storage_config
|
||||
type StorageConfigResponse struct {
|
||||
Storages []StorageConfigSummary `json:"storages,omitempty"`
|
||||
}
|
||||
|
||||
// StorageConfigSummary is a summarized storage config entry
|
||||
type StorageConfigSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Nodes []string `json:"nodes,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Shared bool `json:"shared"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// StoragePoolSummary is a summarized storage pool
|
||||
type StoragePoolSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Nodes []string `json:"nodes,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
Path string `json:"path,omitempty"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
UsedGB float64 `json:"used_gb"`
|
||||
TotalGB float64 `json:"total_gb"`
|
||||
|
||||
@@ -80,6 +80,16 @@ type StorageProvider interface {
|
||||
GetCephClusters() []models.CephCluster
|
||||
}
|
||||
|
||||
// StorageConfigProvider provides storage configuration data.
|
||||
type StorageConfigProvider interface {
|
||||
GetStorageConfig(instance string) ([]StorageConfigSummary, error)
|
||||
}
|
||||
|
||||
// GuestConfigProvider provides guest configuration data (VM/LXC).
|
||||
type GuestConfigProvider interface {
|
||||
GetGuestConfig(guestType, instance, node string, vmid int) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
// DiskHealthProvider provides disk health information from host agents
|
||||
type DiskHealthProvider interface {
|
||||
GetHosts() []models.Host
|
||||
@@ -101,36 +111,148 @@ type DiscoveryProvider interface {
|
||||
ListDiscoveriesByType(resourceType string) ([]*ResourceDiscoveryInfo, error)
|
||||
ListDiscoveriesByHost(hostID string) ([]*ResourceDiscoveryInfo, error)
|
||||
FormatForAIContext(discoveries []*ResourceDiscoveryInfo) string
|
||||
// TriggerDiscovery initiates discovery for a resource and returns the result
|
||||
TriggerDiscovery(ctx context.Context, resourceType, hostID, resourceID string) (*ResourceDiscoveryInfo, error)
|
||||
}
|
||||
|
||||
// ResolvedResourceInfo contains the minimal information needed for tool validation.
|
||||
// This is an interface to avoid import cycles with the chat package.
|
||||
type ResolvedResourceInfo interface {
|
||||
GetResourceID() string
|
||||
GetResourceType() string
|
||||
GetTargetHost() string
|
||||
GetAgentID() string
|
||||
GetAdapter() string
|
||||
GetVMID() int
|
||||
GetNode() string
|
||||
GetAllowedActions() []string
|
||||
// New structured identity methods
|
||||
GetProviderUID() string
|
||||
GetKind() string
|
||||
GetAliases() []string
|
||||
}
|
||||
|
||||
// ResourceRegistration contains all fields needed to register a discovered resource.
|
||||
// This structured approach replaces the long parameter list for clarity.
|
||||
type ResourceRegistration struct {
|
||||
// Identity
|
||||
Kind string // Resource type: "node", "vm", "lxc", "docker_container", etc.
|
||||
ProviderUID string // Stable provider ID (container ID, VMID, pod UID)
|
||||
Name string // Primary display name
|
||||
Aliases []string // Additional names that resolve to this resource
|
||||
|
||||
// Scope
|
||||
HostUID string
|
||||
HostName string
|
||||
ParentUID string
|
||||
ParentKind string
|
||||
ClusterUID string
|
||||
Namespace string
|
||||
|
||||
// Legacy fields (for backwards compatibility)
|
||||
VMID int
|
||||
Node string
|
||||
LocationChain []string
|
||||
|
||||
// Executor paths
|
||||
Executors []ExecutorRegistration
|
||||
}
|
||||
|
||||
// ExecutorRegistration describes how an executor can reach a resource.
|
||||
type ExecutorRegistration struct {
|
||||
ExecutorID string
|
||||
Adapter string
|
||||
Actions []string
|
||||
Priority int
|
||||
}
|
||||
|
||||
// ResolvedContextProvider provides session-scoped resource resolution.
|
||||
// Query and discovery tools add resources; action tools validate against them.
|
||||
// This interface is implemented by the chat package's ResolvedContext.
|
||||
type ResolvedContextProvider interface {
|
||||
// AddResolvedResource adds a resource that was found via query/discovery.
|
||||
// Uses the new structured registration format.
|
||||
AddResolvedResource(reg ResourceRegistration)
|
||||
|
||||
// GetResolvedResourceByID retrieves a resource by its canonical ID (kind:provider_uid)
|
||||
GetResolvedResourceByID(resourceID string) (ResolvedResourceInfo, bool)
|
||||
|
||||
// GetResolvedResourceByAlias retrieves a resource by any of its aliases
|
||||
GetResolvedResourceByAlias(alias string) (ResolvedResourceInfo, bool)
|
||||
|
||||
// ValidateResourceForAction checks if a resource can perform an action
|
||||
// Returns the resource if valid, error if not found or action not allowed
|
||||
ValidateResourceForAction(resourceID, action string) (ResolvedResourceInfo, error)
|
||||
|
||||
// HasAnyResources returns true if at least one resource has been discovered
|
||||
HasAnyResources() bool
|
||||
|
||||
// WasRecentlyAccessed checks if a resource was accessed within the given time window.
|
||||
// Used for routing validation to distinguish "this turn" from "session-wide" context.
|
||||
WasRecentlyAccessed(resourceID string, window time.Duration) bool
|
||||
|
||||
// GetRecentlyAccessedResources returns resource IDs accessed within the given time window.
|
||||
GetRecentlyAccessedResources(window time.Duration) []string
|
||||
|
||||
// MarkExplicitAccess marks a resource as recently accessed, indicating user intent.
|
||||
// Call this for single-resource operations (get, explicit select) but NOT for bulk
|
||||
// operations (list, search) to avoid poisoning routing validation.
|
||||
MarkExplicitAccess(resourceID string)
|
||||
}
|
||||
|
||||
// RecentAccessWindow is the time window used to determine "recently referenced" resources.
|
||||
// Resources accessed within this window are considered to be from the current turn/exchange.
|
||||
const RecentAccessWindow = 30 * time.Second
|
||||
|
||||
// ResourceDiscoveryInfo represents discovered information about a resource
|
||||
type ResourceDiscoveryInfo struct {
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
HostID string `json:"host_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
ServiceType string `json:"service_type"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ServiceVersion string `json:"service_version"`
|
||||
Category string `json:"category"`
|
||||
CLIAccess string `json:"cli_access"`
|
||||
Facts []DiscoveryFact `json:"facts"`
|
||||
ConfigPaths []string `json:"config_paths"`
|
||||
DataPaths []string `json:"data_paths"`
|
||||
UserNotes string `json:"user_notes,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
AIReasoning string `json:"ai_reasoning,omitempty"`
|
||||
DiscoveredAt time.Time `json:"discovered_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
HostID string `json:"host_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
ServiceType string `json:"service_type"`
|
||||
ServiceName string `json:"service_name"`
|
||||
ServiceVersion string `json:"service_version"`
|
||||
Category string `json:"category"`
|
||||
CLIAccess string `json:"cli_access"`
|
||||
Facts []DiscoveryFact `json:"facts"`
|
||||
ConfigPaths []string `json:"config_paths"`
|
||||
DataPaths []string `json:"data_paths"`
|
||||
LogPaths []string `json:"log_paths,omitempty"` // Log file paths or commands (e.g., journalctl)
|
||||
Ports []DiscoveryPortInfo `json:"ports"`
|
||||
BindMounts []DiscoveryMount `json:"bind_mounts,omitempty"` // For Docker: host->container path mappings
|
||||
UserNotes string `json:"user_notes,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
AIReasoning string `json:"ai_reasoning,omitempty"`
|
||||
DiscoveredAt time.Time `json:"discovered_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DiscoveryPortInfo represents a listening port discovered on a resource
|
||||
type DiscoveryPortInfo struct {
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Process string `json:"process,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// DiscoveryMount represents a bind mount (host path -> container path)
|
||||
type DiscoveryMount struct {
|
||||
ContainerName string `json:"container_name,omitempty"` // Docker container name (for Docker inside LXC/VM)
|
||||
Source string `json:"source"` // Host path (where to actually write files)
|
||||
Destination string `json:"destination"` // Container path (what the service sees)
|
||||
Type string `json:"type,omitempty"` // Mount type: bind, volume, tmpfs
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
}
|
||||
|
||||
// DiscoveryFact represents a discovered fact about a resource
|
||||
type DiscoveryFact struct {
|
||||
Category string `json:"category"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"` // 0-1 confidence for this fact
|
||||
}
|
||||
|
||||
// ControlLevel represents the AI's permission level for infrastructure control
|
||||
@@ -160,10 +282,12 @@ type ExecutorConfig struct {
|
||||
FindingsProvider FindingsProvider
|
||||
|
||||
// Optional providers - infrastructure
|
||||
BackupProvider BackupProvider
|
||||
StorageProvider StorageProvider
|
||||
DiskHealthProvider DiskHealthProvider
|
||||
UpdatesProvider UpdatesProvider
|
||||
BackupProvider BackupProvider
|
||||
StorageProvider StorageProvider
|
||||
StorageConfigProvider StorageConfigProvider
|
||||
GuestConfigProvider GuestConfigProvider
|
||||
DiskHealthProvider DiskHealthProvider
|
||||
UpdatesProvider UpdatesProvider
|
||||
|
||||
// Optional providers - management
|
||||
MetadataUpdater MetadataUpdater
|
||||
@@ -199,10 +323,12 @@ type PulseToolExecutor struct {
|
||||
findingsProvider FindingsProvider
|
||||
|
||||
// Infrastructure context providers
|
||||
backupProvider BackupProvider
|
||||
storageProvider StorageProvider
|
||||
diskHealthProvider DiskHealthProvider
|
||||
updatesProvider UpdatesProvider
|
||||
backupProvider BackupProvider
|
||||
storageProvider StorageProvider
|
||||
storageConfigProvider StorageConfigProvider
|
||||
guestConfigProvider GuestConfigProvider
|
||||
diskHealthProvider DiskHealthProvider
|
||||
updatesProvider UpdatesProvider
|
||||
|
||||
// Management providers
|
||||
metadataUpdater MetadataUpdater
|
||||
@@ -227,10 +353,34 @@ type PulseToolExecutor struct {
|
||||
targetID string
|
||||
isAutonomous bool
|
||||
|
||||
// Session-scoped resolved context for resource validation
|
||||
// This is set per-session by the agentic loop before tool execution
|
||||
resolvedContext ResolvedContextProvider
|
||||
|
||||
// Telemetry callback for recording metrics
|
||||
// This is optional - if nil, no telemetry is recorded
|
||||
telemetryCallback TelemetryCallback
|
||||
|
||||
// Tool registry
|
||||
registry *ToolRegistry
|
||||
}
|
||||
|
||||
// TelemetryCallback is called when the executor needs to record telemetry.
|
||||
// This allows the chat layer to handle metrics without import cycles.
|
||||
type TelemetryCallback interface {
|
||||
// RecordStrictResolutionBlock records when strict resolution blocks an action
|
||||
RecordStrictResolutionBlock(tool, action string)
|
||||
// RecordAutoRecoveryAttempt records an auto-recovery attempt
|
||||
RecordAutoRecoveryAttempt(errorCode, tool string)
|
||||
// RecordAutoRecoverySuccess records a successful auto-recovery
|
||||
RecordAutoRecoverySuccess(errorCode, tool string)
|
||||
// RecordRoutingMismatchBlock records when routing validation blocks an operation
|
||||
// that targeted a parent host when a child resource was recently referenced.
|
||||
// targetKind: "node" (the kind being targeted)
|
||||
// childKind: "lxc", "vm", "docker_container" (the kind of the more specific resource)
|
||||
RecordRoutingMismatchBlock(tool, targetKind, childKind string)
|
||||
}
|
||||
|
||||
// NewPulseToolExecutor creates a new Pulse tool executor with the given configuration
|
||||
func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor {
|
||||
e := &PulseToolExecutor{
|
||||
@@ -244,6 +394,8 @@ func NewPulseToolExecutor(cfg ExecutorConfig) *PulseToolExecutor {
|
||||
findingsProvider: cfg.FindingsProvider,
|
||||
backupProvider: cfg.BackupProvider,
|
||||
storageProvider: cfg.StorageProvider,
|
||||
storageConfigProvider: cfg.StorageConfigProvider,
|
||||
guestConfigProvider: cfg.GuestConfigProvider,
|
||||
diskHealthProvider: cfg.DiskHealthProvider,
|
||||
updatesProvider: cfg.UpdatesProvider,
|
||||
metadataUpdater: cfg.MetadataUpdater,
|
||||
@@ -329,6 +481,16 @@ func (e *PulseToolExecutor) SetStorageProvider(provider StorageProvider) {
|
||||
e.storageProvider = provider
|
||||
}
|
||||
|
||||
// SetStorageConfigProvider sets the storage config provider
|
||||
func (e *PulseToolExecutor) SetStorageConfigProvider(provider StorageConfigProvider) {
|
||||
e.storageConfigProvider = provider
|
||||
}
|
||||
|
||||
// SetGuestConfigProvider sets the guest config provider
|
||||
func (e *PulseToolExecutor) SetGuestConfigProvider(provider GuestConfigProvider) {
|
||||
e.guestConfigProvider = provider
|
||||
}
|
||||
|
||||
// SetDiskHealthProvider sets the disk health provider
|
||||
func (e *PulseToolExecutor) SetDiskHealthProvider(provider DiskHealthProvider) {
|
||||
e.diskHealthProvider = provider
|
||||
@@ -364,11 +526,27 @@ func (e *PulseToolExecutor) SetKnowledgeStoreProvider(provider KnowledgeStorePro
|
||||
e.knowledgeStoreProvider = provider
|
||||
}
|
||||
|
||||
// SetDiscoveryProvider sets the discovery provider for AI-powered discovery
|
||||
// SetDiscoveryProvider sets the discovery provider for infrastructure discovery
|
||||
func (e *PulseToolExecutor) SetDiscoveryProvider(provider DiscoveryProvider) {
|
||||
e.discoveryProvider = provider
|
||||
}
|
||||
|
||||
// SetResolvedContext sets the session-scoped resolved context for resource validation.
|
||||
// This should be called by the agentic loop before executing tools for a session.
|
||||
func (e *PulseToolExecutor) SetResolvedContext(ctx ResolvedContextProvider) {
|
||||
e.resolvedContext = ctx
|
||||
}
|
||||
|
||||
// SetTelemetryCallback sets the telemetry callback for recording metrics
|
||||
func (e *PulseToolExecutor) SetTelemetryCallback(cb TelemetryCallback) {
|
||||
e.telemetryCallback = cb
|
||||
}
|
||||
|
||||
// GetResolvedContext returns the current resolved context (may be nil)
|
||||
func (e *PulseToolExecutor) GetResolvedContext() ResolvedContextProvider {
|
||||
return e.resolvedContext
|
||||
}
|
||||
|
||||
// ListTools returns the list of available tools
|
||||
func (e *PulseToolExecutor) ListTools() []Tool {
|
||||
tools := e.registry.ListTools(e.controlLevel)
|
||||
@@ -387,50 +565,31 @@ func (e *PulseToolExecutor) ListTools() []Tool {
|
||||
|
||||
func (e *PulseToolExecutor) isToolAvailable(name string) bool {
|
||||
switch name {
|
||||
case "pulse_get_capabilities", "pulse_get_url_content", "pulse_get_agent_scope":
|
||||
return true
|
||||
case "pulse_run_command":
|
||||
// Consolidated tools - check based on primary requirements
|
||||
case "pulse_query":
|
||||
return e.stateProvider != nil
|
||||
case "pulse_metrics":
|
||||
return e.stateProvider != nil || e.metricsHistory != nil || e.baselineProvider != nil || e.patternProvider != nil
|
||||
case "pulse_storage":
|
||||
return e.stateProvider != nil || e.storageProvider != nil || e.backupProvider != nil || e.storageConfigProvider != nil || e.diskHealthProvider != nil
|
||||
case "pulse_docker":
|
||||
return e.stateProvider != nil || e.updatesProvider != nil
|
||||
case "pulse_kubernetes":
|
||||
return e.stateProvider != nil
|
||||
case "pulse_alerts":
|
||||
return e.alertProvider != nil || e.findingsProvider != nil || e.findingsManager != nil || e.stateProvider != nil
|
||||
case "pulse_read":
|
||||
return e.agentServer != nil
|
||||
case "pulse_control_guest", "pulse_control_docker":
|
||||
case "pulse_control":
|
||||
return e.agentServer != nil && e.stateProvider != nil
|
||||
case "pulse_set_agent_scope":
|
||||
return e.agentProfileManager != nil
|
||||
case "pulse_set_resource_url":
|
||||
return e.metadataUpdater != nil
|
||||
case "pulse_get_metrics":
|
||||
return e.metricsHistory != nil
|
||||
case "pulse_get_baselines":
|
||||
return e.baselineProvider != nil
|
||||
case "pulse_get_patterns":
|
||||
return e.patternProvider != nil
|
||||
case "pulse_list_alerts":
|
||||
return e.alertProvider != nil
|
||||
case "pulse_list_findings":
|
||||
return e.findingsProvider != nil
|
||||
case "pulse_resolve_finding", "pulse_dismiss_finding":
|
||||
return e.findingsManager != nil
|
||||
case "pulse_list_backups":
|
||||
return e.backupProvider != nil
|
||||
case "pulse_list_storage":
|
||||
return e.storageProvider != nil
|
||||
case "pulse_get_disk_health":
|
||||
return e.diskHealthProvider != nil || e.storageProvider != nil
|
||||
case "pulse_get_host_raid_status", "pulse_get_host_ceph_details":
|
||||
return e.diskHealthProvider != nil
|
||||
case "pulse_list_docker_updates", "pulse_check_docker_updates":
|
||||
return e.updatesProvider != nil
|
||||
case "pulse_update_docker_container":
|
||||
return e.updatesProvider != nil && e.stateProvider != nil
|
||||
case "pulse_get_incident_window":
|
||||
return e.incidentRecorderProvider != nil
|
||||
case "pulse_correlate_events":
|
||||
return e.eventCorrelatorProvider != nil
|
||||
case "pulse_get_relationship_graph":
|
||||
return e.topologyProvider != nil
|
||||
case "pulse_remember", "pulse_recall":
|
||||
return e.knowledgeStoreProvider != nil
|
||||
case "pulse_get_discovery", "pulse_list_discoveries":
|
||||
case "pulse_file_edit":
|
||||
return e.agentServer != nil
|
||||
case "pulse_discovery":
|
||||
return e.discoveryProvider != nil
|
||||
case "pulse_knowledge":
|
||||
return e.knowledgeStoreProvider != nil || e.incidentRecorderProvider != nil || e.eventCorrelatorProvider != nil || e.topologyProvider != nil
|
||||
case "pulse_pmg":
|
||||
return e.stateProvider != nil
|
||||
default:
|
||||
return e.stateProvider != nil
|
||||
}
|
||||
@@ -448,30 +607,44 @@ func (e *PulseToolExecutor) ExecuteTool(ctx context.Context, name string, args m
|
||||
|
||||
// registerTools registers all available tools
|
||||
func (e *PulseToolExecutor) registerTools() {
|
||||
// Query tools (always available)
|
||||
// Consolidated tools (49 tools -> 10 tools)
|
||||
// See plan at /Users/rcourtman/.claude/plans/atomic-wobbling-rose.md
|
||||
|
||||
// pulse_query - search, get, config, topology, list, health
|
||||
e.registerQueryTools()
|
||||
|
||||
// Kubernetes tools (always available)
|
||||
// pulse_metrics - performance, temperatures, network, diskio, disks, baselines, patterns
|
||||
e.registerMetricsTools()
|
||||
|
||||
// pulse_storage - pools, config, backups, snapshots, ceph, replication, pbs_jobs, raid, disk_health, resource_disks
|
||||
e.registerStorageTools()
|
||||
|
||||
// pulse_docker - control, updates, check_updates, update, services, tasks, swarm
|
||||
e.registerDockerTools()
|
||||
|
||||
// pulse_kubernetes - clusters, nodes, pods, deployments
|
||||
e.registerKubernetesTools()
|
||||
|
||||
// Patrol context tools (always available)
|
||||
e.registerPatrolTools()
|
||||
// pulse_alerts - list, findings, resolved, resolve, dismiss
|
||||
e.registerAlertsTools()
|
||||
|
||||
// Infrastructure tools (always available)
|
||||
e.registerInfrastructureTools()
|
||||
// pulse_read - read-only operations (exec, file, find, tail, logs)
|
||||
// This is ALWAYS classified as ToolKindRead and never triggers VERIFYING
|
||||
e.registerReadTools()
|
||||
|
||||
// PMG (Mail Gateway) tools (always available)
|
||||
e.registerPMGTools()
|
||||
// pulse_control - guest control, run commands (requires control permission)
|
||||
// NOTE: For read-only command execution, use pulse_read instead
|
||||
e.registerControlToolsConsolidated()
|
||||
|
||||
// Profile tools - read operations always available
|
||||
e.registerProfileTools()
|
||||
// pulse_file_edit - read, append, write files (requires control permission)
|
||||
e.registerFileTools()
|
||||
|
||||
// Intelligence tools (incident analysis, knowledge management)
|
||||
e.registerIntelligenceTools()
|
||||
// pulse_discovery - get, list discoveries
|
||||
e.registerDiscoveryToolsConsolidated()
|
||||
|
||||
// Discovery tools (AI-powered infrastructure discovery)
|
||||
e.registerDiscoveryTools()
|
||||
// pulse_knowledge - remember, recall, incidents, correlate, relationships
|
||||
e.registerKnowledgeTools()
|
||||
|
||||
// Control tools (conditional on control level)
|
||||
e.registerControlTools()
|
||||
// pulse_pmg - status, mail_stats, queues, spam
|
||||
e.registerPMGToolsConsolidated()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ func (s *stubAgentProfileManager) GetAgentScope(ctx context.Context, agentID str
|
||||
return &AgentScope{AgentID: agentID}, nil
|
||||
}
|
||||
|
||||
type stubStorageConfigProvider struct{}
|
||||
|
||||
func (s *stubStorageConfigProvider) GetStorageConfig(instance string) ([]StorageConfigSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPulseToolExecutor_Setters(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
|
||||
@@ -78,6 +84,10 @@ func TestPulseToolExecutor_Setters(t *testing.T) {
|
||||
exec.SetStorageProvider(storageProvider)
|
||||
assert.Equal(t, storageProvider, exec.storageProvider)
|
||||
|
||||
storageConfigProvider := &stubStorageConfigProvider{}
|
||||
exec.SetStorageConfigProvider(storageConfigProvider)
|
||||
assert.Equal(t, storageConfigProvider, exec.storageConfigProvider)
|
||||
|
||||
diskHealthProvider := &mockDiskHealthProvider{}
|
||||
exec.SetDiskHealthProvider(diskHealthProvider)
|
||||
assert.Equal(t, diskHealthProvider, exec.diskHealthProvider)
|
||||
@@ -94,23 +104,31 @@ func TestPulseToolExecutor_Setters(t *testing.T) {
|
||||
func TestPulseToolExecutor_ListTools(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
tools := exec.ListTools()
|
||||
assert.True(t, containsTool(tools, "pulse_get_capabilities"))
|
||||
assert.False(t, containsTool(tools, "pulse_get_topology"))
|
||||
// pulse_query requires state provider, so it should not be available without one
|
||||
assert.False(t, containsTool(tools, "pulse_query"))
|
||||
|
||||
execWithState := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
||||
stateTools := execWithState.ListTools()
|
||||
assert.True(t, containsTool(stateTools, "pulse_get_topology"))
|
||||
// With state provider, pulse_query should be available
|
||||
assert.True(t, containsTool(stateTools, "pulse_query"))
|
||||
}
|
||||
|
||||
func TestPulseToolExecutor_IsToolAvailable(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
assert.False(t, exec.isToolAvailable("pulse_get_metrics"))
|
||||
assert.False(t, exec.isToolAvailable("pulse_set_agent_scope"))
|
||||
// pulse_metrics requires metrics provider or state provider
|
||||
assert.False(t, exec.isToolAvailable("pulse_metrics"))
|
||||
// pulse_query requires state provider
|
||||
assert.False(t, exec.isToolAvailable("pulse_query"))
|
||||
|
||||
exec.SetMetricsHistory(&mockMetricsHistoryProvider{})
|
||||
exec.SetAgentProfileManager(&stubAgentProfileManager{})
|
||||
assert.True(t, exec.isToolAvailable("pulse_get_metrics"))
|
||||
assert.True(t, exec.isToolAvailable("pulse_set_agent_scope"))
|
||||
// Create new executor with state provider and metrics history
|
||||
execWithProviders := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{},
|
||||
MetricsHistory: &mockMetricsHistoryProvider{},
|
||||
})
|
||||
// Now pulse_metrics should be available with metrics history
|
||||
assert.True(t, execWithProviders.isToolAvailable("pulse_metrics"))
|
||||
// And pulse_query should be available with state provider
|
||||
assert.True(t, execWithProviders.isToolAvailable("pulse_query"))
|
||||
}
|
||||
|
||||
func TestToolRegistry_ListTools(t *testing.T) {
|
||||
|
||||
427
internal/ai/tools/file_docker_test.go
Normal file
427
internal/ai/tools/file_docker_test.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExecuteFileEditDockerContainerValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("InvalidDockerContainerName", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeFileEdit(ctx, map[string]interface{}{
|
||||
"action": "read",
|
||||
"path": "/config/test.json",
|
||||
"target_host": "tower",
|
||||
"docker_container": "my container", // space is invalid
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "invalid character")
|
||||
})
|
||||
|
||||
t.Run("ValidDockerContainerName", func(t *testing.T) {
|
||||
// This should pass validation but fail on agent lookup
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeFileEdit(ctx, map[string]interface{}{
|
||||
"action": "read",
|
||||
"path": "/config/test.json",
|
||||
"target_host": "tower",
|
||||
"docker_container": "my-container_v1.2",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Should fail with "no agent" not "invalid character"
|
||||
assert.NotContains(t, result.Content[0].Text, "invalid character")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteFileReadDocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ReadFromDockerContainer", func(t *testing.T) {
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Should wrap with docker exec
|
||||
return strings.Contains(cmd.Command, "docker exec") &&
|
||||
strings.Contains(cmd.Command, "jellyfin") &&
|
||||
strings.Contains(cmd.Command, "cat") &&
|
||||
strings.Contains(cmd.Command, "/config/settings.json")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: `{"setting": "value"}`,
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileRead(ctx, "/config/settings.json", "tower", "jellyfin")
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "/config/settings.json", resp["path"])
|
||||
assert.Equal(t, "jellyfin", resp["docker_container"])
|
||||
assert.Equal(t, `{"setting": "value"}`, resp["content"])
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("ReadFromHostWithoutDocker", func(t *testing.T) {
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Should NOT wrap with docker exec
|
||||
return !strings.Contains(cmd.Command, "docker exec") &&
|
||||
strings.Contains(cmd.Command, "cat") &&
|
||||
strings.Contains(cmd.Command, "/etc/hostname")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "tower",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileRead(ctx, "/etc/hostname", "tower", "") // empty docker_container
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Nil(t, resp["docker_container"]) // should not be in response
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DockerContainerNotFound", func(t *testing.T) {
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.Anything).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 1,
|
||||
Stderr: "Error: No such container: nonexistent",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileRead(ctx, "/config/test.json", "tower", "nonexistent")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Failed to read file from container 'nonexistent'")
|
||||
assert.Contains(t, result.Content[0].Text, "No such container")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteFileWriteDocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("WriteToDockerContainer", func(t *testing.T) {
|
||||
content := `{"new": "config"}`
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Should wrap with docker exec and use base64
|
||||
return strings.Contains(cmd.Command, "docker exec") &&
|
||||
strings.Contains(cmd.Command, "nginx") &&
|
||||
strings.Contains(cmd.Command, "sh -c") &&
|
||||
strings.Contains(cmd.Command, encodedContent) &&
|
||||
strings.Contains(cmd.Command, "base64 -d") &&
|
||||
strings.Contains(cmd.Command, "/etc/nginx/nginx.conf")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileWrite(ctx, "/etc/nginx/nginx.conf", content, "tower", "nginx", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "write", resp["action"])
|
||||
assert.Equal(t, "nginx", resp["docker_container"])
|
||||
assert.Equal(t, float64(len(content)), resp["bytes_written"])
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("WriteControlledRequiresApproval", func(t *testing.T) {
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelControlled,
|
||||
})
|
||||
result, err := exec.executeFileWrite(ctx, "/config/test.json", "test", "tower", "mycontainer", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
|
||||
assert.Contains(t, result.Content[0].Text, "container: mycontainer")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteFileAppendDocker(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("AppendToDockerContainer", func(t *testing.T) {
|
||||
content := "\nnew line"
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Should use >> for append
|
||||
return strings.Contains(cmd.Command, "docker exec") &&
|
||||
strings.Contains(cmd.Command, "logcontainer") &&
|
||||
strings.Contains(cmd.Command, encodedContent) &&
|
||||
strings.Contains(cmd.Command, ">>") &&
|
||||
strings.Contains(cmd.Command, "/var/log/app.log")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileAppend(ctx, "/var/log/app.log", content, "tower", "logcontainer", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "append", resp["action"])
|
||||
assert.Equal(t, "logcontainer", resp["docker_container"])
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteFileWriteLXCVMTargets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("WriteToLXCRoutedCorrectly", func(t *testing.T) {
|
||||
// Test that file writes to LXC are routed with correct target type/ID
|
||||
// Agent handles sh -c wrapping, so tool sends raw pipeline command
|
||||
content := "test content"
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "delly"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Tool sends raw pipeline, agent wraps in sh -c for LXC
|
||||
return cmd.TargetType == "container" &&
|
||||
cmd.TargetID == "141" &&
|
||||
strings.Contains(cmd.Command, encodedContent) &&
|
||||
strings.Contains(cmd.Command, "| base64 -d >") &&
|
||||
!strings.Contains(cmd.Command, "docker exec")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
Containers: []models.Container{
|
||||
{VMID: 141, Name: "homepage-docker", Node: "delly"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileWrite(ctx, "/opt/test/config.yaml", content, "homepage-docker", "", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "write", resp["action"])
|
||||
assert.Nil(t, resp["docker_container"]) // No Docker container
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("WriteToVMRoutedCorrectly", func(t *testing.T) {
|
||||
// Test that file writes to VMs are routed with correct target type/ID
|
||||
content := "vm config"
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "delly"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.TargetType == "vm" &&
|
||||
cmd.TargetID == "100" &&
|
||||
strings.Contains(cmd.Command, encodedContent)
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
VMs: []models.VM{
|
||||
{VMID: 100, Name: "test-vm", Node: "delly"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileWrite(ctx, "/etc/test.conf", content, "test-vm", "", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("WriteToDirectHost", func(t *testing.T) {
|
||||
// Direct host writes use raw pipeline command
|
||||
content := "host config"
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "host-agent", Hostname: "tower"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "host-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.TargetType == "host" &&
|
||||
strings.Contains(cmd.Command, encodedContent) &&
|
||||
strings.Contains(cmd.Command, "| base64 -d >")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileWrite(ctx, "/tmp/test.txt", content, "tower", "", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("AppendToLXCRoutedCorrectly", func(t *testing.T) {
|
||||
// Append operations to LXC are routed with correct target type/ID
|
||||
content := "\nnew line"
|
||||
encodedContent := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "delly"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.TargetType == "container" &&
|
||||
cmd.TargetID == "141" &&
|
||||
strings.Contains(cmd.Command, encodedContent) &&
|
||||
strings.Contains(cmd.Command, ">>") // append uses >>
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
Containers: []models.Container{
|
||||
{VMID: 141, Name: "homepage-docker", Node: "delly"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileAppend(ctx, "/var/log/app.log", content, "homepage-docker", "", map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "append", resp["action"])
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteFileEditDockerNestedRouting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("DockerInsideLXC", func(t *testing.T) {
|
||||
// Test case: Docker running inside an LXC container
|
||||
// target_host="homepage-docker" (LXC), docker_container="nginx"
|
||||
// Command should route through Proxmox node agent with LXC target type
|
||||
|
||||
agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}}
|
||||
mockAgent := &mockAgentServer{}
|
||||
mockAgent.On("GetConnectedAgents").Return(agents)
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
// Should have container target type for LXC routing
|
||||
// and command should include docker exec
|
||||
return cmd.TargetType == "container" &&
|
||||
cmd.TargetID == "141" &&
|
||||
strings.Contains(cmd.Command, "docker exec") &&
|
||||
strings.Contains(cmd.Command, "nginx")
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "file content",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
Containers: []models.Container{
|
||||
{VMID: 141, Name: "homepage-docker", Node: "pve-node"},
|
||||
},
|
||||
}
|
||||
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeFileRead(ctx, "/config/test.json", "homepage-docker", "nginx")
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp))
|
||||
assert.True(t, resp["success"].(bool))
|
||||
assert.Equal(t, "nginx", resp["docker_container"])
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,10 @@ func TestExecuteGetDiskHealth(t *testing.T) {
|
||||
}
|
||||
diskHealthProv.On("GetHosts").Return(expectedHosts)
|
||||
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_get_disk_health", map[string]interface{}{})
|
||||
// Use consolidated pulse_storage tool with type: "disk_health"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_storage", map[string]interface{}{
|
||||
"type": "disk_health",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
}
|
||||
@@ -41,7 +44,10 @@ func TestExecuteGetTemperatures(t *testing.T) {
|
||||
}
|
||||
stateProv.On("GetState").Return(state)
|
||||
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_get_temperatures", map[string]interface{}{})
|
||||
// Use consolidated pulse_metrics tool with type: "temperatures"
|
||||
result, err := exec.ExecuteTool(context.Background(), "pulse_metrics", map[string]interface{}{
|
||||
"type": "temperatures",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result.IsError)
|
||||
}
|
||||
|
||||
523
internal/ai/tools/kubernetes_control_test.go
Normal file
523
internal/ai/tools/kubernetes_control_test.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateKubernetesResourceID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid simple", "nginx", false},
|
||||
{"valid with dash", "my-app", false},
|
||||
{"valid with dot", "my.app", false},
|
||||
{"valid with numbers", "app123", false},
|
||||
{"valid complex", "my-app-v1.2.3", false},
|
||||
{"empty", "", true},
|
||||
{"uppercase", "MyApp", true},
|
||||
{"underscore", "my_app", true},
|
||||
{"space", "my app", true},
|
||||
{"special char", "my@app", true},
|
||||
{"too long", string(make([]byte, 254)), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKubernetesResourceID(tt.value)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAgentForKubernetesCluster(t *testing.T) {
|
||||
t.Run("NoStateProvider", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{})
|
||||
agentID, cluster, err := exec.findAgentForKubernetesCluster("test")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, agentID)
|
||||
assert.Nil(t, cluster)
|
||||
assert.Contains(t, err.Error(), "state provider not available")
|
||||
})
|
||||
|
||||
t.Run("ClusterNotFound", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
|
||||
agentID, cluster, err := exec.findAgentForKubernetesCluster("nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, agentID)
|
||||
assert.Nil(t, cluster)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("ClusterNoAgent", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: ""},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
|
||||
agentID, cluster, err := exec.findAgentForKubernetesCluster("cluster-1")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, agentID)
|
||||
assert.Nil(t, cluster)
|
||||
assert.Contains(t, err.Error(), "no agent configured")
|
||||
})
|
||||
|
||||
t.Run("FoundByID", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
|
||||
agentID, cluster, err := exec.findAgentForKubernetesCluster("c1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "agent-1", agentID)
|
||||
assert.NotNil(t, cluster)
|
||||
assert.Equal(t, "cluster-1", cluster.Name)
|
||||
})
|
||||
|
||||
t.Run("FoundByDisplayName", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", DisplayName: "Production", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
|
||||
agentID, _, err := exec.findAgentForKubernetesCluster("Production")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "agent-1", agentID)
|
||||
})
|
||||
|
||||
t.Run("FoundByCustomDisplayName", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", CustomDisplayName: "My Cluster", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}})
|
||||
agentID, _, err := exec.findAgentForKubernetesCluster("My Cluster")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "agent-1", agentID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteKubernetesScale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("MissingCluster", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "cluster is required")
|
||||
})
|
||||
|
||||
t.Run("MissingDeployment", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "deployment is required")
|
||||
})
|
||||
|
||||
t.Run("MissingReplicas", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
"deployment": "nginx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "replicas is required")
|
||||
})
|
||||
|
||||
t.Run("InvalidNamespace", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
"deployment": "nginx",
|
||||
"replicas": 3,
|
||||
"namespace": "Invalid_NS",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "invalid namespace")
|
||||
})
|
||||
|
||||
t.Run("ReadOnlyMode", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
ControlLevel: ControlLevelReadOnly,
|
||||
})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"deployment": "nginx",
|
||||
"replicas": 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "not available in read-only mode")
|
||||
})
|
||||
|
||||
t.Run("ControlledRequiresApproval", func(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1", DisplayName: "Cluster One"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
ControlLevel: ControlLevelControlled,
|
||||
})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"deployment": "nginx",
|
||||
"replicas": 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
|
||||
assert.Contains(t, result.Content[0].Text, "scale")
|
||||
})
|
||||
|
||||
t.Run("ExecuteSuccess", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default scale deployment nginx --replicas=3" &&
|
||||
cmd.TargetType == "host"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "deployment.apps/nginx scaled",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"deployment": "nginx",
|
||||
"replicas": 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Successfully scaled")
|
||||
assert.Contains(t, result.Content[0].Text, "nginx")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteKubernetesRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("MissingDeployment", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesRestart(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "deployment is required")
|
||||
})
|
||||
|
||||
t.Run("ExecuteSuccess", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default rollout restart deployment/nginx"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "deployment.apps/nginx restarted",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesRestart(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"deployment": "nginx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Successfully initiated rollout restart")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteKubernetesDeletePod(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("MissingPod", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesDeletePod(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "pod is required")
|
||||
})
|
||||
|
||||
t.Run("ExecuteSuccess", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default delete pod nginx-abc123"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "pod \"nginx-abc123\" deleted",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesDeletePod(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"pod": "nginx-abc123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Successfully deleted pod")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteKubernetesExec(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("MissingCommand", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
"pod": "nginx",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "command is required")
|
||||
})
|
||||
|
||||
t.Run("ExecuteWithoutContainer", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default exec nginx-pod -- cat /etc/nginx/nginx.conf"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "server { listen 80; }",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"pod": "nginx-pod",
|
||||
"command": "cat /etc/nginx/nginx.conf",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Command executed")
|
||||
assert.Contains(t, result.Content[0].Text, "server { listen 80; }")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("ExecuteWithContainer", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n kube-system exec coredns-pod -c coredns -- cat /etc/coredns/Corefile"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: ".:53 { forward . /etc/resolv.conf }",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"namespace": "kube-system",
|
||||
"pod": "coredns-pod",
|
||||
"container": "coredns",
|
||||
"command": "cat /etc/coredns/Corefile",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Command executed")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteKubernetesLogs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("MissingPod", func(t *testing.T) {
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}})
|
||||
result, err := exec.executeKubernetesLogs(ctx, map[string]interface{}{
|
||||
"cluster": "test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.IsError)
|
||||
assert.Contains(t, result.Content[0].Text, "pod is required")
|
||||
})
|
||||
|
||||
t.Run("LogsNoApprovalNeeded", func(t *testing.T) {
|
||||
// Logs should work even in controlled mode without approval
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default logs nginx-pod --tail=50"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "2024-01-01 10:00:00 Request received\n2024-01-01 10:00:01 Response sent",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelControlled, // Even in controlled mode
|
||||
})
|
||||
result, err := exec.executeKubernetesLogs(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"pod": "nginx-pod",
|
||||
"lines": 50,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Should NOT require approval since logs is read-only
|
||||
assert.NotContains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
|
||||
assert.Contains(t, result.Content[0].Text, "Logs from pod")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("LogsWithContainer", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool {
|
||||
return cmd.Command == "kubectl -n default logs nginx-pod -c sidecar --tail=100"
|
||||
})).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "Sidecar logs here",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesLogs(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"pod": "nginx-pod",
|
||||
"container": "sidecar",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "Logs from pod")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("EmptyLogs", func(t *testing.T) {
|
||||
mockAgent := &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}},
|
||||
}
|
||||
mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.Anything).Return(&agentexec.CommandResultPayload{
|
||||
ExitCode: 0,
|
||||
Stdout: "",
|
||||
}, nil)
|
||||
|
||||
state := models.StateSnapshot{
|
||||
KubernetesClusters: []models.KubernetesCluster{
|
||||
{ID: "c1", Name: "cluster-1", AgentID: "agent-1"},
|
||||
},
|
||||
}
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
AgentServer: mockAgent,
|
||||
ControlLevel: ControlLevelAutonomous,
|
||||
})
|
||||
result, err := exec.executeKubernetesLogs(ctx, map[string]interface{}{
|
||||
"cluster": "cluster-1",
|
||||
"pod": "nginx-pod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result.Content[0].Text, "No logs found")
|
||||
mockAgent.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatKubernetesApprovalNeeded(t *testing.T) {
|
||||
result := formatKubernetesApprovalNeeded("scale", "nginx", "default", "production", "kubectl scale...", "approval-123")
|
||||
assert.Contains(t, result, "APPROVAL_REQUIRED")
|
||||
assert.Contains(t, result, "scale")
|
||||
assert.Contains(t, result, "nginx")
|
||||
assert.Contains(t, result, "default")
|
||||
assert.Contains(t, result, "production")
|
||||
assert.Contains(t, result, "approval-123")
|
||||
}
|
||||
@@ -206,6 +206,82 @@ type PromptMessage struct {
|
||||
Content Content `json:"content"`
|
||||
}
|
||||
|
||||
// ToolResponse is a consistent envelope for tool results.
|
||||
// All tool results should use this structure for predictable parsing.
|
||||
type ToolResponse struct {
|
||||
OK bool `json:"ok"` // true if tool succeeded
|
||||
Data interface{} `json:"data,omitempty"` // result data if ok=true
|
||||
Error *ToolError `json:"error,omitempty"` // error details if ok=false
|
||||
Meta map[string]interface{} `json:"meta,omitempty"` // optional metadata
|
||||
}
|
||||
|
||||
// ToolError provides consistent error structure for tool failures.
|
||||
// Use Blocked=true for policy/validation blocks, Failed=true for runtime errors.
|
||||
type ToolError struct {
|
||||
Code string `json:"code"` // Error code (e.g., "STRICT_RESOLUTION", "NOT_FOUND")
|
||||
Message string `json:"message"` // Human-readable message
|
||||
Blocked bool `json:"blocked,omitempty"` // True if blocked by policy/validation
|
||||
Failed bool `json:"failed,omitempty"` // True if runtime failure
|
||||
Retryable bool `json:"retryable,omitempty"` // True if auto-retry might succeed
|
||||
Details map[string]interface{} `json:"details,omitempty"` // Additional context
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrCodeStrictResolution = "STRICT_RESOLUTION"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeActionNotAllowed = "ACTION_NOT_ALLOWED"
|
||||
ErrCodePolicyBlocked = "POLICY_BLOCKED"
|
||||
ErrCodeApprovalRequired = "APPROVAL_REQUIRED"
|
||||
ErrCodeInvalidInput = "INVALID_INPUT"
|
||||
ErrCodeExecutionFailed = "EXECUTION_FAILED"
|
||||
ErrCodeNoAgent = "NO_AGENT"
|
||||
)
|
||||
|
||||
// NewToolSuccess creates a successful tool response
|
||||
func NewToolSuccess(data interface{}) ToolResponse {
|
||||
return ToolResponse{
|
||||
OK: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolSuccessWithMeta creates a successful tool response with metadata
|
||||
func NewToolSuccessWithMeta(data interface{}, meta map[string]interface{}) ToolResponse {
|
||||
return ToolResponse{
|
||||
OK: true,
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolBlockedError creates a policy/validation blocked error
|
||||
func NewToolBlockedError(code, message string, details map[string]interface{}) ToolResponse {
|
||||
return ToolResponse{
|
||||
OK: false,
|
||||
Error: &ToolError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Blocked: true,
|
||||
Details: details,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolFailedError creates a runtime failure error
|
||||
func NewToolFailedError(code, message string, retryable bool, details map[string]interface{}) ToolResponse {
|
||||
return ToolResponse{
|
||||
OK: false,
|
||||
Error: &ToolError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Failed: true,
|
||||
Retryable: retryable,
|
||||
Details: details,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// NewTextContent creates a text content object
|
||||
@@ -244,3 +320,16 @@ func NewJSONResult(data interface{}) CallToolResult {
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResponseResult creates a CallToolResult from a ToolResponse
|
||||
// This provides the consistent envelope while maintaining MCP protocol compatibility
|
||||
func NewToolResponseResult(resp ToolResponse) CallToolResult {
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return NewErrorResult(err)
|
||||
}
|
||||
return CallToolResult{
|
||||
Content: []Content{NewTextContent(string(b))},
|
||||
IsError: !resp.OK,
|
||||
}
|
||||
}
|
||||
|
||||
114
internal/ai/tools/tools_alerts.go
Normal file
114
internal/ai/tools/tools_alerts.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerAlertsTools registers the consolidated pulse_alerts tool
|
||||
func (e *PulseToolExecutor) registerAlertsTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_alerts",
|
||||
Description: `Manage alerts and AI patrol findings.
|
||||
|
||||
Actions:
|
||||
- list: List active threshold alerts (CPU > 80%, disk full, etc.)
|
||||
- findings: List AI patrol findings (detected issues)
|
||||
- resolved: List recently resolved alerts
|
||||
- resolve: Mark a finding as resolved
|
||||
- dismiss: Dismiss a finding as not an issue
|
||||
|
||||
Examples:
|
||||
- List critical alerts: action="list", severity="critical"
|
||||
- List all findings: action="findings"
|
||||
- List resolved: action="resolved"
|
||||
- Resolve finding: action="resolve", finding_id="abc123", resolution_note="Fixed by restarting service"
|
||||
- Dismiss finding: action="dismiss", finding_id="abc123", reason="expected_behavior", note="This is normal during maintenance"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Alert action to perform",
|
||||
Enum: []string{"list", "findings", "resolved", "resolve", "dismiss"},
|
||||
},
|
||||
"severity": {
|
||||
Type: "string",
|
||||
Description: "Filter by severity: critical, warning, info (for list, findings)",
|
||||
Enum: []string{"critical", "warning", "info"},
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Filter by resource type: vm, container, node, docker (for findings)",
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by resource ID (for findings)",
|
||||
},
|
||||
"finding_id": {
|
||||
Type: "string",
|
||||
Description: "Finding ID (for resolve, dismiss)",
|
||||
},
|
||||
"resolution_note": {
|
||||
Type: "string",
|
||||
Description: "Resolution note (for resolve action)",
|
||||
},
|
||||
"note": {
|
||||
Type: "string",
|
||||
Description: "Explanation note (for dismiss action)",
|
||||
},
|
||||
"reason": {
|
||||
Type: "string",
|
||||
Description: "Dismissal reason: not_an_issue, expected_behavior, will_fix_later",
|
||||
Enum: []string{"not_an_issue", "expected_behavior", "will_fix_later"},
|
||||
},
|
||||
"include_dismissed": {
|
||||
Type: "boolean",
|
||||
Description: "Include previously dismissed findings (for findings)",
|
||||
},
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Filter resolved alerts by type",
|
||||
},
|
||||
"level": {
|
||||
Type: "string",
|
||||
Description: "Filter resolved alerts by level: critical, warning",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Number of results to skip",
|
||||
},
|
||||
},
|
||||
Required: []string{"action"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeAlerts(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeAlerts routes to the appropriate alerts handler based on action
|
||||
// All handler functions are implemented in tools_patrol.go
|
||||
func (e *PulseToolExecutor) executeAlerts(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
switch action {
|
||||
case "list":
|
||||
return e.executeListAlerts(ctx, args)
|
||||
case "findings":
|
||||
return e.executeListFindings(ctx, args)
|
||||
case "resolved":
|
||||
return e.executeListResolvedAlerts(ctx, args)
|
||||
case "resolve":
|
||||
return e.executeResolveFinding(ctx, args)
|
||||
case "dismiss":
|
||||
return e.executeDismissFinding(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: list, findings, resolved, resolve, dismiss", action)), nil
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,11 @@ func (e *PulseToolExecutor) registerControlTools() {
|
||||
Name: "pulse_run_command",
|
||||
Description: `Execute a shell command on infrastructure via a connected agent.
|
||||
|
||||
Returns: Command output (stdout/stderr) and exit code. Exit code 0 = success.
|
||||
This tool has built-in user approval - just call it directly when requested.
|
||||
Prefer query tools first. If multiple agents exist and target is unclear, ask which host.
|
||||
|
||||
Use when: User explicitly asks to run a command, or monitoring data is insufficient for a targeted diagnosis.
|
||||
|
||||
Prefer: Pulse monitoring tools (pulse_list_infrastructure, pulse_search_resources, pulse_get_topology, pulse_list_alerts, pulse_get_metrics) before running commands.
|
||||
|
||||
Scope: Target a single host/agent. If multiple agents are connected and target_host is unclear, ask the user to choose.
|
||||
|
||||
Do NOT use for: Checking if something is running (use pulse_get_topology), or starting/stopping VMs/containers (use pulse_control_guest or pulse_control_docker).
|
||||
|
||||
Note: Commands run on the HOST, not inside VMs/containers. To run inside an LXC, use: pct exec <vmid> -- <command>`,
|
||||
Routing: target_host can be a Proxmox host (delly), an LXC name (homepage-docker), or a VM name.
|
||||
Commands targeting LXCs/VMs are automatically routed through the Proxmox host agent.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
@@ -60,17 +54,12 @@ Note: Commands run on the HOST, not inside VMs/containers. To run inside an LXC,
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_control_guest",
|
||||
Description: `Start, stop, or restart Proxmox VMs and LXC containers.
|
||||
Description: `Start, stop, restart, or delete Proxmox VMs and LXC containers.
|
||||
|
||||
Returns: Success message with VM/container name, or error if failed.
|
||||
|
||||
Use when: User asks to start, stop, restart, or shutdown a VM or LXC container.
|
||||
|
||||
Prefer: Use pulse_get_topology or pulse_search_resources to confirm the guest and node before control actions.
|
||||
|
||||
Do NOT use for: Docker containers (use pulse_control_docker), or checking status (use pulse_get_topology).
|
||||
|
||||
Note: These are LXC containers managed by Proxmox, NOT Docker containers. Uses 'pct' commands internally.`,
|
||||
This tool has built-in user approval - just call it directly when requested.
|
||||
Use pulse_search_resources to find the guest first if needed.
|
||||
For Docker containers, use pulse_control_docker instead.
|
||||
Delete requires the guest to be stopped first.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
@@ -80,8 +69,8 @@ Note: These are LXC containers managed by Proxmox, NOT Docker containers. Uses '
|
||||
},
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "start, stop (immediate), shutdown (graceful), or restart",
|
||||
Enum: []string{"start", "stop", "shutdown", "restart"},
|
||||
Description: "start, stop (immediate), shutdown (graceful), restart, or delete (permanent removal - guest must be stopped first)",
|
||||
Enum: []string{"start", "stop", "shutdown", "restart", "delete"},
|
||||
},
|
||||
"force": {
|
||||
Type: "boolean",
|
||||
@@ -147,6 +136,32 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
|
||||
return NewErrorResult(fmt.Errorf("command is required")), nil
|
||||
}
|
||||
|
||||
// Validate resource is in resolved context
|
||||
// Uses command risk classification: read-only commands bypass strict mode
|
||||
// With PULSE_STRICT_RESOLUTION=true, write commands are blocked on undiscovered resources
|
||||
if targetHost != "" {
|
||||
validation := e.validateResolvedResourceForExec(targetHost, command, true)
|
||||
if validation.IsBlocked() {
|
||||
// Hard validation failure - return consistent error envelope
|
||||
return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil
|
||||
}
|
||||
if validation.ErrorMsg != "" {
|
||||
// Soft validation - log warning but allow operation
|
||||
log.Warn().
|
||||
Str("target", targetHost).
|
||||
Str("command", command).
|
||||
Str("validation_error", validation.ErrorMsg).
|
||||
Msg("[Control] Target resource not in resolved context - may indicate model hallucination")
|
||||
}
|
||||
|
||||
// Validate routing context - block if targeting a Proxmox host when child resources exist
|
||||
// This prevents accidentally executing commands on the host when user meant to target an LXC/VM
|
||||
routingResult := e.validateRoutingContext(targetHost)
|
||||
if routingResult.IsBlocked() {
|
||||
return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Control level read_only check is now centralized in registry.Execute()
|
||||
|
||||
// Check if this is a pre-approved execution (agentic loop re-executing after user approval)
|
||||
@@ -197,20 +212,35 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
agentID := e.findAgentForCommand(runOnHost, targetHost)
|
||||
if agentID == "" {
|
||||
// Resolve target to the correct agent and routing info (with full provenance)
|
||||
// If targetHost is an LXC/VM name, this routes to the Proxmox host agent
|
||||
// with the correct TargetType and TargetID for pct exec / qm guest exec
|
||||
routing := e.resolveTargetForCommandFull(targetHost)
|
||||
if routing.AgentID == "" {
|
||||
if targetHost != "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewErrorResult(fmt.Errorf("'%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", targetHost, routing.TargetType)), nil
|
||||
}
|
||||
return NewErrorResult(fmt.Errorf("no agent available for target '%s'. Specify a valid hostname with a connected agent.", targetHost)), nil
|
||||
}
|
||||
return NewErrorResult(fmt.Errorf("no agent available for target")), nil
|
||||
}
|
||||
|
||||
targetType := "container"
|
||||
if runOnHost {
|
||||
targetType = "host"
|
||||
}
|
||||
log.Debug().
|
||||
Str("target_host", targetHost).
|
||||
Str("agent_id", routing.AgentID).
|
||||
Str("agent_host", routing.AgentHostname).
|
||||
Str("resolved_kind", routing.ResolvedKind).
|
||||
Str("resolved_node", routing.ResolvedNode).
|
||||
Str("transport", routing.Transport).
|
||||
Str("target_type", routing.TargetType).
|
||||
Str("target_id", routing.TargetID).
|
||||
Msg("[pulse_control] Routing command execution")
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: targetType,
|
||||
TargetID: e.targetID,
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
@@ -224,11 +254,12 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
|
||||
return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// Success - include guidance to prevent unnecessary verification
|
||||
// Success - always show output explicitly to prevent LLM hallucination
|
||||
// When output is empty, we must be explicit about it so the LLM doesn't fabricate results
|
||||
if output == "" {
|
||||
return NewTextResult("✓ Command completed successfully (exit code 0). No verification needed."), nil
|
||||
return NewTextResult("Command completed successfully (exit code 0).\n\nOutput:\n(no output)"), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("✓ Command completed successfully (exit code 0). No verification needed.\n%s", output)), nil
|
||||
return NewTextResult(fmt.Sprintf("Command completed successfully (exit code 0).\n\nOutput:\n%s", output)), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
@@ -243,9 +274,25 @@ func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[st
|
||||
return NewErrorResult(fmt.Errorf("action is required")), nil
|
||||
}
|
||||
|
||||
validActions := map[string]bool{"start": true, "stop": true, "shutdown": true, "restart": true}
|
||||
validActions := map[string]bool{"start": true, "stop": true, "shutdown": true, "restart": true, "delete": true}
|
||||
if !validActions[action] {
|
||||
return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, shutdown, or restart", action)), nil
|
||||
return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, shutdown, restart, or delete", action)), nil
|
||||
}
|
||||
|
||||
// Validate resource is in resolved context
|
||||
// With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources
|
||||
validation := e.validateResolvedResource(guestID, action, true)
|
||||
if validation.IsBlocked() {
|
||||
// Hard validation failure - return consistent error envelope
|
||||
return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil
|
||||
}
|
||||
if validation.ErrorMsg != "" {
|
||||
// Soft validation - log warning but allow operation
|
||||
log.Warn().
|
||||
Str("guest_id", guestID).
|
||||
Str("action", action).
|
||||
Str("validation_error", validation.ErrorMsg).
|
||||
Msg("[ControlGuest] Guest not in resolved context - may indicate model hallucination")
|
||||
}
|
||||
|
||||
// Note: Control level read_only check is now centralized in registry.Execute()
|
||||
@@ -269,6 +316,11 @@ func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[st
|
||||
cmdTool = "qm"
|
||||
}
|
||||
|
||||
// For delete action, verify guest is stopped first
|
||||
if action == "delete" && guest.Status != "stopped" {
|
||||
return NewTextResult(fmt.Sprintf("Cannot delete %s (VMID %d) - it is currently %s. Stop it first, then try deleting again.", guest.Name, guest.VMID, guest.Status)), nil
|
||||
}
|
||||
|
||||
var command string
|
||||
switch action {
|
||||
case "start":
|
||||
@@ -279,6 +331,9 @@ func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[st
|
||||
command = fmt.Sprintf("%s shutdown %d", cmdTool, guest.VMID)
|
||||
case "restart":
|
||||
command = fmt.Sprintf("%s reboot %d", cmdTool, guest.VMID)
|
||||
case "delete":
|
||||
// Delete uses 'destroy' subcommand with --purge to also remove associated storage
|
||||
command = fmt.Sprintf("%s destroy %d --purge", cmdTool, guest.VMID)
|
||||
}
|
||||
|
||||
if force && action == "stop" {
|
||||
@@ -355,6 +410,23 @@ func (e *PulseToolExecutor) executeControlDocker(ctx context.Context, args map[s
|
||||
return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, or restart", action)), nil
|
||||
}
|
||||
|
||||
// Validate resource is in resolved context
|
||||
// With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources
|
||||
validation := e.validateResolvedResource(containerName, action, true)
|
||||
if validation.IsBlocked() {
|
||||
// Hard validation failure - return consistent error envelope
|
||||
return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil
|
||||
}
|
||||
if validation.ErrorMsg != "" {
|
||||
// Soft validation - log warning but allow operation
|
||||
log.Warn().
|
||||
Str("container", containerName).
|
||||
Str("action", action).
|
||||
Str("host", hostName).
|
||||
Str("validation_error", validation.ErrorMsg).
|
||||
Msg("[ControlDocker] Container not in resolved context - may indicate model hallucination")
|
||||
}
|
||||
|
||||
// Note: Control level read_only check is now centralized in registry.Execute()
|
||||
|
||||
// Check if this is a pre-approved execution (agentic loop re-executing after user approval)
|
||||
@@ -392,15 +464,30 @@ func (e *PulseToolExecutor) executeControlDocker(ctx context.Context, args map[s
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
agentID := e.findAgentForDockerHost(dockerHost)
|
||||
if agentID == "" {
|
||||
// Resolve the Docker host to the correct agent and routing info (with full provenance)
|
||||
routing := e.resolveDockerHostRoutingFull(dockerHost)
|
||||
if routing.AgentID == "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewTextResult(fmt.Sprintf("Docker host '%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", dockerHost.Hostname, routing.TargetType)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
log.Debug().
|
||||
Str("docker_host", dockerHost.Hostname).
|
||||
Str("agent_id", routing.AgentID).
|
||||
Str("agent_host", routing.AgentHostname).
|
||||
Str("resolved_kind", routing.ResolvedKind).
|
||||
Str("resolved_node", routing.ResolvedNode).
|
||||
Str("transport", routing.Transport).
|
||||
Str("target_type", routing.TargetType).
|
||||
Str("target_id", routing.TargetID).
|
||||
Msg("[pulse_control docker] Routing docker command execution")
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
@@ -420,29 +507,194 @@ func (e *PulseToolExecutor) executeControlDocker(ctx context.Context, args map[s
|
||||
|
||||
// Helper methods for control tools
|
||||
|
||||
func (e *PulseToolExecutor) findAgentForCommand(runOnHost bool, targetHost string) string {
|
||||
// CommandRoutingResult contains full routing information for command execution.
|
||||
// This provides the provenance needed to verify where commands actually run.
|
||||
type CommandRoutingResult struct {
|
||||
// Routing info for agent
|
||||
AgentID string // The agent that will execute the command
|
||||
TargetType string // "host", "container", or "vm"
|
||||
TargetID string // VMID for LXC/VM, empty for host
|
||||
|
||||
// Provenance info
|
||||
AgentHostname string // Hostname of the agent
|
||||
ResolvedKind string // What kind of resource we resolved to: "node", "lxc", "vm", "docker", "host"
|
||||
ResolvedNode string // Proxmox node name (if applicable)
|
||||
Transport string // How command will be executed: "direct", "pct_exec", "qm_guest_exec"
|
||||
}
|
||||
|
||||
// resolveTargetForCommandFull resolves a target_host to full routing info including provenance.
|
||||
// Use this for write operations where you need to verify execution context.
|
||||
//
|
||||
// CRITICAL ORDERING: Topology resolution (state.ResolveResource) happens FIRST.
|
||||
// Agent hostname matching is a FALLBACK only when the state doesn't know the resource.
|
||||
// This prevents the "hostname collision" bug where an agent with hostname matching an LXC name
|
||||
// causes commands to execute on the node instead of inside the LXC via pct exec.
|
||||
func (e *PulseToolExecutor) resolveTargetForCommandFull(targetHost string) CommandRoutingResult {
|
||||
result := CommandRoutingResult{
|
||||
TargetType: "host",
|
||||
Transport: "direct",
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return ""
|
||||
return result
|
||||
}
|
||||
|
||||
agents := e.agentServer.GetConnectedAgents()
|
||||
if len(agents) == 0 {
|
||||
return ""
|
||||
return result
|
||||
}
|
||||
|
||||
if targetHost != "" {
|
||||
for _, agent := range agents {
|
||||
if agent.Hostname == targetHost || agent.AgentID == targetHost {
|
||||
return agent.AgentID
|
||||
if targetHost == "" {
|
||||
// No target_host specified - require exactly one agent or fail
|
||||
if len(agents) > 1 {
|
||||
return result
|
||||
}
|
||||
result.AgentID = agents[0].AgentID
|
||||
result.AgentHostname = agents[0].Hostname
|
||||
result.ResolvedKind = "host"
|
||||
return result
|
||||
}
|
||||
|
||||
// STEP 1: Consult topology (state) FIRST — this is authoritative.
|
||||
// If the state knows about this resource, use topology-based routing.
|
||||
// This prevents hostname collisions from masquerading as host targets.
|
||||
if e.stateProvider != nil {
|
||||
state := e.stateProvider.GetState()
|
||||
loc := state.ResolveResource(targetHost)
|
||||
|
||||
if loc.Found {
|
||||
// Route based on resource type
|
||||
switch loc.ResourceType {
|
||||
case "node":
|
||||
// Direct Proxmox node
|
||||
nodeAgentID := e.findAgentForNode(loc.Node)
|
||||
result.AgentID = nodeAgentID
|
||||
result.ResolvedKind = "node"
|
||||
result.ResolvedNode = loc.Node
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == nodeAgentID {
|
||||
result.AgentHostname = agent.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
case "lxc":
|
||||
// LXC container - route through Proxmox node agent via pct exec
|
||||
nodeAgentID := e.findAgentForNode(loc.Node)
|
||||
result.ResolvedKind = "lxc"
|
||||
result.ResolvedNode = loc.Node
|
||||
result.TargetType = "container"
|
||||
result.TargetID = fmt.Sprintf("%d", loc.VMID)
|
||||
result.Transport = "pct_exec"
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == nodeAgentID {
|
||||
result.AgentHostname = agent.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
case "vm":
|
||||
// VM - route through Proxmox node agent via qm guest exec
|
||||
nodeAgentID := e.findAgentForNode(loc.Node)
|
||||
result.ResolvedKind = "vm"
|
||||
result.ResolvedNode = loc.Node
|
||||
result.TargetType = "vm"
|
||||
result.TargetID = fmt.Sprintf("%d", loc.VMID)
|
||||
result.Transport = "qm_guest_exec"
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == nodeAgentID {
|
||||
result.AgentHostname = agent.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
case "docker", "dockerhost":
|
||||
// Docker container or Docker host
|
||||
result.ResolvedKind = loc.ResourceType
|
||||
result.ResolvedNode = loc.Node
|
||||
|
||||
if loc.DockerHostType == "lxc" {
|
||||
nodeAgentID := e.findAgentForNode(loc.Node)
|
||||
result.TargetType = "container"
|
||||
result.TargetID = fmt.Sprintf("%d", loc.DockerHostVMID)
|
||||
result.Transport = "pct_exec"
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == nodeAgentID {
|
||||
result.AgentHostname = agent.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
if loc.DockerHostType == "vm" {
|
||||
nodeAgentID := e.findAgentForNode(loc.Node)
|
||||
result.TargetType = "vm"
|
||||
result.TargetID = fmt.Sprintf("%d", loc.DockerHostVMID)
|
||||
result.Transport = "qm_guest_exec"
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == nodeAgentID {
|
||||
result.AgentHostname = agent.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Standalone Docker host - find agent directly
|
||||
for _, agent := range agents {
|
||||
if agent.Hostname == loc.TargetHost || agent.AgentID == loc.TargetHost {
|
||||
result.AgentID = agent.AgentID
|
||||
result.AgentHostname = agent.Hostname
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetHost == "" && len(agents) > 1 {
|
||||
return ""
|
||||
// STEP 2: FALLBACK — agent hostname match.
|
||||
// Only used when the state doesn't know about this resource at all.
|
||||
// This handles standalone hosts without Proxmox topology.
|
||||
for _, agent := range agents {
|
||||
if agent.Hostname == targetHost || agent.AgentID == targetHost {
|
||||
result.AgentID = agent.AgentID
|
||||
result.AgentHostname = agent.Hostname
|
||||
result.ResolvedKind = "host"
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return agents[0].AgentID
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveTargetForCommand resolves a target_host to the correct agent and routing info.
|
||||
// Uses the authoritative ResolveResource function from models.StateSnapshot.
|
||||
// Returns: agentID, targetType ("host", "container", or "vm"), targetID (vmid for LXC/VM)
|
||||
//
|
||||
// CRITICAL ORDERING: Same as resolveTargetForCommandFull — topology first, agent fallback second.
|
||||
func (e *PulseToolExecutor) resolveTargetForCommand(targetHost string) (agentID string, targetType string, targetID string) {
|
||||
// Delegate to the full resolver and extract the triple
|
||||
r := e.resolveTargetForCommandFull(targetHost)
|
||||
return r.AgentID, r.TargetType, r.TargetID
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) findAgentForCommand(runOnHost bool, targetHost string) string {
|
||||
agentID, _, _ := e.resolveTargetForCommand(targetHost)
|
||||
return agentID
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) resolveGuest(guestID string) (*GuestInfo, error) {
|
||||
@@ -627,6 +879,96 @@ func (e *PulseToolExecutor) getAgentHostnameForDockerHost(dockerHost *models.Doc
|
||||
return dockerHost.Hostname
|
||||
}
|
||||
|
||||
// resolveDockerHostRoutingFull resolves a Docker host to the correct agent and routing info
|
||||
// with full provenance metadata. If the Docker host is actually an LXC or VM, it routes
|
||||
// through the Proxmox host agent with the correct TargetType and TargetID so commands
|
||||
// are executed inside the guest.
|
||||
func (e *PulseToolExecutor) resolveDockerHostRoutingFull(dockerHost *models.DockerHost) CommandRoutingResult {
|
||||
result := CommandRoutingResult{
|
||||
TargetType: "host",
|
||||
Transport: "direct",
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
// STEP 1: Check topology — is the Docker host actually an LXC or VM?
|
||||
if e.stateProvider != nil {
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
// Check LXCs
|
||||
for _, ct := range state.Containers {
|
||||
if ct.Name == dockerHost.Hostname {
|
||||
result.ResolvedKind = "lxc"
|
||||
result.ResolvedNode = ct.Node
|
||||
result.TargetType = "container"
|
||||
result.TargetID = fmt.Sprintf("%d", ct.VMID)
|
||||
result.Transport = "pct_exec"
|
||||
nodeAgentID := e.findAgentForNode(ct.Node)
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
result.AgentHostname = ct.Node
|
||||
log.Debug().
|
||||
Str("docker_host", dockerHost.Hostname).
|
||||
Str("node", ct.Node).
|
||||
Int("vmid", ct.VMID).
|
||||
Str("agent", nodeAgentID).
|
||||
Str("transport", result.Transport).
|
||||
Msg("Resolved Docker host as LXC, routing through Proxmox agent")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check VMs
|
||||
for _, vm := range state.VMs {
|
||||
if vm.Name == dockerHost.Hostname {
|
||||
result.ResolvedKind = "vm"
|
||||
result.ResolvedNode = vm.Node
|
||||
result.TargetType = "vm"
|
||||
result.TargetID = fmt.Sprintf("%d", vm.VMID)
|
||||
result.Transport = "qm_guest_exec"
|
||||
nodeAgentID := e.findAgentForNode(vm.Node)
|
||||
if nodeAgentID != "" {
|
||||
result.AgentID = nodeAgentID
|
||||
result.AgentHostname = vm.Node
|
||||
log.Debug().
|
||||
Str("docker_host", dockerHost.Hostname).
|
||||
Str("node", vm.Node).
|
||||
Int("vmid", vm.VMID).
|
||||
Str("agent", nodeAgentID).
|
||||
Str("transport", result.Transport).
|
||||
Msg("Resolved Docker host as VM, routing through Proxmox agent")
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Docker host is not an LXC/VM — use direct agent routing
|
||||
agentID := e.findAgentForDockerHost(dockerHost)
|
||||
result.AgentID = agentID
|
||||
result.ResolvedKind = "dockerhost"
|
||||
if agentID != "" {
|
||||
// Try to get agent hostname
|
||||
agents := e.agentServer.GetConnectedAgents()
|
||||
for _, a := range agents {
|
||||
if a.AgentID == agentID {
|
||||
result.AgentHostname = a.Hostname
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveDockerHostRouting delegates to resolveDockerHostRoutingFull for backwards compatibility.
|
||||
func (e *PulseToolExecutor) resolveDockerHostRouting(dockerHost *models.DockerHost) (agentID string, targetType string, targetID string) {
|
||||
r := e.resolveDockerHostRoutingFull(dockerHost)
|
||||
return r.AgentID, r.TargetType, r.TargetID
|
||||
}
|
||||
|
||||
// createApprovalRecord creates an approval record in the store and returns the approval ID.
|
||||
// Returns empty string if store is not available (approvals will still work, just without persistence).
|
||||
func createApprovalRecord(command, targetType, targetID, targetName, context string) string {
|
||||
|
||||
96
internal/ai/tools/tools_control_consolidated.go
Normal file
96
internal/ai/tools/tools_control_consolidated.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerControlToolsConsolidated registers the consolidated pulse_control tool
|
||||
func (e *PulseToolExecutor) registerControlToolsConsolidated() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_control",
|
||||
Description: `Control Proxmox VMs/LXC containers or execute WRITE commands on infrastructure.
|
||||
|
||||
IMPORTANT: For READ operations (grep, cat, tail, logs, ps, status checks), use pulse_read instead.
|
||||
This tool is for WRITE operations that modify state.
|
||||
|
||||
Types:
|
||||
- guest: Start, stop, restart, shutdown, or delete VMs and LXC containers
|
||||
- command: Execute commands that MODIFY state (restart services, write files, etc.)
|
||||
|
||||
USE pulse_control FOR:
|
||||
- Guest control: start/stop/restart/delete VMs and LXCs
|
||||
- Service management: systemctl restart, service start/stop
|
||||
- Package management: apt install, yum update
|
||||
- File modification: echo > file, sed -i, rm, mv, cp
|
||||
|
||||
DO NOT use pulse_control for:
|
||||
- Reading logs → use pulse_read action=exec or action=logs
|
||||
- Checking status → use pulse_read action=exec
|
||||
- Reading files → use pulse_read action=file
|
||||
- Finding files → use pulse_read action=find
|
||||
|
||||
Examples:
|
||||
- Restart VM: type="guest", guest_id="101", action="restart"
|
||||
- Restart service: type="command", command="systemctl restart nginx", target_host="webserver"
|
||||
|
||||
For Docker container control, use pulse_docker.
|
||||
Note: Delete requires the guest to be stopped first.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Control type: guest or command",
|
||||
Enum: []string{"guest", "command"},
|
||||
},
|
||||
"guest_id": {
|
||||
Type: "string",
|
||||
Description: "For guest: VMID or name",
|
||||
},
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "For guest: start, stop, shutdown, restart, delete",
|
||||
Enum: []string{"start", "stop", "shutdown", "restart", "delete"},
|
||||
},
|
||||
"command": {
|
||||
Type: "string",
|
||||
Description: "For command type: the shell command to execute",
|
||||
},
|
||||
"target_host": {
|
||||
Type: "string",
|
||||
Description: "For command type: hostname to run command on",
|
||||
},
|
||||
"run_on_host": {
|
||||
Type: "boolean",
|
||||
Description: "For command type: run on host (default true)",
|
||||
},
|
||||
"force": {
|
||||
Type: "boolean",
|
||||
Description: "For guest stop: force stop without graceful shutdown",
|
||||
},
|
||||
},
|
||||
Required: []string{"type"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeControl(ctx, args)
|
||||
},
|
||||
RequireControl: true,
|
||||
})
|
||||
}
|
||||
|
||||
// executeControl routes to the appropriate control handler based on type
|
||||
// Handler functions are implemented in tools_control.go
|
||||
func (e *PulseToolExecutor) executeControl(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
controlType, _ := args["type"].(string)
|
||||
switch controlType {
|
||||
case "guest":
|
||||
return e.executeControlGuest(ctx, args)
|
||||
case "command":
|
||||
return e.executeRunCommand(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown type: %s. Use: guest, command", controlType)), nil
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,147 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getCommandContext returns information about how to run commands on a resource.
|
||||
// This helps the AI understand what commands to use with pulse_control.
|
||||
type CommandContext struct {
|
||||
// How to run commands: "direct" (agent on resource), "via_host" (agent on parent host)
|
||||
Method string `json:"method"`
|
||||
// The target_host value to use with pulse_control
|
||||
TargetHost string `json:"target_host"`
|
||||
// Example command pattern (what to pass to pulse_control)
|
||||
Example string `json:"example"`
|
||||
// For containers running inside this resource (e.g., Docker in LXC)
|
||||
NestedExample string `json:"nested_example,omitempty"`
|
||||
}
|
||||
|
||||
// getCLIAccessPattern returns context about the resource type.
|
||||
// Does NOT prescribe how to access - the AI should determine that based on available agents.
|
||||
func getCLIAccessPattern(resourceType, hostID, resourceID string) string {
|
||||
switch resourceType {
|
||||
case "lxc":
|
||||
return fmt.Sprintf("LXC container on Proxmox node '%s' (VMID %s)", hostID, resourceID)
|
||||
case "vm":
|
||||
return fmt.Sprintf("VM on Proxmox node '%s' (VMID %s)", hostID, resourceID)
|
||||
case "docker":
|
||||
return fmt.Sprintf("Docker container '%s' on host '%s'", resourceID, hostID)
|
||||
case "host":
|
||||
return fmt.Sprintf("Host '%s'", hostID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// commonServicePaths contains typical log/config paths for well-known services
|
||||
// These are fallbacks when discovery doesn't find specific paths
|
||||
var commonServicePaths = map[string]struct {
|
||||
LogPaths []string
|
||||
ConfigPaths []string
|
||||
DataPaths []string
|
||||
}{
|
||||
"jellyfin": {
|
||||
LogPaths: []string{"/var/log/jellyfin/", "/config/log/"},
|
||||
ConfigPaths: []string{"/etc/jellyfin/", "/config/"},
|
||||
DataPaths: []string{"/var/lib/jellyfin/", "/config/data/"},
|
||||
},
|
||||
"plex": {
|
||||
LogPaths: []string{"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Logs/"},
|
||||
ConfigPaths: []string{"/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/"},
|
||||
DataPaths: []string{"/var/lib/plexmediaserver/"},
|
||||
},
|
||||
"sonarr": {
|
||||
LogPaths: []string{"/config/logs/"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/config/"},
|
||||
},
|
||||
"radarr": {
|
||||
LogPaths: []string{"/config/logs/"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/config/"},
|
||||
},
|
||||
"prowlarr": {
|
||||
LogPaths: []string{"/config/logs/"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/config/"},
|
||||
},
|
||||
"lidarr": {
|
||||
LogPaths: []string{"/config/logs/"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/config/"},
|
||||
},
|
||||
"postgresql": {
|
||||
LogPaths: []string{"/var/log/postgresql/", "/var/lib/postgresql/data/log/"},
|
||||
ConfigPaths: []string{"/etc/postgresql/", "/var/lib/postgresql/data/"},
|
||||
DataPaths: []string{"/var/lib/postgresql/data/"},
|
||||
},
|
||||
"mysql": {
|
||||
LogPaths: []string{"/var/log/mysql/", "/var/lib/mysql/"},
|
||||
ConfigPaths: []string{"/etc/mysql/"},
|
||||
DataPaths: []string{"/var/lib/mysql/"},
|
||||
},
|
||||
"mariadb": {
|
||||
LogPaths: []string{"/var/log/mysql/", "/var/lib/mysql/"},
|
||||
ConfigPaths: []string{"/etc/mysql/"},
|
||||
DataPaths: []string{"/var/lib/mysql/"},
|
||||
},
|
||||
"nginx": {
|
||||
LogPaths: []string{"/var/log/nginx/"},
|
||||
ConfigPaths: []string{"/etc/nginx/"},
|
||||
DataPaths: []string{"/var/www/"},
|
||||
},
|
||||
"homeassistant": {
|
||||
LogPaths: []string{"/config/home-assistant.log"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/config/"},
|
||||
},
|
||||
"frigate": {
|
||||
LogPaths: []string{"/config/logs/"},
|
||||
ConfigPaths: []string{"/config/"},
|
||||
DataPaths: []string{"/media/frigate/"},
|
||||
},
|
||||
"redis": {
|
||||
LogPaths: []string{"/var/log/redis/"},
|
||||
ConfigPaths: []string{"/etc/redis/"},
|
||||
DataPaths: []string{"/var/lib/redis/"},
|
||||
},
|
||||
"mongodb": {
|
||||
LogPaths: []string{"/var/log/mongodb/"},
|
||||
ConfigPaths: []string{"/etc/mongod.conf"},
|
||||
DataPaths: []string{"/var/lib/mongodb/"},
|
||||
},
|
||||
"grafana": {
|
||||
LogPaths: []string{"/var/log/grafana/"},
|
||||
ConfigPaths: []string{"/etc/grafana/"},
|
||||
DataPaths: []string{"/var/lib/grafana/"},
|
||||
},
|
||||
"prometheus": {
|
||||
LogPaths: []string{"/var/log/prometheus/"},
|
||||
ConfigPaths: []string{"/etc/prometheus/"},
|
||||
DataPaths: []string{"/var/lib/prometheus/"},
|
||||
},
|
||||
"influxdb": {
|
||||
LogPaths: []string{"/var/log/influxdb/"},
|
||||
ConfigPaths: []string{"/etc/influxdb/"},
|
||||
DataPaths: []string{"/var/lib/influxdb/"},
|
||||
},
|
||||
}
|
||||
|
||||
// getCommonServicePaths returns fallback paths for a service type
|
||||
func getCommonServicePaths(serviceType string) (logPaths, configPaths, dataPaths []string) {
|
||||
// Normalize service type (lowercase, remove version numbers)
|
||||
normalized := strings.ToLower(serviceType)
|
||||
// Try to match against known services
|
||||
for key, paths := range commonServicePaths {
|
||||
if strings.Contains(normalized, key) {
|
||||
return paths.LogPaths, paths.ConfigPaths, paths.DataPaths
|
||||
}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// registerDiscoveryTools registers AI-powered infrastructure discovery tools
|
||||
func (e *PulseToolExecutor) registerDiscoveryTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
@@ -40,10 +178,10 @@ This information is critical for proposing correct remediation commands that mat
|
||||
},
|
||||
"host_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: Host/node ID where the resource runs (required for Docker containers)",
|
||||
Description: "Node/host where the resource runs. For VM/LXC: the PVE node from 'node' field. For Docker: the Docker host. For host type: same as resource_id.",
|
||||
},
|
||||
},
|
||||
Required: []string{"resource_type", "resource_id"},
|
||||
Required: []string{"resource_type", "resource_id", "host_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
@@ -93,9 +231,9 @@ Filters:
|
||||
})
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetDiscovery(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
func (e *PulseToolExecutor) executeGetDiscovery(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.discoveryProvider == nil {
|
||||
return NewTextResult("Discovery service not available. Run a discovery scan first."), nil
|
||||
return NewTextResult("Discovery service not available."), nil
|
||||
}
|
||||
|
||||
resourceType, _ := args["resource_type"].(string)
|
||||
@@ -108,21 +246,105 @@ func (e *PulseToolExecutor) executeGetDiscovery(_ context.Context, args map[stri
|
||||
if resourceID == "" {
|
||||
return NewErrorResult(fmt.Errorf("resource_id is required")), nil
|
||||
}
|
||||
if hostID == "" {
|
||||
return NewErrorResult(fmt.Errorf("host_id is required - use the 'node' field from search or get_resource results")), nil
|
||||
}
|
||||
|
||||
// For LXC and VM types, resourceID should be a numeric VMID.
|
||||
// If a name was passed, try to resolve it to a VMID.
|
||||
if (resourceType == "lxc" || resourceType == "vm") && e.stateProvider != nil {
|
||||
if _, err := strconv.Atoi(resourceID); err != nil {
|
||||
// Not a number - try to resolve the name to a VMID
|
||||
state := e.stateProvider.GetState()
|
||||
resolved := false
|
||||
|
||||
if resourceType == "lxc" {
|
||||
for _, c := range state.Containers {
|
||||
if strings.EqualFold(c.Name, resourceID) && c.Node == hostID {
|
||||
resourceID = fmt.Sprintf("%d", c.VMID)
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if resourceType == "vm" {
|
||||
for _, vm := range state.VMs {
|
||||
if strings.EqualFold(vm.Name, resourceID) && vm.Node == hostID {
|
||||
resourceID = fmt.Sprintf("%d", vm.VMID)
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !resolved {
|
||||
return NewErrorResult(fmt.Errorf("could not resolve resource name '%s' to a VMID on host '%s'", resourceID, hostID)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First try to get existing discovery
|
||||
discovery, err := e.discoveryProvider.GetDiscoveryByResource(resourceType, hostID, resourceID)
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to get discovery: %w", err)), nil
|
||||
}
|
||||
|
||||
// Compute CLI access pattern (always useful, even if discovery fails)
|
||||
cliAccess := getCLIAccessPattern(resourceType, hostID, resourceID)
|
||||
|
||||
// If no discovery exists, trigger one
|
||||
if discovery == nil {
|
||||
discovery, err = e.discoveryProvider.TriggerDiscovery(ctx, resourceType, hostID, resourceID)
|
||||
if err != nil {
|
||||
// Even on failure, provide cli_access so AI can investigate manually
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"found": false,
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"host_id": hostID,
|
||||
"cli_access": cliAccess,
|
||||
"message": fmt.Sprintf("Discovery failed: %v", err),
|
||||
"hint": "Use pulse_control with type='command' to investigate. Try checking /var/log/ for logs.",
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
if discovery == nil {
|
||||
// No discovery but provide cli_access for manual investigation
|
||||
return NewJSONResult(map[string]interface{}{
|
||||
"found": false,
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"message": "No discovery data found for this resource. Run a discovery scan to gather information.",
|
||||
"host_id": hostID,
|
||||
"cli_access": cliAccess,
|
||||
"message": "Discovery returned no data. The resource may not be accessible.",
|
||||
"hint": "Use pulse_control with type='command' to investigate. Try listing /var/log/ or checking running processes.",
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Use fallback cli_access if discovery didn't provide one
|
||||
responseCLIAccess := discovery.CLIAccess
|
||||
if responseCLIAccess == "" {
|
||||
responseCLIAccess = cliAccess
|
||||
}
|
||||
|
||||
// Use fallback paths for known services if discovery didn't find specific ones
|
||||
responseConfigPaths := discovery.ConfigPaths
|
||||
responseDataPaths := discovery.DataPaths
|
||||
var responseLogPaths []string
|
||||
|
||||
if discovery.ServiceType != "" {
|
||||
fallbackLogPaths, fallbackConfigPaths, fallbackDataPaths := getCommonServicePaths(discovery.ServiceType)
|
||||
if len(responseConfigPaths) == 0 && len(fallbackConfigPaths) > 0 {
|
||||
responseConfigPaths = fallbackConfigPaths
|
||||
}
|
||||
if len(responseDataPaths) == 0 && len(fallbackDataPaths) > 0 {
|
||||
responseDataPaths = fallbackDataPaths
|
||||
}
|
||||
if len(fallbackLogPaths) > 0 {
|
||||
responseLogPaths = fallbackLogPaths
|
||||
}
|
||||
}
|
||||
|
||||
// Return the discovery information
|
||||
response := map[string]interface{}{
|
||||
"found": true,
|
||||
@@ -135,14 +357,19 @@ func (e *PulseToolExecutor) executeGetDiscovery(_ context.Context, args map[stri
|
||||
"service_name": discovery.ServiceName,
|
||||
"service_version": discovery.ServiceVersion,
|
||||
"category": discovery.Category,
|
||||
"cli_access": discovery.CLIAccess,
|
||||
"config_paths": discovery.ConfigPaths,
|
||||
"data_paths": discovery.DataPaths,
|
||||
"cli_access": responseCLIAccess,
|
||||
"config_paths": responseConfigPaths,
|
||||
"data_paths": responseDataPaths,
|
||||
"confidence": discovery.Confidence,
|
||||
"discovered_at": discovery.DiscoveredAt,
|
||||
"updated_at": discovery.UpdatedAt,
|
||||
}
|
||||
|
||||
// Add log paths if we have them (from fallback or discovery)
|
||||
if len(responseLogPaths) > 0 {
|
||||
response["log_paths"] = responseLogPaths
|
||||
}
|
||||
|
||||
// Add facts if present
|
||||
if len(discovery.Facts) > 0 {
|
||||
facts := make([]map[string]string, 0, len(discovery.Facts))
|
||||
@@ -166,6 +393,25 @@ func (e *PulseToolExecutor) executeGetDiscovery(_ context.Context, args map[stri
|
||||
response["ai_reasoning"] = discovery.AIReasoning
|
||||
}
|
||||
|
||||
// Add listening ports if present
|
||||
if len(discovery.Ports) > 0 {
|
||||
ports := make([]map[string]interface{}, 0, len(discovery.Ports))
|
||||
for _, p := range discovery.Ports {
|
||||
port := map[string]interface{}{
|
||||
"port": p.Port,
|
||||
"protocol": p.Protocol,
|
||||
}
|
||||
if p.Process != "" {
|
||||
port["process"] = p.Process
|
||||
}
|
||||
if p.Address != "" {
|
||||
port["address"] = p.Address
|
||||
}
|
||||
ports = append(ports, port)
|
||||
}
|
||||
response["ports"] = ports
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
@@ -236,6 +482,11 @@ func (e *PulseToolExecutor) executeListDiscoveries(_ context.Context, args map[s
|
||||
result["facts_count"] = len(d.Facts)
|
||||
}
|
||||
|
||||
// Add ports count
|
||||
if len(d.Ports) > 0 {
|
||||
result["ports_count"] = len(d.Ports)
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
|
||||
87
internal/ai/tools/tools_discovery_consolidated.go
Normal file
87
internal/ai/tools/tools_discovery_consolidated.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerDiscoveryToolsConsolidated registers the consolidated pulse_discovery tool
|
||||
func (e *PulseToolExecutor) registerDiscoveryToolsConsolidated() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_discovery",
|
||||
Description: `Get deep AI-discovered information about services (log paths, config locations, service details).
|
||||
|
||||
Actions:
|
||||
- get: Trigger discovery and get detailed info for a specific resource. Use this when you need deep context about a container/VM (where logs are, config paths, service details). Requires resource_type, resource_id, and host_id - use pulse_query action="search" first if you don't know these.
|
||||
- list: Search existing discoveries only. Will NOT find resources that haven't been discovered yet. Use action="get" to trigger discovery for new resources.
|
||||
|
||||
Workflow for investigating applications:
|
||||
1. Use pulse_query action="search" to find the resource by name
|
||||
2. Use pulse_discovery action="get" with the resource details to get deep context (log paths, config locations)
|
||||
3. Use pulse_control type="command" to run commands (check logs, query app state, etc.)
|
||||
|
||||
Examples:
|
||||
- Trigger discovery: action="get", resource_type="docker", resource_id="jellyfin", host_id="docker-host-1"
|
||||
- Search existing: action="list", service_type="postgresql"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Discovery action: get or list",
|
||||
Enum: []string{"get", "list"},
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "For get: resource type (vm, lxc, docker, host)",
|
||||
Enum: []string{"vm", "lxc", "docker", "host"},
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "For get: resource identifier (VMID, container name, hostname)",
|
||||
},
|
||||
"host_id": {
|
||||
Type: "string",
|
||||
Description: "For get: node/host where resource runs",
|
||||
},
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "For list: filter by resource type",
|
||||
Enum: []string{"vm", "lxc", "docker", "host"},
|
||||
},
|
||||
"host": {
|
||||
Type: "string",
|
||||
Description: "For list: filter by host/node ID",
|
||||
},
|
||||
"service_type": {
|
||||
Type: "string",
|
||||
Description: "For list: filter by service type (e.g., frigate, postgresql)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "For list: maximum results (default: 50)",
|
||||
},
|
||||
},
|
||||
Required: []string{"action"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeDiscovery(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeDiscovery routes to the appropriate discovery handler based on action
|
||||
// Handler functions are implemented in tools_discovery.go
|
||||
func (e *PulseToolExecutor) executeDiscovery(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
switch action {
|
||||
case "get":
|
||||
return e.executeGetDiscovery(ctx, args)
|
||||
case "list":
|
||||
return e.executeListDiscoveries(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: get, list", action)), nil
|
||||
}
|
||||
}
|
||||
201
internal/ai/tools/tools_docker.go
Normal file
201
internal/ai/tools/tools_docker.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// registerDockerTools registers the consolidated pulse_docker tool
|
||||
func (e *PulseToolExecutor) registerDockerTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_docker",
|
||||
Description: `Manage Docker containers, updates, and Swarm services.
|
||||
|
||||
Actions:
|
||||
- control: Start, stop, or restart containers
|
||||
- updates: List containers with pending image updates
|
||||
- check_updates: Trigger update check on a host
|
||||
- update: Update a container to latest image (requires control permission)
|
||||
- services: List Docker Swarm services
|
||||
- tasks: List Docker Swarm tasks
|
||||
- swarm: Get Swarm cluster status
|
||||
|
||||
To check Docker container logs or run commands inside containers, use pulse_control with type="command":
|
||||
command="docker logs jellyfin --tail 100"
|
||||
command="docker exec jellyfin cat /config/log/log.txt"
|
||||
|
||||
Examples:
|
||||
- Restart container: action="control", container="nginx", operation="restart"
|
||||
- List updates: action="updates", host="Tower"
|
||||
- Update container: action="update", container="nginx", host="Tower"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Docker action to perform",
|
||||
Enum: []string{"control", "updates", "check_updates", "update", "services", "tasks", "swarm"},
|
||||
},
|
||||
"container": {
|
||||
Type: "string",
|
||||
Description: "Container name or ID (for control, update)",
|
||||
},
|
||||
"host": {
|
||||
Type: "string",
|
||||
Description: "Docker host name or ID",
|
||||
},
|
||||
"operation": {
|
||||
Type: "string",
|
||||
Description: "Control operation: start, stop, restart (for action: control)",
|
||||
Enum: []string{"start", "stop", "restart"},
|
||||
},
|
||||
"service": {
|
||||
Type: "string",
|
||||
Description: "Filter by service name or ID (for tasks)",
|
||||
},
|
||||
"stack": {
|
||||
Type: "string",
|
||||
Description: "Filter by stack name (for services)",
|
||||
},
|
||||
},
|
||||
Required: []string{"action"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeDocker(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeDocker routes to the appropriate docker handler based on action
|
||||
func (e *PulseToolExecutor) executeDocker(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
switch action {
|
||||
case "control":
|
||||
return e.executeDockerControl(ctx, args)
|
||||
case "updates":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeListDockerUpdates(ctx, args)
|
||||
case "check_updates":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeCheckDockerUpdates(ctx, args)
|
||||
case "update":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeUpdateDockerContainer(ctx, args)
|
||||
case "services":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeListDockerServices(ctx, args)
|
||||
case "tasks":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeListDockerTasks(ctx, args)
|
||||
case "swarm":
|
||||
// Uses existing function from tools_infrastructure.go
|
||||
return e.executeGetSwarmStatus(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: control, updates, check_updates, update, services, tasks, swarm", action)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// executeDockerControl handles start/stop/restart of Docker containers
|
||||
// This is a new consolidated handler that merges pulse_control_docker functionality
|
||||
func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
containerName, _ := args["container"].(string)
|
||||
hostName, _ := args["host"].(string)
|
||||
operation, _ := args["operation"].(string)
|
||||
|
||||
if containerName == "" {
|
||||
return NewErrorResult(fmt.Errorf("container name is required")), nil
|
||||
}
|
||||
if operation == "" {
|
||||
return NewErrorResult(fmt.Errorf("operation is required (start, stop, restart)")), nil
|
||||
}
|
||||
|
||||
validOperations := map[string]bool{"start": true, "stop": true, "restart": true}
|
||||
if !validOperations[operation] {
|
||||
return NewErrorResult(fmt.Errorf("invalid operation: %s. Use start, stop, or restart", operation)), nil
|
||||
}
|
||||
|
||||
// Check if read-only mode
|
||||
if e.controlLevel == ControlLevelReadOnly {
|
||||
return NewTextResult("Docker control actions are not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
// Check if this is a pre-approved execution
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
container, dockerHost, err := e.resolveDockerContainer(containerName, hostName)
|
||||
if err != nil {
|
||||
return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil
|
||||
}
|
||||
|
||||
command := fmt.Sprintf("docker %s %s", operation, container.Name)
|
||||
|
||||
// Get the agent hostname for approval records
|
||||
agentHostname := e.getAgentHostnameForDockerHost(dockerHost)
|
||||
|
||||
// Skip approval checks if pre-approved
|
||||
if !preApproved && e.policy != nil {
|
||||
decision := e.policy.Evaluate(command)
|
||||
if decision == agentexec.PolicyBlock {
|
||||
return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil
|
||||
}
|
||||
if decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
|
||||
approvalID := createApprovalRecord(command, "docker", container.Name, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name))
|
||||
return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if !preApproved && e.controlLevel == ControlLevelControlled {
|
||||
approvalID := createApprovalRecord(command, "docker", container.Name, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name))
|
||||
return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Resolve the Docker host to the correct agent and routing info (with full provenance)
|
||||
routing := e.resolveDockerHostRoutingFull(dockerHost)
|
||||
if routing.AgentID == "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewTextResult(fmt.Sprintf("Docker host '%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", dockerHost.Hostname, routing.TargetType)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("docker_host", dockerHost.Hostname).
|
||||
Str("agent_id", routing.AgentID).
|
||||
Str("agent_host", routing.AgentHostname).
|
||||
Str("resolved_kind", routing.ResolvedKind).
|
||||
Str("resolved_node", routing.ResolvedNode).
|
||||
Str("transport", routing.Transport).
|
||||
Str("target_type", routing.TargetType).
|
||||
Str("target_id", routing.TargetID).
|
||||
Msg("[pulse_docker] Routing docker command execution")
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("Successfully executed 'docker %s' on container '%s' (host: %s). State updates in ~10s.\n%s", operation, container.Name, dockerHost.Hostname, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
551
internal/ai/tools/tools_file.go
Normal file
551
internal/ai/tools/tools_file.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ExecutionProvenance tracks where a command actually executed.
|
||||
// This makes it observable whether a command ran on the intended target
|
||||
// or fell back to a different execution context.
|
||||
type ExecutionProvenance struct {
|
||||
// What the model requested
|
||||
RequestedTargetHost string `json:"requested_target_host"`
|
||||
|
||||
// What we resolved it to
|
||||
ResolvedKind string `json:"resolved_kind"` // "host", "lxc", "vm", "docker"
|
||||
ResolvedNode string `json:"resolved_node"` // Proxmox node name (if applicable)
|
||||
ResolvedUID string `json:"resolved_uid"` // VMID or container ID
|
||||
|
||||
// How we executed it
|
||||
AgentHost string `json:"agent_host"` // Hostname of the agent that executed
|
||||
Transport string `json:"transport"` // "direct", "pct_exec", "qm_guest_exec"
|
||||
}
|
||||
|
||||
// registerFileTools registers the file editing tool
|
||||
func (e *PulseToolExecutor) registerFileTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_file_edit",
|
||||
Description: `Read and edit files on remote hosts, LXC containers, VMs, and Docker containers safely.
|
||||
|
||||
Actions:
|
||||
- read: Read the contents of a file
|
||||
- append: Append content to the end of a file
|
||||
- write: Write/overwrite a file with new content (creates if doesn't exist)
|
||||
|
||||
This tool handles escaping automatically - just provide the content as-is.
|
||||
Use this instead of shell commands for editing config files (YAML, JSON, etc.)
|
||||
|
||||
Routing: target_host can be a Proxmox host (delly), an LXC name (homepage-docker), or a VM name. Commands are automatically routed through the appropriate agent.
|
||||
|
||||
Docker container support: Use docker_container to access files INSIDE a Docker container. The target_host specifies where Docker is running.
|
||||
|
||||
Examples:
|
||||
- Read from LXC: action="read", path="/opt/app/config.yaml", target_host="homepage-docker"
|
||||
- Write to host: action="write", path="/tmp/test.txt", content="hello", target_host="delly"
|
||||
- Read from Docker: action="read", path="/config/settings.json", target_host="tower", docker_container="jellyfin"
|
||||
- Write to Docker: action="write", path="/tmp/test.txt", content="hello", target_host="tower", docker_container="nginx"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "File action: read, append, or write",
|
||||
Enum: []string{"read", "append", "write"},
|
||||
},
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "Absolute path to the file",
|
||||
},
|
||||
"content": {
|
||||
Type: "string",
|
||||
Description: "Content to write or append (for append/write actions)",
|
||||
},
|
||||
"target_host": {
|
||||
Type: "string",
|
||||
Description: "Hostname where the file exists (or where Docker is running)",
|
||||
},
|
||||
"docker_container": {
|
||||
Type: "string",
|
||||
Description: "Docker container name (for files inside containers)",
|
||||
},
|
||||
},
|
||||
Required: []string{"action", "path", "target_host"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeFileEdit(ctx, args)
|
||||
},
|
||||
RequireControl: true,
|
||||
})
|
||||
}
|
||||
|
||||
// executeFileEdit handles file read/write operations
|
||||
func (e *PulseToolExecutor) executeFileEdit(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
content, _ := args["content"].(string)
|
||||
targetHost, _ := args["target_host"].(string)
|
||||
dockerContainer, _ := args["docker_container"].(string)
|
||||
|
||||
if path == "" {
|
||||
return NewErrorResult(fmt.Errorf("path is required")), nil
|
||||
}
|
||||
if targetHost == "" {
|
||||
return NewErrorResult(fmt.Errorf("target_host is required")), nil
|
||||
}
|
||||
|
||||
// Validate path - must be absolute
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return NewErrorResult(fmt.Errorf("path must be absolute (start with /)")), nil
|
||||
}
|
||||
|
||||
// Validate docker_container if provided (simple alphanumeric + _ + -)
|
||||
if dockerContainer != "" {
|
||||
for _, c := range dockerContainer {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
|
||||
return NewErrorResult(fmt.Errorf("invalid character '%c' in docker_container name", c)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly && action != "read" {
|
||||
return NewTextResult("File editing is not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "read":
|
||||
return e.executeFileRead(ctx, path, targetHost, dockerContainer)
|
||||
case "append":
|
||||
if content == "" {
|
||||
return NewErrorResult(fmt.Errorf("content is required for append action")), nil
|
||||
}
|
||||
return e.executeFileAppend(ctx, path, content, targetHost, dockerContainer, args)
|
||||
case "write":
|
||||
if content == "" {
|
||||
return NewErrorResult(fmt.Errorf("content is required for write action")), nil
|
||||
}
|
||||
return e.executeFileWrite(ctx, path, content, targetHost, dockerContainer, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: read, append, write", action)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// executeFileRead reads a file's contents
|
||||
func (e *PulseToolExecutor) executeFileRead(ctx context.Context, path, targetHost, dockerContainer string) (CallToolResult, error) {
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Validate routing context - block if targeting a Proxmox host when child resources exist
|
||||
// This prevents accidentally reading files from the host when user meant to read from an LXC/VM
|
||||
routingResult := e.validateRoutingContext(targetHost)
|
||||
if routingResult.IsBlocked() {
|
||||
return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil
|
||||
}
|
||||
|
||||
// Use full routing resolution - includes provenance for debugging
|
||||
routing := e.resolveTargetForCommandFull(targetHost)
|
||||
if routing.AgentID == "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewTextResult(fmt.Sprintf("'%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", targetHost, routing.TargetType)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No agent found for host '%s'. Check that the hostname is correct and an agent is connected.", targetHost)), nil
|
||||
}
|
||||
|
||||
var command string
|
||||
if dockerContainer != "" {
|
||||
// File is inside Docker container
|
||||
command = fmt.Sprintf("docker exec %s cat %s", shellEscape(dockerContainer), shellEscape(path))
|
||||
} else {
|
||||
// File is on host filesystem (existing behavior)
|
||||
command = fmt.Sprintf("cat %s", shellEscape(path))
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to read file: %w", err)), nil
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
errMsg := result.Stderr
|
||||
if errMsg == "" {
|
||||
errMsg = result.Stdout
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
return NewTextResult(fmt.Sprintf("Failed to read file from container '%s' (exit code %d): %s", dockerContainer, result.ExitCode, errMsg)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Failed to read file (exit code %d): %s", result.ExitCode, errMsg)), nil
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"path": path,
|
||||
"content": result.Stdout,
|
||||
"host": targetHost,
|
||||
"size": len(result.Stdout),
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
response["docker_container"] = dockerContainer
|
||||
}
|
||||
// Include execution provenance for observability
|
||||
response["execution"] = buildExecutionProvenance(targetHost, routing)
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
// executeFileAppend appends content to a file
|
||||
func (e *PulseToolExecutor) executeFileAppend(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Validate routing context - block if targeting a Proxmox host when child resources exist
|
||||
// This prevents accidentally writing files to the host when user meant to write to an LXC/VM
|
||||
routingResult := e.validateRoutingContext(targetHost)
|
||||
if routingResult.IsBlocked() {
|
||||
return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil
|
||||
}
|
||||
|
||||
// Validate resource is in resolved context (write operation)
|
||||
// With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources
|
||||
validation := e.validateResolvedResource(targetHost, "append", true)
|
||||
if validation.IsBlocked() {
|
||||
// Hard validation failure - return consistent error envelope
|
||||
return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil
|
||||
}
|
||||
// Soft validation warnings are logged inside validateResolvedResource
|
||||
|
||||
// Use full routing resolution - includes provenance for debugging
|
||||
routing := e.resolveTargetForCommandFull(targetHost)
|
||||
if routing.AgentID == "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewTextResult(fmt.Sprintf("'%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", targetHost, routing.TargetType)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No agent found for host '%s'. Check that the hostname is correct and an agent is connected.", targetHost)), nil
|
||||
}
|
||||
|
||||
// INVARIANT: If the target resolves to a child resource (LXC/VM), writes MUST execute
|
||||
// inside that context via pct_exec/qm_guest_exec. No silent node fallback.
|
||||
if err := e.validateWriteExecutionContext(targetHost, routing); err != nil {
|
||||
return NewToolResponseResult(err.ToToolResponse()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Skip approval checks if pre-approved or in autonomous mode
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
target := targetHost
|
||||
if dockerContainer != "" {
|
||||
target = fmt.Sprintf("%s (container: %s)", targetHost, dockerContainer)
|
||||
}
|
||||
approvalID := createApprovalRecord(
|
||||
fmt.Sprintf("Append to file: %s", path),
|
||||
"file",
|
||||
path,
|
||||
target,
|
||||
fmt.Sprintf("Append %d bytes to %s", len(content), path),
|
||||
)
|
||||
return NewTextResult(formatFileApprovalNeeded(path, target, "append", len(content), approvalID)), nil
|
||||
}
|
||||
|
||||
// Use base64 encoding to safely transfer content
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
var command string
|
||||
if dockerContainer != "" {
|
||||
// Append inside Docker container - docker exec needs its own sh -c
|
||||
command = fmt.Sprintf("docker exec %s sh -c 'echo %s | base64 -d >> %s'",
|
||||
shellEscape(dockerContainer), encoded, shellEscape(path))
|
||||
} else {
|
||||
// For host/LXC/VM targets - agent handles sh -c wrapping for LXC/VM
|
||||
command = fmt.Sprintf("echo '%s' | base64 -d >> %s", encoded, shellEscape(path))
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to append to file: %w", err)), nil
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
errMsg := result.Stderr
|
||||
if errMsg == "" {
|
||||
errMsg = result.Stdout
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
return NewTextResult(fmt.Sprintf("Failed to append to file in container '%s' (exit code %d): %s", dockerContainer, result.ExitCode, errMsg)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Failed to append to file (exit code %d): %s", result.ExitCode, errMsg)), nil
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"action": "append",
|
||||
"path": path,
|
||||
"host": targetHost,
|
||||
"bytes_written": len(content),
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
response["docker_container"] = dockerContainer
|
||||
}
|
||||
// Include execution provenance for observability
|
||||
response["execution"] = buildExecutionProvenance(targetHost, routing)
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
// executeFileWrite writes content to a file (overwrites)
|
||||
func (e *PulseToolExecutor) executeFileWrite(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
// Validate routing context - block if targeting a Proxmox host when child resources exist
|
||||
// This prevents accidentally writing files to the host when user meant to write to an LXC/VM
|
||||
routingResult := e.validateRoutingContext(targetHost)
|
||||
if routingResult.IsBlocked() {
|
||||
return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil
|
||||
}
|
||||
|
||||
// Validate resource is in resolved context (write operation)
|
||||
// With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources
|
||||
validation := e.validateResolvedResource(targetHost, "write", true)
|
||||
if validation.IsBlocked() {
|
||||
// Hard validation failure - return consistent error envelope
|
||||
return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil
|
||||
}
|
||||
// Soft validation warnings are logged inside validateResolvedResource
|
||||
|
||||
// Use full routing resolution - includes provenance for debugging
|
||||
routing := e.resolveTargetForCommandFull(targetHost)
|
||||
if routing.AgentID == "" {
|
||||
if routing.TargetType == "container" || routing.TargetType == "vm" {
|
||||
return NewTextResult(fmt.Sprintf("'%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", targetHost, routing.TargetType)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("No agent found for host '%s'. Check that the hostname is correct and an agent is connected.", targetHost)), nil
|
||||
}
|
||||
|
||||
// INVARIANT: If the target resolves to a child resource (LXC/VM), writes MUST execute
|
||||
// inside that context via pct_exec/qm_guest_exec. No silent node fallback.
|
||||
if err := e.validateWriteExecutionContext(targetHost, routing); err != nil {
|
||||
return NewToolResponseResult(err.ToToolResponse()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Skip approval checks if pre-approved or in autonomous mode
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
target := targetHost
|
||||
if dockerContainer != "" {
|
||||
target = fmt.Sprintf("%s (container: %s)", targetHost, dockerContainer)
|
||||
}
|
||||
approvalID := createApprovalRecord(
|
||||
fmt.Sprintf("Write file: %s", path),
|
||||
"file",
|
||||
path,
|
||||
target,
|
||||
fmt.Sprintf("Write %d bytes to %s", len(content), path),
|
||||
)
|
||||
return NewTextResult(formatFileApprovalNeeded(path, target, "write", len(content), approvalID)), nil
|
||||
}
|
||||
|
||||
// Use base64 encoding to safely transfer content
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
||||
var command string
|
||||
if dockerContainer != "" {
|
||||
// Write inside Docker container - docker exec needs its own sh -c
|
||||
command = fmt.Sprintf("docker exec %s sh -c 'echo %s | base64 -d > %s'",
|
||||
shellEscape(dockerContainer), encoded, shellEscape(path))
|
||||
} else {
|
||||
// For host/LXC/VM targets - agent handles sh -c wrapping for LXC/VM
|
||||
command = fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, shellEscape(path))
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: routing.TargetType,
|
||||
TargetID: routing.TargetID,
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to write file: %w", err)), nil
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
errMsg := result.Stderr
|
||||
if errMsg == "" {
|
||||
errMsg = result.Stdout
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
return NewTextResult(fmt.Sprintf("Failed to write file in container '%s' (exit code %d): %s", dockerContainer, result.ExitCode, errMsg)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Failed to write file (exit code %d): %s", result.ExitCode, errMsg)), nil
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"action": "write",
|
||||
"path": path,
|
||||
"host": targetHost,
|
||||
"bytes_written": len(content),
|
||||
}
|
||||
if dockerContainer != "" {
|
||||
response["docker_container"] = dockerContainer
|
||||
}
|
||||
// Include execution provenance for observability
|
||||
response["execution"] = buildExecutionProvenance(targetHost, routing)
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
// ErrExecutionContextUnavailable is returned when a write operation targets a child resource
|
||||
// (LXC/VM) but the execution cannot be properly routed into that resource context.
|
||||
// This prevents silent fallback to node-level execution, which would write files on the
|
||||
// Proxmox host instead of inside the LXC/VM.
|
||||
type ErrExecutionContextUnavailable struct {
|
||||
TargetHost string // What the model requested
|
||||
ResolvedKind string // What the state says it is (lxc, vm)
|
||||
ResolvedNode string // Which Proxmox node it's on
|
||||
Transport string // What transport we got (should be pct_exec but might be "direct")
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ErrExecutionContextUnavailable) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *ErrExecutionContextUnavailable) ToToolResponse() ToolResponse {
|
||||
return NewToolBlockedError("EXECUTION_CONTEXT_UNAVAILABLE", e.Message, map[string]interface{}{
|
||||
"target_host": e.TargetHost,
|
||||
"resolved_kind": e.ResolvedKind,
|
||||
"resolved_node": e.ResolvedNode,
|
||||
"transport": e.Transport,
|
||||
"auto_recoverable": false,
|
||||
"recovery_hint": "Cannot write files to this target. The execution context (LXC/VM) is not reachable via pct exec/qm guest exec. Verify the agent is installed on the Proxmox node and the target is running.",
|
||||
})
|
||||
}
|
||||
|
||||
// validateWriteExecutionContext ensures write operations execute inside the correct context.
|
||||
//
|
||||
// INVARIANT: If state.ResolveResource says the target is an LXC/VM, writes MUST use
|
||||
// pct_exec/qm_guest_exec to run inside that container. A "direct" transport on a child
|
||||
// resource means we'd write to the Proxmox host's filesystem instead — which is always wrong.
|
||||
//
|
||||
// This catches the scenario where:
|
||||
// 1. target_host="homepage-docker" (an LXC)
|
||||
// 2. An agent on the node matches "homepage-docker" as a direct hostname
|
||||
// 3. Command runs on the node without pct exec → writes to node filesystem
|
||||
func (e *PulseToolExecutor) validateWriteExecutionContext(targetHost string, routing CommandRoutingResult) *ErrExecutionContextUnavailable {
|
||||
if e.stateProvider == nil {
|
||||
return nil // Can't validate without state
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
loc := state.ResolveResource(targetHost)
|
||||
if !loc.Found {
|
||||
return nil // Unknown resource, nothing to validate
|
||||
}
|
||||
|
||||
// Only validate for child resources (LXC/VM)
|
||||
isChildResource := loc.ResourceType == "lxc" || loc.ResourceType == "vm"
|
||||
if !isChildResource {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For child resources, the routing MUST use pct_exec or qm_guest_exec
|
||||
// If it resolved as "direct" (host type), that means we'd execute on the node, not inside the LXC/VM
|
||||
if routing.Transport == "direct" && routing.TargetType == "host" {
|
||||
log.Warn().
|
||||
Str("target_host", targetHost).
|
||||
Str("resolved_kind", loc.ResourceType).
|
||||
Str("resolved_node", loc.Node).
|
||||
Str("agent_hostname", routing.AgentHostname).
|
||||
Str("transport", routing.Transport).
|
||||
Msg("[FileWrite] BLOCKED: Write would execute on node, not inside child resource. " +
|
||||
"Agent matched target hostname directly, but state says target is LXC/VM.")
|
||||
|
||||
return &ErrExecutionContextUnavailable{
|
||||
TargetHost: targetHost,
|
||||
ResolvedKind: loc.ResourceType,
|
||||
ResolvedNode: loc.Node,
|
||||
Transport: routing.Transport,
|
||||
Message: fmt.Sprintf(
|
||||
"'%s' is a %s on node '%s', but the write would execute on the Proxmox node instead of inside the %s. "+
|
||||
"This happens when an agent matches the hostname directly instead of routing via pct exec. "+
|
||||
"The file would be written to the node's filesystem, not the %s's filesystem.",
|
||||
targetHost, loc.ResourceType, loc.Node, loc.ResourceType, loc.ResourceType),
|
||||
}
|
||||
}
|
||||
|
||||
// Also validate: if resolved as LXC but no agent found for the node
|
||||
if routing.AgentID == "" {
|
||||
return &ErrExecutionContextUnavailable{
|
||||
TargetHost: targetHost,
|
||||
ResolvedKind: loc.ResourceType,
|
||||
ResolvedNode: loc.Node,
|
||||
Transport: "none",
|
||||
Message: fmt.Sprintf(
|
||||
"'%s' is a %s on node '%s', but no agent is available on that Proxmox node. "+
|
||||
"Install the Pulse Unified Agent on '%s' to enable file operations inside the %s.",
|
||||
targetHost, loc.ResourceType, loc.Node, loc.Node, loc.ResourceType),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildExecutionProvenance creates provenance metadata for tool responses.
|
||||
// This makes it observable WHERE a command actually executed.
|
||||
func buildExecutionProvenance(targetHost string, routing CommandRoutingResult) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"requested_target_host": targetHost,
|
||||
"resolved_kind": routing.ResolvedKind,
|
||||
"resolved_node": routing.ResolvedNode,
|
||||
"agent_host": routing.AgentHostname,
|
||||
"transport": routing.Transport,
|
||||
"target_type": routing.TargetType,
|
||||
"target_id": routing.TargetID,
|
||||
}
|
||||
}
|
||||
|
||||
// findAgentByHostname finds an agent ID by hostname
|
||||
func (e *PulseToolExecutor) findAgentByHostname(hostname string) string {
|
||||
if e.agentServer == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
agents := e.agentServer.GetConnectedAgents()
|
||||
hostnameLower := strings.ToLower(hostname)
|
||||
|
||||
for _, agent := range agents {
|
||||
// Match by hostname (case-insensitive) or by agentID (case-sensitive)
|
||||
if strings.ToLower(agent.Hostname) == hostnameLower || agent.AgentID == hostname {
|
||||
return agent.AgentID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shellEscape escapes a string for safe use in shell commands
|
||||
func shellEscape(s string) string {
|
||||
// Use single quotes and escape any existing single quotes
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
// formatFileApprovalNeeded formats an approval-required response for file operations
|
||||
func formatFileApprovalNeeded(path, host, action string, size int, approvalID string) string {
|
||||
return fmt.Sprintf(`APPROVAL_REQUIRED: {"type":"approval_required","approval_id":"%s","action":"file_%s","path":"%s","host":"%s","size":%d,"message":"File %s operation requires approval"}`,
|
||||
approvalID, action, path, host, size, action)
|
||||
}
|
||||
@@ -63,6 +63,35 @@ func (e *PulseToolExecutor) registerInfrastructureTools() {
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_storage_config",
|
||||
Description: `Get Proxmox storage configuration (cluster storage.cfg), including nodes, path, and enabled/active flags.
|
||||
|
||||
Use when: You need to confirm if a storage pool is configured for specific nodes or if it is disabled.`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"storage_id": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by storage ID (e.g. 'local-lvm')",
|
||||
},
|
||||
"instance": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter by Proxmox instance or cluster name",
|
||||
},
|
||||
"node": {
|
||||
Type: "string",
|
||||
Description: "Optional: filter to storages that include this node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeGetStorageConfig(ctx, args)
|
||||
},
|
||||
})
|
||||
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_get_disk_health",
|
||||
@@ -814,8 +843,14 @@ func (e *PulseToolExecutor) executeListStorage(_ context.Context, args map[strin
|
||||
pool := StoragePoolSummary{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Node: s.Node,
|
||||
Instance: s.Instance,
|
||||
Nodes: s.Nodes,
|
||||
Type: s.Type,
|
||||
Status: s.Status,
|
||||
Enabled: s.Enabled,
|
||||
Active: s.Active,
|
||||
Path: s.Path,
|
||||
UsagePercent: s.Usage * 100,
|
||||
UsedGB: float64(s.Used) / (1024 * 1024 * 1024),
|
||||
TotalGB: float64(s.Total) / (1024 * 1024 * 1024),
|
||||
@@ -867,6 +902,54 @@ func (e *PulseToolExecutor) executeListStorage(_ context.Context, args map[strin
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetStorageConfig(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
storageID, _ := args["storage_id"].(string)
|
||||
instance, _ := args["instance"].(string)
|
||||
node, _ := args["node"].(string)
|
||||
|
||||
storageID = strings.TrimSpace(storageID)
|
||||
instance = strings.TrimSpace(instance)
|
||||
node = strings.TrimSpace(node)
|
||||
|
||||
if e.storageConfigProvider == nil {
|
||||
return NewTextResult("Storage configuration not available."), nil
|
||||
}
|
||||
|
||||
configs, err := e.storageConfigProvider.GetStorageConfig(instance)
|
||||
if err != nil {
|
||||
return NewErrorResult(err), nil
|
||||
}
|
||||
|
||||
response := StorageConfigResponse{}
|
||||
for _, cfg := range configs {
|
||||
if storageID != "" && !strings.EqualFold(cfg.ID, storageID) && !strings.EqualFold(cfg.Name, storageID) {
|
||||
continue
|
||||
}
|
||||
if instance != "" && !strings.EqualFold(cfg.Instance, instance) {
|
||||
continue
|
||||
}
|
||||
if node != "" && !storageConfigHasNode(cfg.Nodes, node) {
|
||||
continue
|
||||
}
|
||||
response.Storages = append(response.Storages, cfg)
|
||||
}
|
||||
|
||||
if response.Storages == nil {
|
||||
response.Storages = []StorageConfigSummary{}
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func storageConfigHasNode(nodes []string, node string) bool {
|
||||
for _, n := range nodes {
|
||||
if strings.EqualFold(strings.TrimSpace(n), node) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetDiskHealth(_ context.Context, _ map[string]interface{}) (CallToolResult, error) {
|
||||
if e.diskHealthProvider == nil && e.storageProvider == nil {
|
||||
return NewTextResult("Disk health information not available."), nil
|
||||
|
||||
96
internal/ai/tools/tools_knowledge.go
Normal file
96
internal/ai/tools/tools_knowledge.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerKnowledgeTools registers the consolidated pulse_knowledge tool
|
||||
func (e *PulseToolExecutor) registerKnowledgeTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_knowledge",
|
||||
Description: `Manage AI knowledge, notes, and incident analysis.
|
||||
|
||||
Actions:
|
||||
- remember: Save a note about a resource for future reference
|
||||
- recall: Retrieve saved notes about a resource
|
||||
- incidents: Get high-resolution incident recording data
|
||||
- correlate: Get correlated events around a timestamp
|
||||
- relationships: Get resource dependency graph
|
||||
|
||||
Examples:
|
||||
- Save note: action="remember", resource_id="101", note="Production database server", category="purpose"
|
||||
- Recall: action="recall", resource_id="101"
|
||||
- Get incidents: action="incidents", resource_id="101"
|
||||
- Correlate events: action="correlate", resource_id="101", window_minutes=30
|
||||
- Get relationships: action="relationships", resource_id="101"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"action": {
|
||||
Type: "string",
|
||||
Description: "Knowledge action to perform",
|
||||
Enum: []string{"remember", "recall", "incidents", "correlate", "relationships"},
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Resource ID to operate on",
|
||||
},
|
||||
"note": {
|
||||
Type: "string",
|
||||
Description: "For remember: the note to save",
|
||||
},
|
||||
"category": {
|
||||
Type: "string",
|
||||
Description: "For remember/recall: note category (purpose, owner, maintenance, issue)",
|
||||
},
|
||||
"window_id": {
|
||||
Type: "string",
|
||||
Description: "For incidents: specific incident window ID",
|
||||
},
|
||||
"timestamp": {
|
||||
Type: "string",
|
||||
Description: "For correlate: ISO timestamp to center search around (default: now)",
|
||||
},
|
||||
"window_minutes": {
|
||||
Type: "integer",
|
||||
Description: "For correlate: time window in minutes (default: 15)",
|
||||
},
|
||||
"depth": {
|
||||
Type: "integer",
|
||||
Description: "For relationships: levels to traverse (default: 1, max: 3)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "For incidents: max windows to return (default: 5)",
|
||||
},
|
||||
},
|
||||
Required: []string{"action", "resource_id"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeKnowledge(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeKnowledge routes to the appropriate knowledge handler based on action
|
||||
// Handler functions are implemented in tools_intelligence.go
|
||||
func (e *PulseToolExecutor) executeKnowledge(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
action, _ := args["action"].(string)
|
||||
switch action {
|
||||
case "remember":
|
||||
return e.executeRemember(ctx, args)
|
||||
case "recall":
|
||||
return e.executeRecall(ctx, args)
|
||||
case "incidents":
|
||||
return e.executeGetIncidentWindow(ctx, args)
|
||||
case "correlate":
|
||||
return e.executeCorrelateEvents(ctx, args)
|
||||
case "relationships":
|
||||
return e.executeGetRelationshipGraph(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: remember, recall, incidents, correlate, relationships", action)), nil
|
||||
}
|
||||
}
|
||||
861
internal/ai/tools/tools_kubernetes.go
Normal file
861
internal/ai/tools/tools_kubernetes.go
Normal file
@@ -0,0 +1,861 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
// registerKubernetesTools registers the consolidated pulse_kubernetes tool
|
||||
func (e *PulseToolExecutor) registerKubernetesTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_kubernetes",
|
||||
Description: `Query and control Kubernetes clusters, nodes, pods, and deployments.
|
||||
|
||||
Query types:
|
||||
- clusters: List all Kubernetes clusters with health summary
|
||||
- nodes: List nodes in a cluster with capacity and status
|
||||
- pods: List pods with optional namespace/status filters
|
||||
- deployments: List deployments with replica status
|
||||
|
||||
Control types (require control permission):
|
||||
- scale: Scale a deployment (set replicas)
|
||||
- restart: Restart a deployment (rollout restart)
|
||||
- delete_pod: Delete a pod
|
||||
- exec: Execute a command inside a pod
|
||||
- logs: Get pod logs
|
||||
|
||||
Examples:
|
||||
- List clusters: type="clusters"
|
||||
- Get pods: type="pods", cluster="production", namespace="default"
|
||||
- Scale deployment: type="scale", cluster="production", deployment="nginx", namespace="default", replicas=3
|
||||
- Restart deployment: type="restart", cluster="production", deployment="nginx", namespace="default"
|
||||
- Delete pod: type="delete_pod", cluster="production", pod="nginx-abc123", namespace="default"
|
||||
- Exec in pod: type="exec", cluster="production", pod="nginx-abc123", namespace="default", command="cat /etc/nginx/nginx.conf"
|
||||
- Get logs: type="logs", cluster="production", pod="nginx-abc123", namespace="default", lines=100`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Operation type",
|
||||
Enum: []string{"clusters", "nodes", "pods", "deployments", "scale", "restart", "delete_pod", "exec", "logs"},
|
||||
},
|
||||
"cluster": {
|
||||
Type: "string",
|
||||
Description: "Cluster name or ID",
|
||||
},
|
||||
"namespace": {
|
||||
Type: "string",
|
||||
Description: "Kubernetes namespace (default: 'default')",
|
||||
},
|
||||
"deployment": {
|
||||
Type: "string",
|
||||
Description: "Deployment name (for scale, restart)",
|
||||
},
|
||||
"pod": {
|
||||
Type: "string",
|
||||
Description: "Pod name (for delete_pod, exec, logs)",
|
||||
},
|
||||
"container": {
|
||||
Type: "string",
|
||||
Description: "Container name (for exec, logs - uses first container if omitted)",
|
||||
},
|
||||
"command": {
|
||||
Type: "string",
|
||||
Description: "Command to execute (for exec)",
|
||||
},
|
||||
"replicas": {
|
||||
Type: "integer",
|
||||
Description: "Desired replica count (for scale)",
|
||||
},
|
||||
"lines": {
|
||||
Type: "integer",
|
||||
Description: "Number of log lines to return (for logs, default: 100)",
|
||||
},
|
||||
"status": {
|
||||
Type: "string",
|
||||
Description: "Filter by pod phase: Running, Pending, Failed, Succeeded (for pods)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Number of results to skip",
|
||||
},
|
||||
},
|
||||
Required: []string{"type"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeKubernetes(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeKubernetes routes to the appropriate kubernetes handler based on type
|
||||
func (e *PulseToolExecutor) executeKubernetes(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
resourceType, _ := args["type"].(string)
|
||||
switch resourceType {
|
||||
case "clusters":
|
||||
return e.executeGetKubernetesClusters(ctx)
|
||||
case "nodes":
|
||||
return e.executeGetKubernetesNodes(ctx, args)
|
||||
case "pods":
|
||||
return e.executeGetKubernetesPods(ctx, args)
|
||||
case "deployments":
|
||||
return e.executeGetKubernetesDeployments(ctx, args)
|
||||
// Control operations
|
||||
case "scale":
|
||||
return e.executeKubernetesScale(ctx, args)
|
||||
case "restart":
|
||||
return e.executeKubernetesRestart(ctx, args)
|
||||
case "delete_pod":
|
||||
return e.executeKubernetesDeletePod(ctx, args)
|
||||
case "exec":
|
||||
return e.executeKubernetesExec(ctx, args)
|
||||
case "logs":
|
||||
return e.executeKubernetesLogs(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown type: %s. Use: clusters, nodes, pods, deployments, scale, restart, delete_pod, exec, logs", resourceType)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetKubernetesClusters(_ context.Context) (CallToolResult, error) {
|
||||
if e.stateProvider == nil {
|
||||
return NewTextResult("State provider not available."), nil
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
if len(state.KubernetesClusters) == 0 {
|
||||
return NewTextResult("No Kubernetes clusters found. Kubernetes monitoring may not be configured."), nil
|
||||
}
|
||||
|
||||
var clusters []KubernetesClusterSummary
|
||||
for _, c := range state.KubernetesClusters {
|
||||
readyNodes := 0
|
||||
for _, node := range c.Nodes {
|
||||
if node.Ready {
|
||||
readyNodes++
|
||||
}
|
||||
}
|
||||
|
||||
displayName := c.DisplayName
|
||||
if c.CustomDisplayName != "" {
|
||||
displayName = c.CustomDisplayName
|
||||
}
|
||||
|
||||
clusters = append(clusters, KubernetesClusterSummary{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
DisplayName: displayName,
|
||||
Server: c.Server,
|
||||
Version: c.Version,
|
||||
Status: c.Status,
|
||||
NodeCount: len(c.Nodes),
|
||||
PodCount: len(c.Pods),
|
||||
DeploymentCount: len(c.Deployments),
|
||||
ReadyNodes: readyNodes,
|
||||
})
|
||||
}
|
||||
|
||||
response := KubernetesClustersResponse{
|
||||
Clusters: clusters,
|
||||
Total: len(clusters),
|
||||
}
|
||||
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetKubernetesNodes(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.stateProvider == nil {
|
||||
return NewTextResult("State provider not available."), nil
|
||||
}
|
||||
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
// Find the cluster (also match CustomDisplayName)
|
||||
var cluster *KubernetesClusterSummary
|
||||
for _, c := range state.KubernetesClusters {
|
||||
if c.ID == clusterArg || c.Name == clusterArg || c.DisplayName == clusterArg || c.CustomDisplayName == clusterArg {
|
||||
displayName := c.DisplayName
|
||||
if c.CustomDisplayName != "" {
|
||||
displayName = c.CustomDisplayName
|
||||
}
|
||||
cluster = &KubernetesClusterSummary{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
|
||||
var nodes []KubernetesNodeSummary
|
||||
for _, node := range c.Nodes {
|
||||
nodes = append(nodes, KubernetesNodeSummary{
|
||||
UID: node.UID,
|
||||
Name: node.Name,
|
||||
Ready: node.Ready,
|
||||
Unschedulable: node.Unschedulable,
|
||||
Roles: node.Roles,
|
||||
KubeletVersion: node.KubeletVersion,
|
||||
ContainerRuntimeVersion: node.ContainerRuntimeVersion,
|
||||
OSImage: node.OSImage,
|
||||
Architecture: node.Architecture,
|
||||
CapacityCPU: node.CapacityCPU,
|
||||
CapacityMemoryBytes: node.CapacityMemoryBytes,
|
||||
CapacityPods: node.CapacityPods,
|
||||
AllocatableCPU: node.AllocCPU,
|
||||
AllocatableMemoryBytes: node.AllocMemoryBytes,
|
||||
AllocatablePods: node.AllocPods,
|
||||
})
|
||||
}
|
||||
|
||||
response := KubernetesNodesResponse{
|
||||
Cluster: cluster.DisplayName,
|
||||
Nodes: nodes,
|
||||
Total: len(nodes),
|
||||
}
|
||||
if response.Nodes == nil {
|
||||
response.Nodes = []KubernetesNodeSummary{}
|
||||
}
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Kubernetes cluster '%s' not found.", clusterArg)), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetKubernetesPods(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.stateProvider == nil {
|
||||
return NewTextResult("State provider not available."), nil
|
||||
}
|
||||
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
|
||||
namespaceFilter, _ := args["namespace"].(string)
|
||||
statusFilter, _ := args["status"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
// Find the cluster (also match CustomDisplayName)
|
||||
for _, c := range state.KubernetesClusters {
|
||||
if c.ID == clusterArg || c.Name == clusterArg || c.DisplayName == clusterArg || c.CustomDisplayName == clusterArg {
|
||||
displayName := c.DisplayName
|
||||
if c.CustomDisplayName != "" {
|
||||
displayName = c.CustomDisplayName
|
||||
}
|
||||
|
||||
var pods []KubernetesPodSummary
|
||||
totalPods := 0
|
||||
filteredCount := 0
|
||||
|
||||
for _, pod := range c.Pods {
|
||||
// Apply filters
|
||||
if namespaceFilter != "" && pod.Namespace != namespaceFilter {
|
||||
continue
|
||||
}
|
||||
if statusFilter != "" && !strings.EqualFold(pod.Phase, statusFilter) {
|
||||
continue
|
||||
}
|
||||
|
||||
filteredCount++
|
||||
|
||||
// Apply pagination
|
||||
if totalPods < offset {
|
||||
totalPods++
|
||||
continue
|
||||
}
|
||||
if len(pods) >= limit {
|
||||
totalPods++
|
||||
continue
|
||||
}
|
||||
|
||||
var containers []KubernetesPodContainerSummary
|
||||
for _, container := range pod.Containers {
|
||||
containers = append(containers, KubernetesPodContainerSummary{
|
||||
Name: container.Name,
|
||||
Ready: container.Ready,
|
||||
State: container.State,
|
||||
RestartCount: container.RestartCount,
|
||||
Reason: container.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
pods = append(pods, KubernetesPodSummary{
|
||||
UID: pod.UID,
|
||||
Name: pod.Name,
|
||||
Namespace: pod.Namespace,
|
||||
NodeName: pod.NodeName,
|
||||
Phase: pod.Phase,
|
||||
Reason: pod.Reason,
|
||||
Restarts: pod.Restarts,
|
||||
QoSClass: pod.QoSClass,
|
||||
OwnerKind: pod.OwnerKind,
|
||||
OwnerName: pod.OwnerName,
|
||||
Containers: containers,
|
||||
})
|
||||
totalPods++
|
||||
}
|
||||
|
||||
response := KubernetesPodsResponse{
|
||||
Cluster: displayName,
|
||||
Pods: pods,
|
||||
Total: len(c.Pods),
|
||||
Filtered: filteredCount,
|
||||
}
|
||||
if response.Pods == nil {
|
||||
response.Pods = []KubernetesPodSummary{}
|
||||
}
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Kubernetes cluster '%s' not found.", clusterArg)), nil
|
||||
}
|
||||
|
||||
func (e *PulseToolExecutor) executeGetKubernetesDeployments(_ context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
if e.stateProvider == nil {
|
||||
return NewTextResult("State provider not available."), nil
|
||||
}
|
||||
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
|
||||
namespaceFilter, _ := args["namespace"].(string)
|
||||
limit := intArg(args, "limit", 100)
|
||||
offset := intArg(args, "offset", 0)
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
// Find the cluster (also match CustomDisplayName)
|
||||
for _, c := range state.KubernetesClusters {
|
||||
if c.ID == clusterArg || c.Name == clusterArg || c.DisplayName == clusterArg || c.CustomDisplayName == clusterArg {
|
||||
displayName := c.DisplayName
|
||||
if c.CustomDisplayName != "" {
|
||||
displayName = c.CustomDisplayName
|
||||
}
|
||||
|
||||
var deployments []KubernetesDeploymentSummary
|
||||
filteredCount := 0
|
||||
count := 0
|
||||
|
||||
for _, dep := range c.Deployments {
|
||||
// Apply namespace filter
|
||||
if namespaceFilter != "" && dep.Namespace != namespaceFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
filteredCount++
|
||||
|
||||
// Apply pagination
|
||||
if count < offset {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
if len(deployments) >= limit {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
|
||||
deployments = append(deployments, KubernetesDeploymentSummary{
|
||||
UID: dep.UID,
|
||||
Name: dep.Name,
|
||||
Namespace: dep.Namespace,
|
||||
DesiredReplicas: dep.DesiredReplicas,
|
||||
ReadyReplicas: dep.ReadyReplicas,
|
||||
AvailableReplicas: dep.AvailableReplicas,
|
||||
UpdatedReplicas: dep.UpdatedReplicas,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
response := KubernetesDeploymentsResponse{
|
||||
Cluster: displayName,
|
||||
Deployments: deployments,
|
||||
Total: len(c.Deployments),
|
||||
Filtered: filteredCount,
|
||||
}
|
||||
if response.Deployments == nil {
|
||||
response.Deployments = []KubernetesDeploymentSummary{}
|
||||
}
|
||||
return NewJSONResult(response), nil
|
||||
}
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("Kubernetes cluster '%s' not found.", clusterArg)), nil
|
||||
}
|
||||
|
||||
// ========== Kubernetes Control Operations ==========
|
||||
|
||||
// findAgentForKubernetesCluster finds the agent for a Kubernetes cluster
|
||||
func (e *PulseToolExecutor) findAgentForKubernetesCluster(clusterArg string) (string, *models.KubernetesCluster, error) {
|
||||
if e.stateProvider == nil {
|
||||
return "", nil, fmt.Errorf("state provider not available")
|
||||
}
|
||||
|
||||
state := e.stateProvider.GetState()
|
||||
|
||||
for i := range state.KubernetesClusters {
|
||||
c := &state.KubernetesClusters[i]
|
||||
if c.ID == clusterArg || c.Name == clusterArg || c.DisplayName == clusterArg || c.CustomDisplayName == clusterArg {
|
||||
if c.AgentID == "" {
|
||||
return "", nil, fmt.Errorf("cluster '%s' has no agent configured - kubectl commands cannot be executed", clusterArg)
|
||||
}
|
||||
return c.AgentID, c, nil
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("kubernetes cluster '%s' not found", clusterArg)
|
||||
}
|
||||
|
||||
// validateKubernetesResourceID validates a Kubernetes resource identifier (namespace, pod, deployment, container names)
|
||||
func validateKubernetesResourceID(value string) error {
|
||||
if value == "" {
|
||||
return fmt.Errorf("value cannot be empty")
|
||||
}
|
||||
// Kubernetes resource names must be valid DNS subdomains: lowercase, alphanumeric, '-' and '.'
|
||||
// Max 253 characters
|
||||
if len(value) > 253 {
|
||||
return fmt.Errorf("value too long (max 253 characters)")
|
||||
}
|
||||
for _, c := range value {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.') {
|
||||
return fmt.Errorf("invalid character '%c' in resource name", c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeKubernetesScale scales a deployment
|
||||
func (e *PulseToolExecutor) executeKubernetesScale(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
namespace, _ := args["namespace"].(string)
|
||||
deployment, _ := args["deployment"].(string)
|
||||
replicas := intArg(args, "replicas", -1)
|
||||
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
if deployment == "" {
|
||||
return NewErrorResult(fmt.Errorf("deployment is required")), nil
|
||||
}
|
||||
if replicas < 0 {
|
||||
return NewErrorResult(fmt.Errorf("replicas is required and must be >= 0")), nil
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if err := validateKubernetesResourceID(namespace); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil
|
||||
}
|
||||
if err := validateKubernetesResourceID(deployment); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid deployment: %w", err)), nil
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly {
|
||||
return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg)
|
||||
if err != nil {
|
||||
return NewTextResult(err.Error()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Build command
|
||||
command := fmt.Sprintf("kubectl -n %s scale deployment %s --replicas=%d", namespace, deployment, replicas)
|
||||
|
||||
// Request approval if needed
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
displayName := cluster.DisplayName
|
||||
if cluster.CustomDisplayName != "" {
|
||||
displayName = cluster.CustomDisplayName
|
||||
}
|
||||
approvalID := createApprovalRecord(command, "kubernetes", deployment, displayName, fmt.Sprintf("Scale deployment %s to %d replicas", deployment, replicas))
|
||||
return NewTextResult(formatKubernetesApprovalNeeded("scale", deployment, namespace, displayName, command, approvalID)), nil
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("✓ Successfully scaled deployment '%s' to %d replicas in namespace '%s'. Action complete - no verification needed.\n%s", deployment, replicas, namespace, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("kubectl command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// executeKubernetesRestart restarts a deployment via rollout restart
|
||||
func (e *PulseToolExecutor) executeKubernetesRestart(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
namespace, _ := args["namespace"].(string)
|
||||
deployment, _ := args["deployment"].(string)
|
||||
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
if deployment == "" {
|
||||
return NewErrorResult(fmt.Errorf("deployment is required")), nil
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if err := validateKubernetesResourceID(namespace); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil
|
||||
}
|
||||
if err := validateKubernetesResourceID(deployment); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid deployment: %w", err)), nil
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly {
|
||||
return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg)
|
||||
if err != nil {
|
||||
return NewTextResult(err.Error()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Build command
|
||||
command := fmt.Sprintf("kubectl -n %s rollout restart deployment/%s", namespace, deployment)
|
||||
|
||||
// Request approval if needed
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
displayName := cluster.DisplayName
|
||||
if cluster.CustomDisplayName != "" {
|
||||
displayName = cluster.CustomDisplayName
|
||||
}
|
||||
approvalID := createApprovalRecord(command, "kubernetes", deployment, displayName, fmt.Sprintf("Restart deployment %s", deployment))
|
||||
return NewTextResult(formatKubernetesApprovalNeeded("restart", deployment, namespace, displayName, command, approvalID)), nil
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("✓ Successfully initiated rollout restart for deployment '%s' in namespace '%s'. Action complete - pods will restart gradually.\n%s", deployment, namespace, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("kubectl command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// executeKubernetesDeletePod deletes a pod
|
||||
func (e *PulseToolExecutor) executeKubernetesDeletePod(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
namespace, _ := args["namespace"].(string)
|
||||
pod, _ := args["pod"].(string)
|
||||
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
if pod == "" {
|
||||
return NewErrorResult(fmt.Errorf("pod is required")), nil
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if err := validateKubernetesResourceID(namespace); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil
|
||||
}
|
||||
if err := validateKubernetesResourceID(pod); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid pod: %w", err)), nil
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly {
|
||||
return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg)
|
||||
if err != nil {
|
||||
return NewTextResult(err.Error()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Build command
|
||||
command := fmt.Sprintf("kubectl -n %s delete pod %s", namespace, pod)
|
||||
|
||||
// Request approval if needed
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
displayName := cluster.DisplayName
|
||||
if cluster.CustomDisplayName != "" {
|
||||
displayName = cluster.CustomDisplayName
|
||||
}
|
||||
approvalID := createApprovalRecord(command, "kubernetes", pod, displayName, fmt.Sprintf("Delete pod %s", pod))
|
||||
return NewTextResult(formatKubernetesApprovalNeeded("delete_pod", pod, namespace, displayName, command, approvalID)), nil
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
return NewTextResult(fmt.Sprintf("✓ Successfully deleted pod '%s' in namespace '%s'. If managed by a controller, a new pod will be created.\n%s", pod, namespace, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("kubectl command failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// executeKubernetesExec executes a command inside a pod
|
||||
func (e *PulseToolExecutor) executeKubernetesExec(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
namespace, _ := args["namespace"].(string)
|
||||
pod, _ := args["pod"].(string)
|
||||
container, _ := args["container"].(string)
|
||||
command, _ := args["command"].(string)
|
||||
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
if pod == "" {
|
||||
return NewErrorResult(fmt.Errorf("pod is required")), nil
|
||||
}
|
||||
if command == "" {
|
||||
return NewErrorResult(fmt.Errorf("command is required")), nil
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if err := validateKubernetesResourceID(namespace); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil
|
||||
}
|
||||
if err := validateKubernetesResourceID(pod); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid pod: %w", err)), nil
|
||||
}
|
||||
if container != "" {
|
||||
if err := validateKubernetesResourceID(container); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid container: %w", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check control level
|
||||
if e.controlLevel == ControlLevelReadOnly {
|
||||
return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil
|
||||
}
|
||||
|
||||
agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg)
|
||||
if err != nil {
|
||||
return NewTextResult(err.Error()), nil
|
||||
}
|
||||
|
||||
// Check if pre-approved
|
||||
preApproved := isPreApproved(args)
|
||||
|
||||
// Build kubectl command
|
||||
var kubectlCmd string
|
||||
if container != "" {
|
||||
kubectlCmd = fmt.Sprintf("kubectl -n %s exec %s -c %s -- %s", namespace, pod, container, command)
|
||||
} else {
|
||||
kubectlCmd = fmt.Sprintf("kubectl -n %s exec %s -- %s", namespace, pod, command)
|
||||
}
|
||||
|
||||
// Request approval if needed
|
||||
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
|
||||
displayName := cluster.DisplayName
|
||||
if cluster.CustomDisplayName != "" {
|
||||
displayName = cluster.CustomDisplayName
|
||||
}
|
||||
approvalID := createApprovalRecord(kubectlCmd, "kubernetes", pod, displayName, fmt.Sprintf("Execute command in pod %s", pod))
|
||||
return NewTextResult(formatKubernetesApprovalNeeded("exec", pod, namespace, displayName, kubectlCmd, approvalID)), nil
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: kubectlCmd,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
// Always show output explicitly to prevent LLM hallucination
|
||||
if result.ExitCode == 0 {
|
||||
if output == "" {
|
||||
return NewTextResult(fmt.Sprintf("Command executed in pod '%s' (exit code 0).\n\nOutput:\n(no output)", pod)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Command executed in pod '%s' (exit code 0).\n\nOutput:\n%s", pod, output)), nil
|
||||
}
|
||||
|
||||
if output == "" {
|
||||
return NewTextResult(fmt.Sprintf("Command in pod '%s' exited with code %d.\n\nOutput:\n(no output)", pod, result.ExitCode)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Command in pod '%s' exited with code %d.\n\nOutput:\n%s", pod, result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// executeKubernetesLogs retrieves pod logs
|
||||
func (e *PulseToolExecutor) executeKubernetesLogs(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
clusterArg, _ := args["cluster"].(string)
|
||||
namespace, _ := args["namespace"].(string)
|
||||
pod, _ := args["pod"].(string)
|
||||
container, _ := args["container"].(string)
|
||||
lines := intArg(args, "lines", 100)
|
||||
|
||||
if clusterArg == "" {
|
||||
return NewErrorResult(fmt.Errorf("cluster is required")), nil
|
||||
}
|
||||
if pod == "" {
|
||||
return NewErrorResult(fmt.Errorf("pod is required")), nil
|
||||
}
|
||||
if namespace == "" {
|
||||
namespace = "default"
|
||||
}
|
||||
|
||||
// Validate identifiers
|
||||
if err := validateKubernetesResourceID(namespace); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil
|
||||
}
|
||||
if err := validateKubernetesResourceID(pod); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid pod: %w", err)), nil
|
||||
}
|
||||
if container != "" {
|
||||
if err := validateKubernetesResourceID(container); err != nil {
|
||||
return NewErrorResult(fmt.Errorf("invalid container: %w", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Logs is a read operation, but still requires a connected agent
|
||||
agentID, _, err := e.findAgentForKubernetesCluster(clusterArg)
|
||||
if err != nil {
|
||||
return NewTextResult(err.Error()), nil
|
||||
}
|
||||
|
||||
// Build kubectl command - logs is read-only so no approval needed
|
||||
var kubectlCmd string
|
||||
if container != "" {
|
||||
kubectlCmd = fmt.Sprintf("kubectl -n %s logs %s -c %s --tail=%d", namespace, pod, container, lines)
|
||||
} else {
|
||||
kubectlCmd = fmt.Sprintf("kubectl -n %s logs %s --tail=%d", namespace, pod, lines)
|
||||
}
|
||||
|
||||
if e.agentServer == nil {
|
||||
return NewErrorResult(fmt.Errorf("no agent server available")), nil
|
||||
}
|
||||
|
||||
result, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: kubectlCmd,
|
||||
TargetType: "host",
|
||||
TargetID: "",
|
||||
})
|
||||
if err != nil {
|
||||
return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil
|
||||
}
|
||||
|
||||
output := result.Stdout
|
||||
if result.Stderr != "" && result.ExitCode != 0 {
|
||||
output += "\n" + result.Stderr
|
||||
}
|
||||
|
||||
if result.ExitCode == 0 {
|
||||
if output == "" {
|
||||
return NewTextResult(fmt.Sprintf("No logs found for pod '%s' in namespace '%s'", pod, namespace)), nil
|
||||
}
|
||||
return NewTextResult(fmt.Sprintf("Logs from pod '%s' (last %d lines):\n%s", pod, lines, output)), nil
|
||||
}
|
||||
|
||||
return NewTextResult(fmt.Sprintf("kubectl logs failed (exit code %d):\n%s", result.ExitCode, output)), nil
|
||||
}
|
||||
|
||||
// formatKubernetesApprovalNeeded formats an approval-required response for Kubernetes operations
|
||||
func formatKubernetesApprovalNeeded(action, resource, namespace, cluster, command, approvalID string) string {
|
||||
payload := map[string]interface{}{
|
||||
"type": "approval_required",
|
||||
"approval_id": approvalID,
|
||||
"action": action,
|
||||
"resource": resource,
|
||||
"namespace": namespace,
|
||||
"cluster": cluster,
|
||||
"command": command,
|
||||
"how_to_approve": "Click the approval button in the chat to execute this action.",
|
||||
"do_not_retry": true,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return "APPROVAL_REQUIRED: " + string(b)
|
||||
}
|
||||
110
internal/ai/tools/tools_metrics.go
Normal file
110
internal/ai/tools/tools_metrics.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerMetricsTools registers the consolidated pulse_metrics tool
|
||||
func (e *PulseToolExecutor) registerMetricsTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_metrics",
|
||||
Description: `Get performance metrics, baselines, and sensor data.
|
||||
|
||||
Types:
|
||||
- performance: Historical CPU/memory/disk metrics over 24h or 7d
|
||||
- temperatures: CPU, disk, and sensor temperatures from hosts
|
||||
- network: Network interface statistics (rx/tx bytes, speed)
|
||||
- diskio: Disk I/O statistics (read/write bytes, ops)
|
||||
- disks: Physical disk health (SMART, wearout, temperatures)
|
||||
- baselines: Learned normal behavior baselines for resources
|
||||
- patterns: Detected operational patterns and predictions
|
||||
|
||||
Examples:
|
||||
- Get 24h metrics: type="performance", period="24h"
|
||||
- Get VM metrics: type="performance", resource_id="101"
|
||||
- Get host temps: type="temperatures", host="pve01"
|
||||
- Get disk health: type="disks", node="pve01"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Metric type to query",
|
||||
Enum: []string{"performance", "temperatures", "network", "diskio", "disks", "baselines", "patterns"},
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by specific resource ID (for performance, baselines)",
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Filter by resource type: vm, container, node (for performance, baselines)",
|
||||
},
|
||||
"host": {
|
||||
Type: "string",
|
||||
Description: "Filter by hostname (for temperatures, network, diskio)",
|
||||
},
|
||||
"node": {
|
||||
Type: "string",
|
||||
Description: "Filter by Proxmox node (for disks)",
|
||||
},
|
||||
"instance": {
|
||||
Type: "string",
|
||||
Description: "Filter by Proxmox instance (for disks)",
|
||||
},
|
||||
"period": {
|
||||
Type: "string",
|
||||
Description: "Time period for performance: 24h or 7d (default: 24h)",
|
||||
Enum: []string{"24h", "7d"},
|
||||
},
|
||||
"health": {
|
||||
Type: "string",
|
||||
Description: "Filter disks by health status: PASSED, FAILED, UNKNOWN",
|
||||
},
|
||||
"disk_type": {
|
||||
Type: "string",
|
||||
Description: "Filter disks by type: nvme, sata, sas",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Number of results to skip",
|
||||
},
|
||||
},
|
||||
Required: []string{"type"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeMetrics(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeMetrics routes to the appropriate metrics handler based on type
|
||||
// All handler functions are implemented in tools_patrol.go and tools_infrastructure.go
|
||||
func (e *PulseToolExecutor) executeMetrics(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
metricType, _ := args["type"].(string)
|
||||
switch metricType {
|
||||
case "performance":
|
||||
return e.executeGetMetrics(ctx, args)
|
||||
case "temperatures":
|
||||
return e.executeGetTemperatures(ctx, args)
|
||||
case "network":
|
||||
return e.executeGetNetworkStats(ctx, args)
|
||||
case "diskio":
|
||||
return e.executeGetDiskIOStats(ctx, args)
|
||||
case "disks":
|
||||
return e.executeListPhysicalDisks(ctx, args)
|
||||
case "baselines":
|
||||
return e.executeGetBaselines(ctx, args)
|
||||
case "patterns":
|
||||
return e.executeGetPatterns(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown type: %s. Use: performance, temperatures, network, diskio, disks, baselines, patterns", metricType)), nil
|
||||
}
|
||||
}
|
||||
64
internal/ai/tools/tools_pmg_consolidated.go
Normal file
64
internal/ai/tools/tools_pmg_consolidated.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerPMGToolsConsolidated registers the consolidated pulse_pmg tool
|
||||
func (e *PulseToolExecutor) registerPMGToolsConsolidated() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_pmg",
|
||||
Description: `Query Proxmox Mail Gateway status and statistics.
|
||||
|
||||
Types:
|
||||
- status: Instance status and health (nodes, uptime, load)
|
||||
- mail_stats: Mail flow statistics (counts, spam, virus, bounces)
|
||||
- queues: Mail queue status (active, deferred, hold)
|
||||
- spam: Spam quarantine statistics and score distribution
|
||||
|
||||
Examples:
|
||||
- Get status: type="status"
|
||||
- Get specific instance: type="status", instance="pmg01"
|
||||
- Get mail stats: type="mail_stats"
|
||||
- Get queue status: type="queues"
|
||||
- Get spam stats: type="spam"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "PMG query type",
|
||||
Enum: []string{"status", "mail_stats", "queues", "spam"},
|
||||
},
|
||||
"instance": {
|
||||
Type: "string",
|
||||
Description: "Optional: specific PMG instance name or ID",
|
||||
},
|
||||
},
|
||||
Required: []string{"type"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executePMG(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executePMG routes to the appropriate PMG handler based on type
|
||||
func (e *PulseToolExecutor) executePMG(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
pmgType, _ := args["type"].(string)
|
||||
switch pmgType {
|
||||
case "status":
|
||||
return e.executeGetPMGStatus(ctx, args)
|
||||
case "mail_stats":
|
||||
return e.executeGetMailStats(ctx, args)
|
||||
case "queues":
|
||||
return e.executeGetMailQueues(ctx, args)
|
||||
case "spam":
|
||||
return e.executeGetSpamStats(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown type: %s. Use: status, mail_stats, queues, spam", pmgType)), nil
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,80 +3,12 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
func TestExecuteGetCapabilities(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{},
|
||||
AgentServer: &mockAgentServer{
|
||||
agents: []agentexec.ConnectedAgent{
|
||||
{Hostname: "host1", Version: "1.0", Platform: "linux"},
|
||||
},
|
||||
},
|
||||
MetricsHistory: &mockMetricsHistoryProvider{},
|
||||
BaselineProvider: &BaselineMCPAdapter{},
|
||||
PatternProvider: &PatternMCPAdapter{},
|
||||
AlertProvider: &mockAlertProvider{},
|
||||
FindingsProvider: &mockFindingsProvider{},
|
||||
ControlLevel: ControlLevelControlled,
|
||||
ProtectedGuests: []string{"100"},
|
||||
})
|
||||
|
||||
result, err := executor.executeGetCapabilities(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var response CapabilitiesResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.ControlLevel != string(ControlLevelControlled) || response.ConnectedAgents != 1 {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
if !response.Features.Control || !response.Features.MetricsHistory {
|
||||
t.Fatalf("unexpected features: %+v", response.Features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetURLContent(t *testing.T) {
|
||||
t.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "ok")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("hello"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
||||
|
||||
if result, _ := executor.executeGetURLContent(context.Background(), map[string]interface{}{}); !result.IsError {
|
||||
t.Fatal("expected error when url missing")
|
||||
}
|
||||
|
||||
result, err := executor.executeGetURLContent(context.Background(), map[string]interface{}{
|
||||
"url": server.URL,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var response URLFetchResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || response.Headers["X-Test"] != "ok" {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteListInfrastructureAndTopology(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}},
|
||||
@@ -261,34 +193,7 @@ func TestExecuteSearchResources_Errors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetResourceURLAndGetResource(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
||||
|
||||
if result, _ := executor.executeSetResourceURL(context.Background(), map[string]interface{}{}); !result.IsError {
|
||||
t.Fatal("expected error when resource_type missing")
|
||||
}
|
||||
|
||||
updater := &fakeMetadataUpdater{}
|
||||
executor.metadataUpdater = updater
|
||||
result, err := executor.executeSetResourceURL(context.Background(), map[string]interface{}{
|
||||
"resource_type": "guest",
|
||||
"resource_id": "100",
|
||||
"url": "http://example",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(updater.resourceArgs) != 3 || updater.resourceArgs[2] != "http://example" {
|
||||
t.Fatalf("unexpected updater args: %+v", updater.resourceArgs)
|
||||
}
|
||||
var setResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &setResp); err != nil {
|
||||
t.Fatalf("decode set response: %v", err)
|
||||
}
|
||||
if setResp["action"] != "set" {
|
||||
t.Fatalf("unexpected set response: %+v", setResp)
|
||||
}
|
||||
|
||||
func TestExecuteGetResource(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
VMs: []models.VM{{ID: "vm1", VMID: 100, Name: "vm1", Status: "running", Node: "node1"}},
|
||||
Containers: []models.Container{{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1"}},
|
||||
@@ -302,7 +207,9 @@ func TestExecuteSetResourceURLAndGetResource(t *testing.T) {
|
||||
}},
|
||||
}},
|
||||
}
|
||||
executor.stateProvider = &mockStateProvider{state: state}
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{
|
||||
StateProvider: &mockStateProvider{state: state},
|
||||
})
|
||||
|
||||
resource, _ := executor.executeGetResource(context.Background(), map[string]interface{}{
|
||||
"resource_type": "vm",
|
||||
@@ -379,32 +286,6 @@ func TestExecuteGetResource_DockerDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSetResourceURL_ClearAndMissingUpdater(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{})
|
||||
result, _ := executor.executeSetResourceURL(context.Background(), map[string]interface{}{
|
||||
"resource_type": "vm",
|
||||
"resource_id": "100",
|
||||
})
|
||||
if result.Content[0].Text != "Metadata updater not available." {
|
||||
t.Fatalf("unexpected response: %s", result.Content[0].Text)
|
||||
}
|
||||
|
||||
updater := &fakeMetadataUpdater{}
|
||||
executor.metadataUpdater = updater
|
||||
result, _ = executor.executeSetResourceURL(context.Background(), map[string]interface{}{
|
||||
"resource_type": "vm",
|
||||
"resource_id": "100",
|
||||
"url": "",
|
||||
})
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp["action"] != "cleared" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntArg(t *testing.T) {
|
||||
if got := intArg(map[string]interface{}{}, "limit", 10); got != 10 {
|
||||
t.Fatalf("unexpected default: %d", got)
|
||||
@@ -414,95 +295,6 @@ func TestIntArg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateFetchURL(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), ""); err == nil {
|
||||
t.Fatal("expected error for empty URL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidURL", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "http://%"); err == nil {
|
||||
t.Fatal("expected error for invalid URL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotAbsolute", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "example.com"); err == nil {
|
||||
t.Fatal("expected error for relative URL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BadScheme", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "ftp://example.com"); err == nil {
|
||||
t.Fatal("expected error for scheme")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Credentials", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "http://user:pass@example.com"); err == nil {
|
||||
t.Fatal("expected error for credentials")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Fragment", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "https://example.com/#frag"); err == nil {
|
||||
t.Fatal("expected error for fragment")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MissingHost", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "http:///"); err == nil {
|
||||
t.Fatal("expected error for missing host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BlockedHost", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "http://localhost"); err == nil {
|
||||
t.Fatal("expected error for blocked host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BlockedIP", func(t *testing.T) {
|
||||
if _, err := parseAndValidateFetchURL(context.Background(), "http://127.0.0.1"); err == nil {
|
||||
t.Fatal("expected error for blocked IP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowLoopback", func(t *testing.T) {
|
||||
t.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true")
|
||||
parsed, err := parseAndValidateFetchURL(context.Background(), "http://127.0.0.1:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if parsed.Hostname() != "127.0.0.1" {
|
||||
t.Fatalf("unexpected host: %s", parsed.Hostname())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsBlockedFetchIP(t *testing.T) {
|
||||
if !isBlockedFetchIP(nil) {
|
||||
t.Fatal("expected nil IP to be blocked")
|
||||
}
|
||||
if !isBlockedFetchIP(net.ParseIP("0.0.0.0")) {
|
||||
t.Fatal("expected unspecified IP to be blocked")
|
||||
}
|
||||
if !isBlockedFetchIP(net.ParseIP("169.254.1.1")) {
|
||||
t.Fatal("expected link-local IP to be blocked")
|
||||
}
|
||||
if isBlockedFetchIP(net.ParseIP("8.8.8.8")) {
|
||||
t.Fatal("expected global IP to be allowed")
|
||||
}
|
||||
|
||||
t.Run("LoopbackAllowed", func(t *testing.T) {
|
||||
t.Setenv("PULSE_AI_ALLOW_LOOPBACK", "true")
|
||||
if isBlockedFetchIP(net.ParseIP("127.0.0.1")) {
|
||||
t.Fatal("expected loopback IP to be allowed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteListInfrastructurePaginationAndDockerFilter(t *testing.T) {
|
||||
state := models.StateSnapshot{
|
||||
Nodes: []models.Node{
|
||||
@@ -645,20 +437,3 @@ func TestExecuteGetResource_MissingArgs(t *testing.T) {
|
||||
t.Fatal("expected error for missing resource_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGetURLContent_InvalidURL(t *testing.T) {
|
||||
executor := NewPulseToolExecutor(ExecutorConfig{})
|
||||
result, err := executor.executeGetURLContent(context.Background(), map[string]interface{}{
|
||||
"url": "ftp://example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var response URLFetchResponse
|
||||
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Error == "" {
|
||||
t.Fatalf("expected error response: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
146
internal/ai/tools/tools_storage.go
Normal file
146
internal/ai/tools/tools_storage.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registerStorageTools registers the consolidated pulse_storage tool
|
||||
func (e *PulseToolExecutor) registerStorageTools() {
|
||||
e.registry.Register(RegisteredTool{
|
||||
Definition: Tool{
|
||||
Name: "pulse_storage",
|
||||
Description: `Query storage pools, backups, snapshots, Ceph, and replication.
|
||||
|
||||
Types:
|
||||
- pools: Storage pool usage and health (ZFS, Ceph, LVM, etc.)
|
||||
- config: Proxmox storage.cfg configuration
|
||||
- backups: Backup status for VMs/containers (PBS and PVE)
|
||||
- backup_tasks: Recent backup task history
|
||||
- snapshots: VM/container snapshots
|
||||
- ceph: Ceph cluster status from Proxmox API
|
||||
- ceph_details: Detailed Ceph status from host agents
|
||||
- replication: Proxmox replication job status
|
||||
- pbs_jobs: PBS backup, sync, verify, prune jobs
|
||||
- raid: Host RAID array status
|
||||
- disk_health: SMART and RAID health from agents
|
||||
- resource_disks: VM/container filesystem usage
|
||||
|
||||
Examples:
|
||||
- List storage pools: type="pools"
|
||||
- Get specific storage: type="pools", storage_id="local-lvm"
|
||||
- Get backups for VM: type="backups", resource_id="101"
|
||||
- Get Ceph status: type="ceph"
|
||||
- Get replication jobs: type="replication"`,
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"type": {
|
||||
Type: "string",
|
||||
Description: "Storage type to query",
|
||||
Enum: []string{"pools", "config", "backups", "backup_tasks", "snapshots", "ceph", "ceph_details", "replication", "pbs_jobs", "raid", "disk_health", "resource_disks"},
|
||||
},
|
||||
"storage_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by storage ID (for pools, config)",
|
||||
},
|
||||
"resource_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by VM/container ID (for backups, snapshots, resource_disks)",
|
||||
},
|
||||
"guest_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by guest ID (for snapshots, backup_tasks)",
|
||||
},
|
||||
"vm_id": {
|
||||
Type: "string",
|
||||
Description: "Filter by VM ID (for replication)",
|
||||
},
|
||||
"instance": {
|
||||
Type: "string",
|
||||
Description: "Filter by Proxmox/PBS instance",
|
||||
},
|
||||
"node": {
|
||||
Type: "string",
|
||||
Description: "Filter by node name",
|
||||
},
|
||||
"host": {
|
||||
Type: "string",
|
||||
Description: "Filter by host (for raid, ceph_details)",
|
||||
},
|
||||
"cluster": {
|
||||
Type: "string",
|
||||
Description: "Filter by Ceph cluster name",
|
||||
},
|
||||
"job_type": {
|
||||
Type: "string",
|
||||
Description: "Filter PBS jobs by type: backup, sync, verify, prune, garbage",
|
||||
Enum: []string{"backup", "sync", "verify", "prune", "garbage"},
|
||||
},
|
||||
"state": {
|
||||
Type: "string",
|
||||
Description: "Filter RAID arrays by state: clean, degraded, rebuilding",
|
||||
},
|
||||
"status": {
|
||||
Type: "string",
|
||||
Description: "Filter backup tasks by status: ok, error",
|
||||
},
|
||||
"resource_type": {
|
||||
Type: "string",
|
||||
Description: "Filter by type: vm or lxc (for resource_disks)",
|
||||
},
|
||||
"min_usage": {
|
||||
Type: "number",
|
||||
Description: "Only show resources with disk usage above this percentage (for resource_disks)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 100)",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Number of results to skip",
|
||||
},
|
||||
},
|
||||
Required: []string{"type"},
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
||||
return exec.executeStorage(ctx, args)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeStorage routes to the appropriate storage handler based on type
|
||||
// All handler functions are implemented in tools_infrastructure.go
|
||||
func (e *PulseToolExecutor) executeStorage(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
storageType, _ := args["type"].(string)
|
||||
switch storageType {
|
||||
case "pools":
|
||||
return e.executeListStorage(ctx, args)
|
||||
case "config":
|
||||
return e.executeGetStorageConfig(ctx, args)
|
||||
case "backups":
|
||||
return e.executeListBackups(ctx, args)
|
||||
case "backup_tasks":
|
||||
return e.executeListBackupTasks(ctx, args)
|
||||
case "snapshots":
|
||||
return e.executeListSnapshots(ctx, args)
|
||||
case "ceph":
|
||||
return e.executeGetCephStatus(ctx, args)
|
||||
case "ceph_details":
|
||||
return e.executeGetHostCephDetails(ctx, args)
|
||||
case "replication":
|
||||
return e.executeGetReplication(ctx, args)
|
||||
case "pbs_jobs":
|
||||
return e.executeListPBSJobs(ctx, args)
|
||||
case "raid":
|
||||
return e.executeGetHostRAIDStatus(ctx, args)
|
||||
case "disk_health":
|
||||
return e.executeGetDiskHealth(ctx, args)
|
||||
case "resource_disks":
|
||||
return e.executeGetResourceDisks(ctx, args)
|
||||
default:
|
||||
return NewErrorResult(fmt.Errorf("unknown type: %s. Use: pools, config, backups, backup_tasks, snapshots, ceph, ceph_details, replication, pbs_jobs, raid, disk_health, resource_disks", storageType)), nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user