Implement VMware vCenter connections slice

This commit is contained in:
rcourtman 2026-03-30 17:56:37 +01:00
parent ca49305cb5
commit 9b19cb4446
32 changed files with 4060 additions and 123 deletions

View file

@ -89,6 +89,8 @@ management, and fleet control surfaces.
65. `frontend-modern/src/components/Settings/platformConnectionsModel.ts`
66. `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`
67. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`
68. `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`
69. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`
## Shared Boundaries
@ -125,7 +127,7 @@ management, and fleet control surfaces.
4. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice.
4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance.
5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, the API-backed platform connections workspace shell, route model, reporting summary owner, shared install/inventory/dialog section owners, the split infrastructure install/reporting state owners, the split direct-node/discovery infrastructure settings owners plus their shared model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureReportingPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInventorySection.tsx`, `frontend-modern/src/components/Settings/InfrastructureActiveRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureIgnoredRowDetails.tsx`, `frontend-modern/src/components/Settings/InfrastructureStopMonitoringDialog.tsx`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/ProxmoxSettingsPanel.tsx`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ProxmoxDirectWorkspace.tsx`, `frontend-modern/src/components/Settings/ProxmoxConfiguredNodesTable.tsx`, `frontend-modern/src/components/Settings/ProxmoxDirectConnectionsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDiscoveryResultsCard.tsx`, `frontend-modern/src/components/Settings/ProxmoxDeleteNodeDialog.tsx`, `frontend-modern/src/components/Settings/ProxmoxNodeModalStack.tsx`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureReportingState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/useProxmoxDirectWorkspaceState.ts`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`.
7. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
8. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
9. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
@ -531,6 +533,13 @@ may show one VMware connection's poll health, last error classification, and
observed contribution summary, but it must not force the operator to manage
separate Automation API versus VI JSON sessions or understand multi-client
runtime wiring just to use the shared platform-connections path.
That same lifecycle-owned settings slice now also owns the shared VMware
summary and handoff framing. `InfrastructurePlatformConnectionsSummaryCard.tsx`,
`InfrastructureReportingPanel.tsx`, `useInfrastructureSettingsState.ts`, and
`useSettingsInfrastructurePanelProps.ts` must surface VMware availability and
connection counts from the same shared infrastructure settings state that owns
the VMware panel itself, rather than letting reporting cards or adjacent setup
surfaces grow a second VMware availability fetch or a VMware-only handoff path.
That same infrastructure workspace boundary now also owns the first-run
handoff copy for new operators. `InfrastructureWorkspace.tsx` must tell a new
Pulse user to start with `Install on a host` to add the first monitored

View file

@ -700,6 +700,21 @@ summary surface. Phase 1 must also keep the negative space explicit: no public
`/api/vmware/events`, `/api/vmware/tasks`, or VMware control routes should be
introduced while inventory, alerts, history, and Assistant reads still route
through the shared canonical Pulse surfaces.
That same `/api/vmware/connections` family now also owns the current phase-1
implementation contract under `internal/api/vmware_handlers.go`,
`internal/api/router.go`, `internal/api/router_routes_registration.go`, and
`frontend-modern/src/api/vmware.ts`. The list response must carry one redacted
stored connection shape plus canonical last-test status and observed
contribution summary (`hosts`, `vms`, `datastores`, `viRelease`) so the shared
settings workspace can render VMware status without another provider-local
inventory route. `POST /api/vmware/connections/test` must stay the draft test
surface, while `POST /api/vmware/connections/{id}/test` remains the saved
connection retest surface: a row-level saved retest with no payload should
refresh the stored runtime summary, but an edit-form test overlay must preserve
the stored summary until a real save succeeds. The explicit disabled path also
stays on this boundary: `404 vmware_disabled` means the operator or runtime has
opted out of the default-on VMware candidate, not that the platform requires a
different onboarding contract.
That same backend API boundary now also owns the negative space around
assistant control. Wiring native TrueNAS app actions into
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend

View file

@ -9,7 +9,7 @@
"contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
"status_file": "docs/release-control/v6/internal/status.json",
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
"dependency_subsystem_ids": []
"dependency_subsystem_ids": ["agent-lifecycle"]
}
```
@ -78,6 +78,7 @@ work extends shared components instead of creating new local variants.
47. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts`
48. `frontend-modern/src/features/`
49. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx`
50. `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`
50. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx`
51. `frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx`
52. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
@ -141,6 +142,7 @@ work extends shared components instead of creating new local variants.
instead of introducing page-local framing
3. Add feature-specific presentation only when no shared primitive should own it
4. Add guardrail tests when a new shared pattern is introduced
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
## Forbidden Paths
@ -257,16 +259,18 @@ work extends shared components instead of creating new local variants.
`GeneralSettingsPanel.tsx` must state those facts plainly instead of
reverting to a stronger but inaccurate shorthand.
16. Keep infrastructure settings-shell API alternatives on the shared shell
contract. `InfrastructureWorkspace.tsx`, `settingsHeaderMeta.ts`,
`settingsNavigationModel.ts`, and shared empty-state/setup guidance must
contract. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
`frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, and
shared empty-state/setup guidance must
present `Platform connections` as the canonical API-backed alternative for
Proxmox, TrueNAS, and future provider integrations instead of reviving
top-level `Direct Proxmox` wording or shell-local provider routes.
17. Keep the infrastructure settings platform-connections summary and provider
workspaces on one shared state source. `useInfrastructureSettingsState.ts`,
`useSettingsInfrastructurePanelProps.ts`,
`InfrastructurePlatformConnectionsSummaryCard.tsx`,
`PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must
workspaces on one shared state source. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, and `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx` must
derive TrueNAS connection counts and availability from the shared
infrastructure settings state instead of letting the reporting summary and
the provider-specific panel issue separate connection fetches.
@ -1594,21 +1598,28 @@ reference cases, and
locks that direct-root contract so single-surface pages do not quietly regain
redundant outer spacing chrome.
The same shared settings-shell boundary now also owns the API-backed
alternative path inside Infrastructure Operations. `InfrastructureWorkspace.tsx`,
`InfrastructurePlatformConnectionsSummaryCard.tsx`, `settingsHeaderMeta.ts`,
`settingsNavigationModel.ts`, `dashboardEmptyStatePresentation.ts`,
`infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must
alternative path inside Infrastructure Operations. `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, `frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,
`frontend-modern/src/components/Settings/settingsNavigationModel.ts`, `frontend-modern/src/utils/dashboardEmptyStatePresentation.ts`,
`frontend-modern/src/utils/infrastructureEmptyStatePresentation.ts`, and adjacent setup guidance must
treat `Platform connections` as the canonical API-backed alternative for
Proxmox, TrueNAS, and future provider integrations instead of reviving
top-level `Direct Proxmox` wording or shell-local provider routes.
That same settings-shell contract also owns the shared platform-connections
summary state. `useInfrastructureSettingsState.ts`,
`useSettingsInfrastructurePanelProps.ts`,
`InfrastructurePlatformConnectionsSummaryCard.tsx`,
`PlatformConnectionsWorkspace.tsx`, and `TrueNASSettingsPanel.tsx` must derive
Proxmox/PBS/PMG/TrueNAS counts and availability from one shared infrastructure
settings state source instead of letting the reporting summary and the
provider-specific panel fetch the same TrueNAS connection state separately.
summary state. `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`,
`frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`,
`frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`,
`frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx`, `frontend-modern/src/components/Settings/TrueNASSettingsPanel.tsx`, and
`frontend-modern/src/components/Settings/VMwareSettingsPanel.tsx` must derive Proxmox/PBS/PMG/TrueNAS/VMware counts
and availability from one shared infrastructure settings state source instead
of letting the reporting summary and the provider-specific panels fetch the
same connection state separately.
That same shared settings-shell boundary also owns provider parity inside the
platform workspace. Adding VMware to the shared `Platform connections`
subtabs may extend the same card, empty-state, dialog, and summary-shell
patterns used by TrueNAS, but it must not introduce a VMware-only outer page
shell, alternate settings route hierarchy, or another summary vocabulary for
connection health and contribution counts.
That same shared filter-presentation boundary also owns infrastructure
route-filter continuity. `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical source option such as `truenas` visible in

View file

@ -91,6 +91,7 @@ querying, and the operator-facing storage health presentation layer.
25. Keep provider-backed poll cadence and settings-runtime health on the adjacent platform-connections contract. When shared `internal/api/` and poller wiring expose TrueNAS last-sync status, failure summaries, discovered contribution counts, manual saved-test status refresh, or platform handoff links in settings, storage and recovery may consume the resulting datasets, apps, disks, and recovery artifacts but must not redefine those settings-runtime health semantics or connection-level handoffs in storage/recovery-local transport or page flows.
26. Keep cross-surface recovery handoffs on the shared route-helper contract. When infrastructure or other unified-resource consumers expose TrueNAS recovery links, they must reuse the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder with owned `platform` and `node` queries instead of inventing drawer-local recovery URLs or treating PBS services as the only infrastructure-to-recovery path.
27. Keep alert-side recovery drill-ins on that same shared route-helper contract. When alert investigation surfaces such as resource-incident panels expose recovery follow-up links for TrueNAS or future API-backed platforms, they must route through the canonical `frontend-modern/src/routing/resourceLinks.ts` recovery builder instead of freezing alert-local recovery URLs or introducing another provider-shaped recovery handoff vocabulary.
28. Keep VMware onboarding runtime and recovery semantics separate on that same adjacent platform-connections contract. When `internal/api/router.go`, `internal/api/router_routes_registration.go`, or `internal/api/vmware_handlers.go` evolve VMware connection CRUD, saved-test refresh, or observed datastore/VM snapshot visibility, storage and recovery may consume the resulting shared context but must not treat those onboarding/runtime payloads as canonical recovery artifacts, restore capability, or recovery-local control transport.
## Forbidden Paths
@ -1635,3 +1636,11 @@ Storage and recovery must not treat vSphere snapshots, changed-disk/block
visibility, or datastore presence as canonical Pulse recovery artifacts,
restore capability, or recovery-backed Assistant control until a later
governed slice adds those contracts explicitly.
That same storage/recovery boundary also keeps the onboarding runtime separate
from recovery semantics. `internal/api/router.go`,
`internal/api/router_routes_registration.go`, and
`internal/api/vmware_handlers.go` may expose VMware connection CRUD, saved-test
health refresh, and observed datastore/VM snapshot visibility on the shared
platform-connections surface, but storage and recovery must treat that data as
setup/runtime context only, not as proof that VMware has joined the canonical
recovery artifact or restore plane.

View file

@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { apiFetchJSON } from '@/utils/apiClient';
import { VMwareAPI, isRedactedVMwareSecret } from '@/api/vmware';
vi.mock('@/utils/apiClient', () => ({
apiFetchJSON: vi.fn(),
}));
describe('VMwareAPI', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('normalizes listed connections from the API contract', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce([
{
id: ' conn-1 ',
name: ' lab-vcenter ',
host: ' vcsa.lab.local ',
port: 443,
username: ' administrator@vsphere.local ',
password: ' ******** ',
insecureSkipVerify: true,
enabled: true,
test: {
lastAttemptAt: ' 2026-03-30T12:00:00Z ',
lastSuccessAt: ' 2026-03-30T12:00:01Z ',
},
observed: {
collectedAt: ' 2026-03-30T12:00:02Z ',
hosts: 3,
vms: 42,
datastores: 6,
viRelease: ' 8.0.3 ',
},
},
]);
await expect(VMwareAPI.listConnections()).resolves.toEqual([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: true,
enabled: true,
test: {
lastAttemptAt: '2026-03-30T12:00:00Z',
lastSuccessAt: '2026-03-30T12:00:01Z',
lastError: undefined,
},
observed: {
collectedAt: '2026-03-30T12:00:02Z',
hosts: 3,
vms: 42,
datastores: 6,
viRelease: '8.0.3',
},
},
]);
});
it('creates, updates, deletes, and tests connections through canonical routes', async () => {
vi.mocked(apiFetchJSON)
.mockResolvedValueOnce({
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
insecureSkipVerify: false,
enabled: true,
})
.mockResolvedValueOnce({
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
insecureSkipVerify: true,
enabled: false,
})
.mockResolvedValueOnce({ success: true, id: 'conn-1' })
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: true });
await VMwareAPI.createConnection({
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: 'secret',
enabled: true,
});
await VMwareAPI.updateConnection('conn/1', {
host: 'vcsa.lab.local',
port: 8443,
username: 'operator@vsphere.local',
password: '********',
insecureSkipVerify: true,
enabled: false,
});
await VMwareAPI.deleteConnection('conn/1');
await expect(
VMwareAPI.testConnection({
host: 'vcsa.lab.local',
username: 'administrator@vsphere.local',
password: 'secret',
}),
).resolves.toEqual({ success: true });
await expect(
VMwareAPI.testSavedConnection('conn/1', {
host: 'vcsa.lab.local',
username: 'operator@vsphere.local',
password: '********',
insecureSkipVerify: true,
}),
).resolves.toEqual({ success: true });
expect(apiFetchJSON).toHaveBeenNthCalledWith(
1,
'/api/vmware/connections',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: 'secret',
enabled: true,
}),
}),
);
expect(apiFetchJSON).toHaveBeenNthCalledWith(
2,
'/api/vmware/connections/conn%2F1',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
host: 'vcsa.lab.local',
port: 8443,
username: 'operator@vsphere.local',
password: '********',
insecureSkipVerify: true,
enabled: false,
}),
}),
);
expect(apiFetchJSON).toHaveBeenNthCalledWith(
3,
'/api/vmware/connections/conn%2F1',
expect.objectContaining({ method: 'DELETE' }),
);
expect(apiFetchJSON).toHaveBeenNthCalledWith(
4,
'/api/vmware/connections/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
host: 'vcsa.lab.local',
username: 'administrator@vsphere.local',
password: 'secret',
}),
}),
);
expect(apiFetchJSON).toHaveBeenNthCalledWith(
5,
'/api/vmware/connections/conn%2F1/test',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
host: 'vcsa.lab.local',
username: 'operator@vsphere.local',
password: '********',
insecureSkipVerify: true,
}),
}),
);
});
it('recognizes the masked-secret placeholder used for update preservation', () => {
expect(isRedactedVMwareSecret('********')).toBe(true);
expect(isRedactedVMwareSecret(' ******** ')).toBe(true);
expect(isRedactedVMwareSecret('secret')).toBe(false);
expect(isRedactedVMwareSecret(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,194 @@
import { apiFetchJSON } from '@/utils/apiClient';
import {
arrayOrUndefined,
finiteNumberOrUndefined,
optionalTrimmedString,
strictBoolean,
trimmedString,
} from './responseUtils';
const VMWARE_CONNECTIONS_PATH = '/api/vmware/connections';
const REDACTED_SECRET = '********';
type RawVMwareConnection = Partial<VMwareConnection>;
type RawVMwareConnectionTestError = Partial<VMwareConnectionTestError>;
type RawVMwareConnectionTest = Partial<VMwareConnectionTestStatus>;
type RawVMwareConnectionObservedSummary = Partial<VMwareConnectionObservedSummary>;
export interface VMwareConnectionTestError {
at?: string;
message?: string;
category?: string;
}
export interface VMwareConnectionTestStatus {
lastAttemptAt?: string;
lastSuccessAt?: string;
lastError?: VMwareConnectionTestError;
}
export interface VMwareConnectionObservedSummary {
collectedAt?: string;
hosts: number;
vms: number;
datastores: number;
viRelease?: string;
}
export interface VMwareConnection {
id: string;
name: string;
host: string;
port?: number;
username?: string;
password?: string;
insecureSkipVerify: boolean;
enabled: boolean;
test?: VMwareConnectionTestStatus;
observed?: VMwareConnectionObservedSummary;
}
export interface VMwareConnectionInput {
name?: string;
host: string;
port?: number;
username?: string;
password?: string;
insecureSkipVerify?: boolean;
enabled?: boolean;
}
export interface VMwareConnectionTestResult {
success: boolean;
}
const normalizeVMwareConnectionTestError = (
error: RawVMwareConnectionTestError | undefined,
): VMwareConnectionTestError | undefined => {
if (!error || typeof error !== 'object') return undefined;
return {
at: optionalTrimmedString(error.at),
message: optionalTrimmedString(error.message),
category: optionalTrimmedString(error.category),
};
};
const normalizeVMwareConnectionTest = (
test: RawVMwareConnectionTest | undefined,
): VMwareConnectionTestStatus | undefined => {
if (!test || typeof test !== 'object') return undefined;
return {
lastAttemptAt: optionalTrimmedString(test.lastAttemptAt),
lastSuccessAt: optionalTrimmedString(test.lastSuccessAt),
lastError: normalizeVMwareConnectionTestError(test.lastError),
};
};
const normalizeVMwareConnectionObservedSummary = (
observed: RawVMwareConnectionObservedSummary | undefined,
): VMwareConnectionObservedSummary | undefined => {
if (!observed || typeof observed !== 'object') return undefined;
return {
collectedAt: optionalTrimmedString(observed.collectedAt),
hosts: finiteNumberOrUndefined(observed.hosts) ?? 0,
vms: finiteNumberOrUndefined(observed.vms) ?? 0,
datastores: finiteNumberOrUndefined(observed.datastores) ?? 0,
viRelease: optionalTrimmedString(observed.viRelease),
};
};
const normalizeVMwareConnection = (connection: RawVMwareConnection): VMwareConnection => ({
id: trimmedString(connection.id),
name: optionalTrimmedString(connection.name) ?? '',
host: trimmedString(connection.host),
port: finiteNumberOrUndefined(connection.port),
username: optionalTrimmedString(connection.username),
password: optionalTrimmedString(connection.password),
insecureSkipVerify: strictBoolean(connection.insecureSkipVerify),
enabled: strictBoolean(connection.enabled),
test: normalizeVMwareConnectionTest(connection.test),
observed: normalizeVMwareConnectionObservedSummary(connection.observed),
});
const serializeVMwareConnectionInput = (input: VMwareConnectionInput) => ({
...(input.name !== undefined ? { name: input.name } : {}),
host: input.host,
...(input.port !== undefined ? { port: input.port } : {}),
...(input.username !== undefined ? { username: input.username } : {}),
...(input.password !== undefined ? { password: input.password } : {}),
...(input.insecureSkipVerify !== undefined
? { insecureSkipVerify: input.insecureSkipVerify }
: {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
});
export const isRedactedVMwareSecret = (value: string | null | undefined) =>
(value || '').trim() === REDACTED_SECRET;
export class VMwareAPI {
static async listConnections(): Promise<VMwareConnection[]> {
const response = await apiFetchJSON<RawVMwareConnection[]>(VMWARE_CONNECTIONS_PATH);
const list = arrayOrUndefined<RawVMwareConnection>(response) ?? [];
return list.map(normalizeVMwareConnection);
}
static async createConnection(input: VMwareConnectionInput): Promise<VMwareConnection> {
const response = await apiFetchJSON<RawVMwareConnection>(VMWARE_CONNECTIONS_PATH, {
method: 'POST',
body: JSON.stringify(serializeVMwareConnectionInput(input)),
});
return normalizeVMwareConnection(response);
}
static async updateConnection(
id: string,
input: VMwareConnectionInput,
): Promise<VMwareConnection> {
const response = await apiFetchJSON<RawVMwareConnection>(
`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`,
{
method: 'PUT',
body: JSON.stringify(serializeVMwareConnectionInput(input)),
},
);
return normalizeVMwareConnection(response);
}
static async deleteConnection(id: string): Promise<{ success: boolean; id: string }> {
return apiFetchJSON(`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
static async testConnection(input: VMwareConnectionInput): Promise<VMwareConnectionTestResult> {
const response = await apiFetchJSON<Partial<VMwareConnectionTestResult>>(
`${VMWARE_CONNECTIONS_PATH}/test`,
{
method: 'POST',
body: JSON.stringify(serializeVMwareConnectionInput(input)),
},
);
return {
success: strictBoolean(response.success),
};
}
static async testSavedConnection(
id: string,
input?: VMwareConnectionInput,
): Promise<VMwareConnectionTestResult> {
const response = await apiFetchJSON<Partial<VMwareConnectionTestResult>>(
`${VMWARE_CONNECTIONS_PATH}/${encodeURIComponent(id)}/test`,
{
method: 'POST',
...(input !== undefined
? { body: JSON.stringify(serializeVMwareConnectionInput(input)) }
: {}),
},
);
return {
success: strictBoolean(response.success),
};
}
}

