feat: forklift poc first commit

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-04-17 20:15:19 +02:00
parent 495e80267d
commit 9a20ad5edd
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
17 changed files with 2191 additions and 71 deletions

264
forklift Normal file
View File

@ -0,0 +1,264 @@
# Forklift Addon
## Summary
This enhancement details the changes needed to include [forklift](https://github.com/kubev2v/forklift) as a Harvester addon.
The addon can be used as an alternate option for performing vm migrations from vmware.
### Related Issues
https://github.com/harvester/harvester/issues/9588
## Motivation
forklift is a tool similar to vm-import-controller which allows users to migrate VM based workloads from platforms such as vmware and openstack to Harvester. forklift has the added advantage of support [vsphere-xcopy](https://techdocs.broadcom.com/us/en/vmware-cis/vsphere/vsphere/7-0/vsphere-storage/storage-hardware-acceleration-in-vsphere/vsphere-hardware-acceleration-on-block-storage/managing-hardware-acceleration-filter-and-plug-ins/configure-xcopy-parameters.html) based plugins. When used in conjuction with supported storage arrays that are being shared across both vmware and Harvester installations, the plugin offloads the disk clone operation to the storage array. This allows the data to be populated in the kubernetes pvc directly from the vsphere datastore. This speeds up the data migration as the compute and network layer for both vsphere and Harvester components are skipped.
### Goals
- Addon to allow users to enable/disable the forklift addon on a Harvester cluster
- Allow migration of VM's from vsphere to Harvester using forklift
### Non-goals [optional]
- QA of individual vsphere-xcopy plugins
## Proposal
This is where we get down to the nitty gritty of what the proposal actually is.
### User Stories
#### Story 1: Migration VirtualMachines from vmware to Harvester
As a user I wish to migration my existing VM workloads from vmware to Harvester.
### User Experience In Detail
A user can trigger a migration of VM from vmware to Harvester by following the listed steps:
##### create a provider
```yaml
apiVersion: v1
kind: Secret
metadata:
name: vmware-secret
namespace: default
labels:
createdForProviderType: vsphere
createdForResourceType: providers
type: Opaque
stringData:
user: "username"
password: "password"
insecureSkipVerify: "true"
url: "https://vcenter/sdk"
---
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
metadata:
name: vmware-source
namespace: default
spec:
type: vsphere
url: "https://mako-vcenter.devlab.pgu1.suse.com/sdk"
secret:
name: vmware-secret
namespace: default
---
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
metadata:
name: local
namespace: default
spec:
type: openshift
secret: {}
```
##### create a network map defining mapping of vmware networks to Harvester vm networks
```yaml
apiVersion: forklift.konveyor.io/v1beta1
kind: NetworkMap
metadata:
name: vsphere-network-map
namespace: default
spec:
map:
- destination:
type: pod
source:
id: dvportgroup-13025
provider:
destination:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: local
namespace: default
source:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: vmware-source
namespace: default
```
##### create a storage map defining mapping of vmware storage to Harvester storage class
```yaml
apiVersion: forklift.konveyor.io/v1beta1
kind: StorageMap
metadata:
name: vsphere-storage-map
namespace: default
spec:
map:
- destination:
storageClass: longhorn-v2
source:
id: datastore-2040
provider:
destination:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: local
namespace: default
source:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: vmware-source
namespace: default
```
##### create a migration plan which details steps be carried out for a list of VMs in question
```yaml
apiVersion: forklift.konveyor.io/v1beta1
kind: Plan
metadata:
annotations:
populatorLabels: 'True'
name: vmware-gm-ubuntu-test-migration
namespace: default
spec:
map:
network:
apiVersion: forklift.konveyor.io/v1beta1
kind: NetworkMap
name: vsphere-network-map
namespace: default
storage:
apiVersion: forklift.konveyor.io/v1beta1
kind: StorageMap
name: vsphere-storage-map
namespace: default
migrateSharedDisks: true
provider:
destination:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: local
namespace: default
source:
apiVersion: forklift.konveyor.io/v1beta1
kind: Provider
name: vmware-source
namespace: default
targetNamespace: default
vms:
- id: vm-13007
name: gm-ubuntu-test
```
##### trigger the vm migration
```yaml
kind: Migration
metadata:
name: vmware-gm-ubuntu-test-migration
namespace: default
spec:
plan:
name: vmware-gm-ubuntu-test-migration
namespace: default
```
Once the migration is successful the VM's listed in the plan will have been migrated to the Harvester
### API changes
There are no API changes in core Harvester itself.
Enabling the Addon will create the following CRDs on the Harvester cluster:
* forkliftcontrollers.forklift.konveyor.io
* hooks.forklift.konveyor.io
* hosts.forklift.konveyor.io
* migrations.forklift.konveyor.io
* networkmaps.forklift.konveyor.io
* openstackvolumepopulators.forklift.konveyor.io
* ovirtvolumepopulators.forklift.konveyor.io
* plans.forklift.konveyor.io
* providers.forklift.konveyor.io
* storagemaps.forklift.konveyor.io
* vspherexcopyvolumepopulators.forklift.konveyor.io
## Design
### Implementation Overview
Implementation requires two main tasks:
* repackaging of forklift components using SLE BCL base images
* packaging of forklift-operator as a helm chart. Upstream this is managed by [OLM](https://olm.operatorframework.io/)
We are going to skip the introduction of OLM to Harvester as we have our own AppCo and Helm based component delivery mechanism. OLM ties will with `operator-sdk` which we do not leverage, as core Harvester capability leverages the rancher wrangler framework.
The [repo](https://github.com/ibrokethecloud/forklift-packaging) already contains the tooling to repackage and publish all forklift components using SLE BCL or Opensuse images.
The forklift addon will be leveraging the forklift-operator, which will be packaged as a vanilla helm chart. This will allow users to enable / disable the addon as per their needs.
Once the addon is enabled, the users will need to create a `ForkliftController` CR to trigger the installation of forklift components
```yaml
apiVersion: forklift.konveyor.io/v1beta1
kind: ForkliftController
metadata:
name: forklift-controller
namespace: forklift
spec:
feature_ui_plugin: "false"
```
The creation of the `ForkliftController` CR will trigger the following deployments
```
NAME READY UP-TO-DATE AVAILABLE AGE
forklift-api 1/1 1 1 10d
forklift-controller 1/1 1 1 10d
forklift-operator-forklift-operator-ansible 1/1 1 1 10d
forklift-validation 1/1 1 1 10d
forklift-volume-populator-controller 1/1 1 1 10d
```
#### harvester-webhook changes
Forklift controller runs the migration pod with the default seccomp profile. This stops the actual virt-v2v converstion from completing successfully. The Harvester webhook needs a change to patch the migration pod to provide it priveleged permissions to allow successful conversion.
The harvester-webhook will also patch the migration pod's `ImagePullPolicy` from `Always` to `IfNotPresent` to ensure simpler operation in air-gapped environments.
### Test plan
* Enable forklift addon
* Create a `ForkliftController` CR
* Once forklift components have been installed, trigger a VM migration from vmware to forklift by using the sample available earlier in the HEP
* The VM should migrate successfully
### Upgrade strategy
No specific upgrade enhancement is needed. The addon will be upgrade as part of the Harvester upgrade process.
## Note [optional]
* forklift-operator needs [cert-manager ](https://cert-manager.io/). Users will need to manually install cert-manager from `AppCo` or upstream to ensure the forklift components can be initialised successfully by the operator
* forklift leverages [vddk images](https://docs.redhat.com/en/documentation/migration_toolkit_for_virtualization/2.0/html/installing_and_using_the_migration_toolkit_for_virtualization/installing-mtv_mtv#adding-vddk-to-mtv_mtv) which is a proprietary package. We cannot packaging this in our distribution, and users will need to manually build and host this image in their environment to leverage vddk based migration capabilities
* QA has no way to validate vmware-xcopy based plugin capabilities. We may need to leverage vendor relationships to QA and validate the same
* Forklift also introduces [OPA](https://v0-12-2--opa-docs.netlify.app/kubernetes-admission-control/) to run its validation layer.
* A mutating webhook is needed in [harvester-webhook](https://github.com/harvester/harvester/pull/9935) to mutate virt-v2v pods being created by forklift to have privileged escalation. This is to ensure passt interface can initialise successfully inside the pod, to allow for subsequent vm conversion.

View File

@ -47,6 +47,13 @@ import {
VM_IMPORT_SOURCE_O_STATUS, VM_IMPORT_SOURCE_O_STATUS,
VM_IMPORT_SOURCE_OVA_URL, VM_IMPORT_SOURCE_OVA_URL,
VM_IMPORT_SOURCE_OVA_STATUS, 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'; } from './table-headers';
import { ADD_ONS } from './harvester-map'; import { ADD_ONS } from './harvester-map';
import { registerAddonSideNav } from '../utils/dynamic-nav'; import { registerAddonSideNav } from '../utils/dynamic-nav';
@ -1319,4 +1326,168 @@ export function init($plugin, store) {
exact: false, exact: false,
ifHaveType: HCI.HOST_NETWORK_CONFIG, ifHaveType: HCI.HOST_NETWORK_CONFIG,
}); });
// ===========================================================================
// Forklift Addon UI Flow
// ===========================================================================
weightGroup('forklift', 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,
resourceDetail: HCI.FORKLIFT_PROVIDER,
resourceEdit: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
});
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.forklift.labels.provider',
group: 'forklift',
namespaced: true,
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,
resourceDetail: HCI.FORKLIFT_NETWORK_MAP,
resourceEdit: 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.forklift.labels.networkMap',
group: 'forklift',
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,
resourceDetail: HCI.FORKLIFT_STORAGE_MAP,
resourceEdit: 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.forklift.labels.storageMap',
group: 'forklift',
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,
resourceDetail: HCI.FORKLIFT_PLAN,
resourceEdit: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
});
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.forklift.labels.plan',
group: 'forklift',
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,
resourceDetail: HCI.FORKLIFT_MIGRATION,
resourceEdit: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.forklift.labels.migration',
group: 'forklift',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
// Enable SideNav based on Forklift Addon Status
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'forklift',
types: [
HCI.FORKLIFT_PROVIDER,
HCI.FORKLIFT_NETWORK_MAP,
HCI.FORKLIFT_STORAGE_MAP,
HCI.FORKLIFT_PLAN,
HCI.FORKLIFT_MIGRATION,
]
});
// ===========================================================================
} }

