Pulse/internal/ai/tools/tools_storage_test.go

343 lines
9.9 KiB
Go

package tools
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
type stubBackupProvider struct {
backups models.Backups
pbs []models.PBSInstance
}
func (s *stubBackupProvider) GetBackups() models.Backups {
return s.backups
}
func (s *stubBackupProvider) GetPBSInstances() []models.PBSInstance {
return s.pbs
}
type stubStorageProvider struct {
storage []models.Storage
ceph []models.CephCluster
}
func (s *stubStorageProvider) GetStorage() []models.Storage {
return s.storage
}
func (s *stubStorageProvider) GetCephClusters() []models.CephCluster {
return s.ceph
}
type stubDiskHealthProvider struct {
hosts []models.Host
}
func (s *stubDiskHealthProvider) GetHosts() []models.Host {
return s.hosts
}
type stubUpdatesProvider struct {
pending []ContainerUpdateInfo
enabled bool
triggerCalled bool
lastTriggerHost string
lastUpdateHost string
lastUpdateID string
lastUpdateName string
triggerStatus DockerCommandStatus
triggerErr error
updateStatus DockerCommandStatus
updateErr error
updateCheckEnabled bool
}
func (s *stubUpdatesProvider) GetPendingUpdates(hostID string) []ContainerUpdateInfo {
s.lastTriggerHost = hostID
return s.pending
}
func (s *stubUpdatesProvider) TriggerUpdateCheck(hostID string) (DockerCommandStatus, error) {
s.triggerCalled = true
s.lastTriggerHost = hostID
return s.triggerStatus, s.triggerErr
}
func (s *stubUpdatesProvider) UpdateContainer(hostID, containerID, containerName string) (DockerCommandStatus, error) {
s.lastUpdateHost = hostID
s.lastUpdateID = containerID
s.lastUpdateName = containerName
return s.updateStatus, s.updateErr
}
func (s *stubUpdatesProvider) IsUpdateActionsEnabled() bool {
return s.enabled
}
func TestExecuteListBackupsAndStorage(t *testing.T) {
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
executor.backupProvider = &stubBackupProvider{
backups: models.Backups{
PBS: []models.PBSBackup{
{
VMID: "100",
BackupType: "vm",
BackupTime: time.Unix(1000, 0),
Instance: "pbs1",
Datastore: "ds1",
Size: 1024 * 1024 * 1024,
Verified: true,
Protected: true,
},
},
PVE: models.PVEBackups{
StorageBackups: []models.StorageBackup{
{
VMID: 101,
Time: time.Unix(1100, 0),
Size: 2 * 1024 * 1024 * 1024,
Storage: "local",
},
},
BackupTasks: []models.BackupTask{
{
VMID: 101,
Node: "node1",
Status: "OK",
StartTime: time.Unix(1200, 0),
},
},
},
},
pbs: []models.PBSInstance{
{
Name: "pbs1",
Host: "10.0.0.1",
Status: "online",
Datastores: []models.PBSDatastore{
{
Name: "ds1",
Usage: 50.0,
Free: 1024 * 1024 * 1024,
},
},
},
},
}
result, _ := executor.executeListBackups(context.Background(), map[string]interface{}{})
var backupsResp BackupsResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &backupsResp); err != nil {
t.Fatalf("decode backups response: %v", err)
}
if len(backupsResp.PBS) != 1 || backupsResp.PBS[0].SizeGB != 1 {
t.Fatalf("unexpected PBS backups: %+v", backupsResp.PBS)
}
if len(backupsResp.PVE) != 1 || backupsResp.PVE[0].SizeGB != 2 {
t.Fatalf("unexpected PVE backups: %+v", backupsResp.PVE)
}
if len(backupsResp.PBSServers) != 1 || len(backupsResp.PBSServers[0].Datastores) != 1 {
t.Fatalf("unexpected PBS servers: %+v", backupsResp.PBSServers)
}
if len(backupsResp.RecentTasks) != 1 {
t.Fatalf("unexpected recent tasks: %+v", backupsResp.RecentTasks)
}
executor.storageProvider = &stubStorageProvider{
storage: []models.Storage{
{
ID: "store1",
Name: "store1",
Type: "zfs",
Status: "active",
Usage: 25.0,
Used: 1024 * 1024 * 1024,
Total: 4 * 1024 * 1024 * 1024,
Free: 3 * 1024 * 1024 * 1024,
Content: "images",
Shared: false,
ZFSPool: &models.ZFSPool{
Name: "tank",
State: "ONLINE",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
Scan: "scrub",
},
},
},
ceph: []models.CephCluster{
{
Name: "ceph1",
Health: "HEALTH_OK",
HealthMessage: "ok",
UsagePercent: 12.5,
UsedBytes: 2 * 1024 * 1024 * 1024 * 1024,
TotalBytes: 4 * 1024 * 1024 * 1024 * 1024,
NumOSDs: 3,
NumOSDsUp: 3,
NumOSDsIn: 3,
NumMons: 1,
NumMgrs: 1,
},
},
}
result, _ = executor.executeListStorage(context.Background(), map[string]interface{}{})
var storageResp StorageResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &storageResp); err != nil {
t.Fatalf("decode storage response: %v", err)
}
if len(storageResp.Pools) != 1 || storageResp.Pools[0].ZFS == nil {
t.Fatalf("unexpected storage pools: %+v", storageResp.Pools)
}
// Verify UsagePercent is passed through directly (not double-multiplied).
// Storage.Usage is already in 0-100 range from safePercentage().
if storageResp.Pools[0].UsagePercent != 25.0 {
t.Errorf("storage pool UsagePercent = %v, want 25.0 (was double-multiplied before fix)", storageResp.Pools[0].UsagePercent)
}
if len(storageResp.CephClusters) != 1 || storageResp.CephClusters[0].UsedTB != 2 {
t.Fatalf("unexpected ceph clusters: %+v", storageResp.CephClusters)
}
}
func TestStorageUsagePercentNotDoubled(t *testing.T) {
// Regression test: Usage is already 0-100 from safePercentage().
// The bug was multiplying by 100 again, turning 25% into 2500%.
executor := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
// PBS datastore path
executor.backupProvider = &stubBackupProvider{
pbs: []models.PBSInstance{
{
Name: "pbs1",
Host: "10.0.0.1",
Status: "online",
Datastores: []models.PBSDatastore{
{Name: "ds1", Usage: 12.3, Free: 1024 * 1024 * 1024},
},
},
},
}
result, _ := executor.executeListBackups(context.Background(), map[string]interface{}{})
var backupsResp BackupsResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &backupsResp); err != nil {
t.Fatalf("decode backups response: %v", err)
}
if len(backupsResp.PBSServers) != 1 || len(backupsResp.PBSServers[0].Datastores) != 1 {
t.Fatalf("unexpected PBS servers: %+v", backupsResp.PBSServers)
}
got := backupsResp.PBSServers[0].Datastores[0].UsagePercent
if got != 12.3 {
t.Errorf("PBS datastore UsagePercent = %v, want 12.3 (double-multiplied = %v)", got, 12.3*100)
}
// Storage pool path
executor.storageProvider = &stubStorageProvider{
storage: []models.Storage{
{
ID: "s1", Name: "s1", Type: "dir", Status: "active",
Usage: 45.7,
Used: 1024 * 1024 * 1024, Total: 4 * 1024 * 1024 * 1024, Free: 3 * 1024 * 1024 * 1024,
},
},
}
result2, _ := executor.executeListStorage(context.Background(), map[string]interface{}{})
var storageResp StorageResponse
if err := json.Unmarshal([]byte(result2.Content[0].Text), &storageResp); err != nil {
t.Fatalf("decode storage response: %v", err)
}
if len(storageResp.Pools) != 1 {
t.Fatalf("unexpected pools: %+v", storageResp.Pools)
}
got2 := storageResp.Pools[0].UsagePercent
if got2 != 45.7 {
t.Errorf("storage pool UsagePercent = %v, want 45.7 (double-multiplied = %v)", got2, 45.7*100)
}
}
func TestDockerUpdateTools(t *testing.T) {
state := models.StateSnapshot{
DockerHosts: []models.DockerHost{
{
ID: "host1",
Hostname: "docker1",
DisplayName: "Docker One",
Containers: []models.DockerContainer{
{ID: "c1", Name: "/nginx"},
},
},
},
}
stateProv := &mockStateProvider{}
stateProv.On("GetState").Return(state)
executor := NewPulseToolExecutor(ExecutorConfig{
StateProvider: stateProv,
ControlLevel: ControlLevelControlled,
})
updates := &stubUpdatesProvider{
pending: []ContainerUpdateInfo{
{HostID: "host1", ContainerID: "c1", ContainerName: "nginx", UpdateAvailable: true},
},
enabled: true,
triggerStatus: DockerCommandStatus{
ID: "cmd1",
Type: "check",
Status: "queued",
},
updateStatus: DockerCommandStatus{
ID: "cmd2",
Type: "update",
Status: "queued",
},
}
executor.updatesProvider = updates
result, _ := executor.executeListDockerUpdates(context.Background(), map[string]interface{}{"host": "Docker One"})
var listResp DockerUpdatesResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &listResp); err != nil {
t.Fatalf("decode docker updates: %v", err)
}
if listResp.HostID != "host1" || listResp.Total != 1 {
t.Fatalf("unexpected docker updates response: %+v", listResp)
}
result, _ = executor.executeCheckDockerUpdates(context.Background(), map[string]interface{}{"host": "Docker One"})
var checkResp DockerCheckUpdatesResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &checkResp); err != nil {
t.Fatalf("decode docker check response: %v", err)
}
if checkResp.CommandID != "cmd1" || checkResp.HostID != "host1" {
t.Fatalf("unexpected check response: %+v", checkResp)
}
executor.controlLevel = ControlLevelAutonomous
result, _ = executor.executeUpdateDockerContainer(context.Background(), map[string]interface{}{
"host": "Docker One",
"container": "c1",
})
var updateResp DockerUpdateContainerResponse
if err := json.Unmarshal([]byte(result.Content[0].Text), &updateResp); err != nil {
t.Fatalf("decode update response: %v", err)
}
if updates.lastUpdateName != "nginx" || updateResp.CommandID != "cmd2" {
t.Fatalf("unexpected update response: %+v", updateResp)
}
updates.enabled = false
result, _ = executor.executeUpdateDockerContainer(context.Background(), map[string]interface{}{
"host": "Docker One",
"container": "c1",
})
if !strings.Contains(result.Content[0].Text, "updates are disabled") {
t.Fatalf("unexpected disabled response: %s", result.Content[0].Text)
}
}