This commit is contained in:
2026-06-25 13:48:07 -05:00
parent d8342a6475
commit 7f925aa8e5
8 changed files with 128 additions and 9 deletions

View File

@@ -162,6 +162,7 @@ Important environment variables:
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. | | `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. | | `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
| `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. | | `VCENTER_PASSWORD` | vCenter password. Prefer environment pinning for production deployments. |
| `VCENTER_API_MODE` | `auto`, `api`, or `rest`. Defaults to `auto`, which tries modern `/api` then legacy `/rest` on incompatible endpoints. |
| `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. | | `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. |
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. | | `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. | | `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
@@ -531,7 +532,9 @@ Payload:
`personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only `personal`, `shared`, or `group`; hosts default to `shared`. RunPlans can only
target hosts visible to the operator who creates or edits them. target hosts visible to the operator who creates or edits them.
VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). The backend creates a vCenter REST session with `POST /api/session`, lists VMs from `/api/vcenter/vm`, and then best-effort enriches each candidate from guest identity/networking endpoints. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes. VMware vCenter import uses the effective Config / Integrations values (`vcenter_*` settings or the `VCENTER_*` environment variables). `vcenter_api_mode` can be `auto`, `api`, or `rest`. `api` uses the current vSphere Automation API flow (`POST /api/session`, `GET /api/vcenter/vm`). `rest` uses the legacy/community vCenter REST flow (`POST /rest/com/vmware/cis/session`, `GET /rest/vcenter/vm`). `auto` tries the current `/api` profile first and falls back to `/rest` when the endpoint is not supported.
For exact VM-name searches (`field: "name"`, `operator: "equals"`), POSHManager uses the documented server-side name filters: `names=<vm>` for `/api/vcenter/vm` and `filter.names=<vm>` for `/rest/vcenter/vm`. Contains/starts-with/ends-with searches are filtered locally because vCenter VM list filters do not provide wildcard contains semantics. FQDN and IP discovery depends on VMware Tools data; when guest data is unavailable, the import falls back to the VM name as the host address and records the limitation in host notes.
For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview. For `field: "name"` filters, the API scans the full VM summary list returned by vCenter before guest enrichment, so large inventories are not accidentally missed by the preview limit. The preview response includes `diagnostics` with `totalFromVCenter`, `summaryMatched`, `scanned`, `resultLimit`, and `sampleNames`; the wizard displays those values when troubleshooting a no-result preview.

View File