View file

@ -7,6 +7,8 @@ interface InfrastructurePlatformConnectionsSummaryCardProps {
pmgCount: number;
truenasCount: number;
truenasAvailable: boolean;
vmwareCount: number;
vmwareAvailable: boolean;
onManagePlatformConnections: () => void;
}
@ -20,8 +22,8 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
<div>
<h3 class="text-base font-semibold text-base-content">Platform connections</h3>
<p class="text-sm text-muted">
Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, and
TrueNAS all live in the same shared platform-connections workspace.
Manage the API-backed platforms Pulse polls directly. Proxmox VE, PBS, PMG, TrueNAS,
and VMware all live in the same shared platform-connections workspace.
</p>
</div>
<button
@ -33,7 +35,7 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
</button>
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
data-testid="platform-connections-pve"
@ -71,6 +73,22 @@ export const InfrastructurePlatformConnectionsSummaryCard: Component<
: 'Explicitly disabled on this Pulse server.'}
</p>
</div>
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-3"
data-testid="platform-connections-vmware"
>
<div class="text-sm font-medium text-base-content">VMware</div>
<div
class={`mt-1 ${props.vmwareAvailable ? 'text-xl font-semibold text-base-content' : 'text-sm font-medium text-muted'}`}
>
{props.vmwareAvailable ? props.vmwareCount : 'Disabled'}
</div>
<p class="mt-1 text-xs text-muted">
{props.vmwareAvailable
? 'vCenter platform connections'
: 'Explicitly disabled on this Pulse server.'}
</p>
</div>
</div>
</div>
</Card>

View file

@ -27,6 +27,8 @@ export const InfrastructureReportingPanel: Component<InfrastructureReportingPane
pmgCount={props.platformConnectionsSummary().pmgCount}
truenasCount={props.platformConnectionsSummary().truenasCount}
truenasAvailable={props.platformConnectionsSummary().truenasAvailable}
vmwareCount={props.platformConnectionsSummary().vmwareCount}
vmwareAvailable={props.platformConnectionsSummary().vmwareAvailable}
onManagePlatformConnections={props.onManagePlatformConnections}
/>
</div>

View file

