mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-29 03:50:18 +00:00
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
439 lines
14 KiB
Go
439 lines
14 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
func TestExecuteListInfrastructureAndTopology(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}},
|
|
VMs: []models.VM{
|
|
{Name: "vm1", VMID: 100, Status: "running", Node: "node1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{Name: "ct1", VMID: 200, Status: "stopped", Node: "node1"},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "host1",
|
|
Hostname: "h1",
|
|
DisplayName: "Host 1",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "c1", Name: "nginx", State: "running", Image: "nginx"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
AgentServer: &mockAgentServer{
|
|
agents: []agentexec.ConnectedAgent{{Hostname: "node1"}},
|
|
},
|
|
ControlLevel: ControlLevelControlled,
|
|
})
|
|
|
|
result, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "vms",
|
|
"status": "running",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var infra InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &infra); err != nil {
|
|
t.Fatalf("decode infra: %v", err)
|
|
}
|
|
if len(infra.VMs) != 1 || infra.VMs[0].Name != "vm1" {
|
|
t.Fatalf("unexpected infra response: %+v", infra)
|
|
}
|
|
|
|
// Topology includes derived node for VM reference if missing
|
|
state.Nodes = nil
|
|
executor.stateProvider = &mockStateProvider{state: state}
|
|
topologyResult, err := executor.executeGetTopology(context.Background(), map[string]interface{}{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var topology TopologyResponse
|
|
if err := json.Unmarshal([]byte(topologyResult.Content[0].Text), &topology); err != nil {
|
|
t.Fatalf("decode topology: %v", err)
|
|
}
|
|
if topology.Summary.TotalVMs != 1 || len(topology.Proxmox.Nodes) == 0 {
|
|
t.Fatalf("unexpected topology: %+v", topology)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetTopologySummaryOnly(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}},
|
|
VMs: []models.VM{
|
|
{Name: "vm1", VMID: 100, Status: "running", Node: "node1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{Name: "ct1", VMID: 200, Status: "stopped", Node: "node1"},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
Hostname: "host1",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "c1", Name: "nginx", State: "running", Image: "nginx"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeGetTopology(context.Background(), map[string]interface{}{
|
|
"summary_only": true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var topology TopologyResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &topology); err != nil {
|
|
t.Fatalf("decode topology: %v", err)
|
|
}
|
|
if len(topology.Proxmox.Nodes) != 0 {
|
|
t.Fatalf("expected no proxmox nodes, got: %+v", topology.Proxmox.Nodes)
|
|
}
|
|
if len(topology.Docker.Hosts) != 0 {
|
|
t.Fatalf("expected no docker hosts, got: %+v", topology.Docker.Hosts)
|
|
}
|
|
if topology.Summary.TotalVMs != 1 || topology.Summary.TotalDockerHosts != 1 || topology.Summary.TotalDockerContainers != 1 {
|
|
t.Fatalf("unexpected summary: %+v", topology.Summary)
|
|
}
|
|
if topology.Summary.RunningVMs != 1 || topology.Summary.RunningDocker != 1 {
|
|
t.Fatalf("unexpected running summary: %+v", topology.Summary)
|
|
}
|
|
}
|
|
|
|
func TestExecuteSearchResources(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "node1", Name: "node1", Status: "online"}},
|
|
VMs: []models.VM{
|
|
{ID: "vm1", VMID: 100, Name: "web-vm", Status: "running", Node: "node1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "ct1", VMID: 200, Name: "db-ct", Status: "stopped", Node: "node1"},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "host1",
|
|
Hostname: "dock1",
|
|
DisplayName: "Dock 1",
|
|
Status: "online",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "c1", Name: "nginx", State: "running", Image: "nginx:latest"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "nginx",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var response ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(response.Matches) != 1 || response.Matches[0].Type != "docker" || response.Matches[0].Name != "nginx" {
|
|
t.Fatalf("unexpected search response: %+v", response)
|
|
}
|
|
|
|
result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "web",
|
|
"type": "vm",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
response = ResourceSearchResponse{}
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(response.Matches) != 1 || response.Matches[0].Type != "vm" || response.Matches[0].Name != "web-vm" {
|
|
t.Fatalf("unexpected search response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestExecuteSearchResources_Errors(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
|
})
|
|
|
|
result, _ := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for empty query")
|
|
}
|
|
|
|
result, _ = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "node",
|
|
"type": "bad",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for invalid type")
|
|
}
|
|
}
|
|
|
|
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"}},
|
|
DockerHosts: []models.DockerHost{{
|
|
Hostname: "host",
|
|
Containers: []models.DockerContainer{{
|
|
ID: "abc123",
|
|
Name: "nginx",
|
|
State: "running",
|
|
Image: "nginx",
|
|
}},
|
|
}},
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
resource, _ := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
"resource_id": "100",
|
|
})
|
|
var res ResourceResponse
|
|
if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil {
|
|
t.Fatalf("decode resource: %v", err)
|
|
}
|
|
if res.Type != "vm" || res.Name != "vm1" {
|
|
t.Fatalf("unexpected resource: %+v", res)
|
|
}
|
|
|
|
resource, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "docker",
|
|
"resource_id": "abc",
|
|
})
|
|
if err := json.Unmarshal([]byte(resource.Content[0].Text), &res); err != nil {
|
|
t.Fatalf("decode docker resource: %v", err)
|
|
}
|
|
if res.Type != "docker" || res.Name != "nginx" {
|
|
t.Fatalf("unexpected docker resource: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_DockerDetails(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
DockerHosts: []models.DockerHost{{
|
|
Hostname: "dock1",
|
|
Containers: []models.DockerContainer{{
|
|
ID: "abcd1234",
|
|
Name: "web",
|
|
State: "running",
|
|
Image: "nginx:latest",
|
|
Health: "healthy",
|
|
CPUPercent: 1.2,
|
|
MemoryPercent: 3.4,
|
|
MemoryUsage: 1024,
|
|
MemoryLimit: 2048,
|
|
RestartCount: 2,
|
|
Labels: map[string]string{
|
|
"service": "web",
|
|
},
|
|
UpdateStatus: &models.DockerContainerUpdateStatus{
|
|
UpdateAvailable: true,
|
|
},
|
|
Ports: []models.DockerContainerPort{
|
|
{PrivatePort: 80, PublicPort: 8080, Protocol: "tcp", IP: "0.0.0.0"},
|
|
},
|
|
Networks: []models.DockerContainerNetworkLink{
|
|
{Name: "bridge", IPv4: "172.17.0.2"},
|
|
},
|
|
Mounts: []models.DockerContainerMount{
|
|
{Source: "/src", Destination: "/dst", RW: true},
|
|
},
|
|
}},
|
|
}},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, _ := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "docker",
|
|
"resource_id": "web",
|
|
})
|
|
var res ResourceResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &res); err != nil {
|
|
t.Fatalf("decode docker resource: %v", err)
|
|
}
|
|
if !res.UpdateAvailable || len(res.Ports) != 1 || len(res.Networks) != 1 || len(res.Mounts) != 1 {
|
|
t.Fatalf("unexpected docker details: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestIntArg(t *testing.T) {
|
|
if got := intArg(map[string]interface{}{}, "limit", 10); got != 10 {
|
|
t.Fatalf("unexpected default: %d", got)
|
|
}
|
|
if got := intArg(map[string]interface{}{"limit": float64(5)}, "limit", 10); got != 5 {
|
|
t.Fatalf("unexpected value: %d", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteListInfrastructurePaginationAndDockerFilter(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node1", Name: "node1", Status: "online"},
|
|
{ID: "node2", Name: "node2", Status: "offline"},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "host1",
|
|
Hostname: "dock1",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "c1", Name: "app", State: "running"},
|
|
{ID: "c2", Name: "db", State: "stopped"},
|
|
},
|
|
},
|
|
{
|
|
ID: "host2",
|
|
Hostname: "dock2",
|
|
Containers: []models.DockerContainer{
|
|
{ID: "c3", Name: "cache", State: "stopped"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "nodes",
|
|
"limit": 1,
|
|
"offset": 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var nodesResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &nodesResp); err != nil {
|
|
t.Fatalf("decode nodes response: %v", err)
|
|
}
|
|
if len(nodesResp.Nodes) != 1 || nodesResp.Nodes[0].Name != "node2" {
|
|
t.Fatalf("unexpected nodes response: %+v", nodesResp.Nodes)
|
|
}
|
|
if nodesResp.Pagination == nil || nodesResp.Pagination.Total != 2 || nodesResp.Pagination.Offset != 1 {
|
|
t.Fatalf("unexpected pagination: %+v", nodesResp.Pagination)
|
|
}
|
|
|
|
result, err = executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "docker",
|
|
"status": "running",
|
|
"max_docker_containers_per_host": 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var dockerResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &dockerResp); err != nil {
|
|
t.Fatalf("decode docker response: %v", err)
|
|
}
|
|
if len(dockerResp.DockerHosts) != 1 || dockerResp.DockerHosts[0].Hostname != "dock1" {
|
|
t.Fatalf("unexpected docker hosts: %+v", dockerResp.DockerHosts)
|
|
}
|
|
if len(dockerResp.DockerHosts[0].Containers) != 1 || dockerResp.DockerHosts[0].Containers[0].State != "running" {
|
|
t.Fatalf("unexpected docker containers: %+v", dockerResp.DockerHosts[0].Containers)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResourceErrorsAndContainer(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{})
|
|
result, _ := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
"resource_id": "100",
|
|
})
|
|
if result.Content[0].Text != "State information not available." {
|
|
t.Fatalf("unexpected response: %s", result.Content[0].Text)
|
|
}
|
|
|
|
executor.stateProvider = &mockStateProvider{state: models.StateSnapshot{
|
|
Containers: []models.Container{
|
|
{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1"},
|
|
},
|
|
}}
|
|
|
|
result, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "container",
|
|
"resource_id": "ct1",
|
|
})
|
|
var res ResourceResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &res); err != nil {
|
|
t.Fatalf("decode container response: %v", err)
|
|
}
|
|
if res.Type != "container" || res.Name != "ct1" {
|
|
t.Fatalf("unexpected container response: %+v", res)
|
|
}
|
|
|
|
result, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
"resource_id": "999",
|
|
})
|
|
var notFound map[string]interface{}
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), ¬Found); err != nil {
|
|
t.Fatalf("decode not found response: %v", err)
|
|
}
|
|
if notFound["error"] != "not_found" {
|
|
t.Fatalf("unexpected not found response: %+v", notFound)
|
|
}
|
|
|
|
result, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "bad",
|
|
"resource_id": "1",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for invalid resource_type")
|
|
}
|
|
}
|
|
|
|
func TestExecuteListInfrastructure_NoStateProvider(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{})
|
|
result, _ := executor.executeListInfrastructure(context.Background(), map[string]interface{}{})
|
|
if !result.IsError {
|
|
t.Fatal("expected error without state provider")
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_MissingArgs(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: models.StateSnapshot{}},
|
|
})
|
|
|
|
result, _ := executor.executeGetResource(context.Background(), map[string]interface{}{})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for missing resource_type")
|
|
}
|
|
|
|
result, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for missing resource_id")
|
|
}
|
|
}
|