diff --git a/pkg/harvester/components/MappingsCell.vue b/pkg/harvester/components/MappingsCell.vue new file mode 100644 index 00000000..aff6de31 --- /dev/null +++ b/pkg/harvester/components/MappingsCell.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue new file mode 100644 index 00000000..2dc1eb13 --- /dev/null +++ b/pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue @@ -0,0 +1,548 @@ + + + + + diff --git a/pkg/harvester/components/vm-migration/ConfigureProviderStep.vue b/pkg/harvester/components/vm-migration/ConfigureProviderStep.vue new file mode 100644 index 00000000..b7b1775e --- /dev/null +++ b/pkg/harvester/components/vm-migration/ConfigureProviderStep.vue @@ -0,0 +1,538 @@ + + + + + diff --git a/pkg/harvester/components/vm-migration/MappingColumn.vue b/pkg/harvester/components/vm-migration/MappingColumn.vue new file mode 100644 index 00000000..3933e434 --- /dev/null +++ b/pkg/harvester/components/vm-migration/MappingColumn.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/pkg/harvester/components/vm-migration/ReviewMigrationStep.vue b/pkg/harvester/components/vm-migration/ReviewMigrationStep.vue new file mode 100644 index 00000000..35cd2e67 --- /dev/null +++ b/pkg/harvester/components/vm-migration/ReviewMigrationStep.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/pkg/harvester/components/vm-migration/SelectVmsStep.vue b/pkg/harvester/components/vm-migration/SelectVmsStep.vue new file mode 100644 index 00000000..8fe38730 --- /dev/null +++ b/pkg/harvester/components/vm-migration/SelectVmsStep.vue @@ -0,0 +1,578 @@ + + + + + diff --git a/pkg/harvester/config/harvester-cluster.js b/pkg/harvester/config/harvester-cluster.js index b09fa0f8..0305ec3b 100644 --- a/pkg/harvester/config/harvester-cluster.js +++ b/pkg/harvester/config/harvester-cluster.js @@ -47,6 +47,13 @@ import { VM_IMPORT_SOURCE_O_STATUS, VM_IMPORT_SOURCE_OVA_URL, VM_IMPORT_SOURCE_OVA_STATUS, + FORKLIFT_PROVIDER_TYPE, + FORKLIFT_PROVIDER_URL, + FORKLIFT_MAP_SOURCE_PROVIDER, + FORKLIFT_MAP_DEST_PROVIDER, + FORKLIFT_PLAN_TARGET_NS, + FORKLIFT_PLAN_VM_COUNT, + FORKLIFT_MIGRATION_PLAN, } from './table-headers'; import { ADD_ONS } from './harvester-map'; import { registerAddonSideNav } from '../utils/dynamic-nav'; @@ -1319,4 +1326,182 @@ export function init($plugin, store) { exact: false, ifHaveType: HCI.HOST_NETWORK_CONFIG, }); + // =========================================================================== + // Forklift Addon UI Flow + // =========================================================================== + weightGroup('vmMigration', 0, false); + + // Provider + headers(HCI.FORKLIFT_PROVIDER, [ + STATE, + NAME_COL, + NAMESPACE_COL, + FORKLIFT_PROVIDER_TYPE, + FORKLIFT_PROVIDER_URL, + AGE + ]); + configureType(HCI.FORKLIFT_PROVIDER, { + resource: HCI.FORKLIFT_PROVIDER, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_PROVIDER } + } + }); + virtualType({ + name: HCI.FORKLIFT_PROVIDER, + labelKey: 'harvester.addons.vmMigration.labels.provider', + group: 'vmMigration', + namespaced: true, + weight: 100, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_PROVIDER } + } + }); + + // NetworkMap + headers(HCI.FORKLIFT_NETWORK_MAP, [ + STATE, + NAME_COL, + NAMESPACE_COL, + FORKLIFT_MAP_SOURCE_PROVIDER, + FORKLIFT_MAP_DEST_PROVIDER, + AGE + ]); + configureType(HCI.FORKLIFT_NETWORK_MAP, { + resource: HCI.FORKLIFT_NETWORK_MAP, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_NETWORK_MAP } + } + }); + virtualType({ + name: HCI.FORKLIFT_NETWORK_MAP, + labelKey: 'harvester.addons.vmMigration.labels.networkMap', + group: 'vmMigration::Advanced', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_NETWORK_MAP } + } + }); + + // StorageMap + headers(HCI.FORKLIFT_STORAGE_MAP, [ + STATE, + NAME_COL, + NAMESPACE_COL, + FORKLIFT_MAP_SOURCE_PROVIDER, + FORKLIFT_MAP_DEST_PROVIDER, + AGE + ]); + configureType(HCI.FORKLIFT_STORAGE_MAP, { + resource: HCI.FORKLIFT_STORAGE_MAP, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_STORAGE_MAP } + } + }); + virtualType({ + name: HCI.FORKLIFT_STORAGE_MAP, + labelKey: 'harvester.addons.vmMigration.labels.storageMap', + group: 'vmMigration::Advanced', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_STORAGE_MAP } + } + }); + + // Plan + headers(HCI.FORKLIFT_PLAN, [ + STATE, + NAME_COL, + NAMESPACE_COL, + FORKLIFT_MAP_SOURCE_PROVIDER, + FORKLIFT_PLAN_TARGET_NS, + FORKLIFT_PLAN_VM_COUNT, + AGE + ]); + configureType(HCI.FORKLIFT_PLAN, { + resource: HCI.FORKLIFT_PLAN, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_PLAN } + } + }); + virtualType({ + name: HCI.FORKLIFT_PLAN, + labelKey: 'harvester.addons.vmMigration.labels.plan', + group: 'vmMigration::Advanced', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_PLAN } + } + }); + + // Migration + headers(HCI.FORKLIFT_MIGRATION, [ + STATE, + NAME_COL, + NAMESPACE_COL, + FORKLIFT_MIGRATION_PLAN, + AGE + ]); + configureType(HCI.FORKLIFT_MIGRATION, { + resource: HCI.FORKLIFT_MIGRATION, + location: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_MIGRATION } + } + }); + virtualType({ + name: HCI.FORKLIFT_MIGRATION, + labelKey: 'harvester.addons.vmMigration.labels.migration', + group: 'vmMigration::Advanced', + namespaced: true, + route: { + name: `${ PRODUCT_NAME }-c-cluster-resource`, + params: { resource: HCI.FORKLIFT_MIGRATION } + } + }); + configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] }); + virtualType({ + name: 'forklift-create', + labelKey: 'harvester.addons.vmMigration.labels.dashboard', + group: 'vmMigration', + namespaced: true, + weight: 200, + route: { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration`, + params: {} + } + }); + + // Register the dashboard entry directly — it's a virtual type with no schema, + // so it cannot go through registerAddonSideNav (which filters by schema). + basicType(['forklift-create'], 'vmMigration'); + + // Enable SideNav based on Forklift Addon Status + registerAddonSideNav(store, PRODUCT_NAME, { + addonName: ADD_ONS.FORKLIFT_OPERATOR, + resourceType: HCI.ADD_ONS, + navGroup: 'vmMigration', + types: [ + HCI.FORKLIFT_PROVIDER, + ] + }); + registerAddonSideNav(store, PRODUCT_NAME, { + addonName: ADD_ONS.FORKLIFT_OPERATOR, + resourceType: HCI.ADD_ONS, + navGroup: 'vmMigration::Advanced', + types: [ + HCI.FORKLIFT_NETWORK_MAP, + HCI.FORKLIFT_STORAGE_MAP, + HCI.FORKLIFT_PLAN, + HCI.FORKLIFT_MIGRATION, + ] + }); + // =========================================================================== } diff --git a/pkg/harvester/config/harvester-map.js b/pkg/harvester/config/harvester-map.js index 95323b8c..172ef6ac 100644 --- a/pkg/harvester/config/harvester-map.js +++ b/pkg/harvester/config/harvester-map.js @@ -79,6 +79,7 @@ export const ADD_ONS = { VM_IMPORT_CONTROLLER: 'vm-import-controller', LVM_DRIVER: 'lvm.driver.harvesterhci.io', KUBEOVN_OPERATOR: 'kubeovn-operator', + FORKLIFT_OPERATOR: 'forklift-operator', }; export const CSI_SECRETS = { @@ -92,6 +93,9 @@ export const CSI_SECRETS = { CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace' }; +export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1'; +export const FORKLIFT_NAMESPACE = 'forklift'; + // Some harvester CRD type is not equal to model file name, define the mapping here export const HARVESTER_CRD_MAP = { node: HCI.HOST, diff --git a/pkg/harvester/config/table-headers.js b/pkg/harvester/config/table-headers.js index 92c1c3de..5615a6a4 100644 --- a/pkg/harvester/config/table-headers.js +++ b/pkg/harvester/config/table-headers.js @@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = { sort: 'status.status', align: 'left', }; + +// ======================================== +// Forklift table headers +// ======================================== + +// Provider type column in forklift.konveyor.io.provider list page +export const FORKLIFT_PROVIDER_TYPE = { + name: 'providerType', + labelKey: 'harvester.tableHeaders.vmMigrationProviderType', + value: 'spec.type', + sort: 'spec.type', + align: 'left', +}; + +// Provider URL column in forklift.konveyor.io.provider list page +export const FORKLIFT_PROVIDER_URL = { + name: 'providerUrl', + labelKey: 'harvester.tableHeaders.vmMigrationProviderUrl', + value: 'spec.url', + sort: 'spec.url', + align: 'left', +}; + +// Source provider column in forklift network/storage map list page +export const FORKLIFT_MAP_SOURCE_PROVIDER = { + name: 'sourceProvider', + labelKey: 'harvester.tableHeaders.vmMigrationMapSourceProvider', + value: 'spec.provider.source.name', + sort: 'spec.provider.source.name', + align: 'left', +}; + +// Destination provider column in forklift network/storage map list page +export const FORKLIFT_MAP_DEST_PROVIDER = { + name: 'destProvider', + labelKey: 'harvester.tableHeaders.vmMigrationMapDestProvider', + value: 'spec.provider.destination.name', + sort: 'spec.provider.destination.name', + align: 'left', +}; + +// Target namespace column in forklift.konveyor.io.plan list page +export const FORKLIFT_PLAN_TARGET_NS = { + name: 'targetNamespace', + labelKey: 'harvester.tableHeaders.vmMigrationPlanTargetNs', + value: 'spec.targetNamespace', + sort: 'spec.targetNamespace', + align: 'left', +}; + +// VM count column in forklift.konveyor.io.plan list page +export const FORKLIFT_PLAN_VM_COUNT = { + name: 'vmCount', + labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount', + value: 'spec.vms.length', + sort: 'spec.vms.length', + align: 'left', +}; + +// Plan reference column in forklift.konveyor.io.migration list page +export const FORKLIFT_MIGRATION_PLAN = { + name: 'plan', + labelKey: 'harvester.tableHeaders.vmMigrationMigrationPlan', + value: 'spec.plan.name', + sort: 'spec.plan.name', + align: 'left', +}; diff --git a/pkg/harvester/edit/forklift.konveyor.io.provider.vue b/pkg/harvester/edit/forklift.konveyor.io.provider.vue new file mode 100644 index 00000000..3f5e83aa --- /dev/null +++ b/pkg/harvester/edit/forklift.konveyor.io.provider.vue @@ -0,0 +1,24 @@ + diff --git a/pkg/harvester/l10n/en-us.yaml b/pkg/harvester/l10n/en-us.yaml index b482dd24..ace78c48 100644 --- a/pkg/harvester/l10n/en-us.yaml +++ b/pkg/harvester/l10n/en-us.yaml @@ -1,3 +1,8 @@ +wizard: + create: Create + next: Proceed + previous: Back + generic: tip: Tip resourceExternalLinkTips: 'External Link' @@ -31,6 +36,7 @@ nav: Logging: Logging 'Monitoring and Logging': Monitoring and Logging vmimport: Virtual Machine Imports + vmMigration: VM Migration resourceTable: groupBy: @@ -44,6 +50,8 @@ members: projectMembership: Project Membership asyncButton: + createAndStart: + action: Create and Start restart: action: Save and Restart success: Restarted @@ -365,6 +373,13 @@ harvester: v4ip: V4 IP v6ip: V6 IP eipName: EIP Name + vmMigrationProviderType: Type + vmMigrationProviderUrl: URL + vmMigrationMapSourceProvider: Source Provider + vmMigrationMapDestProvider: Destination Provider + vmMigrationPlanTargetNs: Target Namespace + vmMigrationPlanVmCount: VMs + vmMigrationMigrationPlan: Plan tab: volume: Volumes network: Networks @@ -1814,6 +1829,178 @@ harvester: '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.' + vmMigration: + generic: + unknown: Unknown + identifier: Identifier + podNetwork: Pod Network + usedBy: "Used by:" + vlan: "VLAN {id}" + vCpu: "{count} vCPU" + memoryGb: "{value} GB" + memoryTb: "{value} TB" + elapsed: + hoursAndMinutes: "{hours} {hours, plural, one {hour} other {hours}} and {minutes} {minutes, plural, one {minute} other {minutes}}" + hours: "{hours} {hours, plural, one {hour} other {hours}}" + minutes: "{minutes} {minutes, plural, one {minute} other {minutes}}" + errors: + failedLoadPlans: Failed to load migration plans + failedLoadVms: Failed to load virtual machines + failedRefreshVms: Failed to refresh virtual machines + failedLoadProviders: Failed to load providers + failedLoadProvider: "Failed to load provider: {error}" + failedResolveDetails: Failed to resolve network and datastore details + failedDecodeCredentials: Failed to decode provider credentials + connectionFailed: Connection failed + providerNotReady: Provider not ready + labels: + dashboard: Migrations + provider: Providers + networkMap: Network Maps + storageMap: Storage Maps + plan: Migration Plans + migration: Migrations + providerWizard: + title: Create Provider + editTitle: Edit Provider + wizard: + title: VM Migration + steps: + configureProvider: + label: Provider + description: Configure Provider + selectVms: + label: VMs + description: Select VMs + configureMappings: + label: Mappings + description: Define Mappings + reviewMigration: + label: Migration Plan + description: Review Migration Plan + fields: + username: Username + password: Password + configureProvider: + title: Configure Provider + description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration + connectionDetails: Connection Details + providerSelect: Provider + createNew: Create new provider + requirementsTitle: Requirements + requirementsText: "To migrate virtual machines from VMware, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher." + name: Name + urlLabel: vCenter/ESXi URL + urlPlaceholder: "https://vcenter.example.com/sdk" + urlHint: Enter the full URL including https:// + skipSsl: Skip SSL certificate verification + skipSslHint: Not recommended for production environments + testConnection: + action: Test Connection + success: Connection successful + error: Connection failed + waiting: Testing connection... + testSuccess: Connection test passed + testFailed: Connection test failed + testMissingFields: Please fill in all required fields before testing + testTimeout: Connection test timed out — the provider did not report a status in time + save: Save Provider and Continue + saveExisting: Check Provider and Continue + selectVms: + title: Select Virtual Machines + description: Select the virtual machines you want to migrate from the source provider + discovered: "{count} VMs discovered from" + lastSynced: "Last synced {time} ago • " + refreshNow: Refresh now + availableVms: Available Virtual Machines + selected: selected + saveSelection: Save Selection and Continue + selectAllBanner: + pageOnly: "Your current selection includes only VMs currently shown on the page." + selectAll: "Select all {count} VMs from this provider" + allSelected: "Your current selection includes all VMs." + clearSelection: "Clear Selection" + manualEntry: + title: Add Virtual Machines + description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names. + vmId: VM ID + vmIdPlaceholder: "e.g. vm-60527" + add: Add + added: VMs added + columns: + vmName: VM Name + vmId: VM ID + os: Operating System + resources: Resources + powerState: Power State + network: Network + datastore: Datastore + dashboard: + title: VMware to Harvester Migration + description: Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester. + createPlan: Create Migration + tableTitle: Migrations + columns: + status: Status + plan: Migration Plan + progress: Progress + mappings: Mappings + progress: + failed: Failed + migration: Migration + initializingMigration: Initializing migration + finishedSuccessfully: Finished Successfully + step: "Step {index}" + vmCount: "{count} VMs" + vmId: "id: {id}" + configureMappings: + title: Set Mappings + description: Map VMware networks and datastores to Harvester and Longhorn target resources + save: Save Mappings and Continue + noTemplate: Start from scratch + removeMap: Remove Map + networkMapping: + title: Network Mapping + description: Map VMware port groups to Harvester networks + placeholder: Choose a Harvester network + template: Use existing network mapping as template + options: + podNetworking: Pod Networking + ignored: Ignored + storageMapping: + title: Storage Mapping + description: Map VMware datastores to Harvester storage classes + placeholder: Choose a Longhorn Storage Volume + template: Use existing storage mapping as template + reviewMigration: + title: Review Migration Plan + description: Confirm your migration settings before starting the transfer of VMs to the target cluster. + planName: Name + planNamePlaceholder: "e.g. my-migration-plan" + migrationDetails: Migration Details + totalVms: Total VMs + vcpu: vCPU + memory: Memory + storage: Storage + source: Source + targetNamespace: Target Namespace + migrationMode: Migration Mode + coldMigration: Cold Migration + virtualMachines: Virtual Machines + warningTitle: "Cold Migration Warning" + warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders" + startMigration: Start Migration Plan + plan: + states: + error: Error + canceled: Canceled + inProgress: In Progress + active: Succeeded + actions: + start: Start + restart: Restart + stop: Stop + vmImport: titles: basic: Basic @@ -2449,3 +2636,28 @@ typeLabel: one { Virtual Machine Import } other { Virtual Machine Imports } } + forklift.konveyor.io.provider: |- + {count, plural, + one { Provider } + other { Providers } + } + forklift.konveyor.io.networkmap: |- + {count, plural, + one { Network Map } + other { Network Maps } + } + forklift.konveyor.io.storagemap: |- + {count, plural, + one { Storage Map } + other { Storage Maps } + } + forklift.konveyor.io.plan: |- + {count, plural, + one { Migration Plan } + other { Migration Plans } + } + forklift.konveyor.io.migration: |- + {count, plural, + one { Migration } + other { Migrations } + } diff --git a/pkg/harvester/models/forklift.konveyor.io.plan.js b/pkg/harvester/models/forklift.konveyor.io.plan.js new file mode 100644 index 00000000..7e586666 --- /dev/null +++ b/pkg/harvester/models/forklift.konveyor.io.plan.js @@ -0,0 +1,258 @@ +import HarvesterResource from './harvester'; +import { HCI } from '../types'; +import { PRODUCT_NAME } from '../config/harvester'; +import { randomStr } from '@shell/utils/string'; + +export default class ForkliftPlan extends HarvesterResource { + get listLocation() { + return { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration`, + params: { + product: this.$rootGetters['productId'], + cluster: this.$rootGetters['clusterId'], + }, + }; + } + + get planFailed() { + const conditions = this.status?.conditions || []; + + return conditions.some((c) => c.type === 'Failed' && c.status === 'True'); + } + + get planCritical() { + const conditions = this.status?.conditions || []; + + return conditions.some((c) => c.category === 'Critical' && c.status === 'True'); + } + + get criticalMessages() { + const conditions = this.status?.conditions || []; + + return conditions + .filter((c) => c.category === 'Critical' && c.status === 'True') + .map((c) => c.message); + } + + get stateDescription() { + const messages = []; + const conditions = this.status?.conditions || []; + + if (this.planFailed) { + const failedMsg = conditions.find((c) => c.type === 'Failed' && c.status === 'True')?.message; + + if (failedMsg) { + messages.push(failedMsg); + } + } + + if (this.planCritical) { + messages.push(...this.criticalMessages); + } + + return messages.length > 0 ? messages.join(' ') : null; + } + + get stateObj() { + return { + error: this.planFailed || this.planCritical, + transitioning: this.isMigrating, + message: this.stateDescription, + }; + } + + get isMigrating() { + const migration = this.status?.migration; + + return !!migration?.started && !migration?.completed; + } + + get planCanceled() { + const history = this.status?.migration?.history || []; + + if (history.length === 0) { + return false; + } + + const latest = history[history.length - 1]; + const conditions = latest.conditions || []; + + return conditions.some((c) => c.type === 'Canceled' && c.status === 'True'); + } + + get planSucceeded() { + const migration = this.status?.migration; + + return !!migration?.completed && !this.planFailed && !this.planCanceled; + } + + get stateDisplay() { + if (this.planFailed) { + return this.t('harvester.addons.vmMigration.plan.states.error'); + } + + if (this.planCritical) { + return this.t('harvester.addons.vmMigration.plan.states.error'); + } + + if (this.planCanceled) { + return this.t('harvester.addons.vmMigration.plan.states.canceled'); + } + + if (this.isMigrating) { + return this.t('harvester.addons.vmMigration.plan.states.inProgress'); + } + + return this.t('harvester.addons.vmMigration.plan.states.active'); + } + + get stateBackground() { + if (this.planFailed) { + return 'bg-error'; + } + + if (this.planCritical) { + return 'bg-error'; + } + + if (this.planCanceled) { + return 'bg-warning'; + } + + if (this.isMigrating) { + return 'bg-info'; + } + + return 'bg-success'; + } + + get isForkliftDashboard() { + const route = this.currentRouter()?.currentRoute?.value; + + return route?.name?.endsWith('-vm-migration'); + } + + get _availableActions() { + const canStop = this.isMigrating && !this.planCanceled; + const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical); + const out = []; + + if (canStart) { + out.push({ + action: 'startMigration', + enabled: true, + icon: 'icon icon-play', + label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'), + }); + } + + if (canStop) { + out.push({ + action: 'stopMigration', + enabled: true, + icon: 'icon icon-pause', + label: this.t('harvester.addons.vmMigration.plan.actions.stop'), + }); + } + + if (this.isForkliftDashboard) { + out.push({ + action: 'promptRemove', + altAction: 'remove', + label: this.t('action.remove'), + icon: 'icon icon-trash', + bulkable: true, + enabled: this.canDelete, + bulkAction: 'promptRemove', + weight: -10, + }); + + return out; + } else { + out.push(...super._availableActions); + + return out; + } + } + + async stopMigration() { + const history = this.status?.migration?.history || []; + + if (history.length === 0) { + return; + } + + const latest = history[history.length - 1]; + const migrationName = latest.migration?.name; + + if (migrationName) { + const selfUrl = this.linkFor('self'); + const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`); + + await this.$dispatch('request', { url, method: 'DELETE' }); + } + } + + async startMigration() { + const namespace = this.metadata.namespace; + const history = this.status?.migration?.history || []; + + // Delete previous migrations before starting a new one + for (const entry of history) { + const name = entry.migration?.name; + + if (name) { + const selfUrl = this.linkFor('self'); + const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`); + + try { + await this.$dispatch('request', { url, method: 'DELETE' }); + } catch (e) { + // Migration may already be gone — ignore 404s + } + } + } + + const migration = await this.$dispatch('create', { + type: HCI.FORKLIFT_MIGRATION, + metadata: { + name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`, + namespace, + ownerReferences: [ + { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + name: this.metadata.name, + uid: this.metadata.uid, + blockOwnerDeletion: true, + }, + ], + }, + spec: { + plan: { + apiVersion: 'forklift.konveyor.io/v1beta1', + kind: 'Plan', + name: this.metadata.name, + namespace, + }, + }, + }); + + await migration.save(); + } + + async deletePlan() { + await this.remove(); + } + + /** + * Deleting a Plan cascades via ownerReferences set at creation time. + * Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider (→ Secret). + * Use foreground propagation to ensure children are deleted before the parent. + */ + remove(opt = {}) { + opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' }; + + return this._remove(opt); + } +} diff --git a/pkg/harvester/models/forklift.konveyor.io.provider.js b/pkg/harvester/models/forklift.konveyor.io.provider.js new file mode 100644 index 00000000..5ef2eee2 --- /dev/null +++ b/pkg/harvester/models/forklift.konveyor.io.provider.js @@ -0,0 +1,36 @@ +import HarvesterResource from './harvester'; +import { PRODUCT_NAME } from '../config/harvester'; + +export default class ForkliftProvider extends HarvesterResource { + get _createLocation() { + return { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`, + params: { + product: this.$rootGetters['productId'], + cluster: this.$rootGetters['clusterId'], + } + }; + } + + get _editLocation() { + return { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`, + params: { + product: this.$rootGetters['productId'], + cluster: this.$rootGetters['clusterId'], + }, + query: { providerId: `${ this.metadata.namespace }/${ this.metadata.name }` }, + }; + } + + /** + * Deleting a Provider cascades via ownerReferences set at creation time. + * Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap. + * Use foreground propagation to ensure children are deleted before the parent. + */ + remove(opt = {}) { + opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' }; + + return this._remove(opt); + } +} diff --git a/pkg/harvester/pages/c/_cluster/vm-migration/index.vue b/pkg/harvester/pages/c/_cluster/vm-migration/index.vue new file mode 100644 index 00000000..6fe483ff --- /dev/null +++ b/pkg/harvester/pages/c/_cluster/vm-migration/index.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue b/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue new file mode 100644 index 00000000..29f9ffd8 --- /dev/null +++ b/pkg/harvester/pages/c/_cluster/vm-migration/provider-wizard.vue @@ -0,0 +1,286 @@ + + + diff --git a/pkg/harvester/pages/c/_cluster/vm-migration/vm-migration-wizard.vue b/pkg/harvester/pages/c/_cluster/vm-migration/vm-migration-wizard.vue new file mode 100644 index 00000000..8c20689c --- /dev/null +++ b/pkg/harvester/pages/c/_cluster/vm-migration/vm-migration-wizard.vue @@ -0,0 +1,289 @@ + + + diff --git a/pkg/harvester/routing/harvester-routing.js b/pkg/harvester/routing/harvester-routing.js index 19cd6f69..055c8545 100644 --- a/pkg/harvester/routing/harvester-routing.js +++ b/pkg/harvester/routing/harvester-routing.js @@ -16,6 +16,9 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue'; import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue'; import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue'; import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue'; +import ForkliftDashboard from '../pages/c/_cluster/vm-migration/index.vue'; +import ForkliftVmMigrationWizard from '../pages/c/_cluster/vm-migration/vm-migration-wizard.vue'; +import ForkliftProviderWizard from '../pages/c/_cluster/vm-migration/provider-wizard.vue'; const routes = [ { @@ -83,6 +86,18 @@ const routes = [ name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`, path: `/:product/c/:cluster/projectsnamespaces`, component: ProjectNamespaces, + }, { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration`, + path: `/:product/c/:cluster/vm-migration`, + component: ForkliftDashboard, + }, { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`, + path: `/:product/c/:cluster/vm-migration/wizard`, + component: ForkliftVmMigrationWizard, + }, { + name: `${ PRODUCT_NAME }-c-cluster-vm-migration-provider-wizard`, + path: `/:product/c/:cluster/vm-migration/provider-wizard`, + component: ForkliftProviderWizard, }, { name: `${ PRODUCT_NAME }-c-cluster-resource`, path: `/:product/c/:cluster/:resource`, diff --git a/pkg/harvester/types.ts b/pkg/harvester/types.ts index caaa8adf..5193beae 100644 --- a/pkg/harvester/types.ts +++ b/pkg/harvester/types.ts @@ -5,77 +5,82 @@ // 2. `vpc-nat-gateways.kubeovn.io` -> kubeovn.io.vpcnatgateway, the CRD name for the `VpcNatGateway` resource in the `kubeovn.io` API group. export const HCI = { - VM: 'kubevirt.io.virtualmachine', - VMI: 'kubevirt.io.virtualmachineinstance', - VMIM: 'kubevirt.io.virtualmachineinstancemigration', - VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate', - VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion', - IMAGE: 'harvesterhci.io.virtualmachineimage', - SSH: 'harvesterhci.io.keypair', - VOLUME: 'harvesterhci.io.volume', - USER: 'harvesterhci.io.user', - SETTING: 'harvesterhci.io.setting', - UPGRADE: 'harvesterhci.io.upgrade', - UPGRADE_LOG: 'harvesterhci.io.upgradelog', - SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup', - BACKUP: 'harvesterhci.io.virtualmachinebackup', - RESTORE: 'harvesterhci.io.virtualmachinerestore', - NODE_NETWORK: 'network.harvesterhci.io.nodenetwork', - CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork', - HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig', - SUBNET: 'kubeovn.io.subnet', - VPC: 'kubeovn.io.vpc', - IP: 'kubeovn.io.ip', - VLAN: 'kubeovn.io.vlan', - IPTABLES_EIP: 'kubeovn.io.iptableseip', - IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule', - IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule', - PROVIDER_NETWORK: 'kubeovn.io.providernetwork', - VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway', - VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader', - SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle', - NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition', - CLUSTER: 'harvesterhci.io.management.cluster', - DASHBOARD: 'harvesterhci.io.dashboard', - BLOCK_DEVICE: 'harvesterhci.io.blockdevice', - CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate', - HOST: 'harvesterhci.io.host', - VERSION: 'harvesterhci.io.version', - SNAPSHOT: 'harvesterhci.io.volumesnapshot', - VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot', - ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig', - CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow', - CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput', - FLOW: 'harvesterhci.io.logging.flow', - OUTPUT: 'harvesterhci.io.logging.output', - STORAGE: 'harvesterhci.io.storage', - RESOURCE_QUOTA: 'harvesterhci.io.resourcequota', - KSTUNED: 'node.harvesterhci.io.ksmtuned', - HUGEPAGES: 'node.harvesterhci.io.hugepage', - PCI_DEVICE: 'devices.harvesterhci.io.pcidevice', - PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim', - SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice', - VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice', - SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice', - USB_DEVICE: 'devices.harvesterhci.io.usbdevice', - USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim', - MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration', - VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig', - VLAN_STATUS: 'network.harvesterhci.io.vlanstatus', - ADD_ONS: 'harvesterhci.io.addon', - LINK_MONITOR: 'network.harvesterhci.io.linkmonitor', - SECRET: 'harvesterhci.io.secret', - INVENTORY: 'metal.harvesterhci.io.inventory', - LB: 'loadbalancer.harvesterhci.io.loadbalancer', - 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', - + VM: 'kubevirt.io.virtualmachine', + VMI: 'kubevirt.io.virtualmachineinstance', + VMIM: 'kubevirt.io.virtualmachineinstancemigration', + VM_TEMPLATE: 'harvesterhci.io.virtualmachinetemplate', + VM_VERSION: 'harvesterhci.io.virtualmachinetemplateversion', + IMAGE: 'harvesterhci.io.virtualmachineimage', + SSH: 'harvesterhci.io.keypair', + VOLUME: 'harvesterhci.io.volume', + USER: 'harvesterhci.io.user', + SETTING: 'harvesterhci.io.setting', + UPGRADE: 'harvesterhci.io.upgrade', + UPGRADE_LOG: 'harvesterhci.io.upgradelog', + SCHEDULE_VM_BACKUP: 'harvesterhci.io.schedulevmbackup', + BACKUP: 'harvesterhci.io.virtualmachinebackup', + RESTORE: 'harvesterhci.io.virtualmachinerestore', + NODE_NETWORK: 'network.harvesterhci.io.nodenetwork', + CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork', + HOST_NETWORK_CONFIG: 'network.harvesterhci.io.hostnetworkconfig', + SUBNET: 'kubeovn.io.subnet', + VPC: 'kubeovn.io.vpc', + IP: 'kubeovn.io.ip', + VLAN: 'kubeovn.io.vlan', + IPTABLES_EIP: 'kubeovn.io.iptableseip', + IPTABLES_SNAT_RULE: 'kubeovn.io.iptablessnatrule', + IPTABLES_DNAT_RULE: 'kubeovn.io.iptablesdnatrule', + PROVIDER_NETWORK: 'kubeovn.io.providernetwork', + VPC_NAT_GATEWAY: 'kubeovn.io.vpcnatgateway', + VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader', + SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle', + NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition', + CLUSTER: 'harvesterhci.io.management.cluster', + DASHBOARD: 'harvesterhci.io.dashboard', + BLOCK_DEVICE: 'harvesterhci.io.blockdevice', + CLOUD_TEMPLATE: 'harvesterhci.io.cloudtemplate', + HOST: 'harvesterhci.io.host', + VERSION: 'harvesterhci.io.version', + SNAPSHOT: 'harvesterhci.io.volumesnapshot', + VM_SNAPSHOT: 'harvesterhci.io.vmsnapshot', + ALERTMANAGERCONFIG: 'harvesterhci.io.monitoring.alertmanagerconfig', + CLUSTER_FLOW: 'harvesterhci.io.logging.clusterflow', + CLUSTER_OUTPUT: 'harvesterhci.io.logging.clusteroutput', + FLOW: 'harvesterhci.io.logging.flow', + OUTPUT: 'harvesterhci.io.logging.output', + STORAGE: 'harvesterhci.io.storage', + RESOURCE_QUOTA: 'harvesterhci.io.resourcequota', + KSTUNED: 'node.harvesterhci.io.ksmtuned', + HUGEPAGES: 'node.harvesterhci.io.hugepage', + PCI_DEVICE: 'devices.harvesterhci.io.pcidevice', + PCI_CLAIM: 'devices.harvesterhci.io.pcideviceclaim', + SR_IOV: 'devices.harvesterhci.io.sriovnetworkdevice', + VGPU_DEVICE: 'devices.harvesterhci.io.vgpudevice', + SR_IOVGPU_DEVICE: 'devices.harvesterhci.io.sriovgpudevice', + USB_DEVICE: 'devices.harvesterhci.io.usbdevice', + USB_CLAIM: 'devices.harvesterhci.io.usbdeviceclaim', + MIG_CONFIGURATION: 'devices.harvesterhci.io.migconfiguration', + VLAN_CONFIG: 'network.harvesterhci.io.vlanconfig', + VLAN_STATUS: 'network.harvesterhci.io.vlanstatus', + ADD_ONS: 'harvesterhci.io.addon', + LINK_MONITOR: 'network.harvesterhci.io.linkmonitor', + SECRET: 'harvesterhci.io.secret', + INVENTORY: 'metal.harvesterhci.io.inventory', + LB: 'loadbalancer.harvesterhci.io.loadbalancer', + 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', + // Forklift CRDs + FORKLIFT_PROVIDER: 'forklift.konveyor.io.provider', + FORKLIFT_NETWORK_MAP: 'forklift.konveyor.io.networkmap', + FORKLIFT_STORAGE_MAP: 'forklift.konveyor.io.storagemap', + FORKLIFT_PLAN: 'forklift.konveyor.io.plan', + FORKLIFT_MIGRATION: 'forklift.konveyor.io.migration', }; export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot'; diff --git a/pkg/harvester/utils/forklift.js b/pkg/harvester/utils/forklift.js new file mode 100644 index 00000000..8387c5ec --- /dev/null +++ b/pkg/harvester/utils/forklift.js @@ -0,0 +1,80 @@ +// Shared helpers for the Forklift VM-migration feature. +// Keep the map-spec builders here so the wizard step and the review step +// (and any future edit flow) stay in sync instead of drifting apart. + +export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1'; + +/** + * Decode a base64 value stored in a k8s Secret, tolerating malformed input. + */ +export function decodeSecretValue(val) { + try { + return atob(val || ''); + } catch (e) { + return ''; + } +} + +/** + * Bytes → whole GiB (rounded). Returns 0 for falsy input. + */ +export function bytesToGB(bytes) { + return Math.round((bytes || 0) / (1024 * 1024 * 1024)); +} + +/** + * MiB → whole GiB (rounded). Returns 0 for falsy input. + */ +export function mbToGB(mb) { + return Math.round((mb || 0) / 1024); +} + +/** + * Build the `spec.map` entries for a Forklift NetworkMap from wizard entries. + * Entries without a chosen target are dropped so we never emit an invalid + * multus destination with an empty name. + * + * @param {Array} entries network mapping entries ({ name, id, target }) + * @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix + */ +export function buildNetworkMapEntries(entries = [], defaultNamespace) { + return entries + .filter((entry) => !!entry.target) + .map((entry) => { + const source = { name: entry.name, id: entry.id }; + + if (entry.target === 'pod') { + return { source, destination: { type: 'pod' } }; + } + + if (entry.target === 'ignored') { + return { source, destination: { type: 'ignored' } }; + } + + const parts = entry.target.split('/'); + const name = parts.length > 1 ? parts[1] : parts[0]; + const namespace = parts.length > 1 ? parts[0] : defaultNamespace; + + return { + source, + destination: { + type: 'multus', name, namespace + }, + }; + }); +} + +/** + * Build the `spec.map` entries for a Forklift StorageMap from wizard entries. + * Entries without a chosen target are dropped. + * + * @param {Array} entries storage mapping entries ({ name, id, target }) + */ +export function buildStorageMapEntries(entries = []) { + return entries + .filter((entry) => !!entry.target) + .map((entry) => ({ + source: { name: entry.name, id: entry.id }, + destination: { storageClass: entry.target }, + })); +} diff --git a/pkg/harvester/utils/router.js b/pkg/harvester/utils/router.js new file mode 100644 index 00000000..5e12e1a3 --- /dev/null +++ b/pkg/harvester/utils/router.js @@ -0,0 +1,11 @@ +// Mirrors Resource class methods from @shell/plugins/dashboard-store/resource-class.js +// useRouter()/useRoute() from vue-router don't work in extension mode, +// so we use the same global access pattern the shell uses internally. + +export function currentRouter() { + return window.$globalApp.$router; +} + +export function currentRoute() { + return window.$globalApp.$route; +}