@ -9,6 +9,7 @@ import {
getPlatformConnectionsViewFromPath,
} from './platformConnectionsModel';
import { TrueNASSettingsPanel } from './TrueNASSettingsPanel';
import { VMwareSettingsPanel } from './VMwareSettingsPanel';
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSettingsProps> = (
@ -22,7 +23,9 @@ export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSetti
<div class="space-y-6">
<Subtabs
value={activeView()}
onChange={(value) => navigate(buildPlatformConnectionsPath(value as 'proxmox' | 'truenas'))}
onChange={(value) =>
navigate(buildPlatformConnectionsPath(value as 'proxmox' | 'truenas' | 'vmware'))
}
ariaLabel="Platform connections"
tabs={PLATFORM_CONNECTIONS_TABS.map((tab) => ({
value: tab.id,
@ -35,6 +38,10 @@ export const PlatformConnectionsWorkspace: Component<InfrastructurePlatformSetti
<TrueNASSettingsPanel state={props.trueNASSettings} />
</Match>
<Match when={activeView() === 'vmware'}>
<VMwareSettingsPanel state={props.vmwareSettings} />
</Match>
<Match when={activeView() === 'proxmox'}>
<ProxmoxSettingsPanel {...props} embedded />
</Match>

View file

@ -0,0 +1,500 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import Boxes from 'lucide-solid/icons/boxes';
import ShieldAlert from 'lucide-solid/icons/shield-alert';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { Card } from '@/components/shared/Card';
import { Dialog } from '@/components/shared/Dialog';
import type { VMwareConnection } from '@/api/vmware';
import { formatNumber, formatRelativeTime } from '@/utils/format';
import {
formCheckbox,
formControl,
formField,
formHelpText,
formLabel,
} from '@/components/shared/Form';
import { getSettingsConfigurationLoadingState } from '@/utils/settingsShellPresentation';
import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState';
const buttonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const primaryButtonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const dangerButtonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/30';
const connectionMetaBadgeClass =
'inline-flex items-center rounded-full border border-border bg-surface px-2 py-0.5 text-xs font-medium text-muted';
const getConnectionHealthPresentation = (connection: VMwareConnection) => {
if (!connection.enabled) {
return {
label: 'Disabled',
className: 'bg-surface text-muted',
detail: 'Manual validation paused',
error: null,
};
}
const test = connection.test;
const lastSuccessAt = test?.lastSuccessAt;
const lastError = test?.lastError;
const lastErrorAfterSuccess =
!!lastError?.at &&
(!lastSuccessAt || new Date(lastError.at).getTime() >= new Date(lastSuccessAt).getTime());
if (lastError && lastErrorAfterSuccess) {
return {
label: 'Validation failing',
className: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
detail: lastError.at
? `Last validation ${formatRelativeTime(lastError.at, { compact: true })}`
: 'Last validation failed',
error: lastError.message || null,
};
}
if (lastSuccessAt) {
return {
label: 'Validated',
className: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
detail: `Last validation ${formatRelativeTime(lastSuccessAt, { compact: true })}`,
error: null,
};
}
return {
label: 'Awaiting first validation',
className: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
detail: 'Pulse has not completed the first vCenter validation yet',
error: null,
};
};
const getConnectionObservedMetrics = (connection: VMwareConnection) => {
const observed = connection.observed;
if (!observed) return [];
return [
{ label: 'host', value: observed.hosts },
{ label: 'vm', value: observed.vms },
{ label: 'datastore', value: observed.datastores },
].filter((item) => item.value > 0);
};
const pluralize = (count: number, singular: string): string =>
count === 1 ? singular : `${singular}s`;
interface VMwareSettingsPanelProps {
state: VMwareSettingsPanelState;
}
export const VMwareSettingsPanel: Component<VMwareSettingsPanelProps> = (props) => {
const state = props.state;
return (
<div class="space-y-6">
<CalloutCard
tone="info"
title="VMware vSphere platform integration"
description={
<>
<p>
Connect VMware through vCenter so Pulse can validate API-backed access and stage the
canonical phase-1 floor for hosts, virtual machines, datastores, and shared alert or
assistant read paths.
</p>
<p class="mt-2">
Phase 1 is vCenter-only and API-first. Direct ESXi onboarding and write-path control
stay out of scope here.
</p>
</>
}
icon={<Boxes class="h-5 w-5" />}
/>
<Show when={state.featureDisabled()}>
<CalloutCard
tone="warning"
title="VMware integration is disabled"
description={
<>
<p>
{state.featureDisabledMessage() ||
'VMware integration has been explicitly disabled on this Pulse server.'}
</p>
<p class="mt-2">
Remove <code>PULSE_ENABLE_VMWARE=false</code> or set it back to <code>true</code> on
the Pulse server, then restart the service before managing VMware connections.
</p>
</>
}
icon={<ShieldAlert class="h-5 w-5" />}
/>
</Show>
<Show when={!state.featureDisabled()}>
<Card padding="lg" class="rounded-xl border border-border shadow-sm">
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h3 class="text-base font-semibold text-base-content">VMware connections</h3>
<p class="text-sm text-muted">
Manage the vCenter endpoints Pulse should validate and use as the shared VMware
platform onboarding boundary.
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class={buttonClass}
onClick={() => void state.loadConnections()}
disabled={state.loading()}
>
Refresh
</button>
<button type="button" class={primaryButtonClass} onClick={state.openCreateDialog}>
Add VMware connection
</button>
</div>
</div>
<Show when={state.loading()}>
<div class="flex items-center justify-center rounded-md border border-dashed border-border bg-surface-alt py-12 text-sm text-muted">
{getSettingsConfigurationLoadingState().text}
</div>
</Show>
<Show when={!state.loading() && state.loadingError()}>
<CalloutCard
tone="danger"
title="Failed to load VMware connections"
description={
<>
<p>{state.loadingError()}</p>
<button
type="button"
class={`mt-3 ${buttonClass}`}
onClick={() => void state.loadConnections()}
>
Retry
</button>
</>
}
/>
</Show>
<Show
when={!state.loading() && !state.loadingError() && state.connections().length === 0}
>
<div class="rounded-lg border border-dashed border-border bg-surface-alt px-6 py-12 text-center">
<p class="text-base font-medium text-base-content">No VMware connections yet</p>
<p class="mt-1 text-sm text-muted">
Add the first vCenter endpoint Pulse should validate.
</p>
</div>
</Show>
<Show
when={!state.loading() && !state.loadingError() && state.connections().length > 0}
>
<div class="space-y-3">
<For each={state.connections()}>
{(connection) => {
const health = () => getConnectionHealthPresentation(connection);
const observedMetrics = () => getConnectionObservedMetrics(connection);
return (
<div
class="rounded-lg border border-border bg-surface-alt px-4 py-4"
data-testid={`vmware-connection-${connection.id}`}
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-base-content">
{connection.name || connection.host}
</h4>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
connection.enabled
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'bg-surface text-muted'
}`}
>
{connection.enabled ? 'Enabled' : 'Disabled'}
</span>
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${health().className}`}
>
{health().label}
</span>
</div>
<p class="text-sm text-muted">
{connection.host}
{connection.port ? `:${connection.port}` : ''}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>{connection.username || 'Username not set'}</span>
<span aria-hidden="true"></span>
<span>vCenter</span>
<Show when={connection.insecureSkipVerify}>
<>
<span aria-hidden="true"></span>
<span>Skip TLS verification</span>
</>
</Show>
<Show when={connection.test?.lastAttemptAt || health().detail}>
<>
<span aria-hidden="true"></span>
<span>{health().detail}</span>
</>
</Show>
</div>
<Show when={health().error}>
<p class="text-xs font-medium text-red-700 dark:text-red-300">
{health().error}
</p>
</Show>
</div>
<Show when={connection.observed}>
<div class="space-y-2">
<div class="flex flex-wrap gap-2">
<Show when={connection.observed?.collectedAt}>
<span class={connectionMetaBadgeClass}>
Validated{' '}
{formatRelativeTime(connection.observed?.collectedAt, {
compact: true,
})}
</span>
</Show>
<For each={observedMetrics()}>
{(item) => (
<span class={connectionMetaBadgeClass}>
{formatNumber(item.value)}{' '}
{pluralize(item.value, item.label)}
</span>
)}
</For>
<Show when={connection.observed?.viRelease}>
<span class={connectionMetaBadgeClass}>
VI JSON {connection.observed?.viRelease}
</span>
</Show>
</div>
</div>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class={buttonClass}
onClick={() => void state.testSavedConnection(connection)}
disabled={state.testing()}
>
Test
</button>
<button
type="button"
class={buttonClass}
onClick={() => state.openEditDialog(connection)}
>
Edit
</button>
<button
type="button"
class={dangerButtonClass}
onClick={() => state.openDeleteDialog(connection)}
>
Delete
</button>
</div>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</Card>
</Show>
<Dialog
isOpen={state.dialogOpen()}
onClose={state.closeDialog}
ariaLabel={state.editingConnection() ? 'Edit VMware connection' : 'Add VMware connection'}
panelClass="w-full max-w-2xl"
>
<div class="space-y-6 p-6">
<div class="space-y-1">
<h3 class="text-lg font-semibold text-base-content">
{state.editingConnection() ? 'Edit VMware connection' : 'Add VMware connection'}
</h3>
<p class="text-sm text-muted">
Configure the vCenter endpoint Pulse should validate for the VMware platform.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label class={formField}>
<span class={formLabel}>Name</span>
<input
class={formControl}
value={state.form().name}
onInput={(event) => state.updateForm({ name: event.currentTarget.value })}
placeholder="lab-vcenter"
/>
</label>
<label class={formField}>
<span class={formLabel}>Host</span>
<input
class={formControl}
value={state.form().host}
onInput={(event) => state.updateForm({ host: event.currentTarget.value })}
placeholder="vcsa.lab.local"
/>
</label>
<label class={formField}>
<span class={formLabel}>Port</span>
<input
class={formControl}
inputMode="numeric"
value={state.form().port}
onInput={(event) => state.updateForm({ port: event.currentTarget.value })}
placeholder="443"
/>
</label>
<label class={formField}>
<span class={formLabel}>Username</span>
<input
class={formControl}
value={state.form().username}
onInput={(event) => state.updateForm({ username: event.currentTarget.value })}
placeholder="administrator@vsphere.local"
/>
</label>
<label class={`${formField} sm:col-span-2`}>
<span class={formLabel}>Password</span>
<input
class={formControl}
type="password"
value={state.form().password}
onInput={(event) => state.updateForm({ password: event.currentTarget.value })}
placeholder={
state.form().hasStoredPassword ? 'Saved password retained unless replaced' : ''
}
/>
<Show when={state.form().hasStoredPassword}>
<span class={formHelpText}>Leave this blank to keep the saved password.</span>
</Show>
</label>
</div>
<div class="space-y-3 rounded-md border border-border bg-surface-alt px-4 py-3">
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={state.form().insecureSkipVerify}
onChange={(event) =>
state.updateForm({ insecureSkipVerify: event.currentTarget.checked })
}
/>
<span class="text-sm text-base-content">Skip TLS verification</span>
</label>
<label class="flex items-center gap-3">
<input
type="checkbox"
class={formCheckbox}
checked={state.form().enabled}
onChange={(event) => state.updateForm({ enabled: event.currentTarget.checked })}
/>
<span class="text-sm text-base-content">Enable this vCenter connection</span>
</label>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
class={buttonClass}
onClick={state.closeDialog}
disabled={state.saving() || state.testing()}
>
Cancel
</button>
<button
type="button"
class={buttonClass}
onClick={() => void state.testCurrentForm()}
disabled={state.saving() || state.testing()}
>
{state.testing() ? 'Testing…' : 'Test connection'}
</button>
<button
type="button"
class={primaryButtonClass}
onClick={() => void state.saveCurrentForm()}
disabled={state.saving() || state.testing()}
>
{state.saving()
? state.editingConnection()
? 'Saving…'
: 'Adding…'
: state.editingConnection()
? 'Save connection'
: 'Add connection'}
</button>
</div>
</div>
</Dialog>
<Dialog
isOpen={state.deleteDialogOpen()}
onClose={state.closeDeleteDialog}
ariaLabel="Delete VMware connection"
panelClass="w-full max-w-lg"
>
<div class="space-y-5 p-6">
<div class="space-y-1">
<h3 class="text-lg font-semibold text-base-content">Delete VMware connection</h3>
<p class="text-sm text-muted">
Remove{' '}
<span class="font-medium text-base-content">
{state.pendingDeleteConnection()?.name || state.pendingDeleteConnection()?.host}
</span>{' '}
from the configured VMware platform connections.
</p>
</div>
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
class={buttonClass}
onClick={state.closeDeleteDialog}
disabled={state.deleting()}
>
Cancel
</button>
<button
type="button"
class={dangerButtonClass}
onClick={() => void state.deletePendingConnection()}
disabled={state.deleting()}
>
{state.deleting() ? 'Deleting…' : 'Delete connection'}
</button>
</div>
</div>
</Dialog>
</div>
);
};
export default VMwareSettingsPanel;

View file

@ -89,10 +89,14 @@ let securityStatusResponse = { requiresAuth: true, apiTokenConfigured: false };
describe('InfrastructureOperationsController ownership guardrails', () => {
it('routes controller and workspace panels through the shared infrastructure operations state owner', () => {
expect(infrastructureOperationsControllerSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureOperationsControllerSource).toContain(
'InfrastructureOperationsStateProvider',
);
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInstallerSection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureInventorySection');
expect(infrastructureOperationsControllerSource).toContain('InfrastructureStopMonitoringDialog');
expect(infrastructureOperationsControllerSource).toContain(
'InfrastructureStopMonitoringDialog',
);
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
expect(infrastructureReportingPanelSource).toContain('InfrastructureOperationsStateProvider');
@ -102,11 +106,10 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
'./InfrastructurePlatformConnectionsSummaryCard',
);
expect(infrastructureReportingPanelSource).not.toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
'Platform connections',
);
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
expect(infrastructureOperationsStateSource).toContain("./infrastructureOperationsModel");
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
expect(infrastructureOperationsStateSource).toContain('./infrastructureOperationsModel');
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureInstallState');
expect(infrastructureOperationsStateSource).toContain('./useInfrastructureReportingState');
expect(infrastructureOperationsStateSource).toContain(
@ -118,7 +121,9 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
expect(infrastructureOperationsStateSource).not.toContain('renderInstallerSection');
expect(infrastructureOperationsStateSource).not.toContain('renderInventorySection');
expect(infrastructureOperationsStateSource).not.toContain('renderStopMonitoringDialog');
expect(infrastructureInstallStateSource).toContain('export const useInfrastructureInstallState');
expect(infrastructureInstallStateSource).toContain(
'export const useInfrastructureInstallState',
);
expect(infrastructureInstallStateSource).toContain('MonitoringAPI.getState()');
expect(infrastructureInstallStateSource).toContain('./infrastructureWorkspaceModel');
expect(infrastructureInstallStateSource).toContain(
@ -140,16 +145,18 @@ describe('InfrastructureOperationsController ownership guardrails', () => {
'export const useInfrastructureDiscoveryRuntimeState',
);
expect(infrastructureDiscoveryRuntimeStateSource).toContain("apiFetch('/api/discover'");
expect(infrastructureDiscoveryRuntimeStateSource).toContain(
'SettingsAPI.updateSystemSettings',
);
expect(infrastructureDiscoveryRuntimeStateSource).toContain('SettingsAPI.updateSystemSettings');
expect(infrastructureInstallerSectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureInstallerSectionSource).toContain('Advanced connection and install options');
expect(infrastructureInstallerSectionSource).toContain(
'Advanced connection and install options',
);
expect(infrastructureInstallerSectionSource).toContain(
'Show advanced connection and install options',
);
expect(infrastructureInventorySectionSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureStopMonitoringDialogSource).toContain('useInfrastructureOperationsContext');
expect(infrastructureStopMonitoringDialogSource).toContain(
'useInfrastructureOperationsContext',
);
expect(infrastructureOperationsModelSource).toContain('export const getRowReportingSummary');
expect(infrastructureOperationsModelSource).toContain(
'export const getPowerShellInstallProfileEnvFromFlags',
@ -168,6 +175,8 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
pmgCount={3}
truenasCount={4}
truenasAvailable={true}
vmwareCount={5}
vmwareAvailable={true}
onManagePlatformConnections={onManagePlatformConnections}
/>
));
@ -176,8 +185,11 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
expect(screen.getByText('PBS')).toBeInTheDocument();
expect(screen.getByText('PMG')).toBeInTheDocument();
expect(screen.getByText('TrueNAS')).toBeInTheDocument();
expect(screen.getByText('VMware')).toBeInTheDocument();
expect(screen.getByTestId('platform-connections-truenas')).toHaveTextContent('4');
expect(screen.getByTestId('platform-connections-vmware')).toHaveTextContent('5');
expect(screen.getByText('API-backed NAS connections')).toBeInTheDocument();
expect(screen.getByText('vCenter platform connections')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open platform connections' }));
expect(onManagePlatformConnections).toHaveBeenCalledTimes(1);
@ -191,12 +203,19 @@ describe('InfrastructurePlatformConnectionsSummaryCard', () => {
pmgCount={0}
truenasCount={0}
truenasAvailable={false}
vmwareCount={0}
vmwareAvailable={false}
onManagePlatformConnections={() => {}}
/>
));
expect(screen.getByText('Disabled')).toBeInTheDocument();
expect(screen.getByText('Explicitly disabled on this Pulse server.')).toBeInTheDocument();
expect(
within(screen.getByTestId('platform-connections-truenas')).getByText('Disabled'),
).toBeInTheDocument();
expect(
within(screen.getByTestId('platform-connections-vmware')).getByText('Disabled'),
).toBeInTheDocument();
expect(screen.getAllByText('Explicitly disabled on this Pulse server.')).toHaveLength(2);
});
});
@ -633,7 +652,10 @@ const setupComponent = (
));
};
const setupWithResources = (resources: any[], connectedInfrastructure: ConnectedInfrastructureItem[]) => {
const setupWithResources = (
resources: any[],
connectedInfrastructure: ConnectedInfrastructureItem[],
) => {
setMockResources(resources);
const [state] = createStore<Pick<State, 'connectedInfrastructure'>>({
@ -730,10 +752,13 @@ beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('navigator', { clipboard: { writeText: clipboardSpy } } as unknown as Navigator);
vi.stubGlobal('Blob', MockBlob as unknown as typeof Blob);
vi.stubGlobal('URL', Object.assign(URL, {
createObjectURL: createObjectURLMock,
revokeObjectURL: revokeObjectURLMock,
}));
vi.stubGlobal(
'URL',
Object.assign(URL, {
createObjectURL: createObjectURLMock,
revokeObjectURL: revokeObjectURLMock,
}),
);
listProfilesMock.mockResolvedValue([]);
listAssignmentsMock.mockResolvedValue([]);
@ -790,7 +815,9 @@ describe('InfrastructureOperationsController token generation', () => {
'setup_handoff',
);
expect(notificationSuccessMock).not.toHaveBeenCalled();
expect(screen.getByText(/Security configured\. Save these first-run credentials now\./i)).toBeInTheDocument();
expect(
screen.getByText(/Security configured\. Save these first-run credentials now\./i),
).toBeInTheDocument();
expect(
screen.getByText(/Pulse already prepared the first scoped install token for this handoff/i),
).toBeInTheDocument();
@ -933,7 +960,9 @@ describe('InfrastructureOperationsController agent lookup', () => {
const host = createAgent({ hostname: 'first-host.local', displayName: 'First Host' });
getStateMock
.mockResolvedValueOnce(buildMonitoringState())
.mockResolvedValue(buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] })));
.mockResolvedValue(
buildMonitoringState(buildConnectedInfrastructureFromFixtures({ hosts: [host] })),
);
setupComponent();
@ -1099,7 +1128,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
expect(detailsRow).not.toBeNull();
const details = within(detailsRow as HTMLElement);
expect(screen.getByText('Browse reporting items')).toBeInTheDocument();
expect(screen.getByText('Select a reporting item to open its details drawer.')).toBeInTheDocument();
expect(
screen.getByText('Select a reporting item to open its details drawer.'),
).toBeInTheDocument();
expect(details.getByText('Selected reporting item')).toBeInTheDocument();
expect(details.getByText('Machine overview')).toBeInTheDocument();
expect(details.getByText('Surface controls')).toBeInTheDocument();
@ -1261,45 +1292,44 @@ describe('InfrastructureOperationsController managed agents table', () => {
expect(
details.getByText('Container runtime coverage reported from this machine.'),
).toBeInTheDocument();
expect(
details.getByText('Proxmox node telemetry linked to this machine.'),
).toBeInTheDocument();
expect(details.getByText('Proxmox node telemetry linked to this machine.')).toBeInTheDocument();
expect(details.getByText('Agent ID')).toBeInTheDocument();
expect(details.getByText('Docker runtime ID')).toBeInTheDocument();
expect(details.getByText('Node ID')).toBeInTheDocument();
expect(details.getAllByText('delly-resource').length).toBeGreaterThan(0);
expect(
details.getByRole('button', { name: /Open platform connections/i }),
).toBeInTheDocument();
expect(details.getByRole('button', { name: /Open platform connections/i })).toBeInTheDocument();
expect(details.getAllByText('delly-agent').length).toBeGreaterThan(0);
expect(details.getAllByText('delly-docker').length).toBeGreaterThan(0);
});
it('routes api-backed truenas rows to platform connections instead of machine uninstall actions', async () => {
setupWithResources([], [
{
id: 'truenas-main',
name: 'Tower NAS',
displayName: 'Tower NAS',
hostname: 'truenas.local',
status: 'active',
healthStatus: 'online',
lastSeen: Date.now(),
version: '25.04.0',
upgradePlatform: 'linux',
surfaces: [
{
id: 'truenas:truenas.local',
kind: 'truenas',
label: 'TrueNAS data',
detail:
'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.',
idLabel: 'Hostname',
idValue: 'truenas.local',
},
],
},
]);
setupWithResources(
[],
[
{
id: 'truenas-main',
name: 'Tower NAS',
displayName: 'Tower NAS',
hostname: 'truenas.local',
status: 'active',
healthStatus: 'online',
lastSeen: Date.now(),
version: '25.04.0',
upgradePlatform: 'linux',
surfaces: [
{
id: 'truenas:truenas.local',
kind: 'truenas',
label: 'TrueNAS data',
detail:
'System, storage, app, and recovery telemetry polled through the configured TrueNAS connection.',
idLabel: 'Hostname',
idValue: 'truenas.local',
},
],
},
],
);
await waitFor(() => {
expect(screen.getAllByText('Reporting now').length).toBeGreaterThan(0);
@ -1313,7 +1343,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
const details = within(detailsRow as HTMLElement);
expect(details.queryByText('Machine actions')).not.toBeInTheDocument();
expect(details.queryByRole('button', { name: /Copy uninstall command/i })).not.toBeInTheDocument();
expect(
details.queryByRole('button', { name: /Copy uninstall command/i }),
).not.toBeInTheDocument();
const openPlatformConnectionsButton = details.getByRole('button', {
name: /Open platform connections/i,
@ -1427,7 +1459,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
).toBeInTheDocument();
expect(screen.queryByText('Proxmox data')).not.toBeInTheDocument();
expect(
screen.getByText('Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.'),
screen.getByText(
'Pulse is currently receiving live reports from 1 host, 1 Docker runtime, and 1 PBS server.',
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /details for Tower/i }));
@ -1569,7 +1603,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
expect(screen.getAllByText('delly').length).toBeGreaterThan(0);
});
expect(
screen.queryByText('Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.'),
screen.queryByText(
'Pulse is receiving host telemetry, Docker runtime data, and Proxmox data from this item.',
),
).not.toBeInTheDocument();
});
@ -2209,12 +2245,12 @@ describe('InfrastructureOperationsController managed agents table', () => {
expect(screen.getByText('Docker runtime')).toBeInTheDocument();
expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument();
expect(
screen.getByText(
'Pulse is currently receiving live reports from 1 host.',
),
screen.getByText('Pulse is currently receiving live reports from 1 host.'),
).toBeInTheDocument();
expect(screen.queryByText('Missing expected coverage')).not.toBeInTheDocument();
expect(screen.queryByText('No Kubernetes reporter is currently connected.')).not.toBeInTheDocument();
expect(
screen.queryByText('No Kubernetes reporter is currently connected.'),
).not.toBeInTheDocument();
expect(
screen.getByText(
/Items you explicitly told Pulse to ignore stay out of live reporting until reconnect is allowed\./i,
@ -2263,8 +2299,12 @@ describe('InfrastructureOperationsController managed agents table', () => {
expect(screen.getAllByText('Docker runtime data').length).toBeGreaterThan(0);
const ignoredDrawer = screen.getByRole('dialog', { name: 'Ignored item details' });
const ignoredDetails = within(ignoredDrawer);
expect(ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i })).toBeInTheDocument();
expect(ignoredDetails.queryByRole('button', { name: 'Stop monitoring' })).not.toBeInTheDocument();
expect(
ignoredDetails.getByRole('button', { name: /Allow Docker reconnect/i }),
).toBeInTheDocument();
expect(
ignoredDetails.queryByRole('button', { name: 'Stop monitoring' }),
).not.toBeInTheDocument();
expect(screen.getByText('Showing 0 of 0 active records.')).toBeInTheDocument();
expect(screen.getByText('1 item(s) are currently ignored by Pulse.')).toBeInTheDocument();
});
@ -2354,7 +2394,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
);
expect(screen.getAllByText('Tower').length).toBeGreaterThan(0);
fireEvent.click(screen.getAllByText('Tower')[0]);
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(
0,
);
expect(notificationSuccessMock).toHaveBeenCalledWith(
'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.',
);
@ -2421,7 +2463,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
});
expect(screen.getAllByText('Ignored by Pulse').length).toBeGreaterThan(0);
fireEvent.click(screen.getAllByText('Tower')[0]);
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: 'Allow host reconnect' }).length).toBeGreaterThan(
0,
);
});
it('force-removes docker runtimes from Pulse inventory', async () => {
@ -2451,7 +2495,9 @@ describe('InfrastructureOperationsController managed agents table', () => {
},
);
fireEvent.click(screen.getAllByText('Tower')[0]);
expect(screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length).toBeGreaterThan(0);
expect(
screen.getAllByRole('button', { name: 'Allow Docker reconnect' }).length,
).toBeGreaterThan(0);
expect(notificationSuccessMock).toHaveBeenCalledWith(
'Monitoring stopped for Tower. Pulse will ignore future reports until reconnect is allowed.',
);

View file

@ -5,6 +5,7 @@ import { PlatformConnectionsWorkspace } from '../PlatformConnectionsWorkspace';
let mockPathname = '/settings/infrastructure/platforms/proxmox';
const navigateSpy = vi.hoisted(() => vi.fn());
const trueNASStateSpy = vi.hoisted(() => vi.fn());
const vmwareStateSpy = vi.hoisted(() => vi.fn());
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
@ -26,10 +27,18 @@ vi.mock('../TrueNASSettingsPanel', () => ({
},
}));
vi.mock('../VMwareSettingsPanel', () => ({
VMwareSettingsPanel: (props: { state: unknown }) => {
vmwareStateSpy(props.state);
return <div data-testid="vmware-settings">vmware</div>;
},
}));
describe('PlatformConnectionsWorkspace', () => {
beforeEach(() => {
navigateSpy.mockReset();
trueNASStateSpy.mockReset();
vmwareStateSpy.mockReset();
mockPathname = '/settings/infrastructure/platforms/proxmox';
});
@ -47,6 +56,7 @@ describe('PlatformConnectionsWorkspace', () => {
pbsNodes: () => [],
pmgNodes: () => [],
trueNASSettings: { connections: () => [], featureDisabled: () => false },
vmwareSettings: { connections: () => [], featureDisabled: () => false },
} as any)}
/>
) as any,
@ -57,6 +67,7 @@ describe('PlatformConnectionsWorkspace', () => {
expect(screen.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByTestId('proxmox-settings')).toBeInTheDocument();
});
@ -68,6 +79,14 @@ describe('PlatformConnectionsWorkspace', () => {
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/truenas');
});
it('navigates to the canonical VMware route from the shared subtabs', () => {
renderWorkspace();
fireEvent.click(screen.getByRole('tab', { name: 'VMware' }));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure/platforms/vmware');
});
it('treats legacy TrueNAS routes as the TrueNAS workspace', () => {
mockPathname = '/settings/infrastructure/truenas';
renderWorkspace();
@ -81,4 +100,18 @@ describe('PlatformConnectionsWorkspace', () => {
}),
);
});
it('treats the canonical VMware route as the VMware workspace', () => {
mockPathname = '/settings/infrastructure/platforms/vmware';
renderWorkspace();
expect(screen.getByRole('tab', { name: 'VMware' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('vmware-settings')).toBeInTheDocument();
expect(vmwareStateSpy).toHaveBeenCalledWith(
expect.objectContaining({
connections: expect.any(Function),
featureDisabled: expect.any(Function),
}),
);
});
});

View file

@ -0,0 +1,152 @@
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { VMwareConnection } from '@/api/vmware';
import { VMwareSettingsPanel } from '../VMwareSettingsPanel';
import type { VMwareSettingsPanelState } from '../useVMwareSettingsPanelState';
const mockState = vi.hoisted(() => ({
closeDeleteDialog: vi.fn(),
closeDialog: vi.fn(),
connections: vi.fn((): VMwareConnection[] => []),
deleteDialogOpen: vi.fn(() => false),
deletePendingConnection: vi.fn(),
deleting: vi.fn(() => false),
dialogOpen: vi.fn(() => false),
editingConnection: vi.fn((): VMwareConnection | null => null),
featureDisabled: vi.fn(() => false),
featureDisabledMessage: vi.fn(() => ''),
form: vi.fn(() => ({
name: '',
host: '',
port: '443',
username: '',
password: '',
insecureSkipVerify: false,
enabled: true,
hasStoredPassword: false,
})),
loadConnections: vi.fn(),
loading: vi.fn(() => false),
loadingError: vi.fn(() => null),
openCreateDialog: vi.fn(),
openDeleteDialog: vi.fn(),
openEditDialog: vi.fn(),
pendingDeleteConnection: vi.fn((): VMwareConnection | null => null),
saveCurrentForm: vi.fn(),
saving: vi.fn(() => false),
testCurrentForm: vi.fn(),
testSavedConnection: vi.fn(),
testing: vi.fn(() => false),
updateForm: vi.fn(),
}));
describe('VMwareSettingsPanel', () => {
beforeEach(() => {
Object.values(mockState).forEach((value) => {
if (typeof value === 'function' && 'mockReset' in value) {
value.mockReset();
}
});
mockState.connections.mockReturnValue([]);
mockState.deleteDialogOpen.mockReturnValue(false);
mockState.deleting.mockReturnValue(false);
mockState.dialogOpen.mockReturnValue(false);
mockState.editingConnection.mockReturnValue(null);
mockState.featureDisabled.mockReturnValue(false);
mockState.featureDisabledMessage.mockReturnValue('');
mockState.form.mockReturnValue({
name: '',
host: '',
port: '443',
username: '',
password: '',
insecureSkipVerify: false,
enabled: true,
hasStoredPassword: false,
});
mockState.loading.mockReturnValue(false);
mockState.loadingError.mockReturnValue(null);
mockState.pendingDeleteConnection.mockReturnValue(null);
mockState.saving.mockReturnValue(false);
mockState.testing.mockReturnValue(false);
});
afterEach(() => {
cleanup();
});
const renderPanel = () =>
render(() => <VMwareSettingsPanel state={mockState as unknown as VMwareSettingsPanelState} />);
it('renders the settings shell and existing connections', () => {
mockState.connections.mockReturnValue([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
test: {
lastSuccessAt: new Date(Date.now() - 60_000).toISOString(),
},
observed: {
collectedAt: new Date(Date.now() - 60_000).toISOString(),
hosts: 3,
vms: 42,
datastores: 6,
viRelease: '8.0.3',
},
},
{
id: 'conn-2',
name: 'staging-vcenter',
host: 'staging.lab.local',
username: 'operator@vsphere.local',
insecureSkipVerify: true,
enabled: false,
},
]);
renderPanel();
expect(screen.getByText('VMware vSphere platform integration')).toBeInTheDocument();
expect(screen.getByText('VMware connections')).toBeInTheDocument();
expect(screen.getByText('lab-vcenter')).toBeInTheDocument();
expect(screen.getByText('Validated')).toBeInTheDocument();
expect(
within(screen.getByTestId('vmware-connection-conn-2')).getAllByText('Disabled'),
).toHaveLength(2);
expect(screen.getByText('3 hosts')).toBeInTheDocument();
expect(screen.getByText('42 vms')).toBeInTheDocument();
expect(screen.getByText('6 datastores')).toBeInTheDocument();
expect(screen.getByText('VI JSON 8.0.3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add VMware connection' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Add VMware connection' }));
expect(mockState.openCreateDialog).toHaveBeenCalledTimes(1);
fireEvent.click(
within(screen.getByTestId('vmware-connection-conn-1')).getByRole('button', { name: 'Test' }),
);
expect(mockState.testSavedConnection).toHaveBeenCalledWith(
expect.objectContaining({ id: 'conn-1' }),
);
});
it('shows the feature gate warning when the backend path is disabled', () => {
mockState.featureDisabled.mockReturnValue(true);
mockState.featureDisabledMessage.mockReturnValue(
'VMware integration has been explicitly disabled',
);
renderPanel();
expect(screen.getByText('VMware integration is disabled')).toBeInTheDocument();
expect(screen.getByText('VMware integration has been explicitly disabled')).toBeInTheDocument();
expect(screen.getByText(/PULSE_ENABLE_VMWARE=false/)).toBeInTheDocument();
expect(screen.queryByText('VMware connections')).not.toBeInTheDocument();
});
});

View file

@ -16,6 +16,7 @@ import infrastructureOperationsModelSource from '../infrastructureOperationsMode
import infrastructureReportingPanelSource from '../InfrastructureReportingPanel.tsx?raw';
import infrastructurePlatformConnectionsSummaryCardSource from '../InfrastructurePlatformConnectionsSummaryCard.tsx?raw';
import trueNASSettingsPanelSource from '../TrueNASSettingsPanel.tsx?raw';
import vmwareSettingsPanelSource from '../VMwareSettingsPanel.tsx?raw';
import infrastructureInventorySectionSource from '../InfrastructureInventorySection.tsx?raw';
import infrastructureActiveRowDetailsSource from '../InfrastructureActiveRowDetails.tsx?raw';
import infrastructureIgnoredRowDetailsSource from '../InfrastructureIgnoredRowDetails.tsx?raw';
@ -31,6 +32,7 @@ import infrastructureInstallStateSource from '../useInfrastructureInstallState.t
import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw';
import infrastructureReportingStateSource from '../useInfrastructureReportingState.tsx?raw';
import trueNASSettingsStateSource from '../useTrueNASSettingsPanelState.ts?raw';
import vmwareSettingsStateSource from '../useVMwareSettingsPanelState.ts?raw';
import infrastructureSettingsStateSource from '../useInfrastructureSettingsState.ts?raw';
import infrastructureSettingsModelSource from '../infrastructureSettingsModel.ts?raw';
import infrastructureConfiguredNodesStateSource from '../useInfrastructureConfiguredNodesState.ts?raw';
@ -171,10 +173,12 @@ const extractedModules = [
'../PlatformConnectionsWorkspace.tsx',
'../platformConnectionsModel.ts',
'../TrueNASSettingsPanel.tsx',
'../VMwareSettingsPanel.tsx',
'../useInfrastructureInstallState.tsx',
'../useInfrastructureOperationsState.tsx',
'../useInfrastructureReportingState.tsx',
'../useTrueNASSettingsPanelState.ts',
'../useVMwareSettingsPanelState.ts',
'../infrastructureSettingsModel.ts',
'../useInfrastructureConfiguredNodesState.ts',
'../useInfrastructureDiscoveryRuntimeState.ts',
@ -498,7 +502,7 @@ describe('Settings architecture guardrails', () => {
);
expect(settingsNavigationModelSource).toContain('export function resolveCanonicalSettingsPath');
expect(settingsNavigationModelSource).toContain('export function settingsTabPath');
expect(settingsNavigationModelSource).toContain("return INFRASTRUCTURE_INSTALL_PREFIX;");
expect(settingsNavigationModelSource).toContain('return INFRASTRUCTURE_INSTALL_PREFIX;');
expect(settingsNavigationHookSource).toContain('deriveTabFromPath');
expect(settingsNavigationHookSource).toContain('resolveCanonicalSettingsPath');
expect(settingsNavigationHookSource).toContain('settingsTabPath');
@ -512,7 +516,7 @@ describe('Settings architecture guardrails', () => {
expect(settingsShellStateSource).toContain('showPasswordModal');
expect(settingsNavCatalogSource).toContain('export const SETTINGS_NAV_GROUPS');
expect(settingsNavCatalogSource).toContain('export function getSettingsNavItem');
expect(settingsNavCatalogSource).toContain("./selfHostedBillingPresentation");
expect(settingsNavCatalogSource).toContain('./selfHostedBillingPresentation');
expect(settingsNavCatalogSource).toContain('SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle');
expect(settingsNavCatalogSource).not.toContain("label: 'Pulse Pro'");
expect(settingsNavVisibilitySource).toContain('export function shouldHideSettingsNavItem');
@ -587,18 +591,12 @@ describe('Settings architecture guardrails', () => {
expect(monitoredSystemPresentationSource).toContain(
'export function getMonitoredSystemDisclosureToggleLabel',
);
expect(selfHostedCommercialActivationSectionSource).toContain(
'@/utils/licensePresentation',
);
expect(selfHostedCommercialActivationSectionSource).toContain('@/utils/licensePresentation');
expect(selfHostedCommercialActivationSectionSource).toContain(
'SELF_HOSTED_ACTIVATION_PRESENTATION',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain(
'License / Activation Key',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain(
'Start 14-day Pro Trial',
);
expect(selfHostedCommercialActivationSectionSource).not.toContain('License / Activation Key');
expect(selfHostedCommercialActivationSectionSource).not.toContain('Start 14-day Pro Trial');
expect(organizationBillingPanelSource).toContain('./CommercialBillingSections');
expect(organizationBillingPanelSource).toContain('./OrganizationBillingLoadingState');
expect(organizationBillingPanelSource).toContain('./useOrganizationBillingPanelState');
@ -872,9 +870,10 @@ describe('Settings architecture guardrails', () => {
expect(platformConnectionsWorkspaceSource).toContain('./platformConnectionsModel');
expect(platformConnectionsWorkspaceSource).toContain('./ProxmoxSettingsPanel');
expect(platformConnectionsWorkspaceSource).toContain('./TrueNASSettingsPanel');
expect(platformConnectionsWorkspaceSource).toContain('./VMwareSettingsPanel');
expect(platformConnectionsModelSource).toContain('export const PLATFORM_CONNECTIONS_TABS');
expect(platformConnectionsModelSource).toContain(
"export function getPlatformConnectionsViewFromPath",
'export function getPlatformConnectionsViewFromPath',
);
expect(infrastructureInstallPanelSource).toContain('InfrastructureOperationsStateProvider');
expect(infrastructureInstallPanelSource).toContain('InfrastructureInstallerSection');
@ -906,9 +905,13 @@ describe('Settings architecture guardrails', () => {
'export const useInfrastructureOperationsContext',
);
expect(trueNASSettingsPanelSource).toContain('state: TrueNASSettingsPanelState');
expect(vmwareSettingsPanelSource).toContain('state: VMwareSettingsPanelState');
expect(trueNASSettingsStateSource).toContain('@/api/truenas');
expect(trueNASSettingsStateSource).toContain('export function useTrueNASSettingsPanelState');
expect(vmwareSettingsStateSource).toContain('@/api/vmware');
expect(vmwareSettingsStateSource).toContain('export function useVMwareSettingsPanelState');
expect(infrastructureSettingsStateSource).toContain('./useTrueNASSettingsPanelState');
expect(infrastructureSettingsStateSource).toContain('./useVMwareSettingsPanelState');
expect(infrastructureOperationsStateSource).not.toContain('const renderInstallerSection =');
expect(infrastructureOperationsStateSource).not.toContain('const renderInventorySection =');
expect(infrastructureOperationsStateSource).not.toContain('const renderStopMonitoringDialog =');
@ -934,10 +937,9 @@ describe('Settings architecture guardrails', () => {
expect(infrastructureWorkspaceModelSource).toContain(
'export function buildInfrastructureWorkspacePath',
);
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
'Platform connections',
);
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('Platform connections');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('TrueNAS');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain('VMware');
expect(infrastructurePlatformConnectionsSummaryCardSource).toContain(
'Open platform connections',
);
@ -1186,7 +1188,9 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelStateSource).not.toContain("'advanced_reporting'");
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(reportingCatalogModelSource).toContain('export function buildReportingCatalogRequest');
expect(reportingCatalogModelSource).toContain('export function buildLegacyReportingCatalogFallback');
expect(reportingCatalogModelSource).toContain(
'export function buildLegacyReportingCatalogFallback',
);
expect(reportingCatalogModelSource).toContain('export function parseReportingCatalog');
expect(reportingCatalogModelSource).toContain('interface ReportingLockedStateDefinition');
expect(reportingCatalogModelSource).toContain('interface ReportingGuidanceDefinition');
@ -1312,14 +1316,18 @@ describe('Settings architecture guardrails', () => {
'export function getStartUpdateErrorMessage',
);
expect(docsLinksSource).toContain("export const SHIPPED_DOCS_ROOT = '/docs'");
expect(docsLinksSource).toContain("export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')");
expect(docsLinksSource).toContain(
"export const PRIVACY_DOC_URL = getShippedDocUrl('PRIVACY.md')",
);
expect(docsLinksSource).toContain(
"export const CONFIGURATION_DOC_URL = getShippedDocUrl('CONFIGURATION.md')",
);
expect(docsLinksSource).toContain(
"export const PROXY_AUTH_DOC_URL = getShippedDocUrl('PROXY_AUTH.md')",
);
expect(docsLinksSource).toContain("export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')");
expect(docsLinksSource).toContain(
"export const SECURITY_DOC_URL = getShippedDocUrl('SECURITY.md')",
);
expect(docsLinksSource).toContain("export const TERMS_DOC_URL = getShippedDocUrl('TERMS.md')");
});

View file

@ -3,10 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { useSettingsInfrastructurePanelProps } from '../useSettingsInfrastructurePanelProps';
const createServiceResource = (
type: 'pbs' | 'pmg',
overrides: Partial<Resource> = {},
): Resource =>
const createServiceResource = (type: 'pbs' | 'pmg', overrides: Partial<Resource> = {}): Resource =>
({
id: `${type}-1`,
type,
@ -31,6 +28,8 @@ const mountHook = (
options?: {
truenasConnections?: Array<{ id: string }>;
truenasFeatureDisabled?: boolean;
vmwareConnections?: Array<{ id: string }>;
vmwareFeatureDisabled?: boolean;
pveCount?: number;
pbsCount?: number;
pmgCount?: number;
@ -65,18 +64,22 @@ const mountHook = (
disableDockerUpdateActionsLocked: () => false,
savingDockerUpdateActions: () => false,
handleDisableDockerUpdateActionsChange: async () => {},
} as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['systemSettings'],
} as unknown as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['systemSettings'],
infrastructureSettings: {
initialLoadComplete: () => true,
discoveryScanStatus: () => ({ scanning: false }),
discoveredNodes: () => [],
pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({} as any)),
pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({} as any)),
pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({} as any)),
pveNodes: () => Array.from({ length: options?.pveCount ?? 0 }, () => ({}) as any),
pbsNodes: () => Array.from({ length: options?.pbsCount ?? 0 }, () => ({}) as any),
pmgNodes: () => Array.from({ length: options?.pmgCount ?? 0 }, () => ({}) as any),
trueNASSettings: {
connections: () => options?.truenasConnections ?? [],
featureDisabled: () => options?.truenasFeatureDisabled ?? false,
},
vmwareSettings: {
connections: () => options?.vmwareConnections ?? [],
featureDisabled: () => options?.vmwareFeatureDisabled ?? false,
},
triggerDiscoveryScan: async () => {},
loadDiscoveredNodes: async () => {},
handleDiscoveryEnabledChange: async () => true,
@ -101,7 +104,9 @@ const mountHook = (
nodePendingDeleteHost: () => '',
nodePendingDeleteType: () => '',
nodePendingDeleteTypeLabel: () => '',
} as Parameters<typeof useSettingsInfrastructurePanelProps>[0]['infrastructureSettings'],
} as unknown as Parameters<
typeof useSettingsInfrastructurePanelProps
>[0]['infrastructureSettings'],
securityStatus: () => null,
});
});
@ -134,18 +139,16 @@ describe('useSettingsInfrastructurePanelProps', () => {
const panelProps = hookState.getInfrastructurePanelProps();
expect(panelProps.pbsInstances()).toEqual([
expect.objectContaining({ name: 'PBS Main' }),
]);
expect(panelProps.pmgInstances()).toEqual([
expect.objectContaining({ name: 'PMG Main' }),
]);
expect(panelProps.pbsInstances()).toEqual([expect.objectContaining({ name: 'PBS Main' })]);
expect(panelProps.pmgInstances()).toEqual([expect.objectContaining({ name: 'PMG Main' })]);
expect(panelProps.platformConnectionsSummary()).toMatchObject({
pveCount: 0,
pbsCount: 0,
pmgCount: 0,
truenasCount: 0,
truenasAvailable: true,
vmwareCount: 0,
vmwareAvailable: true,
});
dispose();
@ -157,6 +160,7 @@ describe('useSettingsInfrastructurePanelProps', () => {
pbsCount: 2,
pmgCount: 3,
truenasConnections: [{ id: 'truenas-1' }, { id: 'truenas-2' }],
vmwareConnections: [{ id: 'vmware-1' }],
});
const panelProps = hookState.getInfrastructurePanelProps();
@ -167,6 +171,8 @@ describe('useSettingsInfrastructurePanelProps', () => {
pmgCount: 3,
truenasCount: 2,
truenasAvailable: true,
vmwareCount: 1,
vmwareAvailable: true,
});
dispose();

View file

@ -0,0 +1,190 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VMwareAPI } from '@/api/vmware';
import { notificationStore } from '@/stores/notifications';
import { useVMwareSettingsPanelState } from '../useVMwareSettingsPanelState';
vi.mock('@/api/vmware', () => ({
VMwareAPI: {
listConnections: vi.fn(),
createConnection: vi.fn(),
updateConnection: vi.fn(),
deleteConnection: vi.fn(),
testConnection: vi.fn(),
testSavedConnection: vi.fn(),
},
isRedactedVMwareSecret: (value: string | null | undefined) => (value || '').trim() === '********',
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
},
}));
describe('useVMwareSettingsPanelState', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('treats a 404 list response as a feature-disabled integration state', async () => {
vi.mocked(VMwareAPI.listConnections).mockRejectedValueOnce({
status: 404,
message: 'VMware integration has been explicitly disabled',
});
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.featureDisabled()).toBe(true));
expect(result.featureDisabledMessage()).toBe('VMware integration has been explicitly disabled');
expect(result.connections()).toEqual([]);
});
it('preserves the masked password when editing an existing connection without replacing the secret', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
},
] as never);
vi.mocked(VMwareAPI.updateConnection).mockResolvedValueOnce({
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
} as never);
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
},
] as never);
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.connections()).toHaveLength(1));
result.openEditDialog(result.connections()[0]);
expect(result.dialogOpen()).toBe(true);
await result.saveCurrentForm();
expect(VMwareAPI.updateConnection).toHaveBeenCalledWith(
'conn-1',
expect.objectContaining({
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
}),
);
expect(result.dialogOpen()).toBe(false);
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection updated');
});
it('tests saved connections through the canonical saved-connection API path', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
},
] as never);
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
test: {
lastSuccessAt: '2026-03-30T10:00:00Z',
},
},
] as never);
vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never);
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.connections()).toHaveLength(1));
await result.testSavedConnection(result.connections()[0]);
expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith('conn-1');
expect(VMwareAPI.testConnection).not.toHaveBeenCalled();
expect(notificationStore.success).toHaveBeenCalledWith(
'VMware connection successful for lab-vcenter',
);
expect(VMwareAPI.listConnections).toHaveBeenCalledTimes(2);
expect(result.connections()[0].test?.lastSuccessAt).toBe('2026-03-30T10:00:00Z');
});
it('tests edited saved connections through the canonical saved-connection API path', async () => {
vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([
{
id: 'conn-1',
name: 'lab-vcenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
},
] as never);
vi.mocked(VMwareAPI.testSavedConnection).mockResolvedValueOnce({ success: true } as never);
const { result } = renderHook(() => useVMwareSettingsPanelState());
await waitFor(() => expect(result.connections()).toHaveLength(1));
result.openEditDialog(result.connections()[0]);
result.updateForm({ host: 'edited.lab.local', port: '8443', insecureSkipVerify: true });
await result.testCurrentForm();
expect(VMwareAPI.testSavedConnection).toHaveBeenCalledWith(
'conn-1',
expect.objectContaining({
host: 'edited.lab.local',
port: 8443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: true,
}),
);
expect(VMwareAPI.testConnection).not.toHaveBeenCalled();
expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful');
});
});