@@ -1136,6 +1136,12 @@ const settingMetadata = {
section: 'Integrations', section: 'Integrations',
secret: true secret: true
}, },
vcenter_api_mode: {
label: 'vCenter API Mode',
description: 'Use auto for modern /api with fallback to legacy /rest. Set api or rest to force one contract.',
placeholder: 'auto',
section: 'Integrations'
},
vcenter_allow_untrusted_tls: { vcenter_allow_untrusted_tls: {
label: 'Allow Untrusted vCenter TLS', label: 'Allow Untrusted vCenter TLS',
description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.', description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.',

View File

@@ -30,6 +30,14 @@
</div> </div>
<p>{{ entry.message }}</p> <p>{{ entry.message }}</p>
</template> </template>
<template v-else-if="entry.info">
<div class="trace-entry-header info">
<Info :size="16" />
<strong>Info</strong>
</div>
<p>{{ entry.message }}</p>
<pre>{{ format(entry.details) }}</pre>
</template>
<template v-else> <template v-else>
<div class="trace-entry-header"> <div class="trace-entry-header">
<span class="trace-method">{{ entry.method || 'INFO' }}</span> <span class="trace-method">{{ entry.method || 'INFO' }}</span>
@@ -58,7 +66,7 @@
</template> </template>
<script setup> <script setup>
import { AlertTriangle, Clipboard } from '@lucide/vue'; import { AlertTriangle, Clipboard, Info } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue'; import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({ const props = defineProps({

View File

@@ -7133,6 +7133,10 @@ input:read-only {
color: #f8c66a; color: #f8c66a;
} }
.trace-entry-header.info {
color: #99e8ff;
}
.trace-method, .trace-method,
.trace-entry-header strong, .trace-entry-header strong,
.trace-entry-header small { .trace-entry-header small {

View File

@@ -45,6 +45,7 @@ export const config = {
baseUrl: process.env.VCENTER_BASE_URL || '', baseUrl: process.env.VCENTER_BASE_URL || '',
username: process.env.VCENTER_USERNAME || '', username: process.env.VCENTER_USERNAME || '',
password: process.env.VCENTER_PASSWORD || '', password: process.env.VCENTER_PASSWORD || '',
apiMode: process.env.VCENTER_API_MODE || 'auto',
allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true' allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
}, },
entra: { entra: {

View File

@@ -7,6 +7,26 @@ const WINDOWS_HINTS = ['windows', 'microsoft'];
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux']; const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
const MAX_TRACE_ENTRIES = 700; const MAX_TRACE_ENTRIES = 700;
const MAX_TRACE_BODY_CHARS = 6000; const MAX_TRACE_BODY_CHARS = 6000;
const API_PROFILES = {
api: {
mode: 'api',
label: 'vSphere Automation API',
sessionPath: '/api/session',
vmListPath: '/api/vcenter/vm',
vmNameParam: 'names',
guestIdentityPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
},
rest: {
mode: 'rest',
label: 'Legacy vCenter REST API',
sessionPath: '/rest/com/vmware/cis/session',
vmListPath: '/rest/vcenter/vm',
vmNameParam: 'filter.names',
guestIdentityPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`,
guestNetworkingPath: (vmId) => `/rest/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`
}
};
export class VCenterTraceError extends Error { export class VCenterTraceError extends Error {
constructor(message, trace = [], statusCode = 502) { constructor(message, trace = [], statusCode = 502) {
@@ -18,11 +38,13 @@ export class VCenterTraceError extends Error {
} }
export function getVCenterSettings() { export function getVCenterSettings() {
const apiMode = getStringSetting('vcenter_api_mode', config.vcenter.apiMode || 'auto').toLowerCase();
return { return {
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled), enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)), baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
username: getStringSetting('vcenter_username', config.vcenter.username), username: getStringSetting('vcenter_username', config.vcenter.username),
password: getStringSetting('vcenter_password', config.vcenter.password), password: getStringSetting('vcenter_password', config.vcenter.password),
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls) allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
}; };
} }
@@ -38,6 +60,7 @@ export function vcenterStatus() {
configured: Boolean(settings.baseUrl && settings.username && settings.password), configured: Boolean(settings.baseUrl && settings.username && settings.password),
baseUrl: settings.baseUrl, baseUrl: settings.baseUrl,
username: settings.username, username: settings.username,
apiMode: settings.apiMode,
allowUntrustedTls: settings.allowUntrustedTls allowUntrustedTls: settings.allowUntrustedTls
}; };
} }
@@ -72,7 +95,7 @@ function summarizePayload(payload) {
} }
function bodyPreview(payload, path) { function bodyPreview(payload, path) {
if (path === '/api/session') return '[redacted vCenter session id]'; if (path === '/api/session' || path === '/rest/com/vmware/cis/session') return '[redacted vCenter session id]';
const raw = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2); const raw = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
const text = raw || ''; const text = raw || '';
return text.length > MAX_TRACE_BODY_CHARS return text.length > MAX_TRACE_BODY_CHARS
@@ -94,6 +117,14 @@ function pushTrace(trace, entry) {
} }
} }
function pushTraceInfo(trace, message, details = {}) {
pushTrace(trace, {
info: true,
message,
details
});
}
async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false, trace = null } = {}) { async function vcenterFetch(settings, path, { method = 'GET', sessionId, allow404 = false, trace = null } = {}) {
const headers = { Accept: 'application/json' }; const headers = { Accept: 'application/json' };
if (sessionId) headers['vmware-api-session-id'] = sessionId; if (sessionId) headers['vmware-api-session-id'] = sessionId;
@@ -157,8 +188,8 @@ function unwrapList(payload) {
return []; return [];
} }
async function createSession(settings, trace = null) { async function createSession(settings, profile, trace = null) {
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST', trace }); const payload = await vcenterFetch(settings, profile.sessionPath, { method: 'POST', trace });
const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id; const sessionId = typeof payload === 'string' ? payload : payload?.value || payload?.session_id || payload?.id;
if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace); if (!sessionId) throw new VCenterTraceError('vCenter session authentication succeeded but no session id was returned.', trace);
return sessionId; return sessionId;
@@ -277,6 +308,17 @@ export function filterSummaryVms(vms = [], filter = {}) {
return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false }; return { vms: matched, summaryMatched: matched.length, summaryFilterApplied: true, summaryFilterFallback: false };
} }
function canUseServerSideNameFilter(filter = {}) {
return filter.field === 'name' && filter.operator === 'equals' && String(filter.value || '').trim();
}
export function buildVmListPath(profile, filter = {}) {
if (!canUseServerSideNameFilter(filter)) return profile.vmListPath;
const params = new URLSearchParams();
params.append(profile.vmNameParam, String(filter.value).trim());
return `${profile.vmListPath}?${params.toString()}`;
}
export function buildHostPayloadFromVm(candidate, options = {}) { export function buildHostPayloadFromVm(candidate, options = {}) {
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))]; const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
return { return {
@@ -307,8 +349,7 @@ export async function previewVCenterHosts(options = {}) {
} catch (err) { } catch (err) {
throw new VCenterTraceError(err.message, trace, 400); throw new VCenterTraceError(err.message, trace, 400);
} }
const sessionId = await createSession(settings, trace); const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId, trace });
const allVms = unwrapList(vmPayload); const allVms = unwrapList(vmPayload);
const summaryFilter = filterSummaryVms(allVms, options.filter); const summaryFilter = filterSummaryVms(allVms, options.filter);
const vms = summaryFilter.vms; const vms = summaryFilter.vms;
@@ -319,6 +360,9 @@ export async function previewVCenterHosts(options = {}) {
summaryMatched: summaryFilter.summaryMatched, summaryMatched: summaryFilter.summaryMatched,
summaryFilterApplied: summaryFilter.summaryFilterApplied, summaryFilterApplied: summaryFilter.summaryFilterApplied,
summaryFilterFallback: summaryFilter.summaryFilterFallback, summaryFilterFallback: summaryFilter.summaryFilterFallback,
apiMode: profile.mode,
apiProfile: profile.label,
serverSideNameFilter: canUseServerSideNameFilter(options.filter),
resultLimit: options.limit || 100, resultLimit: options.limit || 100,
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean), sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
filter: options.filter || {} filter: options.filter || {}
@@ -328,8 +372,8 @@ export async function previewVCenterHosts(options = {}) {
const vmId = vm.vm || vm.id; const vmId = vm.vm || vm.id;
if (!vmId) continue; if (!vmId) continue;
diagnostics.scanned += 1; diagnostics.scanned += 1;
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId, trace); const identity = await optionalVCenterFetch(settings, profile.guestIdentityPath(vmId), sessionId, trace);
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId, trace); const interfaces = await optionalVCenterFetch(settings, profile.guestNetworkingPath(vmId), sessionId, trace);
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces); const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate); if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
if (candidates.length >= (options.limit || 100)) break; if (candidates.length >= (options.limit || 100)) break;
@@ -344,3 +388,37 @@ export async function previewVCenterHosts(options = {}) {
candidates candidates
}; };
} }
function shouldTryFallback(err) {
return err instanceof VCenterTraceError && [404, 405, 501].includes(err.statusCode);
}
async function connectAndListWithProfile(settings, filter, profile, trace) {
pushTraceInfo(trace, `Using ${profile.label}`, {
mode: profile.mode,
sessionPath: profile.sessionPath,
vmListPath: buildVmListPath(profile, filter)
});
const sessionId = await createSession(settings, profile, trace);
const vmPayload = await vcenterFetch(settings, buildVmListPath(profile, filter), { sessionId, trace });
return { profile, sessionId, vmPayload };
}
async function connectAndListVms(settings, filter, trace) {
const modes = settings.apiMode === 'auto' ? ['api', 'rest'] : [settings.apiMode];
let lastError = null;
for (const mode of modes) {
try {
return await connectAndListWithProfile(settings, filter, API_PROFILES[mode], trace);
} catch (err) {
lastError = err;
if (settings.apiMode !== 'auto' || mode === modes.at(-1) || !shouldTryFallback(err)) throw err;
pushTraceInfo(trace, `Falling back after ${API_PROFILES[mode].label} failed`, {
mode,
error: err.message,
statusCode: err.statusCode
});
}
}
throw lastError || new VCenterTraceError('No vCenter API profile could be attempted.', trace);
}

View File

@@ -39,6 +39,7 @@ export function settingDefinitions() {
{ key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') }, { key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') }, { key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') },
{ key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') }, { key: 'vcenter_password', value: config.vcenter.password, pinned: envProvided('VCENTER_PASSWORD') },
{ key: 'vcenter_api_mode', value: config.vcenter.apiMode, pinned: envProvided('VCENTER_API_MODE') },
{ key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') } { key: 'vcenter_allow_untrusted_tls', value: String(config.vcenter.allowUntrustedTls), pinned: envProvided('VCENTER_ALLOW_UNTRUSTED_TLS') }
]; ];
} }

View File

@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { load } from './setup.mjs'; import { load } from './setup.mjs';
const { const {
buildVmListPath,
buildHostPayloadFromVm, buildHostPayloadFromVm,
filterSummaryVms, filterSummaryVms,
normalizeBaseUrl, normalizeBaseUrl,
@@ -64,6 +65,23 @@ test('filterSummaryVms prefilters hostname searches by VM name and falls back wh
assert.equal(fallback.vms.length, 2); assert.equal(fallback.vms.length, 2);
}); });
test('buildVmListPath uses documented exact-name filters for api and rest profiles', () => {
const filter = { field: 'name', operator: 'equals', value: 'MCS-WIN-APP01' };
assert.equal(
buildVmListPath({ vmListPath: '/api/vcenter/vm', vmNameParam: 'names' }, filter),
'/api/vcenter/vm?names=MCS-WIN-APP01'
);
assert.equal(
buildVmListPath({ vmListPath: '/rest/vcenter/vm', vmNameParam: 'filter.names' }, filter),
'/rest/vcenter/vm?filter.names=MCS-WIN-APP01'
);
assert.equal(
buildVmListPath({ vmListPath: '/api/vcenter/vm', vmNameParam: 'names' }, { field: 'name', operator: 'contains', value: 'MCS' }),
'/api/vcenter/vm'
);
});
test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => { test('buildHostPayloadFromVm attaches credential, tags, transport, and import notes', () => {
const payload = buildHostPayloadFromVm( const payload = buildHostPayloadFromVm(
{ {