View File

@ -79,6 +79,7 @@ export const ADD_ONS = {
VM_IMPORT_CONTROLLER: 'vm-import-controller', VM_IMPORT_CONTROLLER: 'vm-import-controller',
LVM_DRIVER: 'lvm.driver.harvesterhci.io', LVM_DRIVER: 'lvm.driver.harvesterhci.io',
KUBEOVN_OPERATOR: 'kubeovn-operator', KUBEOVN_OPERATOR: 'kubeovn-operator',
FORKLIFT_OPERATOR: 'forklift-operator',
}; };
export const CSI_SECRETS = { export const CSI_SECRETS = {
@ -92,6 +93,8 @@ export const CSI_SECRETS = {
CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace' CSI_NODE_EXPAND_SECRET_NAMESPACE: 'csi.storage.k8s.io/node-expand-secret-namespace'
}; };
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
// Some harvester CRD type is not equal to model file name, define the mapping here // Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = { export const HARVESTER_CRD_MAP = {
node: HCI.HOST, node: HCI.HOST,

View File

@ -230,3 +230,70 @@ export const VM_IMPORT_SOURCE_OVA_STATUS = {
sort: 'status.status', sort: 'status.status',
align: 'left', 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.forkliftProviderType',
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.forkliftProviderUrl',
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.forkliftMapSourceProvider',
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.forkliftMapDestProvider',
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.forkliftPlanTargetNs',
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.forkliftPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'center',
};
// Plan reference column in forklift.konveyor.io.migration list page
export const FORKLIFT_MIGRATION_PLAN = {
name: 'plan',
labelKey: 'harvester.tableHeaders.forkliftMigrationPlan',
value: 'spec.plan.name',
sort: 'spec.plan.name',
align: 'left',
};

View File

@ -0,0 +1,119 @@
<script>
import CruResource from '@shell/components/CruResource';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { HCI } from '../types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftMigration',
emits: ['update:value'],
components: {
CruResource,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allPlans = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
},
data() {
if (!this.value.spec) {
this.value.spec = { plan: {} };
}
return {
allPlans: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
planOptions() {
return this.allPlans.map((p) => ({
label: p.metadata.name,
value: p.metadata.name,
}));
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid && !!this.value.spec.plan?.name;
}
},
methods: {
updatePlan(val) {
const plan = this.allPlans.find((p) => p.metadata.name === val);
this.value.spec.plan = {
name: val,
namespace: plan?.metadata?.namespace || this.value.metadata.namespace || 'default',
};
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<div class="row mb-20 mt-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.plan.name"
:options="planOptions"
:label="t('harvester.addons.forklift.fields.plan')"
:mode="mode"
required
@update:value="updatePlan"
/>
</div>
</div>
</CruResource>
</template>

View File

@ -0,0 +1,199 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftNetworkMap',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: [{ source: { id: '' }, destination: { type: 'pod' } }],
provider: {
source: {},
destination: {},
},
};
}
return {
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
destinationTypeOptions() {
return [
{ label: this.t('harvester.addons.forklift.fields.destNetworkPod'), value: 'pod' },
{ label: this.t('harvester.addons.forklift.fields.destNetworkMultus'), value: 'multus' },
];
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addMapping() {
this.value.spec.map.push({ source: { id: '' }, destination: { type: 'pod' } });
},
removeMapping(idx) {
this.value.spec.map.splice(idx, 1);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.networkMappings')"
:weight="2"
>
<div
v-for="(entry, idx) in value.spec.map"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="entry.source.id"
:label="t('harvester.addons.forklift.fields.sourceNetworkId')"
:placeholder="t('harvester.addons.forklift.placeholders.sourceNetworkId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="entry.destination.type"
:options="destinationTypeOptions"
:label="t('harvester.addons.forklift.fields.destNetworkType')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.map.length <= 1"
@click="removeMapping(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addMapping"
>
{{ t('harvester.addons.forklift.actions.addMapping') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -0,0 +1,303 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { FORKLIFT_API_VERSION } from '../config/harvester-map';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftPlan',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
Checkbox,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
this.allNetworkMaps = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
this.allStorageMaps = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: {
network: {},
storage: {},
},
provider: {
source: {},
destination: {},
},
targetNamespace: 'default',
migrateSharedDisks: true,
vms: [{ id: '', name: '' }],
};
}
if (!this.value.metadata.annotations) {
this.value.metadata.annotations = {};
}
this.value.metadata.annotations.populatorLabels = 'True';
return {
allProviders: [],
allNetworkMaps: [],
allStorageMaps: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.targetNamespace', rules: ['targetNsRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
providerOptions() {
return this.allProviders.map((p) => ({
label: `${ p.metadata.name } (${ p.spec?.type || 'unknown' })`,
value: p.metadata.name,
}));
},
networkMapOptions() {
return this.allNetworkMaps.map((m) => ({
label: m.metadata.name,
value: m.metadata.name,
}));
},
storageMapOptions() {
return this.allStorageMaps.map((m) => ({
label: m.metadata.name,
value: m.metadata.name,
}));
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
targetNsRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.targetNamespace') }) : undefined,
};
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addVm() {
this.value.spec.vms.push({ id: '', name: '' });
},
removeVm(idx) {
this.value.spec.vms.splice(idx, 1);
},
updateNetworkMap(val) {
this.value.spec.map.network = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: val,
namespace: this.value.metadata.namespace || 'default',
};
},
updateStorageMap(val) {
this.value.spec.map.storage = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: val,
namespace: this.value.metadata.namespace || 'default',
};
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="4"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.mappings')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.map.network.name"
:options="networkMapOptions"
:label="t('harvester.addons.forklift.fields.networkMap')"
:mode="mode"
required
@update:value="updateNetworkMap"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.map.storage.name"
:options="storageMapOptions"
:label="t('harvester.addons.forklift.fields.storageMap')"
:mode="mode"
required
@update:value="updateStorageMap"
/>
</div>
</div>
</Tab>
<Tab
name="vms"
:label="t('harvester.addons.forklift.titles.vms')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.targetNamespace"
:label="t('harvester.addons.forklift.fields.targetNamespace')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.targetNamespace')"
required
/>
</div>
<div class="col span-6">
<Checkbox
v-model:value="value.spec.migrateSharedDisks"
:label="t('harvester.addons.forklift.fields.migrateSharedDisks')"
:mode="mode"
/>
</div>
</div>
<div
v-for="(vm, idx) in value.spec.vms"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="vm.id"
:label="t('harvester.addons.forklift.fields.vmId')"
:placeholder="t('harvester.addons.forklift.placeholders.vmId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledInput
v-model:value="vm.name"
:label="t('harvester.addons.forklift.fields.vmName')"
:placeholder="t('harvester.addons.forklift.placeholders.vmName')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.vms.length <= 1"
@click="removeVm(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addVm"
>
{{ t('harvester.addons.forklift.actions.addVm') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -0,0 +1,346 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
const PROVIDER_TYPES = [
{ labelKey: 'harvester.addons.forklift.fields.providerTypeVsphere', value: 'vsphere' },
{ labelKey: 'harvester.addons.forklift.fields.providerTypeOpenstack', value: 'openstack' },
{ labelKey: 'harvester.addons.forklift.fields.providerTypeOpenshift', value: 'openshift' },
];
export default {
name: 'EditForkliftProvider',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
Checkbox,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) {
this.value.spec = {
type: 'vsphere',
url: '',
secret: {},
};
}
const initialMode = this.value.spec.secret?.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '',
skipTlsVerify: true,
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.url', rules: ['urlRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
providerTypes() {
return PROVIDER_TYPES.map((pt) => ({
label: this.t(pt.labelKey),
value: pt.value,
}));
},
authModeOptions() {
return [
{ label: this.t('harvester.addons.forklift.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.forklift.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
urlRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.url') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
// Local openshift provider doesn't need auth or URL
if (this.value.spec.type === 'openshift') {
return true;
}
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) {
return false;
}
} else if (!this.value.spec.secret?.name) {
return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.password') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.selectSecret') }) : undefined;
},
async saveProvider(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
// Local openshift provider uses empty secret
if (this.value.spec.type === 'openshift') {
this.value.spec.secret = {};
this.value.spec.url = '';
await this.save(buttonCb);
return;
}
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: this.value.spec.type,
createdForResourceType: 'providers',
}
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(this.newUsername),
password: btoa(this.newPassword),
insecureSkipVerify: btoa(String(this.skipTlsVerify)),
url: btoa(this.value.spec.url),
};
if (this.newCaCert) {
newSecret.data.cacert = btoa(this.newCaCert);
}
await newSecret.save();
this.value.spec.secret = {
name: secretName,
namespace,
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveProvider"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.forklift.titles.basic')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.type"
:options="providerTypes"
:label="t('harvester.addons.forklift.fields.providerType')"
:mode="mode"
required
/>
</div>
<div
v-if="value.spec.type !== 'openshift'"
class="col span-6"
>
<LabeledInput
v-model:value="value.spec.url"
:label="t('harvester.addons.forklift.fields.url')"
:placeholder="t('harvester.addons.forklift.placeholders.url')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.url')"
required
/>
</div>
</div>
</Tab>
<Tab
v-if="value.spec.type !== 'openshift'"
name="auth"
:label="t('harvester.addons.forklift.titles.auth')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.forklift.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.forklift.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.forklift.fields.caCert')"
:placeholder="t('harvester.addons.forklift.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.forklift.fields.insecureSkipVerify')"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.secret.name"
:options="secretOptions"
:label="t('harvester.addons.forklift.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -0,0 +1,208 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { STORAGE_CLASS } from '@shell/config/types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftStorageMap',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
try {
this.allStorageClasses = await this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch {
this.allStorageClasses = [];
}
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: [{ source: { id: '' }, destination: { storageClass: '' } }],
provider: {
source: {},
destination: {},
},
};
}
return {
allStorageClasses: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
storageClassOptions() {
return this.allStorageClasses.map((sc) => ({
label: sc.metadata.name,
value: sc.metadata.name,
}));
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addMapping() {
this.value.spec.map.push({ source: { id: '' }, destination: { storageClass: '' } });
},
removeMapping(idx) {
this.value.spec.map.splice(idx, 1);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.storageMappings')"
:weight="2"
>
<div
v-for="(entry, idx) in value.spec.map"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="entry.source.id"
:label="t('harvester.addons.forklift.fields.sourceDatastoreId')"
:placeholder="t('harvester.addons.forklift.placeholders.sourceDatastoreId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="entry.destination.storageClass"
:options="storageClassOptions"
:label="t('harvester.addons.forklift.fields.destStorageClass')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.map.length <= 1"
@click="removeMapping(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addMapping"
>
{{ t('harvester.addons.forklift.actions.addMapping') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -22,6 +22,7 @@ nav:
Logging: Logging Logging: Logging
'Monitoring and Logging': Monitoring and Logging 'Monitoring and Logging': Monitoring and Logging
vmimport: Virtual Machine Imports vmimport: Virtual Machine Imports
forklift: Forklift Migration
resourceTable: resourceTable:
groupBy: groupBy:
@ -352,6 +353,13 @@ harvester:
v4ip: V4 IP v4ip: V4 IP
v6ip: V6 IP v6ip: V6 IP
eipName: EIP Name eipName: EIP Name
forkliftProviderType: Type
forkliftProviderUrl: URL
forkliftMapSourceProvider: Source Provider
forkliftMapDestProvider: Destination Provider
forkliftPlanTargetNs: Target Namespace
forkliftPlanVmCount: VMs
forkliftMigrationPlan: Plan
tab: tab:
volume: Volumes volume: Volumes
network: Networks network: Networks
@ -1799,6 +1807,61 @@ harvester:
'harvester-csi-driver-lvm': harvester-csi-driver-lvm is an add-on allowing users to create PVC through the LVM with local devices. '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.' 'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
forklift:
labels:
provider: Providers
networkMap: Network Maps
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
titles:
basic: Basic
auth: Authentication
providers: Providers
networkMappings: Network Mappings
storageMappings: Storage Mappings
mappings: Mappings
vms: Virtual Machines
fields:
providerType: Provider Type
providerTypeVsphere: vSphere
providerTypeOpenstack: OpenStack
providerTypeOpenshift: OpenShift / Harvester (Local)
url: URL
username: Username
password: Password
caCert: CA Certificate (PEM)
insecureSkipVerify: Skip TLS Certificate Verification
selectSecret: Select Secret
createSecret: Create New Credentials
useSecret: Use Existing Secret
sourceProvider: Source Provider
destProvider: Destination Provider
sourceNetworkId: Source Network ID
destNetworkType: Destination Network Type
destNetworkPod: Pod Network
destNetworkMultus: Multus
sourceDatastoreId: Source Datastore ID
destStorageClass: Destination Storage Class
networkMap: Network Map
storageMap: Storage Map
targetNamespace: Target Namespace
migrateSharedDisks: Migrate Shared Disks
vmId: VM ID
vmName: VM Name
plan: Migration Plan
placeholders:
url: "e.g. https://vcenter/sdk"
caCert: "-----BEGIN CERTIFICATE----- ..."
sourceNetworkId: "e.g. dvportgroup-13025"
sourceDatastoreId: "e.g. datastore-2040"
vmId: "e.g. vm-13007"
vmName: "e.g. my-vm"
actions:
addMapping: Add Mapping
addVm: Add VM
remove: Remove
vmImport: vmImport:
titles: titles:
basic: Basic basic: Basic
@ -2434,3 +2497,28 @@ typeLabel:
one { Virtual Machine Import } one { Virtual Machine Import }
other { Virtual Machine Imports } 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 }
}

View File

@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_MIGRATION,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_MIGRATION,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_MIGRATION },
};
export default {
name: 'HarvesterForkliftMigration',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_MIGRATION);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_MIGRATION, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_NETWORK_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_NETWORK_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
};
export default {
name: 'HarvesterForkliftNetworkMap',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_NETWORK_MAP);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_NETWORK_MAP, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
export default {
name: 'HarvesterForkliftPlan',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_PLAN);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_PLAN, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
export default {
name: 'HarvesterForkliftProvider',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_PROVIDER);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_PROVIDER, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -0,0 +1,60 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_STORAGE_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_STORAGE_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_STORAGE_MAP },
};
export default {
name: 'HarvesterForkliftStorageMap',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_STORAGE_MAP);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_STORAGE_MAP, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -0,0 +1,47 @@
import { FORKLIFT_API_VERSION } from '../config/harvester-map';
/**
* Shared mixin for Forklift edit pages that need to select
* source and destination providers from available Provider CRs.
*
* Components using this mixin must load providers in their own fetch():
* this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
*/
export default {
data() {
return { allProviders: [] };
},
computed: {
providerOptions() {
return this.allProviders.map((p) => ({
label: `${ p.metadata.name } (${ p.spec?.type || 'unknown' })`,
value: p.metadata.name,
}));
},
},
methods: {
/**
* Build a Forklift object reference for a Provider.
*/
buildProviderRef(name) {
const provider = this.allProviders.find((p) => p.metadata.name === name);
return {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name,
namespace: provider?.metadata?.namespace || this.value.metadata.namespace || 'default',
};
},
updateSourceProvider(val) {
this.value.spec.provider.source = this.buildProviderRef(val);
},
updateDestProvider(val) {
this.value.spec.provider.destination = this.buildProviderRef(val);
},
},
};

View File

@ -75,7 +75,12 @@ export const HCI = {
VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource', VMIMPORT_SOURCE_OVA: 'migration.harvesterhci.io.ovasource',
VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport', VMIMPORT: 'migration.harvesterhci.io.virtualmachineimport',
MIGRATION: 'migration.harvesterhci.io', 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'; export const VOLUME_SNAPSHOT = 'snapshot.storage.k8s.io.volumesnapshot';