mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:22:17 +00:00
2f6bb94ed3
A vSphere 6.x target fails the connect test at the Automation session step with a generic "HTTP 500" message, because /api on those releases routes to the JSON-RPC servlet. When /api/session fails with a non-auth status, probe the legacy /rest CIS session API with the same credentials; if that login works the target predates the JSON APIs Pulse uses, so the test now reports the unsupported-version warning naming the vCenter 8.0U1 floor instead of pointing at credentials. The probe deletes the session it creates. Also reword the frontend guidance for that category in plain terms. Related to #1585.
1162 lines
46 KiB
Go
1162 lines
46 KiB
Go
package vmware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestClientCollectInventoryEnrichesSignals(t *testing.T) {
|
|
server := newVMwareTestServer(t, vmwareTestServerConfig{})
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
snapshot, err := client.CollectInventory(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CollectInventory: %v", err)
|
|
}
|
|
if snapshot == nil {
|
|
t.Fatal("expected inventory snapshot")
|
|
}
|
|
if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 || len(snapshot.Networks) != 1 {
|
|
t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d networks=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores), len(snapshot.Networks))
|
|
}
|
|
|
|
host := snapshot.Hosts[0]
|
|
if host.OverallStatus != "yellow" {
|
|
t.Fatalf("host overall status = %q, want yellow", host.OverallStatus)
|
|
}
|
|
if host.ClusterName != "Prod Compute" || host.DatacenterName != "DC1" {
|
|
t.Fatalf("expected host placement enrichment, got cluster=%q datacenter=%q", host.ClusterName, host.DatacenterName)
|
|
}
|
|
if host.ClusterHAEnabled == nil || !*host.ClusterHAEnabled || host.ClusterDRSEnabled == nil || *host.ClusterDRSEnabled {
|
|
t.Fatalf("expected host cluster service enrichment HA=true DRS=false, got ha=%+v drs=%+v", host.ClusterHAEnabled, host.ClusterDRSEnabled)
|
|
}
|
|
if len(host.DatastoreNames) != 1 || host.DatastoreNames[0] != "nvme-primary" {
|
|
t.Fatalf("expected host datastore names, got %+v", host.DatastoreNames)
|
|
}
|
|
if host.Metrics == nil || host.Metrics.CPUPercent == nil || *host.Metrics.CPUPercent != 21.4 {
|
|
t.Fatalf("expected host cpu metrics, got %+v", host.Metrics)
|
|
}
|
|
if len(host.TriggeredAlarms) != 1 || host.TriggeredAlarms[0].Name != "Host connection degraded" {
|
|
t.Fatalf("expected resolved host alarm metadata, got %+v", host.TriggeredAlarms)
|
|
}
|
|
if len(host.RecentTasks) != 1 || host.RecentTasks[0].Name != "Reconnect host" {
|
|
t.Fatalf("expected host recent task summary, got %+v", host.RecentTasks)
|
|
}
|
|
if len(host.RecentEvents) != 1 || host.RecentEvents[0].Type != "HostConnectionStateEvent" {
|
|
t.Fatalf("expected host recent event summary, got %+v", host.RecentEvents)
|
|
}
|
|
|
|
vm := snapshot.VMs[0]
|
|
if vm.Metrics == nil || vm.Metrics.MemoryPercent == nil || *vm.Metrics.MemoryPercent != 57.5 {
|
|
t.Fatalf("expected VM memory metrics, got %+v", vm.Metrics)
|
|
}
|
|
if len(vm.TriggeredAlarms) != 1 || vm.TriggeredAlarms[0].OverallStatus != "red" {
|
|
t.Fatalf("expected VM alarm signals, got %+v", vm.TriggeredAlarms)
|
|
}
|
|
if vm.RuntimeHostName != "esxi-01.lab.local" || vm.ResourcePoolName != "Tier 1" {
|
|
t.Fatalf("expected VM placement enrichment, got host=%q pool=%q", vm.RuntimeHostName, vm.ResourcePoolName)
|
|
}
|
|
if vm.ClusterHAEnabled == nil || !*vm.ClusterHAEnabled || vm.ClusterDRSEnabled == nil || *vm.ClusterDRSEnabled {
|
|
t.Fatalf("expected VM cluster service enrichment HA=true DRS=false, got ha=%+v drs=%+v", vm.ClusterHAEnabled, vm.ClusterDRSEnabled)
|
|
}
|
|
if vm.InstanceUUID != "vm-instance-201" || vm.BIOSUUID != "vm-bios-201" {
|
|
t.Fatalf("expected VM identity enrichment, got instance=%q bios=%q", vm.InstanceUUID, vm.BIOSUUID)
|
|
}
|
|
if vm.GuestHostname != "app-01.internal" || len(vm.GuestIPAddresses) != 1 || vm.GuestIPAddresses[0] != "10.0.0.21" {
|
|
t.Fatalf("expected VM guest identity enrichment, got host=%q ips=%v", vm.GuestHostname, vm.GuestIPAddresses)
|
|
}
|
|
if vm.Hardware == nil {
|
|
t.Fatalf("expected VM hardware enrichment")
|
|
}
|
|
if vm.Hardware.Version != "VMX_20" || vm.Hardware.UpgradePolicy != "AFTER_CLEAN_SHUTDOWN" || vm.Hardware.UpgradeStatus != "PENDING" {
|
|
t.Fatalf("unexpected VM hardware upgrade context: %+v", vm.Hardware)
|
|
}
|
|
if vm.Hardware.GuestOS != "UBUNTU_64" {
|
|
t.Fatalf("expected VM hardware guest OS, got %+v", vm.Hardware.GuestOS)
|
|
}
|
|
if vm.CPUCount != 6 || vm.Hardware.CPUCoresPerSocket == nil || *vm.Hardware.CPUCoresPerSocket != 3 {
|
|
t.Fatalf("expected VM CPU topology from VM detail, got cpu=%d hardware=%+v", vm.CPUCount, vm.Hardware)
|
|
}
|
|
if vm.MemorySizeMiB != 12288 || vm.Hardware.MemoryHotAddLimitMiB == nil || *vm.Hardware.MemoryHotAddLimitMiB != 24576 {
|
|
t.Fatalf("expected VM memory detail from VM detail, got memory=%d hardware=%+v", vm.MemorySizeMiB, vm.Hardware)
|
|
}
|
|
if len(vm.Hardware.BootDevices) != 2 || vm.Hardware.BootDevices[0].Disks[0] != "2000" || vm.Hardware.BootDevices[1].NIC != "4000" {
|
|
t.Fatalf("expected VM boot device order, got %+v", vm.Hardware.BootDevices)
|
|
}
|
|
if vm.Tools == nil {
|
|
t.Fatalf("expected VM tools enrichment")
|
|
}
|
|
if vm.Tools.RunState != "RUNNING" || vm.Tools.VersionStatus != "CURRENT" || vm.Tools.Version != "12.4.0" {
|
|
t.Fatalf("unexpected VM tools status: %+v", vm.Tools)
|
|
}
|
|
if vm.Tools.AutoUpdateSupported == nil || !*vm.Tools.AutoUpdateSupported {
|
|
t.Fatalf("expected VM tools auto-update support, got %+v", vm.Tools.AutoUpdateSupported)
|
|
}
|
|
if vm.Tools.InstallAttemptCount == nil || *vm.Tools.InstallAttemptCount != 1 {
|
|
t.Fatalf("expected VM tools install attempt count, got %+v", vm.Tools.InstallAttemptCount)
|
|
}
|
|
if vm.Tools.GuestRebootRequested == nil || !*vm.Tools.GuestRebootRequested || len(vm.Tools.GuestRebootComponents) != 1 || vm.Tools.GuestRebootComponents[0] != "drivers" {
|
|
t.Fatalf("expected VM tools reboot request context, got %+v", vm.Tools)
|
|
}
|
|
if len(vm.NetworkAdapters) != 1 {
|
|
t.Fatalf("expected one VM network adapter, got %+v", vm.NetworkAdapters)
|
|
}
|
|
adapter := vm.NetworkAdapters[0]
|
|
if adapter.NIC != "4000" || adapter.Label != "Network adapter 1" || adapter.Type != "VMXNET3" {
|
|
t.Fatalf("unexpected VM network adapter identity: %+v", adapter)
|
|
}
|
|
if adapter.MACAddress != "00:50:56:aa:bb:cc" || adapter.NetworkName != "VM Network" || adapter.State != "CONNECTED" {
|
|
t.Fatalf("unexpected VM network adapter backing/state: %+v", adapter)
|
|
}
|
|
if adapter.PCISlotNumber == nil || *adapter.PCISlotNumber != 160 {
|
|
t.Fatalf("expected VM network adapter PCI slot, got %+v", adapter.PCISlotNumber)
|
|
}
|
|
if !adapter.StartConnected || !adapter.AllowGuestControl || !adapter.WakeOnLANEnabled {
|
|
t.Fatalf("expected VM network adapter connection flags, got %+v", adapter)
|
|
}
|
|
if len(vm.VirtualDisks) != 1 {
|
|
t.Fatalf("expected one VM virtual disk, got %+v", vm.VirtualDisks)
|
|
}
|
|
disk := vm.VirtualDisks[0]
|
|
if disk.Disk != "2000" || disk.Label != "Hard disk 1" || disk.Type != "SCSI" {
|
|
t.Fatalf("unexpected VM virtual disk identity: %+v", disk)
|
|
}
|
|
if disk.SCSIBus == nil || *disk.SCSIBus != 0 || disk.SCSIUnit == nil || *disk.SCSIUnit != 1 {
|
|
t.Fatalf("expected VM virtual disk SCSI address, got bus=%+v unit=%+v", disk.SCSIBus, disk.SCSIUnit)
|
|
}
|
|
if disk.BackingType != "VMDK_FILE" || disk.VMDKFile != "[nvme-primary] app-01/app-01.vmdk" || disk.DatastoreName != "nvme-primary" {
|
|
t.Fatalf("unexpected VM virtual disk backing: %+v", disk)
|
|
}
|
|
if disk.CapacityBytes == nil || *disk.CapacityBytes != 107374182400 {
|
|
t.Fatalf("expected VM virtual disk capacity, got %+v", disk.CapacityBytes)
|
|
}
|
|
if vm.SnapshotCount != 2 {
|
|
t.Fatalf("vm snapshot count = %d, want 2", vm.SnapshotCount)
|
|
}
|
|
if vm.CurrentSnapshotID != "snapshot-202" {
|
|
t.Fatalf("vm current snapshot id = %q, want snapshot-202", vm.CurrentSnapshotID)
|
|
}
|
|
if len(vm.SnapshotTree) != 1 || vm.SnapshotTree[0].Name != "pre-upgrade" || len(vm.SnapshotTree[0].Children) != 1 {
|
|
t.Fatalf("expected VM snapshot tree with one child, got %+v", vm.SnapshotTree)
|
|
}
|
|
if !vm.SnapshotTree[0].Quiesced || !vm.SnapshotTree[0].Children[0].Current {
|
|
t.Fatalf("expected quiesced root and current child snapshot, got %+v", vm.SnapshotTree)
|
|
}
|
|
if len(vm.RecentTasks) != 1 || vm.RecentTasks[0].State != "success" {
|
|
t.Fatalf("expected VM recent task info, got %+v", vm.RecentTasks)
|
|
}
|
|
if len(vm.RecentEvents) != 1 || vm.RecentEvents[0].Type != "VmMessageEvent" {
|
|
t.Fatalf("expected VM recent event info, got %+v", vm.RecentEvents)
|
|
}
|
|
|
|
datastore := snapshot.Datastores[0]
|
|
if datastore.OverallStatus != "yellow" {
|
|
t.Fatalf("datastore overall status = %q, want yellow", datastore.OverallStatus)
|
|
}
|
|
if datastore.Accessible == nil || !*datastore.Accessible {
|
|
t.Fatalf("expected datastore accessibility enrichment, got %+v", datastore.Accessible)
|
|
}
|
|
if len(datastore.HostNames) != 1 || datastore.HostNames[0] != "esxi-01.lab.local" {
|
|
t.Fatalf("expected datastore host attachment enrichment, got %+v", datastore.HostNames)
|
|
}
|
|
if len(datastore.VMNames) != 1 || datastore.VMNames[0] != "app-01" {
|
|
t.Fatalf("expected datastore VM attachment enrichment, got %+v", datastore.VMNames)
|
|
}
|
|
if len(datastore.RecentTasks) != 1 || datastore.RecentTasks[0].Name != "Refresh datastore" {
|
|
t.Fatalf("expected datastore recent task info, got %+v", datastore.RecentTasks)
|
|
}
|
|
if len(datastore.RecentEvents) != 1 || datastore.RecentEvents[0].Type != "DatastoreRenamedEvent" {
|
|
t.Fatalf("expected datastore recent event info, got %+v", datastore.RecentEvents)
|
|
}
|
|
|
|
network := snapshot.Networks[0]
|
|
if network.OverallStatus != "green" {
|
|
t.Fatalf("network overall status = %q, want green", network.OverallStatus)
|
|
}
|
|
if network.DatacenterName != "DC1" || network.FolderName != "Networks" {
|
|
t.Fatalf("expected network placement enrichment, got datacenter=%q folder=%q", network.DatacenterName, network.FolderName)
|
|
}
|
|
if len(network.HostNames) != 1 || network.HostNames[0] != "esxi-01.lab.local" {
|
|
t.Fatalf("expected network host attachment enrichment, got %+v", network.HostNames)
|
|
}
|
|
if len(network.VMNames) != 1 || network.VMNames[0] != "app-01" {
|
|
t.Fatalf("expected network VM attachment enrichment, got %+v", network.VMNames)
|
|
}
|
|
if len(network.RecentEvents) != 1 || network.RecentEvents[0].Type != "NetworkEvent" {
|
|
t.Fatalf("expected network recent event info, got %+v", network.RecentEvents)
|
|
}
|
|
}
|
|
|
|
func TestClientCollectInventoryPreservesBaseInventoryWhenOptionalEnrichmentDegrades(t *testing.T) {
|
|
server := newVMwareTestServer(t, vmwareTestServerConfig{
|
|
denyHostOverallStatus: true,
|
|
unavailableVMGuestInfo: true,
|
|
denyClusterInventory: true,
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
snapshot, err := client.CollectInventory(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("CollectInventory: %v", err)
|
|
}
|
|
if snapshot == nil {
|
|
t.Fatal("expected inventory snapshot")
|
|
}
|
|
if len(snapshot.Hosts) != 1 || len(snapshot.VMs) != 1 || len(snapshot.Datastores) != 1 || len(snapshot.Networks) != 1 {
|
|
t.Fatalf("unexpected inventory sizes: hosts=%d vms=%d datastores=%d networks=%d", len(snapshot.Hosts), len(snapshot.VMs), len(snapshot.Datastores), len(snapshot.Networks))
|
|
}
|
|
// The unavailableVMGuestInfo knob now degrades two REST reads: the
|
|
// guest identity endpoint (topology stage) and the guest local
|
|
// filesystem endpoint (signals stage). That plus the per-stage
|
|
// permission denials gives four issues.
|
|
if len(snapshot.EnrichmentIssues) != 4 {
|
|
t.Fatalf("expected 4 enrichment issues, got %+v", snapshot.EnrichmentIssues)
|
|
}
|
|
seen := make(map[string]InventoryEnrichmentIssue, len(snapshot.EnrichmentIssues))
|
|
for _, issue := range snapshot.EnrichmentIssues {
|
|
key := issue.Stage + "/" + issue.EntityType + "/" + issue.Category
|
|
seen[key] = issue
|
|
}
|
|
if issue, ok := seen["signals/host/permission"]; !ok {
|
|
t.Fatalf("expected signals/host permission issue, got %+v", snapshot.EnrichmentIssues)
|
|
} else if !strings.Contains(issue.Message, "overall status") {
|
|
t.Fatalf("unexpected signals/host permission message: %+v", issue)
|
|
}
|
|
if issue, ok := seen["signals/vm/unavailable"]; !ok {
|
|
t.Fatalf("expected signals/vm unavailable issue for guest local filesystem, got %+v", snapshot.EnrichmentIssues)
|
|
} else if !strings.Contains(issue.Message, "guest local filesystem") {
|
|
t.Fatalf("unexpected signals/vm unavailable message: %+v", issue)
|
|
}
|
|
if issue, ok := seen["topology/cluster/permission"]; !ok {
|
|
t.Fatalf("expected topology/cluster permission issue, got %+v", snapshot.EnrichmentIssues)
|
|
} else if !strings.Contains(issue.Message, "cluster inventory") {
|
|
t.Fatalf("unexpected topology/cluster message: %+v", issue)
|
|
}
|
|
if issue, ok := seen["topology/vm/unavailable"]; !ok {
|
|
t.Fatalf("expected topology/vm unavailable issue for guest identity, got %+v", snapshot.EnrichmentIssues)
|
|
} else if !strings.Contains(issue.Message, "guest identity") {
|
|
t.Fatalf("unexpected topology/vm unavailable message: %+v", issue)
|
|
}
|
|
|
|
host := snapshot.Hosts[0]
|
|
if host.OverallStatus != "" {
|
|
t.Fatalf("expected missing host overall status after degraded read, got %q", host.OverallStatus)
|
|
}
|
|
if host.Metrics == nil || host.Metrics.CPUPercent == nil || *host.Metrics.CPUPercent != 21.4 {
|
|
t.Fatalf("expected host metrics to survive degraded signal read, got %+v", host.Metrics)
|
|
}
|
|
if host.ClusterName != "Prod Compute" || host.DatacenterName != "DC1" {
|
|
t.Fatalf("expected host placement enrichment to survive degraded signal read, got cluster=%q datacenter=%q", host.ClusterName, host.DatacenterName)
|
|
}
|
|
if host.ClusterHAEnabled != nil || host.ClusterDRSEnabled != nil {
|
|
t.Fatalf("expected cluster services to stay empty after degraded cluster inventory read, got ha=%+v drs=%+v", host.ClusterHAEnabled, host.ClusterDRSEnabled)
|
|
}
|
|
|
|
vm := snapshot.VMs[0]
|
|
if vm.GuestHostname != "" || len(vm.GuestIPAddresses) != 0 {
|
|
t.Fatalf("expected guest identity to stay empty after degraded topology read, got host=%q ips=%v", vm.GuestHostname, vm.GuestIPAddresses)
|
|
}
|
|
if vm.Hardware == nil || vm.Hardware.Version != "VMX_20" {
|
|
t.Fatalf("expected hardware enrichment to survive degraded guest read, got %+v", vm.Hardware)
|
|
}
|
|
if vm.Tools == nil || vm.Tools.RunState != "RUNNING" {
|
|
t.Fatalf("expected tools enrichment to survive degraded guest read, got %+v", vm.Tools)
|
|
}
|
|
if len(vm.NetworkAdapters) != 1 || vm.NetworkAdapters[0].NetworkName != "VM Network" {
|
|
t.Fatalf("expected network adapter enrichment to survive degraded guest read, got %+v", vm.NetworkAdapters)
|
|
}
|
|
if len(vm.VirtualDisks) != 1 || vm.VirtualDisks[0].DatastoreName != "nvme-primary" {
|
|
t.Fatalf("expected virtual disk enrichment to survive degraded guest read, got %+v", vm.VirtualDisks)
|
|
}
|
|
if vm.RuntimeHostName != "esxi-01.lab.local" || vm.ResourcePoolName != "Tier 1" {
|
|
t.Fatalf("expected other topology enrichment to survive degraded guest read, got host=%q pool=%q", vm.RuntimeHostName, vm.ResourcePoolName)
|
|
}
|
|
if vm.ClusterHAEnabled != nil || vm.ClusterDRSEnabled != nil {
|
|
t.Fatalf("expected VM cluster services to stay empty after degraded cluster inventory read, got ha=%+v drs=%+v", vm.ClusterHAEnabled, vm.ClusterDRSEnabled)
|
|
}
|
|
}
|
|
|
|
func TestClientTestConnectionRequiresSignalFloor(t *testing.T) {
|
|
server := newVMwareTestServer(t, vmwareTestServerConfig{
|
|
omitHostOverallStatus: true,
|
|
})
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
_, err = client.TestConnection(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected signal-floor validation error")
|
|
}
|
|
connectionErr, ok := err.(*ConnectionError)
|
|
if !ok {
|
|
t.Fatalf("expected ConnectionError, got %T", err)
|
|
}
|
|
if connectionErr.Category != "not_found" {
|
|
t.Fatalf("connection error category = %q, want not_found", connectionErr.Category)
|
|
}
|
|
}
|
|
|
|
func TestClientResolveVIJSONReleaseFallsBackToSupportedProbeFloor(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/sdk/vim25/8.0.3/ServiceInstance/ServiceInstance/content", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"sessionManager": map[string]any{"value": "SessionManager"},
|
|
"perfManager": map[string]any{"value": "PerformanceManager"},
|
|
"eventManager": map[string]any{"value": "EventManager"},
|
|
}); err != nil {
|
|
t.Fatalf("encode service content: %v", err)
|
|
}
|
|
})
|
|
server := httptest.NewTLSServer(mux)
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
release, refs, err := client.resolveVIJSONRelease(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("resolveVIJSONRelease: %v", err)
|
|
}
|
|
if release != "8.0.3" {
|
|
t.Fatalf("release = %q, want 8.0.3", release)
|
|
}
|
|
if refs.SessionManagerMoID != "SessionManager" || refs.PerfManagerMoID != "PerformanceManager" || refs.EventManagerMoID != "EventManager" {
|
|
t.Fatalf("unexpected refs: %+v", refs)
|
|
}
|
|
}
|
|
|
|
func TestClientResolveVIJSONReleaseClassifiesUnsupportedVersionFloor(t *testing.T) {
|
|
server := httptest.NewTLSServer(http.NewServeMux())
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
_, _, err = client.resolveVIJSONRelease(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected unsupported version error")
|
|
}
|
|
connectionErr, ok := err.(*ConnectionError)
|
|
if !ok {
|
|
t.Fatalf("expected ConnectionError, got %T", err)
|
|
}
|
|
if connectionErr.Category != "unsupported_version" {
|
|
t.Fatalf("connection error category = %q, want unsupported_version", connectionErr.Category)
|
|
}
|
|
if !strings.Contains(connectionErr.Message, "9.0.0.0") || !strings.Contains(connectionErr.Message, "8.0.1.0") {
|
|
t.Fatalf("expected message to mention implemented probe floor, got %q", connectionErr.Message)
|
|
}
|
|
}
|
|
|
|
type vmwareTestServerConfig struct {
|
|
omitHostOverallStatus bool
|
|
denyHostOverallStatus bool
|
|
denyClusterInventory bool
|
|
unavailableVMGuestInfo bool
|
|
}
|
|
|
|
func newVMwareTestServer(t *testing.T, cfg vmwareTestServerConfig) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
writeJSON := func(w http.ResponseWriter, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
writeJSON(w, "automation-session")
|
|
})
|
|
|
|
mux.HandleFunc("/api/vcenter/host", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []InventoryHost{{
|
|
Host: "host-101",
|
|
Name: "esxi-01.lab.local",
|
|
ConnectionState: "CONNECTED",
|
|
PowerState: "POWERED_ON",
|
|
HostUUID: "uuid-host-1",
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []InventoryVM{{
|
|
VM: "vm-201",
|
|
Name: "app-01",
|
|
PowerState: "POWERED_ON",
|
|
CPUCount: 4,
|
|
MemorySizeMiB: 8192,
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/datastore", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []InventoryDatastore{{
|
|
Datastore: "datastore-11",
|
|
Name: "nvme-primary",
|
|
Type: "VMFS",
|
|
FreeSpace: 40,
|
|
Capacity: 100,
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/network", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []InventoryNetwork{{
|
|
Network: "network-101",
|
|
Name: "VM Network",
|
|
Type: "STANDARD_PORTGROUP",
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/cluster", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
if cfg.denyClusterInventory {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
writeJSON(w, []map[string]any{{
|
|
"cluster": "domain-c101",
|
|
"name": "Prod Compute",
|
|
"ha_enabled": true,
|
|
"drs_enabled": false,
|
|
}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"guest_os": "UBUNTU_64",
|
|
"power_state": "POWERED_ON",
|
|
"instant_clone_frozen": false,
|
|
"identity": map[string]any{
|
|
"bios_uuid": "vm-bios-201",
|
|
"instance_uuid": "vm-instance-201",
|
|
},
|
|
"hardware": map[string]any{
|
|
"version": "VMX_20",
|
|
"upgrade_policy": "AFTER_CLEAN_SHUTDOWN",
|
|
"upgrade_version": "VMX_21",
|
|
"upgrade_status": "PENDING",
|
|
},
|
|
"boot": map[string]any{
|
|
"type": "EFI",
|
|
"efi_legacy_boot": false,
|
|
"network_protocol": "IPV4",
|
|
"delay": 5000,
|
|
"retry": true,
|
|
"retry_delay": 10000,
|
|
"enter_setup_mode": false,
|
|
},
|
|
"boot_devices": []map[string]any{
|
|
{"type": "DISK", "disks": []string{"2000"}},
|
|
{"type": "ETHERNET", "nic": "4000"},
|
|
},
|
|
"cpu": map[string]any{
|
|
"count": 6,
|
|
"cores_per_socket": 3,
|
|
"hot_add_enabled": true,
|
|
"hot_remove_enabled": false,
|
|
},
|
|
"memory": map[string]any{
|
|
"size_mib": 12288,
|
|
"hot_add_enabled": true,
|
|
"hot_add_increment_size_mib": 256,
|
|
"hot_add_limit_mib": 24576,
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/guest/identity", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
if cfg.unavailableVMGuestInfo {
|
|
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"family": "LINUX",
|
|
"host_name": "app-01.internal",
|
|
"ip_address": "10.0.0.21",
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/guest/local-filesystem", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
// VMware Tools reports guest filesystem usage; the unavailable knob
|
|
// stands in for "Tools not running" which vCenter signals via 503.
|
|
if cfg.unavailableVMGuestInfo {
|
|
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"/": map[string]any{
|
|
"capacity": int64(50 * 1024 * 1024 * 1024),
|
|
"free_space": int64(20 * 1024 * 1024 * 1024),
|
|
"filesystem": "ext4",
|
|
},
|
|
"/var": map[string]any{
|
|
"capacity": int64(20 * 1024 * 1024 * 1024),
|
|
"free_space": int64(5 * 1024 * 1024 * 1024),
|
|
"filesystem": "ext4",
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/tools", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"auto_update_supported": true,
|
|
"install_attempt_count": 1,
|
|
"version_number": 12352,
|
|
"version": "12.4.0",
|
|
"upgrade_policy": "MANUAL",
|
|
"version_status": "CURRENT",
|
|
"install_type": "OPEN_VM_TOOLS",
|
|
"run_state": "RUNNING",
|
|
"guest_reboot_status": map[string]any{
|
|
"reboot_requested": true,
|
|
"requesting_components": []string{"drivers"},
|
|
"request_timestamp": "2026-03-30T18:20:00Z",
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/hardware/ethernet", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []map[string]any{{"nic": "4000"}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/hardware/ethernet/4000", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"label": "Network adapter 1",
|
|
"type": "VMXNET3",
|
|
"upt_compatibility_enabled": true,
|
|
"upt_v2_compatibility_enabled": false,
|
|
"mac_type": "GENERATED",
|
|
"mac_address": "00:50:56:aa:bb:cc",
|
|
"pci_slot_number": 160,
|
|
"wake_on_lan_enabled": true,
|
|
"backing": map[string]any{
|
|
"type": "STANDARD_PORTGROUP",
|
|
"network": "network-101",
|
|
"network_name": "VM Network",
|
|
"distributed_switch_uuid": "",
|
|
"distributed_port": "",
|
|
},
|
|
"state": "CONNECTED",
|
|
"start_connected": true,
|
|
"allow_guest_control": true,
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/hardware/disk", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, []map[string]any{{"disk": "2000"}})
|
|
})
|
|
mux.HandleFunc("/api/vcenter/vm/vm-201/hardware/disk/2000", func(w http.ResponseWriter, r *http.Request) {
|
|
requireAutomationSession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"label": "Hard disk 1",
|
|
"type": "SCSI",
|
|
"scsi": map[string]any{
|
|
"bus": 0,
|
|
"unit": 1,
|
|
},
|
|
"backing": map[string]any{
|
|
"type": "VMDK_FILE",
|
|
"vmdk_file": "[nvme-primary] app-01/app-01.vmdk",
|
|
},
|
|
"capacity": 107374182400,
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/ServiceInstance/ServiceInstance/content", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, map[string]any{
|
|
"sessionManager": map[string]any{"value": "SessionManager"},
|
|
"perfManager": map[string]any{"value": "PerformanceManager"},
|
|
"eventManager": map[string]any{"value": "EventManager"},
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/SessionManager/SessionManager/Login", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("vmware-api-session-id", "vi-session")
|
|
writeJSON(w, map[string]any{"value": "ok"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/PerformanceManager/PerformanceManager/perfCounter", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{
|
|
{"key": 1, "groupInfo": map[string]any{"key": "cpu"}, "nameInfo": map[string]any{"key": "usage"}, "rollupType": "average"},
|
|
{"key": 2, "groupInfo": map[string]any{"key": "mem"}, "nameInfo": map[string]any{"key": "usage"}, "rollupType": "average"},
|
|
{"key": 3, "groupInfo": map[string]any{"key": "mem"}, "nameInfo": map[string]any{"key": "totalCapacity"}, "rollupType": "average"},
|
|
{"key": 4, "groupInfo": map[string]any{"key": "net"}, "nameInfo": map[string]any{"key": "bytesRx"}, "rollupType": "average"},
|
|
{"key": 5, "groupInfo": map[string]any{"key": "net"}, "nameInfo": map[string]any{"key": "bytesTx"}, "rollupType": "average"},
|
|
{"key": 6, "groupInfo": map[string]any{"key": "disk"}, "nameInfo": map[string]any{"key": "read"}, "rollupType": "average"},
|
|
{"key": 7, "groupInfo": map[string]any{"key": "disk"}, "nameInfo": map[string]any{"key": "write"}, "rollupType": "average"},
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/PerformanceManager/PerformanceManager/QueryPerfProviderSummary", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"currentSupported": true,
|
|
"refreshRate": 20,
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/PerformanceManager/PerformanceManager/QueryAvailablePerfMetric", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
var request struct {
|
|
Entity struct {
|
|
Type string `json:"type"`
|
|
} `json:"entity"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode available perf request: %v", err)
|
|
}
|
|
switch request.Entity.Type {
|
|
case "HostSystem":
|
|
writeJSON(w, []map[string]any{
|
|
{"counterId": 1, "instance": ""},
|
|
{"counterId": 2, "instance": ""},
|
|
{"counterId": 3, "instance": ""},
|
|
{"counterId": 4, "instance": "vmnic0"},
|
|
{"counterId": 5, "instance": "vmnic0"},
|
|
{"counterId": 6, "instance": "naa.1"},
|
|
{"counterId": 7, "instance": "naa.1"},
|
|
})
|
|
case "VirtualMachine":
|
|
writeJSON(w, []map[string]any{
|
|
{"counterId": 1, "instance": ""},
|
|
{"counterId": 2, "instance": ""},
|
|
{"counterId": 4, "instance": "4000"},
|
|
{"counterId": 5, "instance": "4000"},
|
|
{"counterId": 6, "instance": "2000"},
|
|
{"counterId": 7, "instance": "2000"},
|
|
})
|
|
default:
|
|
writeJSON(w, []map[string]any{})
|
|
}
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/PerformanceManager/PerformanceManager/QueryPerf", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
var request struct {
|
|
QuerySpec []struct {
|
|
Entity struct {
|
|
Type string `json:"type"`
|
|
Value string `json:"value"`
|
|
} `json:"entity"`
|
|
} `json:"querySpec"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode query perf request: %v", err)
|
|
}
|
|
if len(request.QuerySpec) != 1 {
|
|
t.Fatalf("expected single query spec, got %d", len(request.QuerySpec))
|
|
}
|
|
switch request.QuerySpec[0].Entity.Type {
|
|
case "HostSystem":
|
|
writeJSON(w, []map[string]any{{
|
|
"entity": map[string]any{"type": "HostSystem", "value": "host-101"},
|
|
"value": []map[string]any{
|
|
{"id": map[string]any{"counterId": 1, "instance": ""}, "value": []int64{2140}},
|
|
{"id": map[string]any{"counterId": 2, "instance": ""}, "value": []int64{6320}},
|
|
{"id": map[string]any{"counterId": 3, "instance": ""}, "value": []int64{40960}},
|
|
{"id": map[string]any{"counterId": 4, "instance": "vmnic0"}, "value": []int64{1}},
|
|
{"id": map[string]any{"counterId": 5, "instance": "vmnic0"}, "value": []int64{2}},
|
|
{"id": map[string]any{"counterId": 6, "instance": "naa.1"}, "value": []int64{4}},
|
|
{"id": map[string]any{"counterId": 7, "instance": "naa.1"}, "value": []int64{8}},
|
|
},
|
|
}})
|
|
case "VirtualMachine":
|
|
writeJSON(w, []map[string]any{{
|
|
"entity": map[string]any{"type": "VirtualMachine", "value": "vm-201"},
|
|
"value": []map[string]any{
|
|
{"id": map[string]any{"counterId": 1, "instance": ""}, "value": []int64{3810}},
|
|
{"id": map[string]any{"counterId": 2, "instance": ""}, "value": []int64{5750}},
|
|
{"id": map[string]any{"counterId": 4, "instance": "4000"}, "value": []int64{3}},
|
|
{"id": map[string]any{"counterId": 5, "instance": "4000"}, "value": []int64{4}},
|
|
{"id": map[string]any{"counterId": 6, "instance": "2000"}, "value": []int64{5}},
|
|
{"id": map[string]any{"counterId": 7, "instance": "2000"}, "value": []int64{6}},
|
|
},
|
|
}})
|
|
default:
|
|
writeJSON(w, []map[string]any{})
|
|
}
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/EventManager/EventManager/QueryEvents", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
var request struct {
|
|
Filter struct {
|
|
Entity *struct {
|
|
Entity struct {
|
|
Type string `json:"type"`
|
|
Value string `json:"value"`
|
|
} `json:"entity"`
|
|
Recursion string `json:"recursion"`
|
|
} `json:"entity"`
|
|
MaxCount int `json:"maxCount"`
|
|
} `json:"filter"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode query events request: %v", err)
|
|
}
|
|
if request.Filter.Entity == nil {
|
|
t.Fatal("expected event filter entity")
|
|
}
|
|
if request.Filter.Entity.Recursion != "self" {
|
|
t.Fatalf("event recursion = %q, want self", request.Filter.Entity.Recursion)
|
|
}
|
|
if request.Filter.MaxCount != 3 {
|
|
t.Fatalf("event maxCount = %d, want 3", request.Filter.MaxCount)
|
|
}
|
|
switch request.Filter.Entity.Entity.Type {
|
|
case "HostSystem":
|
|
writeJSON(w, []map[string]any{{
|
|
"key": 101,
|
|
"userName": "administrator@vsphere.local",
|
|
"createdTime": "2026-03-30T18:15:00Z",
|
|
"fullFormattedMessage": "Host entered maintenance evaluation",
|
|
"eventTypeId": "HostConnectionStateEvent",
|
|
}})
|
|
case "VirtualMachine":
|
|
writeJSON(w, []map[string]any{{
|
|
"key": 201,
|
|
"userName": "vpxuser",
|
|
"createdTime": "2026-03-30T18:12:00Z",
|
|
"fullFormattedMessage": "Snapshot completed successfully",
|
|
"eventTypeId": "VmMessageEvent",
|
|
}})
|
|
case "Datastore":
|
|
writeJSON(w, []map[string]any{{
|
|
"key": 301,
|
|
"userName": "storage-admin",
|
|
"createdTime": "2026-03-30T18:09:00Z",
|
|
"fullFormattedMessage": "Datastore metadata refreshed",
|
|
"eventTypeId": "DatastoreRenamedEvent",
|
|
}})
|
|
case "Network":
|
|
writeJSON(w, []map[string]any{{
|
|
"key": 401,
|
|
"userName": "network-admin",
|
|
"createdTime": "2026-03-30T18:07:00Z",
|
|
"fullFormattedMessage": "Network metadata refreshed",
|
|
"eventTypeId": "NetworkEvent",
|
|
}})
|
|
default:
|
|
writeJSON(w, []map[string]any{})
|
|
}
|
|
})
|
|
|
|
if !cfg.omitHostOverallStatus {
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/overallStatus", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
if cfg.denyHostOverallStatus {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
writeJSON(w, "yellow")
|
|
})
|
|
}
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/triggeredAlarmState", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{
|
|
"alarm": map[string]any{"type": "Alarm", "value": "alarm-11"},
|
|
"overallStatus": "yellow",
|
|
"acknowledged": false,
|
|
"time": "2026-03-30T18:12:00Z",
|
|
}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/recentTask", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "Task", "value": "task-11"}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "esxi-01.lab.local")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "ClusterComputeResource", "value": "domain-c101"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/HostSystem/host-101/datastore", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "Datastore", "value": "datastore-11"}})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/overallStatus", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "green")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/triggeredAlarmState", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{
|
|
"alarm": map[string]any{"type": "Alarm", "value": "alarm-21"},
|
|
"overallStatus": "red",
|
|
"acknowledged": false,
|
|
"time": "2026-03-30T18:13:00Z",
|
|
}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/recentTask", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "Task", "value": "task-21"}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"currentSnapshot": map[string]any{"type": "VirtualMachineSnapshot", "value": "snapshot-202"},
|
|
"rootSnapshotList": []map[string]any{{
|
|
"snapshot": map[string]any{"type": "VirtualMachineSnapshot", "value": "snapshot-201"},
|
|
"name": "pre-upgrade",
|
|
"description": "Before application upgrade",
|
|
"id": 101,
|
|
"createTime": "2026-03-28T18:15:00Z",
|
|
"state": "poweredOn",
|
|
"quiesced": true,
|
|
"childSnapshotList": []map[string]any{{
|
|
"snapshot": map[string]any{"type": "VirtualMachineSnapshot", "value": "snapshot-202"},
|
|
"name": "post-migration-checkpoint",
|
|
"id": 102,
|
|
"createTime": "2026-03-29T18:15:00Z",
|
|
"state": "poweredOn",
|
|
"quiesced": false,
|
|
"childSnapshotList": []map[string]any{},
|
|
}},
|
|
}},
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Folder", "value": "group-v7"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/runtime", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"host": map[string]any{"type": "HostSystem", "value": "host-101"},
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/resourcePool", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "ResourcePool", "value": "resgroup-22"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/VirtualMachine/vm-201/datastore", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "Datastore", "value": "datastore-11"}})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/overallStatus", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "yellow")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/triggeredAlarmState", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/recentTask", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "Task", "value": "task-31"}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "nvme-primary")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Folder", "value": "group-s4"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/summary", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"accessible": true,
|
|
"multipleHostAccess": true,
|
|
"maintenanceMode": "normal",
|
|
"url": "ds:///vmfs/volumes/datastore-11/",
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/host", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "HostSystem", "value": "host-101"}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datastore/datastore-11/vm", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "VirtualMachine", "value": "vm-201"}})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/overallStatus", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "green")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/triggeredAlarmState", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/recentTask", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "VM Network")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Folder", "value": "group-n4"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/host", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "HostSystem", "value": "host-101"}})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Network/network-101/vm", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, []map[string]any{{"type": "VirtualMachine", "value": "vm-201"}})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/ClusterComputeResource/domain-c101/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Prod Compute")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/ClusterComputeResource/domain-c101/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Folder", "value": "group-h4"})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-h4/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Prod Hosts")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-h4/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-v7/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Production VMs")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-v7/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-s4/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Shared Datastores")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-s4/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-n4/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Networks")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Folder/group-n4/parent", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "Datacenter", "value": "datacenter-1"})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Datacenter/datacenter-1/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "DC1")
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/ResourcePool/resgroup-22/name", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, "Tier 1")
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/ResourcePool/resgroup-22/owner", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"type": "ClusterComputeResource", "value": "domain-c101"})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Alarm/alarm-11/info", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"name": "Host connection degraded"})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Alarm/alarm-21/info", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{"name": "VM replication fault"})
|
|
})
|
|
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Task/task-11/info", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"name": map[string]any{"localizedMessage": "Reconnect host"},
|
|
"state": "running",
|
|
"startTime": "2026-03-30T18:14:00Z",
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Task/task-21/info", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"name": map[string]any{"localizedMessage": "Create snapshot"},
|
|
"state": "success",
|
|
"descriptionId": "VirtualMachine.createSnapshot",
|
|
"startTime": "2026-03-30T18:10:00Z",
|
|
})
|
|
})
|
|
mux.HandleFunc("/sdk/vim25/9.0.0.0/Task/task-31/info", func(w http.ResponseWriter, r *http.Request) {
|
|
requireVISession(t, r)
|
|
writeJSON(w, map[string]any{
|
|
"name": map[string]any{"localizedMessage": "Refresh datastore"},
|
|
"state": "queued",
|
|
"startTime": "2026-03-30T18:11:00Z",
|
|
})
|
|
})
|
|
|
|
return httptest.NewTLSServer(mux)
|
|
}
|
|
|
|
func requireAutomationSession(t *testing.T, r *http.Request) {
|
|
t.Helper()
|
|
if got := strings.TrimSpace(r.Header.Get("vmware-api-session-id")); got != "automation-session" {
|
|
t.Fatalf("automation session header = %q, want automation-session", got)
|
|
}
|
|
}
|
|
|
|
func requireVISession(t *testing.T, r *http.Request) {
|
|
t.Helper()
|
|
if got := strings.TrimSpace(r.Header.Get("vmware-api-session-id")); got != "vi-session" {
|
|
t.Fatalf("vi-json session header = %q, want vi-session", got)
|
|
}
|
|
}
|
|
|
|
func TestClientCreateAutomationSessionClassifiesLegacyCISVCenter(t *testing.T) {
|
|
// vSphere 6.x (#1585): /api/session fails because /api routes to the
|
|
// JSON-RPC servlet, while the legacy /rest CIS session API works. The
|
|
// failure must classify as unsupported_version, not a generic endpoint
|
|
// error.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/session", func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "Missing Content-Type header", http.StatusInternalServerError)
|
|
})
|
|
legacyDeleted := false
|
|
mux.HandleFunc("/rest/com/vmware/cis/session", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
if _, _, ok := r.BasicAuth(); !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if _, err := w.Write([]byte(`{"value":"legacy-session"}`)); err != nil {
|
|
t.Fatalf("write legacy session response: %v", err)
|
|
}
|
|
case http.MethodDelete:
|
|
legacyDeleted = true
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
server := httptest.NewTLSServer(mux)
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
_, err = client.createAutomationSession(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected unsupported version error")
|
|
}
|
|
connectionErr, ok := err.(*ConnectionError)
|
|
if !ok {
|
|
t.Fatalf("expected ConnectionError, got %T", err)
|
|
}
|
|
if connectionErr.Category != "unsupported_version" {
|
|
t.Fatalf("connection error category = %q, want unsupported_version", connectionErr.Category)
|
|
}
|
|
if !strings.Contains(connectionErr.Message, "8.0U1") {
|
|
t.Fatalf("expected message to name the supported floor, got %q", connectionErr.Message)
|
|
}
|
|
if !legacyDeleted {
|
|
t.Fatal("expected probe to delete the legacy session it created")
|
|
}
|
|
}
|
|
|
|
func TestClientCreateAutomationSessionKeepsEndpointErrorWithoutLegacyCIS(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/session", func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
})
|
|
server := httptest.NewTLSServer(mux)
|
|
defer server.Close()
|
|
|
|
client, err := NewClient(ClientConfig{
|
|
Host: server.URL,
|
|
Username: "admin",
|
|
Password: "secret",
|
|
InsecureSkipVerify: true,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewClient: %v", err)
|
|
}
|
|
|
|
_, err = client.createAutomationSession(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected endpoint error")
|
|
}
|
|
connectionErr, ok := err.(*ConnectionError)
|
|
if !ok {
|
|
t.Fatalf("expected ConnectionError, got %T", err)
|
|
}
|
|
if connectionErr.Category != "endpoint" {
|
|
t.Fatalf("connection error category = %q, want endpoint", connectionErr.Category)
|
|
}
|
|
}
|