mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
1931 lines
66 KiB
Go
1931 lines
66 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestQueryResponsesUseCanonicalEmptyCollections(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw any
|
|
keys []string
|
|
}{
|
|
{name: "resource_search", raw: EmptyResourceSearchResponse(), keys: []string{"matches"}},
|
|
{name: "topology_proxmox", raw: EmptyTopologyResponse(), keys: []string{"proxmox", "nodes"}},
|
|
{name: "topology_docker", raw: EmptyTopologyResponse(), keys: []string{"docker", "hosts"}},
|
|
{name: "topology_kubernetes", raw: EmptyTopologyResponse(), keys: []string{"kubernetes", "clusters"}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
payload, err := json.Marshal(tc.raw)
|
|
if err != nil {
|
|
t.Fatalf("marshal %s: %v", tc.name, err)
|
|
}
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode %s: %v", tc.name, err)
|
|
}
|
|
|
|
var current any = decoded
|
|
for _, key := range tc.keys {
|
|
obj, ok := current.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s expected object before %s, got %T", tc.name, key, current)
|
|
}
|
|
current = obj[key]
|
|
}
|
|
|
|
values, ok := current.([]any)
|
|
if !ok || len(values) != 0 {
|
|
t.Fatalf("expected %s to be an empty array, got %T (%v)", tc.name, current, current)
|
|
}
|
|
})
|
|
}
|
|
|
|
payload, err := json.Marshal(TopologyResponse{
|
|
Proxmox: ProxmoxTopology{
|
|
Nodes: []ProxmoxNodeTopology{{
|
|
Name: "node1",
|
|
Status: "online",
|
|
VMs: []TopologyVM{{VMID: 100, Name: "vm1", Status: "running"}},
|
|
}},
|
|
},
|
|
Docker: DockerTopology{
|
|
Hosts: []DockerHostTopology{{
|
|
Hostname: "docker-1",
|
|
}},
|
|
},
|
|
Kubernetes: KubernetesTopology{
|
|
Clusters: []KubernetesClusterTopology{{
|
|
Name: "cluster1",
|
|
Status: "online",
|
|
Nodes: []KubernetesNodeTopology{{
|
|
Name: "kube-node-1",
|
|
Status: "Ready",
|
|
Ready: true,
|
|
}},
|
|
}},
|
|
},
|
|
}.NormalizeCollections())
|
|
if err != nil {
|
|
t.Fatalf("marshal normalized topology: %v", err)
|
|
}
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode normalized topology: %v", err)
|
|
}
|
|
|
|
proxmox := decoded["proxmox"].(map[string]any)
|
|
nodes := proxmox["nodes"].([]any)
|
|
node := nodes[0].(map[string]any)
|
|
vms, ok := node["vms"].([]any)
|
|
if !ok || len(vms) != 1 {
|
|
t.Fatalf("expected proxmox node vms to contain normalized items, got %T (%v)", node["vms"], node["vms"])
|
|
}
|
|
vm := vms[0].(map[string]any)
|
|
if tags, ok := vm["tags"].([]any); !ok || len(tags) != 0 {
|
|
t.Fatalf("expected proxmox node vm tags to be an empty array, got %T (%v)", vm["tags"], vm["tags"])
|
|
}
|
|
if containers, ok := node["containers"].([]any); !ok || len(containers) != 0 {
|
|
t.Fatalf("expected proxmox node containers to be an empty array, got %T (%v)", node["containers"], node["containers"])
|
|
}
|
|
|
|
docker := decoded["docker"].(map[string]any)
|
|
hosts := docker["hosts"].([]any)
|
|
host := hosts[0].(map[string]any)
|
|
if containers, ok := host["containers"].([]any); !ok || len(containers) != 0 {
|
|
t.Fatalf("expected docker host containers to be an empty array, got %T (%v)", host["containers"], host["containers"])
|
|
}
|
|
|
|
kubernetes := decoded["kubernetes"].(map[string]any)
|
|
clusters := kubernetes["clusters"].([]any)
|
|
cluster := clusters[0].(map[string]any)
|
|
if deployments, ok := cluster["deployments"].([]any); !ok || len(deployments) != 0 {
|
|
t.Fatalf("expected kubernetes deployments to be an empty array, got %T (%v)", cluster["deployments"], cluster["deployments"])
|
|
}
|
|
if pods, ok := cluster["pods"].([]any); !ok || len(pods) != 0 {
|
|
t.Fatalf("expected kubernetes pods to be an empty array, got %T (%v)", cluster["pods"], cluster["pods"])
|
|
}
|
|
kubeNodes := cluster["nodes"].([]any)
|
|
kubeNode := kubeNodes[0].(map[string]any)
|
|
if roles, ok := kubeNode["roles"].([]any); !ok || len(roles) != 0 {
|
|
t.Fatalf("expected kubernetes node roles to be an empty array, got %T (%v)", kubeNode["roles"], kubeNode["roles"])
|
|
}
|
|
}
|
|
|
|
func TestResourceAndGuestResponsesUseCanonicalEmptyCollections(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw any
|
|
keys []string
|
|
}{
|
|
{name: "resource_tags", raw: EmptyResourceResponse(), keys: []string{"tags"}},
|
|
{name: "resource_networks", raw: EmptyResourceResponse(), keys: []string{"networks"}},
|
|
{name: "resource_ports", raw: EmptyResourceResponse(), keys: []string{"ports"}},
|
|
{name: "resource_mounts", raw: EmptyResourceResponse(), keys: []string{"mounts"}},
|
|
{name: "guest_mounts", raw: EmptyGuestConfigResponse(), keys: []string{"mounts"}},
|
|
{name: "guest_disks", raw: EmptyGuestConfigResponse(), keys: []string{"disks"}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
payload, err := json.Marshal(tc.raw)
|
|
if err != nil {
|
|
t.Fatalf("marshal %s: %v", tc.name, err)
|
|
}
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode %s: %v", tc.name, err)
|
|
}
|
|
|
|
current := decoded[tc.keys[0]]
|
|
values, ok := current.([]any)
|
|
if !ok || len(values) != 0 {
|
|
t.Fatalf("expected %s to be an empty array, got %T (%v)", tc.name, current, current)
|
|
}
|
|
})
|
|
}
|
|
|
|
payload, err := json.Marshal(EmptyResourceResponse())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty resource: %v", err)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode empty resource: %v", err)
|
|
}
|
|
labels, ok := decoded["labels"].(map[string]any)
|
|
if !ok || len(labels) != 0 {
|
|
t.Fatalf("expected labels to be an empty object, got %T (%v)", decoded["labels"], decoded["labels"])
|
|
}
|
|
|
|
payload, err = json.Marshal(ResourceResponse{
|
|
Type: "app-container",
|
|
ID: "abc",
|
|
Name: "app",
|
|
Networks: []NetworkInfo{{
|
|
Name: "bridge",
|
|
}},
|
|
}.NormalizeCollections())
|
|
if err != nil {
|
|
t.Fatalf("marshal normalized resource: %v", err)
|
|
}
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode normalized resource: %v", err)
|
|
}
|
|
networks := decoded["networks"].([]any)
|
|
network := networks[0].(map[string]any)
|
|
addresses, ok := network["addresses"].([]any)
|
|
if !ok || len(addresses) != 0 {
|
|
t.Fatalf("expected network addresses to be an empty array, got %T (%v)", network["addresses"], network["addresses"])
|
|
}
|
|
|
|
payload, err = json.Marshal(EmptyURLFetchResponse())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty url fetch: %v", err)
|
|
}
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode empty url fetch: %v", err)
|
|
}
|
|
headers, ok := decoded["headers"].(map[string]any)
|
|
if !ok || len(headers) != 0 {
|
|
t.Fatalf("expected url fetch headers to be an empty object, got %T (%v)", decoded["headers"], decoded["headers"])
|
|
}
|
|
|
|
payload, err = json.Marshal(EmptyGuestConfigResponse())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty guest config: %v", err)
|
|
}
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode empty guest config: %v", err)
|
|
}
|
|
raw, ok := decoded["raw"].(map[string]any)
|
|
if !ok || len(raw) != 0 {
|
|
t.Fatalf("expected guest raw to be an empty object, got %T (%v)", decoded["raw"], decoded["raw"])
|
|
}
|
|
}
|
|
|
|
type mockGuestConfigProvider struct {
|
|
lastGuestType string
|
|
lastInstance string
|
|
lastNode string
|
|
lastVMID int
|
|
config map[string]interface{}
|
|
}
|
|
|
|
func (m *mockGuestConfigProvider) GetGuestConfig(guestType, instance, node string, vmID int) (map[string]interface{}, error) {
|
|
m.lastGuestType = guestType
|
|
m.lastInstance = instance
|
|
m.lastNode = node
|
|
m.lastVMID = vmID
|
|
return m.config, nil
|
|
}
|
|
|
|
type registryUnifiedQueryProvider struct {
|
|
*unifiedresources.ResourceRegistry
|
|
}
|
|
|
|
func (p *registryUnifiedQueryProvider) GetByType(t unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return p.ListByType(t)
|
|
}
|
|
|
|
func newTrueNASUnifiedQueryProvider(t *testing.T) *registryUnifiedQueryProvider {
|
|
t.Helper()
|
|
|
|
previous := truenas.IsFeatureEnabled()
|
|
truenas.SetFeatureEnabled(true)
|
|
t.Cleanup(func() {
|
|
truenas.SetFeatureEnabled(previous)
|
|
})
|
|
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
records := truenas.NewDefaultProvider().Records()
|
|
if len(records) == 0 {
|
|
t.Fatal("expected TrueNAS fixture records")
|
|
}
|
|
registry.IngestRecords(unifiedresources.SourceTrueNAS, records)
|
|
return ®istryUnifiedQueryProvider{ResourceRegistry: registry}
|
|
}
|
|
|
|
func newVMwareUnifiedQueryProvider(t *testing.T) *registryUnifiedQueryProvider {
|
|
t.Helper()
|
|
|
|
now := time.Now().UTC()
|
|
diskUsed := int64(512 * 1024 * 1024 * 1024)
|
|
diskTotal := int64(1024 * 1024 * 1024 * 1024)
|
|
datastoreAccessible := true
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestRecords(unifiedresources.SourceVMware, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "vc-1:host:host-101",
|
|
Resource: unifiedresources.Resource{
|
|
ID: "vmware-host-1",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "esxi-01.lab.local",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
VMware: &unifiedresources.VMwareData{
|
|
ConnectionID: "vc-1",
|
|
ConnectionName: "Lab VC",
|
|
ManagedObjectID: "host-101",
|
|
EntityType: "host",
|
|
},
|
|
},
|
|
Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"esxi-01.lab.local"}},
|
|
},
|
|
{
|
|
SourceID: "vc-1:vm:vm-201",
|
|
Resource: unifiedresources.Resource{
|
|
ID: "vmware-vm-1",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Name: "app-01",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
ParentName: "esxi-01.lab.local",
|
|
VMware: &unifiedresources.VMwareData{
|
|
ConnectionID: "vc-1",
|
|
ConnectionName: "Lab VC",
|
|
ManagedObjectID: "vm-201",
|
|
EntityType: "vm",
|
|
RuntimeHostName: "esxi-01.lab.local",
|
|
GuestOSFamily: "ubuntu64Guest",
|
|
CPUCount: 4,
|
|
MemorySizeMiB: 8192,
|
|
},
|
|
},
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
Hostnames: []string{"app-01"},
|
|
IPAddresses: []string{"192.0.2.50"},
|
|
},
|
|
},
|
|
{
|
|
SourceID: "vc-1:datastore:datastore-11",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeStorage,
|
|
Name: "nvme-primary",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
Disk: &unifiedresources.MetricValue{
|
|
Used: &diskUsed,
|
|
Total: &diskTotal,
|
|
Percent: 50,
|
|
},
|
|
},
|
|
Storage: &unifiedresources.StorageMeta{
|
|
Type: "vmfs",
|
|
Platform: "vmware-vsphere",
|
|
Topology: "datastore",
|
|
Enabled: true,
|
|
Active: true,
|
|
Shared: false,
|
|
Nodes: []string{"esxi-01.lab.local"},
|
|
},
|
|
VMware: &unifiedresources.VMwareData{
|
|
ConnectionID: "vc-1",
|
|
ConnectionName: "Lab VC",
|
|
ManagedObjectID: "datastore-11",
|
|
EntityType: "datastore",
|
|
DatastoreAccessible: &datastoreAccessible,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
return ®istryUnifiedQueryProvider{ResourceRegistry: registry}
|
|
}
|
|
|
|
func newCommandCapableAgentQueryProvider() *registryUnifiedQueryProvider {
|
|
now := time.Now().UTC()
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestResources([]unifiedresources.Resource{{
|
|
ID: "agent:lab-host",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "lab-host",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
UpdatedAt: now,
|
|
Agent: &unifiedresources.AgentData{
|
|
Hostname: "lab-host",
|
|
Platform: "linux",
|
|
CommandsEnabled: true,
|
|
},
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
Hostnames: []string{"lab-host"},
|
|
},
|
|
}})
|
|
return ®istryUnifiedQueryProvider{ResourceRegistry: registry}
|
|
}
|
|
|
|
func TestCanonicalQueryListType_StrictV6Tokens(t *testing.T) {
|
|
if got := canonicalQueryListType("k8s-pods"); got != "k8s-pods" {
|
|
t.Fatalf("canonicalQueryListType(k8s-pods) = %q, want k8s-pods", got)
|
|
}
|
|
if got := canonicalQueryListType("k8s_pods"); got != "k8s_pods" {
|
|
t.Fatalf("canonicalQueryListType(k8s_pods) = %q, want k8s_pods", got)
|
|
}
|
|
if got := canonicalQueryListType("kubernetes-clusters"); got != "kubernetes-clusters" {
|
|
t.Fatalf("canonicalQueryListType(kubernetes-clusters) = %q, want kubernetes-clusters", got)
|
|
}
|
|
if got := canonicalQueryListType("system"); got != "systems" {
|
|
t.Fatalf("canonicalQueryListType(system) = %q, want systems", got)
|
|
}
|
|
if got := canonicalQueryListType("agent"); got != "systems" {
|
|
t.Fatalf("canonicalQueryListType(agent) = %q, want systems", got)
|
|
}
|
|
if got := canonicalQueryListType("agents"); got != "systems" {
|
|
t.Fatalf("canonicalQueryListType(agents) = %q, want systems", got)
|
|
}
|
|
if got := canonicalQueryListType("storage"); got != "storage-pools" {
|
|
t.Fatalf("canonicalQueryListType(storage) = %q, want storage-pools", got)
|
|
}
|
|
if got := canonicalQueryListType("storage-pool"); got != "storage-pools" {
|
|
t.Fatalf("canonicalQueryListType(storage-pool) = %q, want storage-pools", got)
|
|
}
|
|
if got := canonicalQueryListType("physical-disk"); got != "physical-disks" {
|
|
t.Fatalf("canonicalQueryListType(physical-disk) = %q, want physical-disks", got)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalQueryTopologyInclude_StrictV6Tokens(t *testing.T) {
|
|
if got := canonicalQueryTopologyInclude("app-container"); got != "app-containers" {
|
|
t.Fatalf("canonicalQueryTopologyInclude(app-container) = %q, want app-containers", got)
|
|
}
|
|
if got := canonicalQueryTopologyInclude("app_container"); got != "app_container" {
|
|
t.Fatalf("canonicalQueryTopologyInclude(app_container) = %q, want app_container", got)
|
|
}
|
|
if got := canonicalQueryTopologyInclude("docker"); got != "docker" {
|
|
t.Fatalf("canonicalQueryTopologyInclude(docker) = %q, want docker", got)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalQuerySearchType_StrictV6Tokens(t *testing.T) {
|
|
if got := canonicalQuerySearchType("agent"); got != "agent" {
|
|
t.Fatalf("canonicalQuerySearchType(agent) = %q, want agent", got)
|
|
}
|
|
if got := canonicalQuerySearchType("docker-host"); got != "docker-host" {
|
|
t.Fatalf("canonicalQuerySearchType(docker-host) = %q, want docker-host", got)
|
|
}
|
|
if got := canonicalQuerySearchType("docker_host"); got != "docker_host" {
|
|
t.Fatalf("canonicalQuerySearchType(docker_host) = %q, want docker_host", got)
|
|
}
|
|
if got := canonicalQuerySearchType("systems"); got != "agent" {
|
|
t.Fatalf("canonicalQuerySearchType(systems) = %q, want agent", got)
|
|
}
|
|
if got := canonicalQuerySearchType("system"); got != "agent" {
|
|
t.Fatalf("canonicalQuerySearchType(system) = %q, want agent", got)
|
|
}
|
|
if got := canonicalQuerySearchType("storage"); got != "storage" {
|
|
t.Fatalf("canonicalQuerySearchType(storage) = %q, want storage", got)
|
|
}
|
|
if got := canonicalQuerySearchType("storage-pools"); got != "storage" {
|
|
t.Fatalf("canonicalQuerySearchType(storage-pools) = %q, want storage", got)
|
|
}
|
|
if got := canonicalQuerySearchType("physical-disks"); got != "physical-disk" {
|
|
t.Fatalf("canonicalQuerySearchType(physical-disks) = %q, want physical-disk", got)
|
|
}
|
|
}
|
|
|
|
func TestCanonicalQueryHelpers_NormalizeCanonicalAgentAndStorageAliases(t *testing.T) {
|
|
if got := canonicalQueryResourceType("system"); got != "agent" {
|
|
t.Fatalf("canonicalQueryResourceType(system) = %q, want agent", got)
|
|
}
|
|
if got := canonicalQueryResourceType("storage-pool"); got != "storage" {
|
|
t.Fatalf("canonicalQueryResourceType(storage-pool) = %q, want storage", got)
|
|
}
|
|
if got := canonicalQueryResourceType("container"); got != "container" {
|
|
t.Fatalf("canonicalQueryResourceType(container) = %q, want container", got)
|
|
}
|
|
if got := canonicalQueryResourceType("docker"); got != "docker" {
|
|
t.Fatalf("canonicalQueryResourceType(docker) = %q, want docker", got)
|
|
}
|
|
if got := canonicalQueryListType("container"); got != "container" {
|
|
t.Fatalf("canonicalQueryListType(container) = %q, want container", got)
|
|
}
|
|
if got := canonicalQuerySearchType("docker"); got != "docker" {
|
|
t.Fatalf("canonicalQuerySearchType(docker) = %q, want docker", got)
|
|
}
|
|
}
|
|
|
|
func TestExecuteListInfrastructureAndTopology(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "node1", Name: "node1", Instance: "pve1", Status: "online"}},
|
|
VMs: []models.VM{
|
|
{ID: "qemu/pve1/node1/100", Name: "vm1", VMID: 100, Instance: "pve1", Status: "running", Node: "node1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "lxc/pve1/node1/200", Name: "ct1", VMID: 200, Instance: "pve1", 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", Instance: "pve1", Status: "online"}},
|
|
VMs: []models.VM{
|
|
{ID: "qemu/pve1/node1/100", Name: "vm1", VMID: 100, Instance: "pve1", Status: "running", Node: "node1"},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "lxc/pve1/node1/200", Name: "ct1", VMID: 200, Instance: "pve1", Status: "stopped", Node: "node1"},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{
|
|
ID: "host1",
|
|
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 len(topology.Kubernetes.Clusters) != 0 {
|
|
t.Fatalf("expected no kubernetes clusters, got: %+v", topology.Kubernetes.Clusters)
|
|
}
|
|
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 TestExecuteGetTopology_UsesCanonicalMaxProxmoxNodesInput(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node-1", Name: "node-1", Instance: "pve1", Status: "online"},
|
|
{ID: "node-2", Name: "node-2", Instance: "pve1", Status: "online"},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeGetTopology(context.Background(), map[string]interface{}{
|
|
"max_proxmox_nodes": 1,
|
|
})
|
|
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) != 1 {
|
|
t.Fatalf("expected one proxmox node after max_proxmox_nodes cap, got %+v", topology.Proxmox.Nodes)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetTopology_RejectsLegacyDockerIncludeAlias(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{})
|
|
result, _ := executor.executeGetTopology(context.Background(), map[string]interface{}{
|
|
"include": "docker",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for legacy include alias")
|
|
}
|
|
if !strings.Contains(result.Content[0].Text, "invalid include") {
|
|
t.Fatalf("unexpected error text: %s", result.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetTopology_KubernetesInclude(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
KubernetesClusters: []models.KubernetesCluster{
|
|
{
|
|
ID: "cluster-1",
|
|
Name: "prod-cluster",
|
|
Status: "online",
|
|
Nodes: []models.KubernetesNode{
|
|
{Name: "worker-1", UID: "node-1", Ready: true, Roles: []string{"worker"}},
|
|
},
|
|
Pods: []models.KubernetesPod{
|
|
{
|
|
UID: "pod-1",
|
|
Name: "api-6f8d5c",
|
|
Namespace: "default",
|
|
Phase: "Running",
|
|
Restarts: 2,
|
|
OwnerKind: "Deployment",
|
|
OwnerName: "api",
|
|
},
|
|
},
|
|
Deployments: []models.KubernetesDeployment{
|
|
{
|
|
UID: "deploy-1",
|
|
Name: "api",
|
|
Namespace: "default",
|
|
DesiredReplicas: 3,
|
|
ReadyReplicas: 2,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeGetTopology(context.Background(), map[string]interface{}{
|
|
"include": "kubernetes",
|
|
})
|
|
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 topology, got: %+v", topology.Proxmox.Nodes)
|
|
}
|
|
if len(topology.Docker.Hosts) != 0 {
|
|
t.Fatalf("expected no docker topology, got: %+v", topology.Docker.Hosts)
|
|
}
|
|
if len(topology.Kubernetes.Clusters) != 1 {
|
|
t.Fatalf("expected one kubernetes cluster, got: %+v", topology.Kubernetes.Clusters)
|
|
}
|
|
|
|
cluster := topology.Kubernetes.Clusters[0]
|
|
if cluster.Name != "prod-cluster" || cluster.NodeCount != 1 || cluster.DeploymentCount != 1 || cluster.PodCount != 1 {
|
|
t.Fatalf("unexpected cluster topology: %+v", cluster)
|
|
}
|
|
if len(cluster.Nodes) != 1 || cluster.Nodes[0].Name != "worker-1" || !cluster.Nodes[0].Ready {
|
|
t.Fatalf("unexpected cluster nodes: %+v", cluster.Nodes)
|
|
}
|
|
if len(cluster.Deployments) != 1 || cluster.Deployments[0].Name != "api" || cluster.Deployments[0].ReadyReplicas != 2 {
|
|
t.Fatalf("unexpected cluster deployments: %+v", cluster.Deployments)
|
|
}
|
|
if len(cluster.Pods) != 1 || cluster.Pods[0].Name != "api-6f8d5c" || cluster.Pods[0].OwnerName != "api" {
|
|
t.Fatalf("unexpected cluster pods: %+v", cluster.Pods)
|
|
}
|
|
|
|
if topology.Summary.TotalK8sClusters != 1 || topology.Summary.TotalK8sNodes != 1 || topology.Summary.TotalK8sDeployments != 1 || topology.Summary.TotalK8sPods != 1 {
|
|
t.Fatalf("unexpected k8s summary totals: %+v", topology.Summary)
|
|
}
|
|
if topology.Summary.RunningK8sPods != 1 {
|
|
t.Fatalf("unexpected k8s 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 != "app-container" || 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)
|
|
}
|
|
|
|
result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "dock",
|
|
"type": "docker-host",
|
|
})
|
|
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 != "docker-host" || response.Matches[0].Name != "Dock 1" {
|
|
t.Fatalf("unexpected docker-host search response: %+v", response)
|
|
}
|
|
|
|
// Canonical type+VMID patterns: "VM100", "system-container200"
|
|
for _, tc := range []struct {
|
|
query string
|
|
wantType string
|
|
wantName string
|
|
}{
|
|
{"system-container200", "system-container", "db-ct"},
|
|
{"VM100", "vm", "web-vm"},
|
|
{"vm100", "vm", "web-vm"},
|
|
} {
|
|
result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": tc.query,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("query %q: unexpected error: %v", tc.query, err)
|
|
}
|
|
response = ResourceSearchResponse{}
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("query %q: decode response: %v", tc.query, err)
|
|
}
|
|
if len(response.Matches) != 1 || response.Matches[0].Type != tc.wantType || response.Matches[0].Name != tc.wantName {
|
|
t.Fatalf("query %q: expected 1 match (%s %s), got %+v", tc.query, tc.wantType, tc.wantName, response.Matches)
|
|
}
|
|
}
|
|
|
|
for _, legacyQuery := range []string{"LXC200", "qemu100", "CT200"} {
|
|
result, err = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": legacyQuery,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("legacy query %q: unexpected error: %v", legacyQuery, err)
|
|
}
|
|
response = ResourceSearchResponse{}
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("legacy query %q: decode response: %v", legacyQuery, err)
|
|
}
|
|
if len(response.Matches) != 0 {
|
|
t.Fatalf("legacy query %q: expected no matches, got %+v", legacyQuery, response.Matches)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
result, _ = executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "node",
|
|
"type": "host",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for legacy host type")
|
|
}
|
|
if !strings.Contains(result.Content[0].Text, "invalid type: host") {
|
|
t.Fatalf("unexpected legacy host type error: %s", result.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
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": "app-container",
|
|
"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 != "app-container" || res.Name != "nginx" {
|
|
t.Fatalf("unexpected docker resource: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestExecuteQuery_UsesCanonicalTrueNASUnifiedResources(t *testing.T) {
|
|
provider := newTrueNASUnifiedQueryProvider(t)
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
})
|
|
|
|
listSystems, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "systems",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list systems: unexpected error: %v", err)
|
|
}
|
|
var systemsResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(listSystems.Content[0].Text), &systemsResp); err != nil {
|
|
t.Fatalf("decode systems response: %v", err)
|
|
}
|
|
if len(systemsResp.Systems) != 1 {
|
|
t.Fatalf("expected one TrueNAS system, got %+v", systemsResp.Systems)
|
|
}
|
|
if systemsResp.Systems[0].Platform != "truenas" || systemsResp.Systems[0].Name != "truenas-main" {
|
|
t.Fatalf("unexpected system summary: %+v", systemsResp.Systems[0])
|
|
}
|
|
|
|
listApps, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "app-containers",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list app containers: unexpected error: %v", err)
|
|
}
|
|
var appsResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(listApps.Content[0].Text), &appsResp); err != nil {
|
|
t.Fatalf("decode app container response: %v", err)
|
|
}
|
|
if len(appsResp.AppContainers) == 0 {
|
|
t.Fatal("expected canonical app containers from TrueNAS fixtures")
|
|
}
|
|
foundNextcloud := false
|
|
for _, app := range appsResp.AppContainers {
|
|
if app.Name != "Nextcloud" {
|
|
continue
|
|
}
|
|
foundNextcloud = true
|
|
if app.Platform != "truenas" || app.Host != "truenas-main" || app.ID != "nextcloud" {
|
|
t.Fatalf("unexpected Nextcloud summary: %+v", app)
|
|
}
|
|
}
|
|
if !foundNextcloud {
|
|
t.Fatalf("expected Nextcloud in app containers, got %+v", appsResp.AppContainers)
|
|
}
|
|
|
|
listPools, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "storage-pools",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list storage pools: unexpected error: %v", err)
|
|
}
|
|
var poolsResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(listPools.Content[0].Text), &poolsResp); err != nil {
|
|
t.Fatalf("decode storage pools response: %v", err)
|
|
}
|
|
if len(poolsResp.StoragePools) != len(truenas.DefaultFixtures().Pools) {
|
|
t.Fatalf("expected only top-level pools, got %+v", poolsResp.StoragePools)
|
|
}
|
|
for _, pool := range poolsResp.StoragePools {
|
|
if strings.Contains(pool.Name, "/") {
|
|
t.Fatalf("expected storage-pools list to exclude datasets, got %+v", poolsResp.StoragePools)
|
|
}
|
|
}
|
|
|
|
listDisks, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "physical-disks",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list physical disks: unexpected error: %v", err)
|
|
}
|
|
var disksResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(listDisks.Content[0].Text), &disksResp); err != nil {
|
|
t.Fatalf("decode physical disks response: %v", err)
|
|
}
|
|
if len(disksResp.PhysicalDisks) == 0 {
|
|
t.Fatal("expected physical disks from TrueNAS fixtures")
|
|
}
|
|
foundHotDisk := false
|
|
for _, disk := range disksResp.PhysicalDisks {
|
|
if disk.DevPath != "/dev/sdc" {
|
|
continue
|
|
}
|
|
foundHotDisk = true
|
|
if disk.Node != "truenas-main" || disk.Health == "" {
|
|
t.Fatalf("unexpected canonical disk summary: %+v", disk)
|
|
}
|
|
}
|
|
if !foundHotDisk {
|
|
t.Fatalf("expected sdc disk in list response, got %+v", disksResp.PhysicalDisks)
|
|
}
|
|
|
|
searchPool, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "archive",
|
|
"type": "storage",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("search storage pools: unexpected error: %v", err)
|
|
}
|
|
var searchResp ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(searchPool.Content[0].Text), &searchResp); err != nil {
|
|
t.Fatalf("decode storage search response: %v", err)
|
|
}
|
|
if len(searchResp.Matches) != 1 || searchResp.Matches[0].Type != "storage" || searchResp.Matches[0].Name != "archive" || searchResp.Matches[0].Platform != "truenas" {
|
|
t.Fatalf("unexpected storage search response: %+v", searchResp.Matches)
|
|
}
|
|
|
|
searchDisk, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "sdc",
|
|
"type": "physical-disk",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("search physical disks: unexpected error: %v", err)
|
|
}
|
|
searchResp = ResourceSearchResponse{}
|
|
if err := json.Unmarshal([]byte(searchDisk.Content[0].Text), &searchResp); err != nil {
|
|
t.Fatalf("decode physical-disk search response: %v", err)
|
|
}
|
|
if len(searchResp.Matches) != 1 || searchResp.Matches[0].Type != "physical-disk" || searchResp.Matches[0].Platform != "truenas" || searchResp.Matches[0].Host != "truenas-main" {
|
|
t.Fatalf("unexpected physical-disk search response: %+v", searchResp.Matches)
|
|
}
|
|
|
|
getSystem, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "agent",
|
|
"resource_id": "truenas-main",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get agent: unexpected error: %v", err)
|
|
}
|
|
var systemRes ResourceResponse
|
|
if err := json.Unmarshal([]byte(getSystem.Content[0].Text), &systemRes); err != nil {
|
|
t.Fatalf("decode agent resource: %v", err)
|
|
}
|
|
if systemRes.Type != "agent" || systemRes.Platform != "truenas" || systemRes.Host != "truenas-main" {
|
|
t.Fatalf("unexpected canonical agent resource: %+v", systemRes)
|
|
}
|
|
|
|
getApp, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "app-container",
|
|
"resource_id": "nextcloud",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get app-container: unexpected error: %v", err)
|
|
}
|
|
var appRes ResourceResponse
|
|
if err := json.Unmarshal([]byte(getApp.Content[0].Text), &appRes); err != nil {
|
|
t.Fatalf("decode app resource: %v", err)
|
|
}
|
|
if appRes.Type != "app-container" || appRes.Name != "Nextcloud" || appRes.Platform != "truenas" || appRes.Host != "truenas-main" {
|
|
t.Fatalf("unexpected canonical app resource: %+v", appRes)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_RegistersTrueNASAppContainerForCanonicalControl(t *testing.T) {
|
|
provider := newTrueNASUnifiedQueryProvider(t)
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
_, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "app-container",
|
|
"resource_id": "nextcloud",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get app-container: unexpected error: %v", err)
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("Nextcloud")
|
|
if !found {
|
|
t.Fatal("expected resolved context to include TrueNAS app by alias")
|
|
}
|
|
if info.GetAdapter() != "truenas" {
|
|
t.Fatalf("expected truenas adapter, got %q", info.GetAdapter())
|
|
}
|
|
if info.GetTargetHost() != "truenas-main" {
|
|
t.Fatalf("expected truenas-main target host, got %q", info.GetTargetHost())
|
|
}
|
|
allowed := strings.Join(info.GetAllowedActions(), ",")
|
|
for _, action := range []string{"query", "get", "start", "stop", "restart"} {
|
|
if !strings.Contains(allowed, action) {
|
|
t.Fatalf("expected allowed actions to include %q, got %v", action, info.GetAllowedActions())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_RegistersVMwareVMAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
"resource_id": "app-01",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get vm: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.Platform != "vmware-vsphere" {
|
|
t.Fatalf("expected VMware platform on response, got %+v", response)
|
|
}
|
|
if response.Node != "esxi-01.lab.local" {
|
|
t.Fatalf("expected VMware runtime host in node field, got %+v", response)
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("app-01")
|
|
if !found {
|
|
t.Fatal("expected resolved context to include VMware VM by alias")
|
|
}
|
|
if info.GetAdapter() != "vmware-vsphere" {
|
|
t.Fatalf("expected VMware adapter, got %q", info.GetAdapter())
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware VM to register read-only actions, got %v", info.GetAllowedActions())
|
|
}
|
|
|
|
controlResult, err := executor.executeControl(context.Background(), map[string]interface{}{
|
|
"type": "resource",
|
|
"resource_id": "app-01",
|
|
"action": "restart",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("executeControl(type=resource): unexpected error: %v", err)
|
|
}
|
|
if !controlResult.IsError {
|
|
t.Fatalf("expected VMware VM restart to be rejected, got %+v", controlResult)
|
|
}
|
|
if !strings.Contains(controlResult.Content[0].Text, "not permitted") {
|
|
t.Fatalf("expected read-only rejection, got %q", controlResult.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_RegistersVMwareAgentAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
hostResources := provider.GetByType(unifiedresources.ResourceTypeAgent)
|
|
if len(hostResources) != 1 {
|
|
t.Fatalf("expected one VMware host resource, got %d", len(hostResources))
|
|
}
|
|
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "agent",
|
|
"resource_id": hostResources[0].ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get agent: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.Type != "agent" || response.Platform != "vmware-vsphere" || response.Host != "esxi-01.lab.local" {
|
|
t.Fatalf("unexpected VMware agent response: %+v", response)
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("esxi-01.lab.local")
|
|
if !found {
|
|
t.Fatal("expected resolved context to include VMware host by alias")
|
|
}
|
|
if info.GetKind() != "agent" {
|
|
t.Fatalf("expected agent kind, got %q", info.GetKind())
|
|
}
|
|
if info.GetAdapter() != "vmware-vsphere" {
|
|
t.Fatalf("expected VMware adapter, got %q", info.GetAdapter())
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware host to register read-only actions, got %v", info.GetAllowedActions())
|
|
}
|
|
|
|
controlResult, err := executor.executeControl(context.Background(), map[string]interface{}{
|
|
"type": "resource",
|
|
"resource_id": "esxi-01.lab.local",
|
|
"action": "restart",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("executeControl(type=resource): unexpected error: %v", err)
|
|
}
|
|
if !controlResult.IsError {
|
|
t.Fatalf("expected VMware host restart to be rejected, got %+v", controlResult)
|
|
}
|
|
if !strings.Contains(controlResult.Content[0].Text, "not permitted") {
|
|
t.Fatalf("expected read-only rejection, got %q", controlResult.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_RegistersVMwareStorageAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
storageResources := provider.GetByType(unifiedresources.ResourceTypeStorage)
|
|
if len(storageResources) != 1 {
|
|
t.Fatalf("expected one VMware storage resource, got %d", len(storageResources))
|
|
}
|
|
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "storage",
|
|
"resource_id": storageResources[0].ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get storage: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.Type != "storage" || response.Platform != "vmware-vsphere" || response.Name != "nvme-primary" {
|
|
t.Fatalf("unexpected VMware storage response: %+v", response)
|
|
}
|
|
if response.Disk == nil || response.Disk.TotalGB == 0 {
|
|
t.Fatalf("expected VMware storage capacity details, got %+v", response)
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("nvme-primary")
|
|
if !found {
|
|
t.Fatal("expected resolved context to include VMware storage by alias")
|
|
}
|
|
if info.GetKind() != "storage" {
|
|
t.Fatalf("expected storage kind, got %q", info.GetKind())
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware storage to register read-only actions, got %v", info.GetAllowedActions())
|
|
}
|
|
}
|
|
|
|
func TestExecuteSearchResources_RegistersVMwareVMAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "app-01",
|
|
"type": "vm",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("search vm: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode search response: %v", err)
|
|
}
|
|
if len(response.Matches) != 1 {
|
|
t.Fatalf("expected one VMware search match, got %+v", response.Matches)
|
|
}
|
|
if response.Matches[0].Platform != "vmware-vsphere" {
|
|
t.Fatalf("expected VMware platform on search match, got %+v", response.Matches[0])
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("app-01")
|
|
if !found {
|
|
t.Fatal("expected VMware VM search result to register in resolved context")
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware search registration to remain read-only, got %v", info.GetAllowedActions())
|
|
}
|
|
}
|
|
|
|
func TestExecuteSearchResources_RegistersVMwareAgentAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "esxi-01",
|
|
"type": "agent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("search agent: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode search response: %v", err)
|
|
}
|
|
if len(response.Matches) != 1 {
|
|
t.Fatalf("expected one VMware host search match, got %+v", response.Matches)
|
|
}
|
|
if response.Matches[0].Type != "agent" || response.Matches[0].Platform != "vmware-vsphere" {
|
|
t.Fatalf("unexpected VMware host search match: %+v", response.Matches[0])
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("esxi-01.lab.local")
|
|
if !found {
|
|
t.Fatal("expected VMware host search result to register in resolved context")
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware host search registration to remain read-only, got %v", info.GetAllowedActions())
|
|
}
|
|
}
|
|
|
|
func TestExecuteSearchResources_RegistersVMwareStorageAsReadOnly(t *testing.T) {
|
|
provider := newVMwareUnifiedQueryProvider(t)
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
result, err := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "nvme-primary",
|
|
"type": "storage",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("search storage: unexpected error: %v", err)
|
|
}
|
|
|
|
var response ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode search response: %v", err)
|
|
}
|
|
if len(response.Matches) != 1 {
|
|
t.Fatalf("expected one VMware storage search match, got %+v", response.Matches)
|
|
}
|
|
if response.Matches[0].Type != "storage" || response.Matches[0].Platform != "vmware-vsphere" {
|
|
t.Fatalf("unexpected VMware storage search match: %+v", response.Matches[0])
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("nvme-primary")
|
|
if !found {
|
|
t.Fatal("expected VMware storage search result to register in resolved context")
|
|
}
|
|
if got := strings.Join(info.GetAllowedActions(), ","); got != "query,get" && got != "get,query" {
|
|
t.Fatalf("expected VMware storage search registration to remain read-only, got %v", info.GetAllowedActions())
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResource_RegistersCommandCapableAgentForExec(t *testing.T) {
|
|
provider := newCommandCapableAgentQueryProvider()
|
|
resolved := &mockResolvedContext{
|
|
resources: make(map[string]ResolvedResourceInfo),
|
|
aliases: make(map[string]ResolvedResourceInfo),
|
|
}
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: provider,
|
|
ReadState: provider.ResourceRegistry,
|
|
})
|
|
executor.SetResolvedContext(resolved)
|
|
|
|
_, err := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "agent",
|
|
"resource_id": "lab-host",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get agent: unexpected error: %v", err)
|
|
}
|
|
|
|
info, found := resolved.GetResolvedResourceByAlias("lab-host")
|
|
if !found {
|
|
t.Fatal("expected command-capable agent to register in resolved context")
|
|
}
|
|
allowed := strings.Join(info.GetAllowedActions(), ",")
|
|
for _, action := range []string{"query", "get", "exec"} {
|
|
if !strings.Contains(allowed, action) {
|
|
t.Fatalf("expected allowed actions to include %q, got %v", action, info.GetAllowedActions())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteQuerySurfacesIncludeGovernedMetadata(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "node1", Name: "node1.internal", Status: "online"},
|
|
},
|
|
VMs: []models.VM{
|
|
{ID: "qemu/pve1/node1/100", VMID: 100, Name: "finance-vm", Status: "running", Node: "node1.internal"},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
getResult, _ := executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "vm",
|
|
"resource_id": "100",
|
|
})
|
|
var resource ResourceResponse
|
|
if err := json.Unmarshal([]byte(getResult.Content[0].Text), &resource); err != nil {
|
|
t.Fatalf("decode resource response: %v", err)
|
|
}
|
|
if resource.Policy == nil {
|
|
t.Fatal("expected governed policy metadata on get response")
|
|
}
|
|
if resource.Policy.Sensitivity != "sensitive" {
|
|
t.Fatalf("unexpected get response sensitivity: %+v", resource.Policy)
|
|
}
|
|
if resource.AISafeSummary == "" {
|
|
t.Fatal("expected aiSafeSummary on get response")
|
|
}
|
|
if strings.Contains(resource.AISafeSummary, "finance-vm") {
|
|
t.Fatalf("aiSafeSummary leaked raw VM name: %q", resource.AISafeSummary)
|
|
}
|
|
|
|
searchResult, _ := executor.executeSearchResources(context.Background(), map[string]interface{}{
|
|
"query": "finance",
|
|
"type": "vm",
|
|
})
|
|
var search ResourceSearchResponse
|
|
if err := json.Unmarshal([]byte(searchResult.Content[0].Text), &search); err != nil {
|
|
t.Fatalf("decode search response: %v", err)
|
|
}
|
|
if len(search.Matches) != 1 {
|
|
t.Fatalf("expected 1 search match, got %+v", search.Matches)
|
|
}
|
|
if search.Matches[0].Policy == nil {
|
|
t.Fatal("expected governed policy metadata on search match")
|
|
}
|
|
if search.Matches[0].AISafeSummary == "" {
|
|
t.Fatal("expected aiSafeSummary on search match")
|
|
}
|
|
|
|
topologyResult, _ := executor.executeGetTopology(context.Background(), map[string]interface{}{
|
|
"include": "proxmox",
|
|
})
|
|
var topology TopologyResponse
|
|
if err := json.Unmarshal([]byte(topologyResult.Content[0].Text), &topology); err != nil {
|
|
t.Fatalf("decode topology response: %v", err)
|
|
}
|
|
if len(topology.Proxmox.Nodes) != 1 {
|
|
t.Fatalf("expected 1 proxmox node, got %+v", topology.Proxmox.Nodes)
|
|
}
|
|
if topology.Proxmox.Nodes[0].Policy == nil {
|
|
t.Fatal("expected governed policy metadata on topology node")
|
|
}
|
|
if len(topology.Proxmox.Nodes[0].VMs) != 1 || topology.Proxmox.Nodes[0].VMs[0].Policy == nil {
|
|
t.Fatalf("expected governed VM metadata in topology response, got %+v", topology.Proxmox.Nodes[0].VMs)
|
|
}
|
|
}
|
|
|
|
func TestGovernedQueryMetadataFromResolvedResourceClonesPolicy(t *testing.T) {
|
|
resource := &unifiedresources.Resource{
|
|
ID: "vm-1",
|
|
Name: "customer-payments",
|
|
Tags: []string{"customer-data"},
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
Hostnames: []string{"payments.internal"},
|
|
IPAddresses: []string{"10.0.0.44"},
|
|
},
|
|
Canonical: &unifiedresources.CanonicalIdentity{
|
|
PlatformID: "payments.internal",
|
|
PrimaryID: "vm:100",
|
|
Aliases: []string{"vm-100"},
|
|
},
|
|
}
|
|
|
|
metadata := governedQueryMetadataFromResolvedResource(resource)
|
|
if metadata.Policy == nil {
|
|
t.Fatal("expected policy metadata")
|
|
}
|
|
if metadata.Policy == resource.Policy {
|
|
t.Fatal("expected metadata policy to be a clone")
|
|
}
|
|
if len(metadata.Policy.Routing.Redact) == 0 {
|
|
t.Fatal("expected redactions on governed metadata policy")
|
|
}
|
|
baselinePolicy := unifiedresources.CloneResourcePolicy(metadata.Policy)
|
|
metadata.Policy.Routing.Redact[0] = unifiedresources.ResourceRedactionAlias
|
|
if baselinePolicy.Routing.Redact[0] != unifiedresources.ResourceRedactionHostname {
|
|
t.Fatalf("expected clone to deep copy redactions, baseline mutated to %#v", baselinePolicy.Routing.Redact)
|
|
}
|
|
if metadata.AISafeSummary == "" {
|
|
t.Fatal("expected ai safe summary")
|
|
}
|
|
if metadata.AISafeSummary == resource.Name {
|
|
t.Fatalf("expected safe summary to avoid raw resource name: %q", metadata.AISafeSummary)
|
|
}
|
|
if metadata.Policy.Sensitivity != unifiedresources.ResourceSensitivityRestricted {
|
|
t.Fatalf("expected restricted sensitivity, got %q", metadata.Policy.Sensitivity)
|
|
}
|
|
if metadata.Policy.Routing.Scope != unifiedresources.ResourceRoutingScopeLocalOnly {
|
|
t.Fatalf("expected local-only routing, got %q", metadata.Policy.Routing.Scope)
|
|
}
|
|
}
|
|
|
|
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": "app-container",
|
|
"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": "app-containers",
|
|
"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.AppContainers) != 1 || dockerResp.AppContainers[0].Name != "app" {
|
|
t.Fatalf("unexpected app containers: %+v", dockerResp.AppContainers)
|
|
}
|
|
if dockerResp.AppContainers[0].Host != "dock1" || dockerResp.AppContainers[0].Status != "running" || dockerResp.AppContainers[0].Platform != "docker" {
|
|
t.Fatalf("unexpected app container summary: %+v", dockerResp.AppContainers[0])
|
|
}
|
|
}
|
|
|
|
func TestExecuteListInfrastructure_KubernetesFilters(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
KubernetesClusters: []models.KubernetesCluster{
|
|
{
|
|
ID: "cluster-1",
|
|
Name: "prod-cluster",
|
|
Status: "online",
|
|
Nodes: []models.KubernetesNode{
|
|
{Name: "worker-1", UID: "node-1", Ready: true, Roles: []string{"worker"}},
|
|
},
|
|
Pods: []models.KubernetesPod{
|
|
{
|
|
UID: "pod-1",
|
|
Name: "api-123",
|
|
Namespace: "default",
|
|
Phase: "Running",
|
|
OwnerKind: "Deployment",
|
|
OwnerName: "api",
|
|
},
|
|
{
|
|
UID: "pod-2",
|
|
Name: "job-123",
|
|
Namespace: "batch",
|
|
Phase: "Succeeded",
|
|
OwnerKind: "Job",
|
|
OwnerName: "cleanup",
|
|
},
|
|
},
|
|
Deployments: []models.KubernetesDeployment{
|
|
{
|
|
UID: "dep-1",
|
|
Name: "api",
|
|
Namespace: "default",
|
|
DesiredReplicas: 3,
|
|
ReadyReplicas: 2,
|
|
AvailableReplicas: 2,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "cluster-2",
|
|
Name: "dev-cluster",
|
|
Status: "warning",
|
|
Nodes: []models.KubernetesNode{
|
|
{Name: "dev-worker-1", UID: "node-2", Ready: false},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: state},
|
|
})
|
|
|
|
result, err := executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "kubernetes",
|
|
"cluster_name": "prod-cluster",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var clustersResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &clustersResp); err != nil {
|
|
t.Fatalf("decode kubernetes clusters response: %v", err)
|
|
}
|
|
if len(clustersResp.K8sClusters) != 1 || clustersResp.K8sClusters[0].Name != "prod-cluster" {
|
|
t.Fatalf("unexpected k8s clusters: %+v", clustersResp.K8sClusters)
|
|
}
|
|
if clustersResp.K8sClusters[0].NodeCount != 1 || clustersResp.K8sClusters[0].DeploymentCount != 1 || clustersResp.K8sClusters[0].PodCount != 2 {
|
|
t.Fatalf("unexpected k8s cluster counts: %+v", clustersResp.K8sClusters[0])
|
|
}
|
|
if clustersResp.Total.K8sClusters != 2 || clustersResp.Total.K8sNodes != 2 || clustersResp.Total.K8sPods != 2 || clustersResp.Total.K8sDeployments != 1 {
|
|
t.Fatalf("unexpected k8s totals: %+v", clustersResp.Total)
|
|
}
|
|
|
|
result, err = executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "k8s-pods",
|
|
"status": "running",
|
|
"cluster_name": "prod-cluster",
|
|
"namespace": "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var podsResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &podsResp); err != nil {
|
|
t.Fatalf("decode k8s pods response: %v", err)
|
|
}
|
|
if len(podsResp.K8sPods) != 1 || podsResp.K8sPods[0].Name != "api-123" {
|
|
t.Fatalf("unexpected k8s pods: %+v", podsResp.K8sPods)
|
|
}
|
|
|
|
result, err = executor.executeListInfrastructure(context.Background(), map[string]interface{}{
|
|
"type": "k8s-deployments",
|
|
"namespace": "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var deploymentsResp InfrastructureResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &deploymentsResp); err != nil {
|
|
t.Fatalf("decode k8s deployments response: %v", err)
|
|
}
|
|
if len(deploymentsResp.K8sDeployments) != 1 || deploymentsResp.K8sDeployments[0].Name != "api" {
|
|
t.Fatalf("unexpected k8s deployments: %+v", deploymentsResp.K8sDeployments)
|
|
}
|
|
}
|
|
|
|
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": "system-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 != "system-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")
|
|
}
|
|
|
|
result, _ = executor.executeGetResource(context.Background(), map[string]interface{}{
|
|
"resource_type": "host",
|
|
"resource_id": "1",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatal("expected error for legacy host resource_type")
|
|
}
|
|
if !strings.Contains(result.Content[0].Text, "invalid resource_type: host") {
|
|
t.Fatalf("unexpected legacy host resource_type error: %s", result.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
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 TestInfrastructureResponse_UsesCanonicalEmptyCollections(t *testing.T) {
|
|
payload, err := json.Marshal(EmptyInfrastructureResponse())
|
|
if err != nil {
|
|
t.Fatalf("marshal empty infrastructure response: %v", err)
|
|
}
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
t.Fatalf("decode empty infrastructure response: %v", err)
|
|
}
|
|
|
|
for _, key := range []string{"systems", "nodes", "vms", "containers", "app_containers", "docker_hosts", "storage_pools", "physical_disks", "k8s_clusters", "k8s_nodes", "k8s_pods", "k8s_deployments"} {
|
|
values, ok := decoded[key].([]any)
|
|
if !ok || len(values) != 0 {
|
|
t.Fatalf("expected %s to serialize as an empty array, got %T (%v)", key, decoded[key], decoded[key])
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetResourceConfig_SystemContainerUsesCanonicalResolution(t *testing.T) {
|
|
guestCfg := &mockGuestConfigProvider{
|
|
config: map[string]interface{}{
|
|
"hostname": "ct1",
|
|
"ostype": "debian",
|
|
"onboot": "1",
|
|
"rootfs": "local-lvm:vm-200-disk-0,size=8G",
|
|
"mp0": "local:200/vm-200-disk-1.raw,mp=/data",
|
|
},
|
|
}
|
|
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: models.StateSnapshot{
|
|
Containers: []models.Container{
|
|
{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1", Instance: "pve1"},
|
|
},
|
|
}},
|
|
GuestConfigProvider: guestCfg,
|
|
})
|
|
|
|
result, _ := executor.executeGetResourceConfig(context.Background(), map[string]interface{}{
|
|
"resource_type": "system-container",
|
|
"resource_id": "ct1",
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("unexpected error result: %s", result.Content[0].Text)
|
|
}
|
|
|
|
var response GuestConfigResponse
|
|
if err := json.Unmarshal([]byte(result.Content[0].Text), &response); err != nil {
|
|
t.Fatalf("decode guest config response: %v", err)
|
|
}
|
|
|
|
if response.GuestType != "system-container" || response.VMID != 200 || response.Node != "node1" {
|
|
t.Fatalf("unexpected guest config response: %+v", response)
|
|
}
|
|
if response.Policy == nil {
|
|
t.Fatal("expected governed policy metadata on guest config response")
|
|
}
|
|
if response.AISafeSummary == "" {
|
|
t.Fatal("expected aiSafeSummary on guest config response")
|
|
}
|
|
if guestCfg.lastGuestType != "container" {
|
|
t.Fatalf("expected provider guestType=container, got %q", guestCfg.lastGuestType)
|
|
}
|
|
if guestCfg.lastInstance != "pve1" || guestCfg.lastNode != "node1" || guestCfg.lastVMID != 200 {
|
|
t.Fatalf("unexpected provider call context: instance=%q node=%q vmid=%d", guestCfg.lastInstance, guestCfg.lastNode, guestCfg.lastVMID)
|
|
}
|
|
}
|
|
|
|
func TestExecuteGetGuestConfig_RejectsLegacyResourceTypes(t *testing.T) {
|
|
executor := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{state: models.StateSnapshot{
|
|
Containers: []models.Container{
|
|
{ID: "ct1", VMID: 200, Name: "ct1", Status: "running", Node: "node1", Instance: "pve1"},
|
|
},
|
|
}},
|
|
GuestConfigProvider: &mockGuestConfigProvider{config: map[string]interface{}{}},
|
|
})
|
|
|
|
for _, resourceType := range []string{"lxc", "host"} {
|
|
t.Run(resourceType, func(t *testing.T) {
|
|
result, _ := executor.executeGetResourceConfig(context.Background(), map[string]interface{}{
|
|
"resource_type": resourceType,
|
|
"resource_id": "ct1",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatalf("expected error for legacy %s resource_type", resourceType)
|
|
}
|
|
if !strings.Contains(result.Content[0].Text, "invalid resource_type: "+resourceType) {
|
|
t.Fatalf("unexpected error text for %s: %s", resourceType, result.Content[0].Text)
|
|
}
|
|
})
|
|
}
|
|
}
|