View file

@ -1,4 +1,4 @@
export type PlatformConnectionsView = 'proxmox' | 'truenas';
export type PlatformConnectionsView = 'proxmox' | 'truenas' | 'vmware';
export interface PlatformConnectionsTabDefinition {
id: PlatformConnectionsView;
@ -9,9 +9,11 @@ export interface PlatformConnectionsTabDefinition {
export const PLATFORM_CONNECTIONS_PREFIX = '/settings/infrastructure/platforms';
const PLATFORM_CONNECTIONS_PROXMOX_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/proxmox`;
const PLATFORM_CONNECTIONS_TRUENAS_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/truenas`;
const PLATFORM_CONNECTIONS_VMWARE_PREFIX = `${PLATFORM_CONNECTIONS_PREFIX}/vmware`;
const LEGACY_PROXMOX_PREFIX = '/settings/infrastructure/proxmox';
const LEGACY_PROXMOX_API_PREFIX = '/settings/infrastructure/api';
const LEGACY_TRUENAS_PREFIX = '/settings/infrastructure/truenas';
const LEGACY_VMWARE_PREFIX = '/settings/infrastructure/vmware';
export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinition[] = [
{
@ -24,12 +26,23 @@ export const PLATFORM_CONNECTIONS_TABS: readonly PlatformConnectionsTabDefinitio
label: 'TrueNAS',
path: PLATFORM_CONNECTIONS_TRUENAS_PREFIX,
},
{
id: 'vmware',
label: 'VMware',
path: PLATFORM_CONNECTIONS_VMWARE_PREFIX,
},
];
export function getPlatformConnectionsViewFromPath(pathname: string): PlatformConnectionsView {
if (pathname.startsWith(PLATFORM_CONNECTIONS_VMWARE_PREFIX)) {
return 'vmware';
}
if (pathname.startsWith(PLATFORM_CONNECTIONS_TRUENAS_PREFIX)) {
return 'truenas';
}
if (pathname.startsWith(LEGACY_VMWARE_PREFIX)) {
return 'vmware';
}
if (pathname.startsWith(LEGACY_TRUENAS_PREFIX)) {
return 'truenas';
}

View file

@ -9,6 +9,7 @@ import type {
NodeType,
} from './infrastructureSettingsModel';
import type { TrueNASSettingsPanelState } from './useTrueNASSettingsPanelState';
import type { VMwareSettingsPanelState } from './useVMwareSettingsPanelState';
export type DiscoveryMode = 'auto' | 'custom';
@ -18,6 +19,8 @@ export interface PlatformConnectionsSummary {
pmgCount: number;
truenasCount: number;
truenasAvailable: boolean;
vmwareCount: number;
vmwareAvailable: boolean;
}
export interface InfrastructurePlatformSettingsProps {
@ -37,6 +40,7 @@ export interface InfrastructurePlatformSettingsProps {
pbsNodes: Accessor<NodeConfigWithStatus[]>;
pmgNodes: Accessor<NodeConfigWithStatus[]>;
trueNASSettings: TrueNASSettingsPanelState;
vmwareSettings: VMwareSettingsPanelState;
platformConnectionsSummary: Accessor<PlatformConnectionsSummary>;
temperatureMonitoringEnabled: Accessor<boolean>;
triggerDiscoveryScan: (options?: { quiet?: boolean }) => Promise<void>;
@ -57,6 +61,10 @@ export interface InfrastructurePlatformSettingsProps {
temperatureMonitoringLocked: Accessor<boolean>;
savingTemperatureSetting: Accessor<boolean>;
handleTemperatureMonitoringChange: (enabled: boolean) => Promise<void>;
disableDockerUpdateActions: Accessor<boolean>;
disableDockerUpdateActionsLocked: Accessor<boolean>;
savingDockerUpdateActions: Accessor<boolean>;
handleDisableDockerUpdateActionsChange: (enabled: boolean) => Promise<void>;
handleNodeTemperatureMonitoringChange: (nodeId: string, enabled: boolean | null) => Promise<void>;
saveNode: (nodeData: Partial<NodeConfig>) => Promise<void>;
showDeleteNodeModal: Accessor<boolean>;

View file

@ -5,6 +5,7 @@ import type { SettingsTab } from './settingsTypes';
import { useInfrastructureConfiguredNodesState } from './useInfrastructureConfiguredNodesState';
import { useInfrastructureDiscoveryRuntimeState } from './useInfrastructureDiscoveryRuntimeState';
import { useTrueNASSettingsPanelState } from './useTrueNASSettingsPanelState';
import { useVMwareSettingsPanelState } from './useVMwareSettingsPanelState';
export type {
DiscoveryScanStatus,
@ -77,6 +78,7 @@ export function useInfrastructureSettingsState({
setSavingTemperatureSetting,
});
const trueNASSettings = useTrueNASSettingsPanelState();
const vmwareSettings = useVMwareSettingsPanelState();
const discoveryRuntime = useInfrastructureDiscoveryRuntimeState({
eventBus,
@ -156,6 +158,7 @@ export function useInfrastructureSettingsState({
return {
initialLoadComplete,
trueNASSettings,
vmwareSettings,
...configuredNodes,
...discoveryRuntime,
};

View file

@ -25,13 +25,15 @@ export function useSettingsInfrastructurePanelProps(
params.resources().filter((resource) => resource.type === 'agent'),
);
const pbsInstances = createMemo(() =>
params.resources()
params
.resources()
.filter((resource) => resource.type === 'pbs')
.map(pbsInstanceFromResource)
.filter((instance): instance is NonNullable<typeof instance> => Boolean(instance)),
);
const pmgInstances = createMemo(() =>
params.resources()
params
.resources()
.filter((resource) => resource.type === 'pmg')
.map(pmgInstanceFromResource)
.filter((instance): instance is NonNullable<typeof instance> => Boolean(instance)),
@ -43,6 +45,8 @@ export function useSettingsInfrastructurePanelProps(
pmgCount: params.infrastructureSettings.pmgNodes().length,
truenasCount: params.infrastructureSettings.trueNASSettings.connections().length,
truenasAvailable: !params.infrastructureSettings.trueNASSettings.featureDisabled(),
vmwareCount: params.infrastructureSettings.vmwareSettings.connections().length,
vmwareAvailable: !params.infrastructureSettings.vmwareSettings.featureDisabled(),
}));
const getInfrastructurePanelProps = (): InfrastructurePlatformSettingsProps => ({
@ -62,6 +66,7 @@ export function useSettingsInfrastructurePanelProps(
pbsNodes: params.infrastructureSettings.pbsNodes,
pmgNodes: params.infrastructureSettings.pmgNodes,
trueNASSettings: params.infrastructureSettings.trueNASSettings,
vmwareSettings: params.infrastructureSettings.vmwareSettings,
platformConnectionsSummary,
temperatureMonitoringEnabled: params.systemSettings.temperatureMonitoringEnabled,
triggerDiscoveryScan: params.infrastructureSettings.triggerDiscoveryScan,

View file

@ -0,0 +1,290 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import {
VMwareAPI,
isRedactedVMwareSecret,
type VMwareConnection,
type VMwareConnectionInput,
} from '@/api/vmware';
import { apiErrorStatus } from '@/api/responseUtils';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
export interface VMwareConnectionFormState {
name: string;
host: string;
port: string;
username: string;
password: string;
insecureSkipVerify: boolean;
enabled: boolean;
hasStoredPassword: boolean;
}
const REDACTED_SECRET = '********';
const createEmptyFormState = (): VMwareConnectionFormState => ({
name: '',
host: '',
port: '443',
username: '',
password: '',
insecureSkipVerify: false,
enabled: true,
hasStoredPassword: false,
});
const buildFormStateFromConnection = (connection: VMwareConnection): VMwareConnectionFormState => ({
name: connection.name || '',
host: connection.host || '',
port: connection.port ? String(connection.port) : '443',
username: connection.username || '',
password: '',
insecureSkipVerify: connection.insecureSkipVerify,
enabled: connection.enabled,
hasStoredPassword: isRedactedVMwareSecret(connection.password),
});
const parseOptionalPort = (value: string): number | undefined => {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error('Port must be a whole number between 1 and 65535');
}
return parsed;
};
const getErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof (error as { message?: unknown }).message === 'string'
) {
const message = (error as { message: string }).message.trim();
if (message) {
return message;
}
}
return fallback;
};
const buildConnectionInput = (form: VMwareConnectionFormState): VMwareConnectionInput => {
const port = parseOptionalPort(form.port);
const name = form.name.trim();
const host = form.host.trim();
const username = form.username.trim();
return {
...(name ? { name } : {}),
host,
...(port !== undefined ? { port } : {}),
username,
password: form.password.trim() || (form.hasStoredPassword ? REDACTED_SECRET : ''),
insecureSkipVerify: form.insecureSkipVerify,
enabled: form.enabled,
};
};
export function useVMwareSettingsPanelState() {
const [connections, setConnections] = createSignal<VMwareConnection[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadingError, setLoadingError] = createSignal<string | null>(null);
const [featureDisabled, setFeatureDisabled] = createSignal(false);
const [featureDisabledMessage, setFeatureDisabledMessage] = createSignal('');
const [dialogOpen, setDialogOpen] = createSignal(false);
const [deleteDialogOpen, setDeleteDialogOpen] = createSignal(false);
const [editingConnectionId, setEditingConnectionId] = createSignal<string | null>(null);
const [pendingDeleteConnection, setPendingDeleteConnection] =
createSignal<VMwareConnection | null>(null);
const [form, setForm] = createSignal<VMwareConnectionFormState>(createEmptyFormState());
const [saving, setSaving] = createSignal(false);
const [testing, setTesting] = createSignal(false);
const [deleting, setDeleting] = createSignal(false);
const editingConnection = createMemo(
() => connections().find((connection) => connection.id === editingConnectionId()) ?? null,
);
const loadConnections = async () => {
setLoading(true);
setLoadingError(null);
try {
const nextConnections = await VMwareAPI.listConnections();
setConnections(nextConnections);
setFeatureDisabled(false);
setFeatureDisabledMessage('');
} catch (error) {
if (apiErrorStatus(error) === 404) {
setFeatureDisabled(true);
setFeatureDisabledMessage(
getErrorMessage(error, 'VMware integration has been explicitly disabled'),
);
setConnections([]);
return;
}
const message = getErrorMessage(error, 'Failed to load VMware connections');
setLoadingError(message);
logger.error('[VMware Settings] Failed to load connections', error);
} finally {
setLoading(false);
}
};
onMount(() => {
void loadConnections();
});
const openCreateDialog = () => {
setEditingConnectionId(null);
setForm(createEmptyFormState());
setDialogOpen(true);
};
const openEditDialog = (connection: VMwareConnection) => {
setEditingConnectionId(connection.id);
setForm(buildFormStateFromConnection(connection));
setDialogOpen(true);
};
const resetDialogState = () => {
setDialogOpen(false);
setEditingConnectionId(null);
setForm(createEmptyFormState());
};
const closeDialog = () => {
if (saving() || testing()) return;
resetDialogState();
};
const openDeleteDialog = (connection: VMwareConnection) => {
setPendingDeleteConnection(connection);
setDeleteDialogOpen(true);
};
const resetDeleteDialogState = () => {
setDeleteDialogOpen(false);
setPendingDeleteConnection(null);
};
const closeDeleteDialog = () => {
if (deleting()) return;
resetDeleteDialogState();
};
const updateForm = (patch: Partial<VMwareConnectionFormState>) =>
setForm((current) => ({ ...current, ...patch }));
const testCurrentForm = async () => {
setTesting(true);
try {
const payload = buildConnectionInput(form());
if (editingConnectionId()) {
await VMwareAPI.testSavedConnection(editingConnectionId()!, payload);
} else {
await VMwareAPI.testConnection(payload);
}
notificationStore.success('VMware connection successful');
return true;
} catch (error) {
const message = getErrorMessage(error, 'VMware connection failed');
notificationStore.error(message);
logger.error('[VMware Settings] Connection test failed', error);
return false;
} finally {
setTesting(false);
}
};
const testSavedConnection = async (connection: VMwareConnection) => {
setTesting(true);
try {
await VMwareAPI.testSavedConnection(connection.id);
notificationStore.success(
`VMware connection successful for ${connection.name || connection.host}`,
);
} catch (error) {
const message = getErrorMessage(error, 'VMware connection failed');
notificationStore.error(message);
logger.error('[VMware Settings] Saved connection test failed', error);
} finally {
setTesting(false);
await loadConnections();
}
};
const saveCurrentForm = async () => {
setSaving(true);
try {
const payload = buildConnectionInput(form());
if (editingConnectionId()) {
await VMwareAPI.updateConnection(editingConnectionId()!, payload);
notificationStore.success('VMware connection updated');
} else {
await VMwareAPI.createConnection(payload);
notificationStore.success('VMware connection added');
}
resetDialogState();
await loadConnections();
} catch (error) {
const message = getErrorMessage(error, 'Failed to save VMware connection');
notificationStore.error(message);
logger.error('[VMware Settings] Save failed', error);
} finally {
setSaving(false);
}
};
const deletePendingConnection = async () => {
const connection = pendingDeleteConnection();
if (!connection) return;
setDeleting(true);
try {
await VMwareAPI.deleteConnection(connection.id);
notificationStore.success(`Removed ${connection.name || connection.host}`);
resetDeleteDialogState();
await loadConnections();
} catch (error) {
const message = getErrorMessage(error, 'Failed to remove VMware connection');
notificationStore.error(message);
logger.error('[VMware Settings] Delete failed', error);
} finally {
setDeleting(false);
}
};
return {
closeDeleteDialog,
closeDialog,
connections,
deleteDialogOpen,
deletePendingConnection,
deleting,
dialogOpen,
editingConnection,
featureDisabled,
featureDisabledMessage,
form,
loadConnections,
loading,
loadingError,
openCreateDialog,
openDeleteDialog,
openEditDialog,
pendingDeleteConnection,
saveCurrentForm,
saving,
testCurrentForm,
testSavedConnection,
testing,
updateForm,
};
}
export type VMwareSettingsPanelState = ReturnType<typeof useVMwareSettingsPanelState>;
export default useVMwareSettingsPanelState;

View file

@ -32,6 +32,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
@ -137,6 +138,62 @@ func TestContract_TrueNASSavedConnectionTestsUpdateRuntimeSummary(t *testing.T)
}
}
func TestContract_VMwareConnectionsDisabledMessageIsExplicit(t *testing.T) {
setVMwareFeatureForTest(t, false)
handler, _ := newVMwareHandlersForTest(t)
req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
rec := httptest.NewRecorder()
handler.HandleList(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 when VMware integration is disabled, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "explicitly disabled") {
t.Fatalf("expected explicit disable message, got %s", rec.Body.String())
}
}
func TestContract_VMwareSavedConnectionTestsUpdateRuntimeSummary(t *testing.T) {
setVMwareFeatureForTest(t, true)
connection := config.VMwareVCenterInstance{
ID: "conn-1",
Name: "lab-vcenter",
Host: "vcsa.lab.local",
Port: 443,
Username: "administrator@vsphere.local",
Password: "super-secret",
Enabled: true,
}
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return &vmware.InventorySummary{Hosts: 3, VMs: 20, Datastores: 4, VIRelease: "8.0.3"}, nil
},
}, nil
}
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
rec := httptest.NewRecorder()
handler.HandleTestSavedConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
status := handler.runtimeStatus(connection.ID)
if status.Test == nil || status.Test.LastSuccessAt == nil {
t.Fatalf("expected saved manual test to refresh runtime summary, got %+v", status.Test)
}
if status.Observed == nil || status.Observed.VMs != 20 {
t.Fatalf("expected saved manual test to refresh observed summary, got %+v", status.Observed)
}
}
func TestContract_SSOTestRejectsMetadataURLWithUserinfo(t *testing.T) {
called := make(chan struct{}, 1)
metadataServer := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -265,6 +265,9 @@ var bareRouteAllowlist = []string{
"/api/truenas/connections",
"/api/truenas/connections/test",
"/api/truenas/connections/",
"/api/vmware/connections",
"/api/vmware/connections/test",
"/api/vmware/connections/",
"/api/health",
"/api/login",
"/api/logout",
@ -390,8 +393,13 @@ var allRouteAllowlist = []string{
"/api/truenas/connections",
"/api/truenas/connections/test",
"/api/truenas/connections/",
"/api/vmware/connections",
"/api/vmware/connections/test",
"/api/vmware/connections/",
"/api/admin/profiles/",
"/api/config/system",
"/api/system/settings/telemetry-preview",
"/api/system/settings/telemetry-reset-id",
"/api/system/mock-mode",
"/api/license/status",
"/api/webhooks/stripe",

View file

@ -52,6 +52,7 @@ import (
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
@ -70,6 +71,7 @@ type Router struct {
alertHandlers *AlertHandlers
configHandlers *ConfigHandlers
trueNASHandlers *TrueNASHandlers
vmwareHandlers *VMwareHandlers
notificationHandlers *NotificationHandlers
notificationQueueHandlers *NotificationQueueHandlers
dockerAgentHandlers *DockerAgentHandlers
@ -350,6 +352,9 @@ func (r *Router) setupRoutes() {
getMonitor: r.configHandlers.getMonitor,
getPoller: func(context.Context) *monitoring.TrueNASPoller { return r.trueNASPoller },
}
r.vmwareHandlers = &VMwareHandlers{
getPersistence: r.configHandlers.getPersistence,
}
recoveryManager := recoverymanager.New(r.multiTenant)
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)
if r.mtMonitor != nil {
@ -373,6 +378,7 @@ func (r *Router) setupRoutes() {
}
if mock.IsMockEnabled() {
truenas.SetFeatureEnabled(true)
vmware.SetFeatureEnabled(true)
mockTrueNASProvider := truenas.NewDefaultProvider()
adapter := trueNASRecordsAdapter{provider: mockTrueNASProvider}
r.resourceHandlers.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, adapter)

View file

@ -223,6 +223,53 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
}
})
// VMware vCenter connection management
r.mux.HandleFunc("/api/vmware/connections", func(w http.ResponseWriter, req *http.Request) {
if r.vmwareHandlers == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
return
}
switch req.Method {
case http.MethodGet:
RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.vmwareHandlers.HandleList))(w, req)
case http.MethodPost:
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleAdd))(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.mux.HandleFunc("/api/vmware/connections/test", func(w http.ResponseWriter, req *http.Request) {
if r.vmwareHandlers == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
return
}
if req.Method == http.MethodPost {
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestConnection))(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.mux.HandleFunc("/api/vmware/connections/", func(w http.ResponseWriter, req *http.Request) {
if r.vmwareHandlers == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "vmware_unavailable", "VMware service unavailable", nil)
return
}
if req.Method == http.MethodPost && strings.HasSuffix(strings.Trim(req.URL.Path, "/"), "/test") {
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleTestSavedConnection))(w, req)
} else if req.Method == http.MethodDelete {
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleDelete))(w, req)
} else if req.Method == http.MethodPut {
RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.vmwareHandlers.HandleUpdate))(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License
// SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles
// r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it

View file

@ -1756,6 +1756,38 @@ func TestTrueNASConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
}
}
func TestVMwareConnectionMutationsRequireSettingsWriteScope(t *testing.T) {
rawToken := "vmware-mutate-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []struct {
method string
path string
body string
}{
{method: http.MethodPost, path: "/api/vmware/connections", body: `{}`},
{method: http.MethodPost, path: "/api/vmware/connections/test", body: `{}`},
{method: http.MethodPut, path: "/api/vmware/connections/conn-1", body: `{}`},
{method: http.MethodDelete, path: "/api/vmware/connections/conn-1", body: ""},
{method: http.MethodPost, path: "/api/vmware/connections/conn-1/test", body: ""},
}
for _, tc := range paths {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for missing settings:write scope on %s %s, got %d", tc.method, tc.path, rec.Code)
}
if !strings.Contains(rec.Body.String(), config.ScopeSettingsWrite) {
t.Fatalf("expected missing scope response to mention %q, got %q", config.ScopeSettingsWrite, rec.Body.String())
}
}
}
func TestConfigExportRequiresSettingsReadScope(t *testing.T) {
rawToken := "config-export-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)

View file

@ -0,0 +1,643 @@
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
)
const vmwareConnectionsPathPrefix = "/api/vmware/connections/"
// VMwareHandlers manages VMware vCenter connection CRUD and connectivity checks.
type VMwareHandlers struct {
getPersistence func(ctx context.Context) *config.ConfigPersistence
newClient func(vmware.ClientConfig) (vmwareClient, error)
statusMu sync.RWMutex
statuses map[string]vmwareConnectionRuntimeStatus
}
type vmwareClient interface {
TestConnection(ctx context.Context) (*vmware.InventorySummary, error)
Close()
}
type vmwareConnectionResponse struct {
config.VMwareVCenterInstance
Test *vmwareConnectionTestStatus `json:"test,omitempty"`
Observed *vmwareConnectionObservedStatus `json:"observed,omitempty"`
}
type vmwareConnectionTestStatus struct {
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
LastError *vmwareConnectionTestError `json:"lastError,omitempty"`
}
type vmwareConnectionTestError struct {
At *time.Time `json:"at,omitempty"`
Message string `json:"message,omitempty"`
Category string `json:"category,omitempty"`
}
type vmwareConnectionObservedStatus struct {
CollectedAt *time.Time `json:"collectedAt,omitempty"`
Hosts int `json:"hosts"`
VMs int `json:"vms"`
Datastores int `json:"datastores"`
VIRelease string `json:"viRelease,omitempty"`
}
type vmwareConnectionRuntimeStatus struct {
Test *vmwareConnectionTestStatus
Observed *vmwareConnectionObservedStatus
}
// HandleAdd stores a new VMware vCenter connection.
func (h *VMwareHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
if mock.IsMockEnabled() {
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
defer r.Body.Close()
var instance config.VMwareVCenterInstance
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return
}
instance.ID = strings.TrimSpace(instance.ID)
if instance.ID == "" {
instance.ID = config.NewVMwareVCenterInstance().ID
}
normalizeVMwareInstance(&instance)
if err := instance.Validate(); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {
return
}
instances, err := persistence.LoadVMwareConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
return
}
instances = append(instances, instance)
if err := persistence.SaveVMwareConfig(instances); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, instance.Redacted())
}
// HandleList returns all configured VMware connections with sensitive fields redacted.
func (h *VMwareHandlers) HandleList(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {
return
}
instances, err := persistence.LoadVMwareConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
return
}
responses := make([]vmwareConnectionResponse, 0, len(instances))
for i := range instances {
item := instances[i]
item.ApplyDefaults()
status := h.runtimeStatus(strings.TrimSpace(item.ID))
responses = append(responses, vmwareConnectionResponse{
VMwareVCenterInstance: item.Redacted(),
Test: status.Test,
Observed: status.Observed,
})
}
writeJSON(w, http.StatusOK, responses)
}
// HandleDelete removes a configured VMware vCenter connection by ID.
func (h *VMwareHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
if mock.IsMockEnabled() {
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
return
}
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
if !ok {
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
return
}
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {
return
}
instances, err := persistence.LoadVMwareConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
return
}
index := -1
for i := range instances {
if strings.TrimSpace(instances[i].ID) == connectionID {
index = i
break
}
}
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
return
}
instances = append(instances[:index], instances[index+1:]...)
if err := persistence.SaveVMwareConfig(instances); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
return
}
h.clearRuntimeStatus(connectionID)
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"id": connectionID,
})
}
// HandleUpdate replaces a configured VMware vCenter connection by ID while
// preserving unchanged masked secrets from the stored record.
func (h *VMwareHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
if mock.IsMockEnabled() {
writeErrorResponse(w, http.StatusForbidden, "mock_mode_enabled", "Cannot modify connections in mock mode", nil)
return
}
connectionID, ok := vmwareConnectionIDFromPath(r.URL.Path)
if !ok {
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
defer r.Body.Close()
var instance config.VMwareVCenterInstance
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return
}
instance.ID = connectionID
normalizeVMwareInstance(&instance)
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {
return
}
instances, err := persistence.LoadVMwareConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
return
}
index := -1
for i := range instances {
if strings.TrimSpace(instances[i].ID) == connectionID {
index = i
break
}
}
if index < 0 {
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
return
}
instance.PreserveMaskedSecrets(instances[index])
if err := instance.Validate(); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
instances[index] = instance
if err := persistence.SaveVMwareConfig(instances); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, instance.Redacted())
}
// HandleTestConnection validates connectivity for a proposed VMware vCenter connection.
func (h *VMwareHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
defer r.Body.Close()
var instance config.VMwareVCenterInstance
if err := json.NewDecoder(r.Body).Decode(&instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return
}
normalizeVMwareInstance(&instance)
if err := instance.Validate(); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
h.writeConnectionTestResult(w, r, instance)
}
// HandleTestSavedConnection validates connectivity for one saved VMware
// connection using the server-owned stored secret material instead of relying
// on frontend redaction placeholders.
func (h *VMwareHandlers) HandleTestSavedConnection(w http.ResponseWriter, r *http.Request) {
if !h.featureEnabled(w) {
return
}
connectionID, ok := vmwareConnectionIDFromTestPath(r.URL.Path)
if !ok {
writeErrorResponse(w, http.StatusBadRequest, "missing_connection_id", "Connection ID is required", nil)
return
}
persistence := h.persistenceForRequest(w, r.Context())
if persistence == nil {
return
}
instances, err := persistence.LoadVMwareConfig()
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()})
return
}
for i := range instances {
instance := instances[i]
if strings.TrimSpace(instance.ID) != connectionID {
continue
}
normalizeVMwareInstance(&instance)
payload, hasPayload, ok := decodeOptionalVMwareInstanceRequest(w, r)
if !ok {
return
}
if hasPayload {
payload.ID = connectionID
normalizeVMwareInstance(&payload)
payload.PreserveMaskedSecrets(instance)
if err := payload.Validate(); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
return
}
instance = payload
}
summary, invalidConfig, err := h.testConnectionInstance(r, instance)
if err != nil {
if !hasPayload {
h.recordTestFailure(connectionID, err, time.Now().UTC())
}
h.writeConnectionFailure(w, invalidConfig, err)
return
}
if !hasPayload {
h.recordTestSuccess(connectionID, summary, time.Now().UTC())
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
return
}
writeErrorResponse(w, http.StatusNotFound, "vmware_not_found", "Connection not found", nil)
}
func (h *VMwareHandlers) writeConnectionTestResult(
w http.ResponseWriter,
r *http.Request,
instance config.VMwareVCenterInstance,
) {
_, invalidConfig, err := h.testConnectionInstance(r, instance)
if err != nil {
h.writeConnectionFailure(w, invalidConfig, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *VMwareHandlers) writeConnectionFailure(w http.ResponseWriter, invalidConfig bool, err error) {
if invalidConfig {
writeErrorResponse(w, http.StatusBadRequest, "vmware_invalid_config", "Invalid VMware vCenter connection configuration", connectionFailureDetails(err))
return
}
writeErrorResponse(w, http.StatusBadRequest, "vmware_connection_failed", "Failed to connect to VMware vCenter", connectionFailureDetails(err))
}
func connectionFailureDetails(err error) map[string]string {
if err == nil {
return nil
}
details := map[string]string{"error": err.Error()}
if category := connectionErrorCategory(err); category != "" {
details["category"] = category
}
return details
}
func connectionErrorCategory(err error) string {
if err == nil {
return ""
}
if connectionErr, ok := err.(*vmware.ConnectionError); ok {
return strings.TrimSpace(connectionErr.Category)
}
return ""
}
func (h *VMwareHandlers) testConnectionInstance(
r *http.Request,
instance config.VMwareVCenterInstance,
) (*vmware.InventorySummary, bool, error) {
newClient := h.newClient
if newClient == nil {
newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) { return vmware.NewClient(cfg) }
}
client, err := newClient(vmware.ClientConfig{
Host: instance.Host,
Port: instance.Port,
Username: instance.Username,
Password: instance.Password,
InsecureSkipVerify: instance.InsecureSkipVerify,
Timeout: 10 * time.Second,
})
if err != nil {
return nil, true, err
}
defer client.Close()
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
summary, err := client.TestConnection(ctx)
if err != nil {
return nil, false, err
}
return summary, false, nil
}
func normalizeVMwareInstance(instance *config.VMwareVCenterInstance) {
if instance == nil {
return
}
instance.Name = strings.TrimSpace(instance.Name)
instance.Host = strings.TrimSpace(instance.Host)
instance.Username = strings.TrimSpace(instance.Username)
instance.Password = strings.TrimSpace(instance.Password)
instance.ApplyDefaults()
}
func decodeOptionalVMwareInstanceRequest(
w http.ResponseWriter,
r *http.Request,
) (config.VMwareVCenterInstance, bool, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return config.VMwareVCenterInstance{}, false, false
}
if len(bytes.TrimSpace(body)) == 0 {
return config.VMwareVCenterInstance{}, false, true
}
var instance config.VMwareVCenterInstance
if err := json.Unmarshal(body, &instance); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()})
return config.VMwareVCenterInstance{}, false, false
}
return instance, true, true
}
func (h *VMwareHandlers) featureEnabled(w http.ResponseWriter) bool {
if vmware.IsFeatureEnabled() {
return true
}
writeErrorResponse(w, http.StatusNotFound, "vmware_disabled", "VMware integration has been explicitly disabled", nil)
return false
}
func (h *VMwareHandlers) persistenceForRequest(w http.ResponseWriter, ctx context.Context) *config.ConfigPersistence {
if h == nil || h.getPersistence == nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
return nil
}
persistence := h.getPersistence(ctx)
if persistence == nil {
writeErrorResponse(w, http.StatusInternalServerError, "vmware_unavailable", "VMware service unavailable", nil)
return nil
}
return persistence
}
func (h *VMwareHandlers) runtimeStatus(connectionID string) vmwareConnectionRuntimeStatus {
if h == nil {
return vmwareConnectionRuntimeStatus{}
}
h.statusMu.RLock()
defer h.statusMu.RUnlock()
if h.statuses == nil {
return vmwareConnectionRuntimeStatus{}
}
status, ok := h.statuses[connectionID]
if !ok {
return vmwareConnectionRuntimeStatus{}
}
return cloneVMwareRuntimeStatus(status)
}
func (h *VMwareHandlers) recordTestSuccess(connectionID string, summary *vmware.InventorySummary, at time.Time) {
if h == nil {
return
}
h.statusMu.Lock()
defer h.statusMu.Unlock()
if h.statuses == nil {
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
}
current := h.statuses[connectionID]
current.Test = &vmwareConnectionTestStatus{
LastAttemptAt: timePointer(at),
LastSuccessAt: timePointer(at),
}
if summary != nil {
current.Observed = &vmwareConnectionObservedStatus{
CollectedAt: timePointer(at),
Hosts: summary.Hosts,
VMs: summary.VMs,
Datastores: summary.Datastores,
VIRelease: strings.TrimSpace(summary.VIRelease),
}
}
h.statuses[connectionID] = current
}
func (h *VMwareHandlers) recordTestFailure(connectionID string, err error, at time.Time) {
if h == nil {
return
}
h.statusMu.Lock()
defer h.statusMu.Unlock()
if h.statuses == nil {
h.statuses = make(map[string]vmwareConnectionRuntimeStatus)
}
current := h.statuses[connectionID]
test := current.Test
if test == nil {
test = &vmwareConnectionTestStatus{}
}
test.LastAttemptAt = timePointer(at)
test.LastError = &vmwareConnectionTestError{
At: timePointer(at),
Message: err.Error(),
Category: connectionErrorCategory(err),
}
current.Test = test
h.statuses[connectionID] = current
}
func (h *VMwareHandlers) clearRuntimeStatus(connectionID string) {
if h == nil {
return
}
h.statusMu.Lock()
defer h.statusMu.Unlock()
if h.statuses == nil {
return
}
delete(h.statuses, connectionID)
}
func cloneVMwareRuntimeStatus(status vmwareConnectionRuntimeStatus) vmwareConnectionRuntimeStatus {
cloned := vmwareConnectionRuntimeStatus{}
if status.Test != nil {
test := *status.Test
if test.LastAttemptAt != nil {
test.LastAttemptAt = timePointer(*test.LastAttemptAt)
}
if test.LastSuccessAt != nil {
test.LastSuccessAt = timePointer(*test.LastSuccessAt)
}
if test.LastError != nil {
errCopy := *test.LastError
if errCopy.At != nil {
errCopy.At = timePointer(*errCopy.At)
}
test.LastError = &errCopy
}
cloned.Test = &test
}
if status.Observed != nil {
observed := *status.Observed
if observed.CollectedAt != nil {
observed.CollectedAt = timePointer(*observed.CollectedAt)
}
cloned.Observed = &observed
}
return cloned
}
func timePointer(value time.Time) *time.Time {
v := value
return &v
}
func vmwareConnectionIDFromPath(path string) (string, bool) {
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
return "", false
}
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
if raw == "" || strings.Contains(raw, "/") {
return "", false
}
connectionID, err := url.PathUnescape(raw)
if err != nil {
return "", false
}
connectionID = strings.TrimSpace(connectionID)
if connectionID == "" || strings.Contains(connectionID, "/") {
return "", false
}
return connectionID, true
}
func vmwareConnectionIDFromTestPath(path string) (string, bool) {
if !strings.HasPrefix(path, vmwareConnectionsPathPrefix) {
return "", false
}
raw := strings.Trim(strings.TrimPrefix(path, vmwareConnectionsPathPrefix), "/")
if !strings.HasSuffix(raw, "/test") {
return "", false
}
raw = strings.TrimSuffix(raw, "/test")
if raw == "" || strings.Contains(raw, "/") {
return "", false
}
connectionID, err := url.PathUnescape(raw)
if err != nil {
return "", false
}
connectionID = strings.TrimSpace(connectionID)
if connectionID == "" || strings.Contains(connectionID, "/") {
return "", false
}
return connectionID, true
}

