From 92d2bac481ff8dc099a70e8d7ac04573ba638a3f Mon Sep 17 00:00:00 2001 From: stefanonardo Date: Tue, 18 Aug 2026 11:05:43 +0200 Subject: [PATCH] OCPBUGS-90834: Fix empty vSphere connection details after upgrade Fall back to parsing the INI-format cloud-provider-config ConfigMap when failureDomains is not populated in the Infrastructure resource. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/VSphereConnectionModal.tsx | 2 +- .../src/components/__tests__/utils.spec.ts | 76 ++++++++ .../vsphere-plugin/src/components/utils.ts | 16 ++ .../__tests__/use-connection-form.spec.ts | 174 ++++++++++++++++++ .../src/hooks/use-connection-form.ts | 142 +++++++++----- 5 files changed, 368 insertions(+), 42 deletions(-) create mode 100644 frontend/packages/vsphere-plugin/src/components/__tests__/utils.spec.ts create mode 100644 frontend/packages/vsphere-plugin/src/hooks/__tests__/use-connection-form.spec.ts diff --git a/frontend/packages/vsphere-plugin/src/components/VSphereConnectionModal.tsx b/frontend/packages/vsphere-plugin/src/components/VSphereConnectionModal.tsx index 6bd57be48d9..f4b01db6cf1 100644 --- a/frontend/packages/vsphere-plugin/src/components/VSphereConnectionModal.tsx +++ b/frontend/packages/vsphere-plugin/src/components/VSphereConnectionModal.tsx @@ -144,7 +144,7 @@ export const VSphereConnectionModal: FC = ({ const models = useConnectionModels(); - const { initValues, isLoaded, error: loadError } = useConnectionForm(); + const { initValues, isLoaded, error: loadError } = useConnectionForm(cloudProviderConfig); const onClose = () => { setModalOpen(false); diff --git a/frontend/packages/vsphere-plugin/src/components/__tests__/utils.spec.ts b/frontend/packages/vsphere-plugin/src/components/__tests__/utils.spec.ts new file mode 100644 index 00000000000..11f6562d7d8 --- /dev/null +++ b/frontend/packages/vsphere-plugin/src/components/__tests__/utils.spec.ts @@ -0,0 +1,76 @@ +import { parseKeyValue, encodeBase64, decodeBase64 } from '../utils'; + +describe('parseKeyValue', () => { + it('should parse INI-style key=value pairs', () => { + const config = `server = "vcenter.example.com" +datacenter = "dc1" +default-datastore = "/dc1/datastore/vsanDatastore"`; + + const result = parseKeyValue(config); + + expect(result.server).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + expect(result['default-datastore']).toBe('/dc1/datastore/vsanDatastore'); + }); + + it('should handle values without quotes', () => { + const config = `server = vcenter.example.com +datacenter = dc1`; + + const result = parseKeyValue(config); + + expect(result.server).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + }); + + it('should skip section headers and empty lines', () => { + const config = `[Global] +secret-name = "vsphere-creds" + +[Workspace] +server = "vcenter.example.com"`; + + const result = parseKeyValue(config); + + expect(result['secret-name']).toBe('vsphere-creds'); + expect(result.server).toBe('vcenter.example.com'); + expect(Object.keys(result)).not.toContain('[Global]'); + }); + + it('should parse a full INI-format vSphere ConfigMap', () => { + const config = `[Global] +secret-name = "vsphere-creds" +secret-namespace = "kube-system" + +[Workspace] +server = "vcenter.example.com" +datacenter = "dc1" +default-datastore = "/dc1/datastore/vsanDatastore" +folder = "/dc1/vm/myfolder" +resourcepool-path = "/dc1/host/mycluster/Resources/mypool" + +[VirtualCenter "vcenter.example.com"] +datacenters = "dc1"`; + + const result = parseKeyValue(config); + + expect(result['secret-name']).toBe('vsphere-creds'); + expect(result['secret-namespace']).toBe('kube-system'); + expect(result.server).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + expect(result['default-datastore']).toBe('/dc1/datastore/vsanDatastore'); + expect(result.folder).toBe('/dc1/vm/myfolder'); + expect(result['resourcepool-path']).toBe('/dc1/host/mycluster/Resources/mypool'); + }); + + it('should return empty object for empty string', () => { + expect(parseKeyValue('')).toEqual({}); + }); +}); + +describe('encodeBase64 / decodeBase64', () => { + it('should round-trip a string', () => { + const original = 'admin@vsphere.local'; + expect(decodeBase64(encodeBase64(original))).toBe(original); + }); +}); diff --git a/frontend/packages/vsphere-plugin/src/components/utils.ts b/frontend/packages/vsphere-plugin/src/components/utils.ts index 2f2e231a7b7..53cc2b04122 100644 --- a/frontend/packages/vsphere-plugin/src/components/utils.ts +++ b/frontend/packages/vsphere-plugin/src/components/utils.ts @@ -4,6 +4,22 @@ import type { ConsoleTFunction } from '@console/dynamic-plugin-sdk'; export const encodeBase64 = (data: string) => Buffer.from(data).toString('base64'); export const decodeBase64 = (data: string) => Buffer.from(data, 'base64').toString('ascii'); +export const parseKeyValue = (config: string, delimiter = '='): { [key: string]: string } => { + const lines = config.split('\n'); + const result: { [key: string]: string } = {}; + lines.forEach((line) => { + const idx = line.indexOf(delimiter); + if (idx > 0) { + const key = line.substring(0, idx).trim(); + let value = line.substring(idx + 1).trim(); + if (value.charAt(0) === '"') value = value.substring(1); + if (value.charAt(value.length - 1) === '"') value = value.substring(0, value.length - 1); + result[key] = value; + } + }); + return result; +}; + export const getErrorMessage = (t: ConsoleTFunction, error: unknown): string => { if (error instanceof Error) { return error.message || ''; diff --git a/frontend/packages/vsphere-plugin/src/hooks/__tests__/use-connection-form.spec.ts b/frontend/packages/vsphere-plugin/src/hooks/__tests__/use-connection-form.spec.ts new file mode 100644 index 00000000000..7fd0443b752 --- /dev/null +++ b/frontend/packages/vsphere-plugin/src/hooks/__tests__/use-connection-form.spec.ts @@ -0,0 +1,174 @@ +import { k8sGet } from '@console/dynamic-plugin-sdk/src/api/core-api'; +import { encodeBase64 } from '../../components/utils'; +import type { ConfigMap } from '../../resources/configMap'; +import type { Infrastructure } from '../../resources/infrastructure'; +import { initialLoad } from '../use-connection-form'; + +jest.mock('@console/dynamic-plugin-sdk/src/api/core-api', () => ({ + k8sGet: jest.fn(), +})); + +const k8sGetMock = k8sGet as jest.Mock; + +const secretModel = { kind: 'Secret', apiVersion: 'v1' } as any; +const infrastructureModel = { + kind: 'Infrastructure', + apiVersion: 'config.openshift.io/v1', +} as any; + +const makeInfrastructure = ( + overrides: Partial = {}, +): Infrastructure => + ({ + spec: { + platformSpec: { + type: 'VSphere', + vsphere: { + vcenters: [{ server: 'vcenter.example.com', datacenters: ['dc1'] }], + failureDomains: [ + { + name: 'fd1', + topology: { + datacenter: 'dc1', + datastore: '/dc1/datastore/vsanDatastore', + folder: '/dc1/vm/myfolder', + computeCluster: '/dc1/host/mycluster', + networks: ['network1'], + }, + }, + ], + ...overrides, + }, + }, + }, + status: { platform: 'VSphere' }, + }) as Infrastructure; + +const makeSecret = (vcenter: string, username: string, password: string) => ({ + data: { + [`${vcenter}.username`]: encodeBase64(username), + [`${vcenter}.password`]: encodeBase64(password), + }, +}); + +const makeIniConfigMap = (): ConfigMap => + ({ + metadata: { name: 'cloud-provider-config', namespace: 'openshift-config' }, + data: { + config: `[Global] +secret-name = "vsphere-creds" +secret-namespace = "kube-system" + +[Workspace] +server = "vcenter.example.com" +datacenter = "dc1" +default-datastore = "/dc1/datastore/vsanDatastore" +folder = "/dc1/vm/myfolder" +resourcepool-path = "/dc1/host/mycluster/Resources/mypool" + +[VirtualCenter "vcenter.example.com"] +datacenters = "dc1"`, + }, + }) as ConfigMap; + +describe('initialLoad', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should read from failureDomains when populated', async () => { + k8sGetMock + .mockResolvedValueOnce(makeInfrastructure()) + .mockResolvedValueOnce(makeSecret('vcenter.example.com', 'admin', 's3cret')); + + const result = await initialLoad(secretModel, infrastructureModel); + + expect(result.vcenter).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + expect(result.defaultDatastore).toBe('/dc1/datastore/vsanDatastore'); + expect(result.folder).toBe('/dc1/vm/myfolder'); + expect(result.vCenterCluster).toBe('mycluster'); + expect(result.network).toBe('network1'); + expect(result.username).toBe('admin'); + expect(result.password).toBe('s3cret'); + }); + + it('should fall back to ConfigMap when failureDomains is empty', async () => { + k8sGetMock + .mockResolvedValueOnce(makeInfrastructure({ failureDomains: [] })) + .mockResolvedValueOnce(makeSecret('vcenter.example.com', 'admin', 's3cret')); + + const result = await initialLoad(secretModel, infrastructureModel, makeIniConfigMap()); + + expect(result.vcenter).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + expect(result.defaultDatastore).toBe('/dc1/datastore/vsanDatastore'); + expect(result.folder).toBe('/dc1/vm/myfolder'); + expect(result.vCenterCluster).toBe('mycluster'); + expect(result.network).toBe(''); + expect(result.username).toBe('admin'); + expect(result.password).toBe('s3cret'); + }); + + it('should fall back to ConfigMap when failureDomains is undefined', async () => { + k8sGetMock + .mockResolvedValueOnce(makeInfrastructure({ failureDomains: undefined })) + .mockResolvedValueOnce(makeSecret('vcenter.example.com', 'admin', 's3cret')); + + const result = await initialLoad(secretModel, infrastructureModel, makeIniConfigMap()); + + expect(result.vcenter).toBe('vcenter.example.com'); + expect(result.datacenter).toBe('dc1'); + expect(result.defaultDatastore).toBe('/dc1/datastore/vsanDatastore'); + expect(result.folder).toBe('/dc1/vm/myfolder'); + }); + + it('should return empty values when failureDomains is empty and no ConfigMap', async () => { + k8sGetMock.mockResolvedValueOnce(makeInfrastructure({ failureDomains: [] })); + + const result = await initialLoad(secretModel, infrastructureModel); + + expect(result.vcenter).toBe(''); + expect(result.datacenter).toBe(''); + expect(result.defaultDatastore).toBe(''); + expect(result.folder).toBe(''); + expect(result.vCenterCluster).toBe(''); + expect(result.network).toBe(''); + expect(result.username).toBe(''); + expect(result.password).toBe(''); + }); + + it('should return empty values with isInit when vCenter is placeholder', async () => { + k8sGetMock.mockResolvedValueOnce( + makeInfrastructure({ + failureDomains: [], + vcenters: [{ server: 'vcenterplaceholder', datacenters: ['dc1'] }], + } as any), + ); + + const result = await initialLoad(secretModel, infrastructureModel, makeIniConfigMap()); + + expect(result.vcenter).toBe(''); + expect(result.isInit).toBe(true); + }); + + it('should extract vCenterCluster from resourcepool-path in ConfigMap fallback', async () => { + k8sGetMock + .mockResolvedValueOnce(makeInfrastructure({ failureDomains: [] })) + .mockResolvedValueOnce(makeSecret('vcenter.example.com', 'admin', 's3cret')); + + const configMap: ConfigMap = { + metadata: { name: 'cloud-provider-config', namespace: 'openshift-config' }, + data: { + config: `server = "vcenter.example.com" +datacenter = "dc1" +default-datastore = "/dc1/datastore/ds1" +resourcepool-path = "/dc1/host/deep-cluster/Resources/pool"`, + }, + } as ConfigMap; + + const result = await initialLoad(secretModel, infrastructureModel, configMap); + + expect(result.vCenterCluster).toBe('deep-cluster'); + }); +}); diff --git a/frontend/packages/vsphere-plugin/src/hooks/use-connection-form.ts b/frontend/packages/vsphere-plugin/src/hooks/use-connection-form.ts index 7fb775004ef..43bdc0a175b 100644 --- a/frontend/packages/vsphere-plugin/src/hooks/use-connection-form.ts +++ b/frontend/packages/vsphere-plugin/src/hooks/use-connection-form.ts @@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next'; import type { K8sModel } from '@console/dynamic-plugin-sdk/src/api/core-api'; import { k8sGet } from '@console/dynamic-plugin-sdk/src/api/core-api'; import type { ConnectionFormFormikValues } from '../components/types'; -import { decodeBase64, getErrorMessage } from '../components/utils'; +import { decodeBase64, getErrorMessage, parseKeyValue } from '../components/utils'; import { VSPHERE_CREDS_SECRET_NAME, VSPHERE_CREDS_SECRET_NAMESPACE } from '../constants'; +import type { ConfigMap } from '../resources/configMap'; import type { Infrastructure } from '../resources/infrastructure'; import { useConnectionModels } from './use-connection-models'; @@ -19,9 +20,88 @@ class LoadError extends Error { } } -const initialLoad = async ( +const loadCredentials = async ( + secretModel: K8sModel, + vcenter: string, +): Promise<{ username: string; password: string }> => { + try { + const secret = await k8sGet({ + model: secretModel, + name: VSPHERE_CREDS_SECRET_NAME, + ns: VSPHERE_CREDS_SECRET_NAMESPACE, + }); + + if (!secret.data) { + // eslint-disable-next-line no-console + console.error(`Unexpected structure of the "${VSPHERE_CREDS_SECRET_NAME}" secret`); + } + + const secretKeyValues = secret.data || {}; + return { + username: decodeBase64(secretKeyValues[`${vcenter}.username`]), + password: decodeBase64(secretKeyValues[`${vcenter}.password`]), + }; + } catch (e) { + // eslint-disable-next-line no-console + console.error( + `Failed to load "${VSPHERE_CREDS_SECRET_NAME}" from "${VSPHERE_CREDS_SECRET_NAMESPACE}" secret: `, + e, + ); + return { username: '', password: '' }; + } +}; + +// Fallback for clusters where failureDomains is not populated (e.g. UPI +// or clusters originally installed before 4.13). Parses the INI-format +// cloud-provider-config ConfigMap instead. +const loadFromConfigMap = async ( + secretModel: K8sModel, + cloudProviderConfig: ConfigMap, + vCenterServerFromInfra: string, +): Promise => { + const { config } = cloudProviderConfig.data; + + let vcenter = vCenterServerFromInfra || ''; + let datacenter = ''; + let defaultDatastore = ''; + let folder = ''; + let vCenterCluster = ''; + + // INI format: [Workspace] server=, datacenter=, default-datastore=, folder=, resourcepool-path= + const keyValues = parseKeyValue(config); + if (!vcenter) { + vcenter = keyValues.server || ''; + } + datacenter = keyValues.datacenter || ''; + defaultDatastore = keyValues['default-datastore'] || ''; + folder = keyValues.folder || ''; + + const resourcePoolPath = keyValues['resourcepool-path'] || ''; + if (resourcePoolPath.length) { + const paths = resourcePoolPath.split('/'); + if (paths.length > 3) { + [, , , vCenterCluster] = paths; + } + } + + const { username, password } = await loadCredentials(secretModel, vcenter); + + return { + vcenter, + datacenter, + defaultDatastore, + folder, + username, + password, + vCenterCluster, + network: '', + }; +}; + +export const initialLoad = async ( secretModel: K8sModel, infrastructureModel: K8sModel, + cloudProviderConfig?: ConfigMap, ): Promise => { const infrastructure = await k8sGet({ model: infrastructureModel, @@ -34,17 +114,21 @@ const initialLoad = async ( const vCenterServer = vSphereCfg?.vcenters?.[0]?.server; if (!vSphereFailureDomain || vCenterServer === 'vcenterplaceholder') { - return { - datacenter: '', - defaultDatastore: '', - folder: '', - password: '', - username: '', - vcenter: '', - vCenterCluster: '', - network: '', - isInit: vCenterServer === 'vcenterplaceholder', - }; + if (vCenterServer === 'vcenterplaceholder' || !cloudProviderConfig?.data?.config) { + return { + datacenter: '', + defaultDatastore: '', + folder: '', + password: '', + username: '', + vcenter: '', + vCenterCluster: '', + network: '', + isInit: vCenterServer === 'vcenterplaceholder', + }; + } + + return loadFromConfigMap(secretModel, cloudProviderConfig, vCenterServer); } const datacenter = vSphereFailureDomain.topology?.datacenter || ''; @@ -58,31 +142,7 @@ const initialLoad = async ( // Load the primary network (first network in the networks array) const network = vSphereFailureDomain.topology?.networks?.[0] || ''; - let username = ''; - let password = ''; - try { - const secret = await k8sGet({ - model: secretModel, - name: VSPHERE_CREDS_SECRET_NAME, - ns: VSPHERE_CREDS_SECRET_NAMESPACE, - }); - - if (!secret.data) { - // eslint-disable-next-line no-console - console.error(`Unexpected structure of the "${VSPHERE_CREDS_SECRET_NAME}" secret`); - } - - const secretKeyValues = secret.data || {}; - username = decodeBase64(secretKeyValues[`${vCenterServer}.username`]); - password = decodeBase64(secretKeyValues[`${vCenterServer}.password`]); - } catch (e) { - // It should be there if referenced - // eslint-disable-next-line no-console - console.error( - `Failed to load "${VSPHERE_CREDS_SECRET_NAME}" from "${VSPHERE_CREDS_SECRET_NAMESPACE}" secret: `, - e, - ); - } + const { username, password } = await loadCredentials(secretModel, vCenterServer); return { datacenter, @@ -96,7 +156,7 @@ const initialLoad = async ( }; }; -export const useConnectionForm = () => { +export const useConnectionForm = (cloudProviderConfig?: ConfigMap) => { const { t } = useTranslation('vsphere-plugin'); const [isLoaded, setIsLoaded] = useState(false); const [error, setError] = useState<{ title: string; message: string }>(); @@ -109,7 +169,7 @@ export const useConnectionForm = () => { return; } try { - const loadResult = await initialLoad(secretModel, infrastructureModel); + const loadResult = await initialLoad(secretModel, infrastructureModel, cloudProviderConfig); setResult(loadResult); } catch (e) { if (e instanceof LoadError) { @@ -122,7 +182,7 @@ export const useConnectionForm = () => { }; doItAsync(); - }, [infrastructureModel, isLoaded, secretModel, t]); + }, [cloudProviderConfig, infrastructureModel, isLoaded, secretModel, t]); return { initValues: result,