diff --git a/pkg/harvester/config/harvester-cluster.js b/pkg/harvester/config/harvester-cluster.js index 5931c28b..1af27b9d 100644 --- a/pkg/harvester/config/harvester-cluster.js +++ b/pkg/harvester/config/harvester-cluster.js @@ -35,8 +35,21 @@ import { SNAPSHOT_TARGET_VOLUME, IMAGE_VIRTUAL_SIZE, IMAGE_STORAGE_CLASS, - HARVESTER_DESCRIPTION + HARVESTER_DESCRIPTION, + VM_IMPORT_SOURCE_VM, + VM_IMPORT_SOURCE_CLUSTER, + VM_IMPORT_STATUS, + VM_IMPORT_SOURCE_V_DC, + VM_IMPORT_SOURCE_V_ENDPOINT, + VM_IMPORT_SOURCE_V_STATUS, + VM_IMPORT_SOURCE_O_REGION, + VM_IMPORT_SOURCE_O_ENDPOINT, + VM_IMPORT_SOURCE_O_STATUS, + VM_IMPORT_SOURCE_OVA_URL, + VM_IMPORT_SOURCE_OVA_STATUS, } from './table-headers'; +import { ADD_ONS } from './harvester-map'; +import { registerAddonSideNav } from '../utils/dynamic-nav'; const TEMPLATE = HCI.VM_VERSION; const MONITORING_GROUP = 'Monitoring & Logging::Monitoring'; @@ -195,6 +208,142 @@ export function init($plugin, store) { exact: false }); + // =========================================================================== + // VM Import Controller UI Flow + // =========================================================================== + // Define group (Hidden by default) + weightGroup('vmimport', 0, false); + + // VirtualMachineImport + headers(HCI.VMIMPORT, [ + STATE, + NAME_COL, + NAMESPACE_COL, + VM_IMPORT_SOURCE_VM, + VM_IMPORT_SOURCE_CLUSTER, + VM_IMPORT_STATUS, + AGE + ]); + configureType(HCI.VMIMPORT, { + resource: HCI.VMIMPORT, + resourceDetail: HCI.VMIMPORT, + resourceEdit: HCI.VMIMPORT, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT } + } + }); + virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav() + name: HCI.VMIMPORT, + labelKey: 'harvester.addons.vmImport.labels.vmimport', + group: 'vmimport', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT } + } + }); + + // Source: VMware + headers(HCI.VMIMPORT_SOURCE_V, [ + STATE, + NAME_COL, + VM_IMPORT_SOURCE_V_ENDPOINT, + VM_IMPORT_SOURCE_V_DC, + VM_IMPORT_SOURCE_V_STATUS, + AGE + ]); + configureType(HCI.VMIMPORT_SOURCE_V, { + resource: HCI.VMIMPORT_SOURCE_V, + resourceDetail: HCI.VMIMPORT_SOURCE_V, + resourceEdit: HCI.VMIMPORT_SOURCE_V, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_V } + } + }); + virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav() + name: HCI.VMIMPORT_SOURCE_V, + labelKey: 'harvester.addons.vmImport.labels.vmimportSourceVMWare', + group: 'vmimport', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_V } + } + }); + + // Source: OpenStack + headers(HCI.VMIMPORT_SOURCE_O, [ + STATE, + NAME_COL, + VM_IMPORT_SOURCE_O_ENDPOINT, + VM_IMPORT_SOURCE_O_REGION, + VM_IMPORT_SOURCE_O_STATUS, + AGE + ]); + configureType(HCI.VMIMPORT_SOURCE_O, { + resource: HCI.VMIMPORT_SOURCE_O, + resourceDetail: HCI.VMIMPORT_SOURCE_O, + resourceEdit: HCI.VMIMPORT_SOURCE_O, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_O } + } + }); + virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav() + name: HCI.VMIMPORT_SOURCE_O, + labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOpenStack', + group: 'vmimport', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_O } + } + }); + + // Source: OVA + headers(HCI.VMIMPORT_SOURCE_OVA, [ + STATE, + NAME_COL, + VM_IMPORT_SOURCE_OVA_URL, + VM_IMPORT_SOURCE_OVA_STATUS, + AGE + ]); + configureType(HCI.VMIMPORT_SOURCE_OVA, { + resource: HCI.VMIMPORT_SOURCE_OVA, + resourceDetail: HCI.VMIMPORT_SOURCE_OVA, + resourceEdit: HCI.VMIMPORT_SOURCE_OVA, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_OVA } + } + }); + virtualType({ // needed to avoid 404 on refresh when combined with registerAddonSideNav() + name: HCI.VMIMPORT_SOURCE_OVA, + labelKey: 'harvester.addons.vmImport.labels.vmimportSourceOVA', + group: 'vmimport', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.VMIMPORT_SOURCE_OVA } + } + }); + + // Enable SideNav based on Addon Status + registerAddonSideNav(store, PRODUCT_NAME, { + addonName: ADD_ONS.VM_IMPORT_CONTROLLER, + resourceType: HCI.ADD_ONS, + navGroup: 'vmimport', + types: [ + HCI.VMIMPORT_SOURCE_V, + HCI.VMIMPORT_SOURCE_O, + HCI.VMIMPORT_SOURCE_OVA, + HCI.VMIMPORT + ] + }); + // =========================================================================== + basicType([HCI.VOLUME]); configureType(HCI.VOLUME, { location: { diff --git a/pkg/harvester/config/table-headers.js b/pkg/harvester/config/table-headers.js index b753aa5b..92c1c3de 100644 --- a/pkg/harvester/config/table-headers.js +++ b/pkg/harvester/config/table-headers.js @@ -131,3 +131,102 @@ export const PROVIDER = { value: 'spec.provider', align: 'left', }; + +// Source VM column in migration.harvesterhci.io.virtualmachineimport list page +export const VM_IMPORT_SOURCE_VM = { + name: 'sourceVm', + labelKey: 'harvester.tableHeaders.vmImportSourceVm', + value: 'spec.virtualMachineName', + sort: 'spec.virtualMachineName', + align: 'left', +}; + +// Source Cluster column in migration.harvesterhci.io.virtualmachineimport list page +export const VM_IMPORT_SOURCE_CLUSTER = { + name: 'sourceCluster', + labelKey: 'harvester.tableHeaders.vmImportSourceCluster', + value: 'spec.sourceCluster.name', + sort: 'spec.sourceCluster.name', + align: 'left', +}; + +// Import Status column in migration.harvesterhci.io.virtualmachineimport list page +export const VM_IMPORT_STATUS = { + name: 'importStatus', + labelKey: 'harvester.tableHeaders.vmImportStatus', + value: 'status.importStatus', + sort: 'status.importStatus', + align: 'left', +}; + +// Datacenter column in migration.harvesterhci.io.vmwaresource list page +export const VM_IMPORT_SOURCE_V_DC = { + name: 'datacenter', + labelKey: 'harvester.tableHeaders.vmImportSourceVDatacenter', + value: 'spec.dc', + sort: 'spec.dc', + align: 'left', +}; + +// Endpoint column in migration.harvesterhci.io.vmwaresource list page +export const VM_IMPORT_SOURCE_V_ENDPOINT = { + name: 'endpoint', + labelKey: 'harvester.tableHeaders.vmImportSourceVEndpoint', + value: 'spec.endpoint', + sort: 'spec.endpoint', + align: 'left', +}; + +// Cluster Status column in migration.harvesterhci.io.vmwaresource list page +export const VM_IMPORT_SOURCE_V_STATUS = { + name: 'clusterStatus', + labelKey: 'harvester.tableHeaders.vmImportSourceVClusterStatus', + value: 'status.status', + sort: 'status.status', + align: 'left', +}; + +// Region column in migration.harvesterhci.io.openstacksource list page +export const VM_IMPORT_SOURCE_O_REGION = { + name: 'region', + labelKey: 'harvester.tableHeaders.vmImportSourceORegion', + value: 'spec.region', + sort: 'spec.region', + align: 'left', +}; + +// Endpoint column in migration.harvesterhci.io.openstacksource list page +export const VM_IMPORT_SOURCE_O_ENDPOINT = { + name: 'endpoint', + labelKey: 'harvester.tableHeaders.vmImportSourceOEndpoint', + value: 'spec.endpoint', + sort: 'spec.endpoint', + align: 'left', +}; + +// Cluster Status column in migration.harvesterhci.io.openstacksource list page +export const VM_IMPORT_SOURCE_O_STATUS = { + name: 'clusterStatus', + labelKey: 'harvester.tableHeaders.vmImportSourceOClusterStatus', + value: 'status.status', + sort: 'status.status', + align: 'left', +}; + +// URL column in migration.harvesterhci.io.ovasource list page +export const VM_IMPORT_SOURCE_OVA_URL = { + name: 'url', + labelKey: 'harvester.tableHeaders.vmImportSourceOVAUrl', + value: 'spec.url', + sort: 'spec.url', + align: 'left', +}; + +// Status column in migration.harvesterhci.io.ovasource list page +export const VM_IMPORT_SOURCE_OVA_STATUS = { + name: 'status', + labelKey: 'harvester.tableHeaders.vmImportSourceOVAStatus', + value: 'status.status', + sort: 'status.status', + align: 'left', +}; diff --git a/pkg/harvester/config/types.js b/pkg/harvester/config/types.js index 39dd3212..472ec034 100644 --- a/pkg/harvester/config/types.js +++ b/pkg/harvester/config/types.js @@ -29,3 +29,15 @@ export const L2VLAN_MODE = { ACCESS: 'access', TRUNK: 'trunk', }; + +export const VMIMPORT_SOURCE_PROVIDER = { + VMWARE: 'vmware', + OPENSTACK: 'openstack', + OVA: 'ova', +}; + +export const VMIMPORT_SOURCE_KINDS = { + VMWARE: 'VmwareSource', + OPENSTACK: 'OpenstackSource', + OVA: 'OvaSource', +}; diff --git a/pkg/harvester/edit/migration.harvesterhci.io.openstacksource.vue b/pkg/harvester/edit/migration.harvesterhci.io.openstacksource.vue new file mode 100644 index 00000000..20bfe0f1 --- /dev/null +++ b/pkg/harvester/edit/migration.harvesterhci.io.openstacksource.vue @@ -0,0 +1,357 @@ + + + diff --git a/pkg/harvester/edit/migration.harvesterhci.io.ovasource.vue b/pkg/harvester/edit/migration.harvesterhci.io.ovasource.vue new file mode 100644 index 00000000..649edc1c --- /dev/null +++ b/pkg/harvester/edit/migration.harvesterhci.io.ovasource.vue @@ -0,0 +1,322 @@ + + + diff --git a/pkg/harvester/edit/migration.harvesterhci.io.virtualmachineimport.vue b/pkg/harvester/edit/migration.harvesterhci.io.virtualmachineimport.vue new file mode 100644 index 00000000..b8971143 --- /dev/null +++ b/pkg/harvester/edit/migration.harvesterhci.io.virtualmachineimport.vue @@ -0,0 +1,570 @@ + + + + + diff --git a/pkg/harvester/edit/migration.harvesterhci.io.vmwaresource.vue b/pkg/harvester/edit/migration.harvesterhci.io.vmwaresource.vue new file mode 100644 index 00000000..7129d2c2 --- /dev/null +++ b/pkg/harvester/edit/migration.harvesterhci.io.vmwaresource.vue @@ -0,0 +1,302 @@ + + + diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index 387fb283..8979af8d 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -20,6 +20,7 @@ nav: Monitoring: Monitoring Logging: Logging 'Monitoring and Logging': Monitoring and Logging + vmimport: Virtual Machine Imports resourceTable: groupBy: @@ -295,6 +296,17 @@ harvester: totalSnapshotQuota: Total Snapshot Quota storageClass: Storage Class restore: Restore + vmImportSourceVm: Source VM + vmImportSourceCluster: Source Cluster + vmImportStatus: Import Status + vmImportSourceVDatacenter: Datacenter + vmImportSourceVEndpoint: Endpoint + vmImportSourceVClusterStatus: Cluster Status + vmImportSourceORegion: Region + vmImportSourceOEndpoint: Endpoint + vmImportSourceOClusterStatus: Cluster Status + vmImportSourceOVAUrl: URL + vmImportSourceOVAStatus: Status tab: volume: Volumes network: Networks @@ -1566,10 +1578,84 @@ harvester: 'harvester-system/harvester-seeder': harvester-seeder is an add-on that uses IPMI and Redfish to discover hardware information and perform out-of-band operations. 'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices. 'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.' + vmImport: titles: basic: Basic + auth: Authentication pvc: Volume + networking: Network Mapping + advanced: Advanced + labels: + vmimport: Virtual Machine Import + vmimportSourceVMWare: Source VMWare + vmimportSourceOpenStack: Source OpenStack + vmimportSourceOVA: Source OVA + fields: + sourceProvider: Source Provider Type + sourceCluster: Source Cluster + vmName: VM Name + targetStorageClass: Target Storage Class + sourceNetwork: Source Network Name + destNetwork: Destination Network + interfaceModel: Interface Model + folder: Folder + diskBus: Default Disk Bus + defaultInterface: Default Network Interface + skipPreflight: Skip Preflight Checks + forcePowerOff: Force Power Off Source VM + username: Username + password: Password + caCert: CA Certificate (PEM) + selectSecret: Select Secret + createSecret: Create New Credentials + useSecret: Use Existing Secret + none: None (Public URL) + placeholders: + selectCluster: Select a cluster... + selectProviderFirst: Select a provider type first + matchSource: Must match the name in the source cluster + folderExample: e.g. /Datacenters/DC1/vm + caCert: "-----BEGIN CERTIFICATE----- ..." + options: + useDefault: Use Default + actions: + addNetwork: Add Network Mapping + remove: Remove + errors: + rfc1123: 'Invalid format. Name must be lowercase, alphanumeric, and cannot contain spaces (e.g. "my-vm-1"). If your Source VM name does not match this, you must rename it on the Source cluster first.' + networkMappingRequired: Every Network Mapping row must have a Source and Destination selected. + openstack: + fields: + endpoint: Identity Service Endpoint + region: Region + projectName: Project Name + domainName: Domain Name + retryCount: Upload Image Retry Count + retryDelay: Upload Image Retry Delay + placeholders: + endpoint: "e.g. https://devstack/identity" + region: e.g. RegionOne + projectName: e.g. admin + domainName: e.g. default + retryCount: "Default: 30" + retryDelay: "Default: 10" + vmware: + fields: + endpoint: vCenter Endpoint + datacenter: Datacenter + placeholders: + endpoint: "e.g. https://vscim/sdk" + datacenter: e.g. DC0 + tooltips: + datacenter: The exact name of the Datacenter object in vCenter + ova: + fields: + url: OVA URL + httpTimeout: HTTP Timeout + placeholders: + url: "e.g. https://download.example.com/images/my-vm.ova" + rancherVcluster: accessRancher: Access the Rancher Dashboard hostname: Hostname diff --git a/pkg/harvester/list/harvesterhci.io.migration.openstacksource.vue b/pkg/harvester/list/harvesterhci.io.migration.openstacksource.vue new file mode 100644 index 00000000..479d52c3 --- /dev/null +++ b/pkg/harvester/list/harvesterhci.io.migration.openstacksource.vue @@ -0,0 +1,60 @@ + + + diff --git a/pkg/harvester/list/harvesterhci.io.migration.virtualmachineimport.vue b/pkg/harvester/list/harvesterhci.io.migration.virtualmachineimport.vue new file mode 100644 index 00000000..10578f6c --- /dev/null +++ b/pkg/harvester/list/harvesterhci.io.migration.virtualmachineimport.vue @@ -0,0 +1,60 @@ + + + diff --git a/pkg/harvester/list/harvesterhci.io.migration.vmwaresource.vue b/pkg/harvester/list/harvesterhci.io.migration.vmwaresource.vue new file mode 100644 index 00000000..006ad222 --- /dev/null +++ b/pkg/harvester/list/harvesterhci.io.migration.vmwaresource.vue @@ -0,0 +1,60 @@ + + + diff --git a/pkg/harvester/types.ts b/pkg/harvester/types.ts index 6f7bd64b..d8c1aa21 100644 --- a/pkg/harvester/types.ts +++ b/pkg/harvester/types.ts @@ -56,6 +56,11 @@ export const HCI = { IP_POOL: 'loadbalancer.harvesterhci.io.ippool', HARVESTER_CONFIG: 'rke-machine-config.cattle.io.harvesterconfig', LVM_VOLUME_GROUP: 'harvesterhci.io.lvmvolumegroup', + VMIMPORT_SOURCE_V: 'migration.harvesterhci.io.vmwaresource', + VMIMPORT_SOURCE_O: 'migration.harvesterhci.io.openstacksource', + VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource', + VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport', + MIGRATION: 'migration.harvesterhci.io', }; export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot'; diff --git a/pkg/harvester/utils/dynamic-nav.js b/pkg/harvester/utils/dynamic-nav.js new file mode 100644 index 00000000..489b7a6f --- /dev/null +++ b/pkg/harvester/utils/dynamic-nav.js @@ -0,0 +1,99 @@ +/** + * Dynamically toggles SideNav entries based on the enabled status of a specific Addon. + * + * @param {Object} store - The Vuex store instance. + * @param {String} productName - The product name (e.g. 'harvester'). + * @param {Object} config - Configuration object. + * @param {String} config.addonName - The name of the addon to watch. + * @param {String} config.resourceType - The schema ID for addons. + * @param {String} config.navGroup - The group name in the side nav. + * @param {Array} config.types - Array of Resource IDs to show/hide. + */ +export function registerAddonSideNav(store, productName, { + addonName, resourceType, navGroup, types +}) { + if (typeof window === 'undefined') { + return; + } + + // Forces the SideNav component to re-render by toggling a dummy user preference. + // Necessary because the menu component does not automatically detect + // changes to the allowed types list. + const kickSideNav = () => { + const TRIGGER = 'ui.refresh.trigger'; + + store.dispatch('type-map/addFavorite', TRIGGER); + + // SideNav component seem to ignore rapid state changes. + // Wait 600ms to ensure the toggle event triggers a re-render. + setTimeout(() => { + store.dispatch('type-map/removeFavorite', TRIGGER); + }, 600); + }; + + // Adds or removes the resource IDs from the product visibility whitelist. + const setMenuVisibility = (visible) => { + if (visible) { + store.commit('type-map/basicType', { + product: productName, + group: navGroup, + types + }); + } else { + // Manually delete the keys from the state object to hide them. + const basicTypes = store.state['type-map'].basicTypes[productName]; + + if (basicTypes) { + types.forEach((t) => delete basicTypes[t]); + } + } + kickSideNav(); + }; + + // Start polling to check if the store is ready. + let attempts = 0; + const MAX_ATTEMPTS = 60; + + const waitForStore = setInterval(() => { + attempts++; + + try { + // Check if the Schema definitions are loaded. + const hasSchema = store.getters[`${ productName }/schemaFor`] && + store.getters[`${ productName }/schemaFor`](resourceType); + + // Check if the resource list data is fully loaded to prevent race conditions. + const hasData = store.getters[`${ productName }/haveAll`] && + store.getters[`${ productName }/haveAll`](resourceType); + + if (hasSchema && hasData) { + // Store is ready. Stop polling. + clearInterval(waitForStore); + + // Watch the specific addon resource for changes to its enabled status. + store.watch( + (state, getters) => { + const addons = getters[`${ productName }/all`](resourceType); + const addon = addons.find((a) => a.metadata.name === addonName); + + return addon?.spec?.enabled === true; + }, + (isEnabled) => { + setMenuVisibility(isEnabled); + }, + { immediate: true, deep: true } + ); + } else if (hasSchema && !hasData) { + // If the schema is ready but the data is missing, request the list from the API. + // Ensures the script does not wait indefinitely if the UI has not loaded the addons yet. + store.dispatch(`${ productName }/findAll`, { type: resourceType }); + } else if (attempts >= MAX_ATTEMPTS) { + // Stop checking if the store does not load within the timeout limit. + clearInterval(waitForStore); + } + } catch (e) { + // Ignore errors if the store module is not yet registered and wait for the next attempt. + if (attempts >= MAX_ATTEMPTS) clearInterval(waitForStore); + } + }, 1000); +}