View file

@ -0,0 +1,547 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/vmware"
)
type fakeVMwareClient struct {
testConnection func(context.Context) (*vmware.InventorySummary, error)
}
func (c *fakeVMwareClient) TestConnection(ctx context.Context) (*vmware.InventorySummary, error) {
if c == nil || c.testConnection == nil {
return &vmware.InventorySummary{}, nil
}
return c.testConnection(ctx)
}
func (c *fakeVMwareClient) Close() {}
func TestVMwareHandlers_HandleAdd_Success(t *testing.T) {
setVMwareFeatureForTest(t, true)
setMockModeForVMwareTest(t, false)
handler, persistence := newVMwareHandlersForTest(t)
body := marshalVMwareRequest(t, map[string]any{
"name": "lab-vcenter",
"host": "vcsa.lab.local",
"port": 443,
"username": "administrator@vsphere.local",
"password": "super-secret",
"enabled": true,
})
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleAdd(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var created config.VMwareVCenterInstance
if err := json.NewDecoder(rec.Body).Decode(&created); err != nil {
t.Fatalf("decode create response: %v", err)
}
if created.ID == "" {
t.Fatalf("expected generated ID, got empty")
}
if created.Password != "********" {
t.Fatalf("expected password redacted, got %q", created.Password)
}
stored, err := persistence.LoadVMwareConfig()
if err != nil {
t.Fatalf("load saved config: %v", err)
}
if len(stored) != 1 {
t.Fatalf("expected 1 saved instance, got %d", len(stored))
}
if stored[0].Password != "super-secret" {
t.Fatalf("expected unredacted password persisted, got %q", stored[0].Password)
}
}
func TestVMwareHandlers_HandleAdd_ValidationAndFeatureGate(t *testing.T) {
t.Run("missing host", func(t *testing.T) {
setVMwareFeatureForTest(t, true)
setMockModeForVMwareTest(t, false)
handler, _ := newVMwareHandlersForTest(t)
body := marshalVMwareRequest(t, map[string]any{
"username": "administrator@vsphere.local",
"password": "super-secret",
})
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleAdd(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("feature disabled", func(t *testing.T) {
setVMwareFeatureForTest(t, false)
setMockModeForVMwareTest(t, false)
handler, _ := newVMwareHandlersForTest(t)
body := marshalVMwareRequest(t, map[string]any{
"host": "vcsa.lab.local",
"username": "administrator@vsphere.local",
"password": "super-secret",
})
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleAdd(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "explicitly disabled") {
t.Fatalf("expected explicit disable message, got %s", rec.Body.String())
}
})
}
func TestVMwareHandlers_HandleList_RedactsSensitiveFieldsAndIncludesRuntimeSummary(t *testing.T) {
setVMwareFeatureForTest(t, true)
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
{
ID: "vc-1",
Name: "lab-a",
Host: "vcsa-a.lab.local",
Port: 443,
Username: "administrator@vsphere.local",
Password: "secret-a",
InsecureSkipVerify: false,
Enabled: true,
},
}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
recordedAt := time.Date(2026, 3, 30, 10, 11, 12, 0, time.UTC)
handler.recordTestSuccess("vc-1", &vmware.InventorySummary{
Hosts: 3,
VMs: 42,
Datastores: 6,
VIRelease: "8.0.3",
}, recordedAt)
req := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
rec := httptest.NewRecorder()
handler.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var listed []vmwareConnectionResponse
if err := json.NewDecoder(rec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected 1 listed instance, got %d", len(listed))
}
if listed[0].Password != "********" {
t.Fatalf("expected password to be redacted, got %q", listed[0].Password)
}
if listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil {
t.Fatalf("expected saved test runtime summary, got %+v", listed[0].Test)
}
if listed[0].Observed == nil {
t.Fatalf("expected observed summary, got nil")
}
if listed[0].Observed.Hosts != 3 || listed[0].Observed.VMs != 42 || listed[0].Observed.Datastores != 6 {
t.Fatalf("unexpected observed counts: %+v", listed[0].Observed)
}
if listed[0].Observed.VIRelease != "8.0.3" {
t.Fatalf("unexpected VI release: %+v", listed[0].Observed)
}
}
func TestVMwareHandlers_HandleDelete_RemovesAndClearsRuntimeSummary(t *testing.T) {
setVMwareFeatureForTest(t, true)
setMockModeForVMwareTest(t, false)
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
{ID: "alpha", Host: "vcsa-a.lab.local", Username: "admin", Password: "a"},
{ID: "beta", Host: "vcsa-b.lab.local", Username: "admin", Password: "b"},
}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
handler.recordTestSuccess("alpha", &vmware.InventorySummary{Hosts: 1}, time.Now().UTC())
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/vmware/connections/alpha", nil)
deleteRec := httptest.NewRecorder()
handler.HandleDelete(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", deleteRec.Code, deleteRec.Body.String())
}
stored, err := persistence.LoadVMwareConfig()
if err != nil {
t.Fatalf("load persisted config: %v", err)
}
if len(stored) != 1 || stored[0].ID != "beta" {
t.Fatalf("expected only beta to remain, got %+v", stored)
}
if status := handler.runtimeStatus("alpha"); status.Test != nil || status.Observed != nil {
t.Fatalf("expected runtime summary to be cleared, got %+v", status)
}
}
func TestVMwareHandlers_HandleUpdate_PreservesMaskedSecretsAndReplacesFields(t *testing.T) {
setVMwareFeatureForTest(t, true)
setMockModeForVMwareTest(t, false)
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
{
ID: "alpha",
Name: "old-name",
Host: "old.lab.local",
Port: 443,
Username: "administrator@vsphere.local",
Password: "super-secret",
InsecureSkipVerify: false,
Enabled: true,
},
}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
body := marshalVMwareRequest(t, map[string]any{
"id": "ignored-id",
"name": "new-name",
"host": "new.lab.local",
"port": 8443,
"username": "operator@vsphere.local",
"password": "********",
"insecureSkipVerify": true,
"enabled": true,
})
req := httptest.NewRequest(http.MethodPut, "/api/vmware/connections/alpha", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var updated config.VMwareVCenterInstance
if err := json.NewDecoder(rec.Body).Decode(&updated); err != nil {
t.Fatalf("decode update response: %v", err)
}
if updated.ID != "alpha" {
t.Fatalf("expected path ID to win, got %q", updated.ID)
}
if updated.Password != "********" {
t.Fatalf("expected password to remain redacted, got %q", updated.Password)
}
stored, err := persistence.LoadVMwareConfig()
if err != nil {
t.Fatalf("load persisted config: %v", err)
}
if len(stored) != 1 {
t.Fatalf("expected 1 stored instance, got %d", len(stored))
}
if stored[0].Host != "new.lab.local" || stored[0].Port != 8443 {
t.Fatalf("expected updated endpoint to persist, got %+v", stored[0])
}
if stored[0].Password != "super-secret" {
t.Fatalf("expected masked password to preserve stored secret, got %q", stored[0].Password)
}
if !stored[0].InsecureSkipVerify {
t.Fatalf("expected insecureSkipVerify update to persist")
}
}
func TestVMwareHandlers_HandleTestConnection_SuccessAndFailure(t *testing.T) {
setVMwareFeatureForTest(t, true)
handler, _ := newVMwareHandlersForTest(t)
var gotConfig vmware.ClientConfig
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
gotConfig = cfg
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.3"}, nil
},
}, nil
}
successBody := marshalVMwareRequest(t, map[string]any{
"host": "vcsa.lab.local",
"port": 8443,
"username": "administrator@vsphere.local",
"password": "super-secret",
})
successReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(successBody))
successRec := httptest.NewRecorder()
handler.HandleTestConnection(successRec, successReq)
if successRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", successRec.Code, successRec.Body.String())
}
if gotConfig.Host != "vcsa.lab.local" || gotConfig.Port != 8443 {
t.Fatalf("unexpected client config: %+v", gotConfig)
}
handler.newClient = nil
failureBody := marshalVMwareRequest(t, map[string]any{
"host": "http://127.0.0.1/path",
"username": "administrator@vsphere.local",
"password": "super-secret",
})
failureReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(failureBody))
failureRec := httptest.NewRecorder()
handler.HandleTestConnection(failureRec, failureReq)
if failureRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for bad host, got %d: %s", failureRec.Code, failureRec.Body.String())
}
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return nil, errors.New("boom")
},
}, nil
}
errorBody := marshalVMwareRequest(t, map[string]any{
"host": "vcsa.lab.local",
"username": "administrator@vsphere.local",
"password": "super-secret",
})
errorReq := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/test", bytes.NewReader(errorBody))
errorRec := httptest.NewRecorder()
handler.HandleTestConnection(errorRec, errorReq)
if errorRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for failing connection, got %d: %s", errorRec.Code, errorRec.Body.String())
}
}
func TestVMwareHandlers_HandleTestSavedConnection_UsesStoredSecretsAndUpdatesRuntimeSummary(t *testing.T) {
setVMwareFeatureForTest(t, true)
connection := config.VMwareVCenterInstance{
ID: "conn-1",
Name: "lab-vcenter",
Host: "vcsa.lab.local",
Port: 443,
Username: "administrator@vsphere.local",
Password: "super-secret",
InsecureSkipVerify: false,
Enabled: true,
}
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
var gotConfig vmware.ClientConfig
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
gotConfig = cfg
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return &vmware.InventorySummary{Hosts: 4, VMs: 25, Datastores: 5, VIRelease: "8.0.3"}, nil
},
}, nil
}
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
rec := httptest.NewRecorder()
handler.HandleTestSavedConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if gotConfig.Host != "vcsa.lab.local" || gotConfig.Password != "super-secret" {
t.Fatalf("unexpected saved client config: %+v", gotConfig)
}
listReq := httptest.NewRequest(http.MethodGet, "/api/vmware/connections", nil)
listRec := httptest.NewRecorder()
handler.HandleList(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list expected 200, got %d: %s", listRec.Code, listRec.Body.String())
}
var listed []vmwareConnectionResponse
if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listed) != 1 || listed[0].Test == nil || listed[0].Test.LastSuccessAt == nil {
t.Fatalf("expected saved retest to update runtime status, got %+v", listed)
}
if listed[0].Observed == nil || listed[0].Observed.VMs != 25 {
t.Fatalf("expected saved retest to update observed summary, got %+v", listed[0].Observed)
}
}
func TestVMwareHandlers_HandleTestSavedConnection_UpdatesRuntimeSummaryFailure(t *testing.T) {
setVMwareFeatureForTest(t, true)
connection := config.VMwareVCenterInstance{
ID: "conn-1",
Host: "vcsa.lab.local",
Username: "administrator@vsphere.local",
Password: "super-secret",
Enabled: true,
}
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{connection}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return nil, &vmware.ConnectionError{Category: "auth", Message: "authentication failed"}
},
}, nil
}
req := httptest.NewRequest(http.MethodPost, "/api/vmware/connections/conn-1/test", nil)
rec := httptest.NewRecorder()
handler.HandleTestSavedConnection(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
status := handler.runtimeStatus(connection.ID)
if status.Test == nil || status.Test.LastError == nil {
t.Fatalf("expected saved retest failure to update runtime summary, got %+v", status.Test)
}
if status.Test.LastError.Message != "authentication failed" || status.Test.LastError.Category != "auth" {
t.Fatalf("unexpected failure summary: %+v", status.Test.LastError)
}
}
func TestVMwareHandlers_HandleTestSavedConnection_MergesEditedPayloadWithoutOverwritingRuntimeSummary(t *testing.T) {
setVMwareFeatureForTest(t, true)
handler, persistence := newVMwareHandlersForTest(t)
if err := persistence.SaveVMwareConfig([]config.VMwareVCenterInstance{
{
ID: "conn-1",
Name: "lab-vcenter",
Host: "vcsa.lab.local",
Port: 443,
Username: "administrator@vsphere.local",
Password: "super-secret",
InsecureSkipVerify: false,
Enabled: true,
},
}); err != nil {
t.Fatalf("seed vmware config: %v", err)
}
previousAt := time.Date(2026, 3, 30, 9, 0, 0, 0, time.UTC)
handler.recordTestSuccess("conn-1", &vmware.InventorySummary{Hosts: 1, VMs: 2, Datastores: 3, VIRelease: "8.0.2.0"}, previousAt)
var gotConfig vmware.ClientConfig
handler.newClient = func(cfg vmware.ClientConfig) (vmwareClient, error) {
gotConfig = cfg
return &fakeVMwareClient{
testConnection: func(context.Context) (*vmware.InventorySummary, error) {
return &vmware.InventorySummary{Hosts: 9, VMs: 99, Datastores: 12, VIRelease: "8.0.3"}, nil
},
}, nil
}
body := marshalVMwareRequest(t, map[string]any{
"name": "edited-vcenter",
"host": "edited.lab.local",
"port": 8443,
"username": "operator@vsphere.local",
"password": "********",
"insecureSkipVerify": true,
"enabled": true,
})
req := httptest.NewRequest(
http.MethodPost,
"/api/vmware/connections/conn-1/test",
bytes.NewReader(body),
)
rec := httptest.NewRecorder()
handler.HandleTestSavedConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if gotConfig.Host != "edited.lab.local" || gotConfig.Port != 8443 {
t.Fatalf("expected edited endpoint, got %+v", gotConfig)
}
if gotConfig.Password != "super-secret" {
t.Fatalf("expected stored password to be reused, got %+v", gotConfig)
}
status := handler.runtimeStatus("conn-1")
if status.Observed == nil || status.Observed.VMs != 2 || status.Observed.VIRelease != "8.0.2.0" {
t.Fatalf("expected existing runtime summary to remain unchanged, got %+v", status.Observed)
}
if status.Test == nil || status.Test.LastSuccessAt == nil || !status.Test.LastSuccessAt.Equal(previousAt) {
t.Fatalf("expected existing last success timestamp to remain unchanged, got %+v", status.Test)
}
}
func newVMwareHandlersForTest(t *testing.T) (*VMwareHandlers, *config.ConfigPersistence) {
t.Helper()
persistence := config.NewConfigPersistence(t.TempDir())
handler := &VMwareHandlers{
getPersistence: func(context.Context) *config.ConfigPersistence { return persistence },
}
return handler, persistence
}
func setVMwareFeatureForTest(t *testing.T, enabled bool) {
t.Helper()
previous := vmware.IsFeatureEnabled()
vmware.SetFeatureEnabled(enabled)
t.Cleanup(func() {
vmware.SetFeatureEnabled(previous)
})
}
func setMockModeForVMwareTest(t *testing.T, enabled bool) {
t.Helper()
previous := mock.IsMockEnabled()
mock.SetEnabled(enabled)
t.Cleanup(func() {
mock.SetEnabled(previous)
})
}
func marshalVMwareRequest(t *testing.T, payload map[string]any) []byte {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
return body
}

