Initial
This commit is contained in:
@@ -162,6 +162,7 @@ Important environment variables:
|
||||
| `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_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. |
|
||||
| `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version 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
|
||||
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.
|
||||
|
||||
|
||||
@@ -1136,6 +1136,12 @@ const settingMetadata = {
|
||||
section: 'Integrations',
|
||||
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: {
|
||||
label: 'Allow Untrusted vCenter TLS',
|
||||
description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.',
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
</div>
|
||||
<p>{{ entry.message }}</p>
|
||||
</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>
|
||||
<div class="trace-entry-header">
|
||||
<span class="trace-method">{{ entry.method || 'INFO' }}</span>
|
||||
@@ -58,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { AlertTriangle, Clipboard } from '@lucide/vue';
|
||||
import { AlertTriangle, Clipboard, Info } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -7133,6 +7133,10 @@ input:read-only {
|
||||
color: #f8c66a;
|
||||
}
|
||||
|
||||
.trace-entry-header.info {
|
||||
color: #99e8ff;
|
||||
}
|
||||
|
||||
.trace-method,
|
||||
.trace-entry-header strong,
|
||||
.trace-entry-header small {
|
||||
|
||||
@@ -45,6 +45,7 @@ export const config = {
|
||||
baseUrl: process.env.VCENTER_BASE_URL || '',
|
||||
username: process.env.VCENTER_USERNAME || '',
|
||||
password: process.env.VCENTER_PASSWORD || '',
|
||||
apiMode: process.env.VCENTER_API_MODE || 'auto',
|
||||
allowUntrustedTls: (process.env.VCENTER_ALLOW_UNTRUSTED_TLS || 'false').toLowerCase() === 'true'
|
||||
},
|
||||
entra: {
|
||||
|
||||
@@ -7,6 +7,26 @@ const WINDOWS_HINTS = ['windows', 'microsoft'];
|
||||
const LINUX_HINTS = ['linux', 'ubuntu', 'debian', 'red hat', 'rhel', 'centos', 'suse', 'oracle linux'];
|
||||
const MAX_TRACE_ENTRIES = 700;
|
||||
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 {
|
||||
constructor(message, trace = [], statusCode = 502) {
|
||||
@@ -18,11 +38,13 @@ export class VCenterTraceError extends Error {
|
||||
}
|
||||
|
||||
export function getVCenterSettings() {
|
||||
const apiMode = getStringSetting('vcenter_api_mode', config.vcenter.apiMode || 'auto').toLowerCase();
|
||||
return {
|
||||
enabled: getBooleanSetting('vcenter_enabled', config.vcenter.enabled),
|
||||
baseUrl: normalizeBaseUrl(getStringSetting('vcenter_base_url', config.vcenter.baseUrl)),
|
||||
username: getStringSetting('vcenter_username', config.vcenter.username),
|
||||
password: getStringSetting('vcenter_password', config.vcenter.password),
|
||||
apiMode: ['auto', 'api', 'rest'].includes(apiMode) ? apiMode : 'auto',
|
||||
allowUntrustedTls: getBooleanSetting('vcenter_allow_untrusted_tls', config.vcenter.allowUntrustedTls)
|
||||
};
|
||||
}
|
||||
@@ -38,6 +60,7 @@ export function vcenterStatus() {
|
||||
configured: Boolean(settings.baseUrl && settings.username && settings.password),
|
||||
baseUrl: settings.baseUrl,
|
||||
username: settings.username,
|
||||
apiMode: settings.apiMode,
|
||||
allowUntrustedTls: settings.allowUntrustedTls
|
||||
};
|
||||
}
|
||||
@@ -72,7 +95,7 @@ function summarizePayload(payload) {
|
||||
}
|
||||
|
||||
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 text = raw || '';
|
||||
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 } = {}) {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (sessionId) headers['vmware-api-session-id'] = sessionId;
|
||||
@@ -157,8 +188,8 @@ function unwrapList(payload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async function createSession(settings, trace = null) {
|
||||
const payload = await vcenterFetch(settings, '/api/session', { method: 'POST', trace });
|
||||
async function createSession(settings, profile, trace = null) {
|
||||
const payload = await vcenterFetch(settings, profile.sessionPath, { method: 'POST', trace });
|
||||
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);
|
||||
return sessionId;
|
||||
@@ -277,6 +308,17 @@ export function filterSummaryVms(vms = [], filter = {}) {
|
||||
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 = {}) {
|
||||
const tags = [...new Set(['vmware', 'vcenter', `vcenter:${candidate.id}`, ...(options.tags || [])].filter(Boolean))];
|
||||
return {
|
||||
@@ -307,8 +349,7 @@ export async function previewVCenterHosts(options = {}) {
|
||||
} catch (err) {
|
||||
throw new VCenterTraceError(err.message, trace, 400);
|
||||
}
|
||||
const sessionId = await createSession(settings, trace);
|
||||
const vmPayload = await vcenterFetch(settings, '/api/vcenter/vm', { sessionId, trace });
|
||||
const { profile, sessionId, vmPayload } = await connectAndListVms(settings, options.filter, trace);
|
||||
const allVms = unwrapList(vmPayload);
|
||||
const summaryFilter = filterSummaryVms(allVms, options.filter);
|
||||
const vms = summaryFilter.vms;
|
||||
@@ -319,6 +360,9 @@ export async function previewVCenterHosts(options = {}) {
|
||||
summaryMatched: summaryFilter.summaryMatched,
|
||||
summaryFilterApplied: summaryFilter.summaryFilterApplied,
|
||||
summaryFilterFallback: summaryFilter.summaryFilterFallback,
|
||||
apiMode: profile.mode,
|
||||
apiProfile: profile.label,
|
||||
serverSideNameFilter: canUseServerSideNameFilter(options.filter),
|
||||
resultLimit: options.limit || 100,
|
||||
sampleNames: allVms.slice(0, 12).map((vm) => vm.name || vm.vm || vm.id).filter(Boolean),
|
||||
filter: options.filter || {}
|
||||
@@ -328,8 +372,8 @@ export async function previewVCenterHosts(options = {}) {
|
||||
const vmId = vm.vm || vm.id;
|
||||
if (!vmId) continue;
|
||||
diagnostics.scanned += 1;
|
||||
const identity = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/identity`, sessionId, trace);
|
||||
const interfaces = await optionalVCenterFetch(settings, `/api/vcenter/vm/${encodeURIComponent(vmId)}/guest/networking/interfaces`, sessionId, trace);
|
||||
const identity = await optionalVCenterFetch(settings, profile.guestIdentityPath(vmId), sessionId, trace);
|
||||
const interfaces = await optionalVCenterFetch(settings, profile.guestNetworkingPath(vmId), sessionId, trace);
|
||||
const candidate = normalizeVCenterVm(vm, identity?.unavailable ? null : identity, interfaces?.unavailable ? null : interfaces);
|
||||
if (vmMatchesFilter(candidate, options.filter)) candidates.push(candidate);
|
||||
if (candidates.length >= (options.limit || 100)) break;
|
||||
@@ -344,3 +388,37 @@ export async function previewVCenterHosts(options = {}) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export function settingDefinitions() {
|
||||
{ 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_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') }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import { load } from './setup.mjs';
|
||||
|
||||
const {
|
||||
buildVmListPath,
|
||||
buildHostPayloadFromVm,
|
||||
filterSummaryVms,
|
||||
normalizeBaseUrl,
|
||||
@@ -64,6 +65,23 @@ test('filterSummaryVms prefilters hostname searches by VM name and falls back wh
|
||||
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', () => {
|
||||
const payload = buildHostPayloadFromVm(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user