Pulse/internal/ai/tools/file_docker_test.go
rcourtman 0013d64c7b 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
2026-01-28 16:50:25 +00:00

427 lines
16 KiB
Go

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)
})
}