View file

@ -33,6 +33,7 @@ type ConfigPersistence struct {
appriseFile string
nodesFile string
trueNASFile string
vmwareFile string
systemFile string
ssoFile string
apiTokensFile string
@ -95,6 +96,7 @@ type resolvedConfigPersistencePaths struct {
appriseFile string
nodesFile string
trueNASFile string
vmwareFile string
systemFile string
ssoFile string
apiTokensFile string
@ -143,6 +145,10 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers
if err != nil {
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve truenas.enc: %w", err)
}
vmwareFile, err := resolveLeaf("vmware.enc")
if err != nil {
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve vmware.enc: %w", err)
}
systemFile, err := resolveLeaf("system.json")
if err != nil {
return "", resolvedConfigPersistencePaths{}, fmt.Errorf("resolve system.json: %w", err)
@ -199,6 +205,7 @@ func resolveConfigPersistencePaths(configDir string) (string, resolvedConfigPers
appriseFile: appriseFile,
nodesFile: nodesFile,
trueNASFile: trueNASFile,
vmwareFile: vmwareFile,
systemFile: systemFile,
ssoFile: ssoFile,
apiTokensFile: apiTokensFile,
@ -248,6 +255,7 @@ func newConfigPersistence(configDir string) (*ConfigPersistence, error) {
appriseFile: resolvedPaths.appriseFile,
nodesFile: resolvedPaths.nodesFile,
trueNASFile: resolvedPaths.trueNASFile,
vmwareFile: resolvedPaths.vmwareFile,
systemFile: resolvedPaths.systemFile,
ssoFile: resolvedPaths.ssoFile,
apiTokensFile: resolvedPaths.apiTokensFile,
@ -604,6 +612,18 @@ func (c *ConfigPersistence) LoadTrueNASConfig() ([]TrueNASInstance, error) {
return loadSlice[TrueNASInstance](c, c.trueNASFile, true)
}
// SaveVMwareConfig persists VMware vCenter instance configuration to encrypted
// storage.
func (c *ConfigPersistence) SaveVMwareConfig(instances []VMwareVCenterInstance) error {
return saveJSON(c, c.vmwareFile, instances, true)
}
// LoadVMwareConfig loads VMware vCenter instance configuration from encrypted
// storage.
func (c *ConfigPersistence) LoadVMwareConfig() ([]VMwareVCenterInstance, error) {
return loadSlice[VMwareVCenterInstance](c, c.vmwareFile, true)
}
// SaveAPITokens persists API token metadata to disk.
func (c *ConfigPersistence) SaveAPITokens(tokens []APITokenRecord) error {
c.mu.Lock()

87
internal/config/vmware.go Normal file
View file

@ -0,0 +1,87 @@
package config
import (
"fmt"
"strings"
"github.com/google/uuid"
)
const vmwareSensitiveMask = "********"
const defaultVMwarePort = 443
// IsVMwareSensitiveMask reports whether the value is the redacted placeholder
// used by the VMware settings API.
func IsVMwareSensitiveMask(value string) bool {
return strings.TrimSpace(value) == vmwareSensitiveMask
}
// VMwareVCenterInstance represents a configured VMware vCenter endpoint.
type VMwareVCenterInstance struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
Enabled bool `json:"enabled"`
}
// NewVMwareVCenterInstance returns a new instance with generated ID and sane
// defaults.
func NewVMwareVCenterInstance() VMwareVCenterInstance {
return VMwareVCenterInstance{
ID: uuid.NewString(),
Port: defaultVMwarePort,
Enabled: true,
}
}
// ApplyDefaults normalizes legacy zero-value config onto the canonical stored
// defaults without changing explicitly configured values.
func (v *VMwareVCenterInstance) ApplyDefaults() {
if v == nil {
return
}
if v.Port <= 0 {
v.Port = defaultVMwarePort
}
}
// Validate performs required VMware configuration checks.
func (v *VMwareVCenterInstance) Validate() error {
if v == nil {
return fmt.Errorf("vmware vcenter instance is required")
}
if strings.TrimSpace(v.Host) == "" {
return fmt.Errorf("vmware vcenter host is required")
}
if strings.TrimSpace(v.Username) == "" || strings.TrimSpace(v.Password) == "" {
return fmt.Errorf("vmware credentials are required: provide username and password")
}
return nil
}
// Redacted returns a copy with sensitive credentials masked.
func (v *VMwareVCenterInstance) Redacted() VMwareVCenterInstance {
if v == nil {
return VMwareVCenterInstance{}
}
redacted := *v
if strings.TrimSpace(redacted.Password) != "" {
redacted.Password = vmwareSensitiveMask
}
return redacted
}
// PreserveMaskedSecrets restores stored credentials when an update payload uses
// the API redaction placeholder for unchanged secret fields.
func (v *VMwareVCenterInstance) PreserveMaskedSecrets(existing VMwareVCenterInstance) {
if v == nil {
return
}
if IsVMwareSensitiveMask(v.Password) {
v.Password = existing.Password
}
}

387
internal/vmware/client.go Normal file
View file

@ -0,0 +1,387 @@
package vmware
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
)
const (
// FeatureVMware allows explicit opt-out of the default-on VMware vCenter
// platform integration.
FeatureVMware = "PULSE_ENABLE_VMWARE"
defaultTimeout = 10 * time.Second
)
var featureVMwareEnabled atomic.Bool
func init() {
featureVMwareEnabled.Store(parseFeatureEnabled(os.Getenv(FeatureVMware)))
}
// IsFeatureEnabled returns whether the VMware vCenter integration is enabled.
func IsFeatureEnabled() bool {
return featureVMwareEnabled.Load()
}
// SetFeatureEnabled allows tests to control the feature flag.
func SetFeatureEnabled(enabled bool) {
featureVMwareEnabled.Store(enabled)
}
func parseFeatureEnabled(raw string) bool {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "", "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return true
}
}
// ConnectionError classifies VMware connection failures for API consumers.
type ConnectionError struct {
Category string
Message string
}
func (e *ConnectionError) Error() string {
if e == nil {
return ""
}
return e.Message
}
// InventorySummary captures the minimum read-side floor proven by a successful
// VMware connection test.
type InventorySummary struct {
Hosts int
VMs int
Datastores int
VIRelease string
}
// ClientConfig configures a VMware vCenter client.
type ClientConfig struct {
Host string
Port int
Username string
Password string
InsecureSkipVerify bool
Timeout time.Duration
}
// Client executes phase-1 VMware connection validation.
type Client struct {
baseURL *url.URL
httpClient *http.Client
username string
password string
}
// NewClient constructs a VMware client from saved connection input.
func NewClient(cfg ClientConfig) (*Client, error) {
baseURL, err := normalizeBaseURL(cfg.Host, cfg.Port)
if err != nil {
return nil, &ConnectionError{Category: "invalid_config", Message: err.Error()}
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("create cookie jar: %w", err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: cfg.InsecureSkipVerify, // operator-controlled onboarding setting
},
}
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
Transport: transport,
Jar: jar,
},
username: strings.TrimSpace(cfg.Username),
password: strings.TrimSpace(cfg.Password),
}, nil
}
// Close releases idle resources held by the underlying HTTP client.
func (c *Client) Close() {
if c == nil || c.httpClient == nil {
return
}
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
transport.CloseIdleConnections()
}
}
// TestConnection validates both the Automation API and VI JSON API families and
// returns a minimal inventory summary on success.
func (c *Client) TestConnection(ctx context.Context) (*InventorySummary, error) {
automationSessionID, err := c.createAutomationSession(ctx)
if err != nil {
return nil, err
}
hosts, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/host", "host inventory")
if err != nil {
return nil, err
}
vms, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/vm", "vm inventory")
if err != nil {
return nil, err
}
datastores, err := c.listAutomationResources(ctx, automationSessionID, "/api/vcenter/datastore", "datastore inventory")
if err != nil {
return nil, err
}
release, sessionManagerMoID, err := c.resolveVIJSONRelease(ctx)
if err != nil {
return nil, err
}
if err := c.loginVIJSON(ctx, release, sessionManagerMoID); err != nil {
return nil, err
}
return &InventorySummary{
Hosts: len(hosts),
VMs: len(vms),
Datastores: len(datastores),
VIRelease: release,
}, nil
}
func normalizeBaseURL(rawHost string, port int) (*url.URL, error) {
host := strings.TrimSpace(rawHost)
if host == "" {
return nil, fmt.Errorf("vmware vcenter host is required")
}
if !strings.Contains(host, "://") {
host = "https://" + host
}
parsed, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("invalid vmware vcenter host: %w", err)
}
if parsed.Scheme != "https" {
return nil, fmt.Errorf("vmware vcenter connections must use https")
}
if parsed.Host == "" {
return nil, fmt.Errorf("vmware vcenter host is required")
}
if parsed.Path != "" && parsed.Path != "/" {
return nil, fmt.Errorf("vmware vcenter host must not include a path")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("vmware vcenter host must not include query or fragment data")
}
if parsed.Port() == "" {
if port <= 0 {
port = 443
}
parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(port))
}
parsed.Path = ""
return parsed, nil
}
func (c *Client) createAutomationSession(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL.String()+"/api/session", nil)
if err != nil {
return "", fmt.Errorf("build automation session request: %w", err)
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", classifyTransportError("automation session", err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return "", fmt.Errorf("read automation session response: %w", readErr)
}
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
default:
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the Automation API session"}
}
return "", &ConnectionError{
Category: "endpoint",
Message: fmt.Sprintf("VMware Automation API session request failed with HTTP %d", resp.StatusCode),
}
}
var sessionID string
if err := json.Unmarshal(body, &sessionID); err != nil || strings.TrimSpace(sessionID) == "" {
return "", &ConnectionError{Category: "endpoint", Message: "VMware Automation API returned an invalid session payload"}
}
return strings.TrimSpace(sessionID), nil
}
func (c *Client) listAutomationResources(
ctx context.Context,
sessionID string,
path string,
label string,
) ([]json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.String()+path, nil)
if err != nil {
return nil, fmt.Errorf("build %s request: %w", label, err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("vmware-api-session-id", sessionID)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, classifyTransportError(label, err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, fmt.Errorf("read %s response: %w", label, readErr)
}
switch resp.StatusCode {
case http.StatusOK:
case http.StatusUnauthorized:
return nil, &ConnectionError{Category: "auth", Message: fmt.Sprintf("VMware authentication failed while reading %s", label)}
case http.StatusForbidden:
return nil, &ConnectionError{Category: "permission", Message: fmt.Sprintf("VMware permissions are insufficient for %s", label)}
default:
return nil, &ConnectionError{
Category: "endpoint",
Message: fmt.Sprintf("VMware %s request failed with HTTP %d", label, resp.StatusCode),
}
}
var items []json.RawMessage
if err := json.Unmarshal(body, &items); err != nil {
return nil, &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware %s response was not valid JSON", label)}
}
return items, nil
}
func (c *Client) resolveVIJSONRelease(ctx context.Context) (string, string, error) {
releases := []string{"9.0.0.0", "8.0.3", "8.0.2.0", "8.0.1.0"}
var lastErr error
for _, release := range releases {
moID, err := c.fetchSessionManagerMoID(ctx, release)
if err == nil {
return release, moID, nil
}
lastErr = err
connectionErr, ok := err.(*ConnectionError)
if !ok || connectionErr.Category != "endpoint" {
return "", "", err
}
}
if lastErr != nil {
return "", "", lastErr
}
return "", "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API release negotiation failed"}
}
func (c *Client) fetchSessionManagerMoID(ctx context.Context, release string) (string, error) {
endpoint := fmt.Sprintf("%s/sdk/vim25/%s/ServiceInstance/ServiceInstance/content", c.baseURL.String(), release)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("build vi-json service instance request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", classifyTransportError("vi-json service content", err)
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return "", fmt.Errorf("read vi-json service content response: %w", readErr)
}
if resp.StatusCode == http.StatusNotFound {
return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API release %s is unavailable", release)}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API service-instance request failed with HTTP %d", resp.StatusCode)}
}
var payload struct {
SessionManager struct {
Value string `json:"value"`
} `json:"sessionManager"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response was not valid JSON"}
}
moID := strings.TrimSpace(payload.SessionManager.Value)
if moID == "" {
return "", &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API service-instance response did not include a session manager reference"}
}
return moID, nil
}
func (c *Client) loginVIJSON(ctx context.Context, release string, sessionManagerMoID string) error {
body, err := json.Marshal(map[string]string{
"userName": c.username,
"password": c.password,
})
if err != nil {
return fmt.Errorf("marshal vi-json login request: %w", err)
}
endpoint := fmt.Sprintf("%s/sdk/vim25/%s/SessionManager/%s/Login", c.baseURL.String(), release, sessionManagerMoID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("build vi-json login request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return classifyTransportError("vi-json login", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
switch resp.StatusCode {
case http.StatusOK:
if strings.TrimSpace(resp.Header.Get("vmware-api-session-id")) == "" {
return &ConnectionError{Category: "endpoint", Message: "VMware VI JSON API login succeeded without returning a session id"}
}
return nil
case http.StatusUnauthorized, http.StatusForbidden:
return &ConnectionError{Category: "auth", Message: "VMware authentication failed while creating the VI JSON API session"}
default:
return &ConnectionError{Category: "endpoint", Message: fmt.Sprintf("VMware VI JSON API login failed with HTTP %d", resp.StatusCode)}
}
}
func classifyTransportError(stage string, err error) error {
if err == nil {
return nil
}
lower := strings.ToLower(err.Error())
if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") {
return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)}
}
var unknownAuthority *x509.UnknownAuthorityError
if errors.As(err, &unknownAuthority) {
return &ConnectionError{Category: "tls", Message: fmt.Sprintf("VMware TLS validation failed during %s", stage)}
}
var netErr net.Error
if errors.As(err, &netErr) {
return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware network error during %s", stage)}
}
return &ConnectionError{Category: "network", Message: fmt.Sprintf("VMware connection failed during %s", stage)}
}

View file

@ -0,0 +1,398 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { test as base, expect } from '@playwright/test';
import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const SCREENSHOT_PATH = path.resolve(
__dirname,
'..',
'..',
'tmp',
'vmware-settings-platform-connections.png',
);
const WORKFLOW_SCREENSHOT_PATH = path.resolve(
__dirname,
'..',
'..',
'tmp',
'vmware-settings-platform-workflow.png',
);
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`vmware-settings-platform-connections-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
});
test.describe('VMware platform connections settings', () => {
test.setTimeout(180_000);
test('renders the platform-connections workspace with the VMware integration shell', async ({
page,
}) => {
const healthyAt = new Date(Date.now() - 5 * 60_000).toISOString();
const failingAt = new Date(Date.now() - 2 * 60_000).toISOString();
await page.route('**/api/vmware/connections', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{
id: 'vmware-1',
name: 'Primary vCenter',
host: 'vcsa-primary.local',
port: 443,
username: 'administrator@vsphere.local',
password: '********',
insecureSkipVerify: false,
enabled: true,
test: {
lastSuccessAt: healthyAt,
},
observed: {
collectedAt: healthyAt,
hosts: 6,
vms: 120,
datastores: 18,
viRelease: '8.0.3',
},
},
{
id: 'vmware-2',
name: 'Staging vCenter',
host: 'vcsa-staging.local',
port: 443,
username: 'operator@vsphere.local',
password: '********',
insecureSkipVerify: true,
enabled: true,
test: {
lastAttemptAt: failingAt,
lastError: {
at: failingAt,
message: 'authentication failed',
category: 'auth',
},
},
},
]),
});
});
await page.goto('/settings/infrastructure/platforms/vmware', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, {
timeout: 15_000,
});
await expect(
page.getByRole('heading', { level: 1, name: 'Infrastructure Operations' }),
).toBeVisible();
await expect(page.getByRole('tab', { name: 'Platform connections' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByRole('tab', { name: 'VMware' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByRole('tab', { name: 'TrueNAS' })).toHaveAttribute(
'aria-selected',
'false',
);
await expect(page.getByRole('tab', { name: 'Proxmox' })).toHaveAttribute(
'aria-selected',
'false',
);
await expect(page.getByText('VMware vSphere platform integration')).toBeVisible();
await expect(page.getByRole('button', { name: 'Add VMware connection' })).toBeVisible();
const primaryCard = page.getByTestId('vmware-connection-vmware-1');
await expect(primaryCard).toContainText('Primary vCenter');
await expect(primaryCard).toContainText('Validated');
await expect(primaryCard).toContainText('6 hosts');
await expect(primaryCard).toContainText('120 vms');
await expect(primaryCard).toContainText('18 datastores');
await expect(primaryCard).toContainText('VI JSON 8.0.3');
const failingCard = page.getByTestId('vmware-connection-vmware-2');
await expect(failingCard).toContainText('Staging vCenter');
await expect(failingCard).toContainText('Validation failing');
await expect(failingCard).toContainText('authentication failed');
await expect(failingCard).toContainText('Skip TLS verification');
fs.mkdirSync(path.dirname(SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
test('adds, edits, retests, and deletes VMware connections through the canonical settings workflow', async ({
page,
}) => {
const validatedAt = new Date(Date.now() - 60_000).toISOString();
let connections: Record<string, unknown>[] = [];
let draftTestPayload: Record<string, unknown> | null = null;
let createPayload: Record<string, unknown> | null = null;
let updatePayload: Record<string, unknown> | null = null;
let draftTestCalls = 0;
const savedTestRequests: Array<{ path: string; payload: Record<string, unknown> | null }> = [];
const deletePaths: string[] = [];
await page.route('**/api/vmware/connections**', async (route) => {
const request = route.request();
const method = request.method();
const pathname = new URL(request.url()).pathname;
if (pathname === '/api/vmware/connections' && method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(connections),
});
return;
}
if (pathname === '/api/vmware/connections/test' && method === 'POST') {
draftTestCalls += 1;
draftTestPayload = JSON.parse(request.postData() || '{}');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
return;
}
if (pathname === '/api/vmware/connections' && method === 'POST') {
createPayload = JSON.parse(request.postData() || '{}');
connections = [
{
id: 'conn-1',
name: createPayload.name,
host: createPayload.host,
port: createPayload.port,
username: createPayload.username,
password: '********',
insecureSkipVerify: createPayload.insecureSkipVerify,
enabled: createPayload.enabled,
test: {
lastSuccessAt: validatedAt,
},
observed: {
collectedAt: validatedAt,
hosts: 4,
vms: 48,
datastores: 8,
viRelease: '8.0.3',
},
},
];
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(connections[0]),
});
return;
}
if (pathname === '/api/vmware/connections/conn-1/test' && method === 'POST') {
savedTestRequests.push({
path: pathname,
payload: request.postData() ? JSON.parse(request.postData() || '{}') : null,
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true }),
});
return;
}
if (pathname === '/api/vmware/connections/conn-1' && method === 'PUT') {
updatePayload = JSON.parse(request.postData() || '{}');
connections = [
{
id: 'conn-1',
name: updatePayload.name,
host: updatePayload.host,
port: updatePayload.port,
username: updatePayload.username,
password: '********',
insecureSkipVerify: updatePayload.insecureSkipVerify,
enabled: updatePayload.enabled,
test: {
lastSuccessAt: validatedAt,
},
observed: {
collectedAt: validatedAt,
hosts: 4,
vms: 48,
datastores: 8,
viRelease: '8.0.3',
},
},
];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(connections[0]),
});
return;
}
if (pathname === '/api/vmware/connections/conn-1' && method === 'DELETE') {
deletePaths.push(pathname);
connections = [];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, id: 'conn-1' }),
});
return;
}
await route.continue();
});
await page.goto('/settings/infrastructure/platforms/vmware', {
waitUntil: 'domcontentloaded',
});
await page.waitForURL(/\/settings\/infrastructure\/platforms\/vmware/, {
timeout: 15_000,
});
await expect(page.getByText('No VMware connections yet')).toBeVisible();
await page.getByRole('button', { name: 'Add VMware connection' }).click();
const dialog = page.getByRole('dialog', { name: 'Add VMware connection' });
await expect(dialog).toBeVisible();
await dialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter');
await dialog.getByPlaceholder('vcsa.lab.local').fill('vcsa.lab.local');
await dialog.getByPlaceholder('443').fill('443');
await dialog.getByPlaceholder('administrator@vsphere.local').fill(
'administrator@vsphere.local',
);
await dialog.locator('input[type="password"]').first().fill('super-secret');
await dialog.getByRole('button', { name: 'Test connection' }).click();
await expect.poll(() => draftTestPayload).toMatchObject({
name: 'Lab vCenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: 'super-secret',
enabled: true,
insecureSkipVerify: false,
});
await dialog.getByRole('button', { name: 'Add connection' }).click();
const connectionCard = page.getByTestId('vmware-connection-conn-1');
await expect(connectionCard).toBeVisible();
await expect(connectionCard).toContainText('Lab vCenter');
await expect(connectionCard).toContainText('Validated');
await expect.poll(() => createPayload).toMatchObject({
name: 'Lab vCenter',
host: 'vcsa.lab.local',
port: 443,
username: 'administrator@vsphere.local',
password: 'super-secret',
enabled: true,
insecureSkipVerify: false,
});
await connectionCard.getByRole('button', { name: 'Edit' }).click();
const editDialog = page.getByRole('dialog', { name: 'Edit VMware connection' });
await expect(editDialog).toBeVisible();
await expect(
editDialog.getByPlaceholder('Saved password retained unless replaced'),
).toBeVisible();
await editDialog.getByPlaceholder('lab-vcenter').fill('Lab vCenter Edited');
await editDialog.getByPlaceholder('vcsa.lab.local').fill('edited.lab.local');
await editDialog.getByPlaceholder('443').fill('8443');
await editDialog.getByPlaceholder('administrator@vsphere.local').fill('operator@vsphere.local');
await editDialog.getByLabel('Skip TLS verification').check();
await editDialog.getByRole('button', { name: 'Test connection' }).click();
await expect.poll(() => savedTestRequests[0]).toMatchObject({
path: '/api/vmware/connections/conn-1/test',
payload: {
name: 'Lab vCenter Edited',
host: 'edited.lab.local',
port: 8443,
username: 'operator@vsphere.local',
password: '********',
enabled: true,
insecureSkipVerify: true,
},
});
await expect.poll(() => draftTestCalls).toBe(1);
await editDialog.getByRole('button', { name: 'Save connection' }).click();
await expect.poll(() => updatePayload).toMatchObject({
name: 'Lab vCenter Edited',
host: 'edited.lab.local',
port: 8443,
username: 'operator@vsphere.local',
password: '********',
enabled: true,
insecureSkipVerify: true,
});
await expect(connectionCard).toContainText('Lab vCenter Edited');
await connectionCard.getByRole('button', { name: 'Test' }).click();
await expect.poll(() => savedTestRequests).toEqual([
expect.objectContaining({
path: '/api/vmware/connections/conn-1/test',
payload: expect.objectContaining({
host: 'edited.lab.local',
password: '********',
insecureSkipVerify: true,
}),
}),
{ path: '/api/vmware/connections/conn-1/test', payload: null },
]);
await expect.poll(() => draftTestCalls).toBe(1);
await connectionCard.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete connection' }).click();
await expect.poll(() => deletePaths).toEqual(['/api/vmware/connections/conn-1']);
await expect(page.getByText('No VMware connections yet')).toBeVisible();
fs.mkdirSync(path.dirname(WORKFLOW_SCREENSHOT_PATH), { recursive: true });
await page.screenshot({ path: WORKFLOW_SCREENSHOT_PATH, fullPage: true });
});
});