Compare commits

...
Author SHA1 Message Date
Andy Lee 66aedf00d4 build: artifact2
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:37:39 +08:00
Andy Lee fbb4b7b41b refactor: copilot review
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:18:22 +08:00
Andy Lee 3817269c0a fix: bugs in VM selection table
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:18:22 +08:00
Andy Lee facddcb9a7 feat: add selected all | clear all actions for selected VM
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:18:21 +08:00
Andy Lee 7db6c81c5c feat: add access/ volume mode model
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:18:21 +08:00
Andy Lee 74ef7b3772 fix: VM migration tab not appear when first login
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 17:18:21 +08:00
Andy Lee b87f367583 fix: prevent VM detail page hang when static IP assigned (#1072)
Make syncStaticIpAnnotations idempotent so it no longer mutates VM
annotations when already in sync, breaking a reactive update loop that
froze the browser on the VM detail page. Also stack multiple IP
addresses vertically in the IP Address column.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-31 16:55:25 +08:00
Andy Lee 92aa8ec603 ci: disable footer-max-line-length rule (#1069)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-29 14:46:07 +08:00
Alejandro BonillaandVolker Theile e013534373 feat: allow Cloud Config for Windows guests (Cloudbase-Init) (#984)
* feat(vm): allow Cloud Config for Windows guests

Currently the Cloud Config editor is completely hidden when a VM's
OS Type is set to Windows, and no `cloudinitdisk` volume or secret
is written to the VM spec even if the user has entered content.

This blocks a common Windows-on-Harvester workflow: using
Cloudbase-Init to run first-boot configuration (install VMDP,
enable OpenSSH server, add authorized_keys, join a domain, run
`runcmd` steps, etc.). Users on Windows today have to hand-edit the
VM YAML after creation to attach a cloudinitdisk, which is fragile
and undiscoverable.

Changes:
- Remove the `v-if="!isWindows"` on the CloudConfig component so
  the editor is available for every OS type. The Sysprep editor
  continues to render only for Windows, so Windows now gets both
  panels; Linux is unchanged.
- Drop the `if (!this.isWindows)` guard around the cloudinitdisk
  volume serialization so the disk is attached whenever the user
  provided user-data or network-data, regardless of OS.
- Drop the `|| this.isWindows` early-return in `saveSecret` so the
  cloud-init secret is persisted for Windows VMs too.
- `getInitUserData` now returns a bare `#cloud-config\n` header for
  Windows instead of the Linux qemu-guest-agent runcmd template
  (which would fail on Cloudbase-Init). VMDP installs the QGA on
  Windows as a native service, so the runcmd path is not needed.

The "Install guest agent" checkbox stays disabled for Windows
because it specifically drives the Linux QGA-via-runcmd recipe.

Fixes point 2 of harvester/harvester#11124.

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>

* fix(vm): do not clear cloud-config on isWindows watcher fire

The isWindows watcher runs on every change of the isWindows computed
property, including on mount when editing an existing Windows VM. Setting
this['userScript'] and this['networkScript'] to undefined at that point
wipes the cloud-config data that was just loaded from the existing VM's
cloudinit secret, so the Advanced -> Cloud Config editor shows empty
even though the VM has data on the wire.

Remove the two clears - Cloud Config is now supported for Windows guests
by this PR, so there is no reason to unset the user/network scripts when
the OS type is Windows. sshKey and installAgent remain gated (SSH keys
are injected via cloud-init on Linux only, and the qemu-guest-agent
package install checkbox does not apply to Windows).

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>

* refactor(vm): Get CloudInit working for VM templates

- Remove unnecessary osType watcher in cloud config. The watcher (see `pkg/harvester/edit/kubevirt.io.virtualmachine/VirtualMachineCloudConfig/DataTemplate.vue`) that cleared the cloud config template for Windows VMs is no longer necessary.
- Automatically strip the CloudInit `User Data` properties `package_update` and `packages` for OS type `Windows`.

Signed-off-by: Volker Theile <vtheile@suse.com>

---------

Signed-off-by: Alejandro Bonilla <abonilla@suse.com>
Signed-off-by: Volker Theile <vtheile@suse.com>
Co-authored-by: Volker Theile <vtheile@suse.com>
2026-07-28 11:32:30 +02:00
Andy Lee d23c3d3b72 revert: PR build artifact (#1066)
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 15:39:20 +08:00
14 changed files with 611 additions and 113 deletions
@@ -6,6 +6,7 @@ on:
- main
- 'release-harvester-v*'
- '*-dev'
- access_volume_mode_demo
pull_request:
branches:
- main
@@ -13,23 +14,13 @@ on:
- '*-dev'
types:
- merged
- labeled
jobs:
build-validation:
name: Build Test
# allow triggering when a PR is labeled with "build-artifact"
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/run-lint.yaml
build:
name: Build and Upload Package
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/build-and-publish-standalone.yaml
needs:
- build-validation
@@ -38,4 +29,4 @@ jobs:
packages: write
id-token: write
with:
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
CI_BRANCH: ${{github.ref_name}}
+1 -1
View File
@@ -36,7 +36,7 @@ module.exports = {
'body-leading-blank': [2, 'always'],
'body-max-line-length': [0, 'always', 100],
'footer-leading-blank': [2, 'always'],
'footer-max-line-length': [2, 'always', 100],
'footer-max-line-length': [0, 'always', 100],
},
// Ignore merge commits and revert commits
ignores: [
@@ -7,10 +7,15 @@ import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import { isInternalStorageClass } from '../../utils/storage-class';
import MappingColumn from './MappingColumn.vue';
import StorageDefaultsModal from './StorageDefaultsModal.vue';
const DEFAULT_VOLUME_MODE = VOLUME_MODE.FILE_SYSTEM;
const DEFAULT_ACCESS_MODES = [ACCESS_MODE.READ_WRITE_MANY];
const props = defineProps({
providerName: { type: String, default: '' },
@@ -35,6 +40,10 @@ const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
// Storage defaults edit modal state.
const showStorageDefaultsModal = ref(false);
const editingStorageEntry = ref(null);
const { networkEntries, storageEntries } = toRefs(props.stepData);
const NAMESPACE = FORKLIFT_NAMESPACE;
@@ -105,7 +114,7 @@ const applyNetworkMapTargets = (mapSpec) => {
});
};
const applyStorageMapTargets = (mapSpec) => {
const applyStorageMapTargets = (mapSpec, { markOverridden = false, captureInherited = false } = {}) => {
if (!mapSpec) {
return;
}
@@ -117,6 +126,30 @@ const applyStorageMapTargets = (mapSpec) => {
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
if (captureInherited) {
entry.inheritedFromProvider = true;
}
if (match.destination.volumeMode) {
entry.volumeMode = match.destination.volumeMode;
if (captureInherited) {
entry.inheritedVolumeMode = match.destination.volumeMode;
}
}
if (match.destination.accessMode) {
entry.accessModes = [match.destination.accessMode];
if (captureInherited) {
entry.inheritedAccessModes = [match.destination.accessMode];
}
}
if (markOverridden) {
entry.overridden = true;
}
}
});
};
@@ -134,12 +167,39 @@ const applyDefaultStorageMap = () => {
(sm) => sm.metadata.name === `${ props.providerName }-storage-map-default`
);
applyStorageMapTargets(defaultMap?.spec?.map);
applyStorageMapTargets(defaultMap?.spec?.map, { captureInherited: true });
};
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
// The "Inherited from provider" hint only appears in the migration plan wizard
// (where mappings inherit from the provider default), not on the provider creation page.
const inheritedProviderName = computed(() => (props.useAllProviderData ? '' : props.providerName));
const openStorageDefaults = (entry) => {
editingStorageEntry.value = entry;
showStorageDefaultsModal.value = true;
};
const closeStorageDefaults = () => {
showStorageDefaultsModal.value = false;
editingStorageEntry.value = null;
};
const applyStorageDefaults = ({ volumeMode, accessModes }) => {
if (editingStorageEntry.value) {
editingStorageEntry.value.volumeMode = volumeMode;
editingStorageEntry.value.accessModes = accessModes;
const inheritedVolumeMode = editingStorageEntry.value.inheritedVolumeMode;
const inheritedAccessMode = editingStorageEntry.value.inheritedAccessModes?.[0];
const selectedAccessMode = accessModes?.[0];
editingStorageEntry.value.overridden = volumeMode !== inheritedVolumeMode || selectedAccessMode !== inheritedAccessMode;
}
};
const canSave = computed(() => {
if (props.useAllProviderData) {
return true;
@@ -202,13 +262,19 @@ const buildStorageEntries = () => {
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id }`,
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
@@ -238,13 +304,19 @@ const buildNetworkEntriesFromProvider = (networksData) => {
const buildStorageEntriesFromProvider = (datastoresData) => {
storageEntries.value = (Array.isArray(datastoresData) ? datastoresData : []).map((ds) => ({
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
name: ds.name || ds.id,
id: ds.id || '',
type: ds.type || '',
capacity: ds.capacity || 0,
target: '',
volumeMode: DEFAULT_VOLUME_MODE,
accessModes: [...DEFAULT_ACCESS_MODES],
inheritedVolumeMode: DEFAULT_VOLUME_MODE,
inheritedAccessModes: [...DEFAULT_ACCESS_MODES],
inheritedFromProvider: false,
overridden: false,
usedBy: [],
_key: `stor-${ ds.id || ds.name }`,
}));
};
@@ -461,7 +533,7 @@ const init = async() => {
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map);
applyStorageMapTargets(props.existingStorageMap.spec.map, { markOverridden: true });
} else {
applyDefaultStorageMap();
}
@@ -511,6 +583,9 @@ init();
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
:show-volume-settings="true"
:inherited-provider-name="inheritedProviderName"
@edit-defaults="openStorageDefaults"
>
<template #source-detail="{ entry }">
<span
@@ -520,6 +595,19 @@ init();
</template>
</MappingColumn>
</div>
<StorageDefaultsModal
v-if="showStorageDefaultsModal && editingStorageEntry"
:storage-class-name="editingStorageEntry.target"
:provider-name="providerName"
:show-inherited="!!inheritedProviderName"
:volume-mode="editingStorageEntry.volumeMode"
:access-modes="editingStorageEntry.accessModes"
:inherited-volume-mode="editingStorageEntry.inheritedVolumeMode"
:inherited-access-modes="editingStorageEntry.inheritedAccessModes"
@apply="applyStorageDefaults"
@close="closeStorageDefaults"
/>
</div>
</template>
@@ -3,20 +3,27 @@ import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE } from '../../config/types';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
// Storage-specific: show the volume/access mode defaults row with an Edit action.
showVolumeSettings: { type: Boolean, default: false },
// When set, the defaults row shows an "Inherited from provider" hint (migration plan wizard only).
inheritedProviderName: { type: String, default: '' },
});
const emit = defineEmits(['edit-defaults']);
// Only offer "Remove Map" for entries that already have a target selected;
// entries without a selection just show the regular options.
const optionsFor = (entry) => {
@@ -38,6 +45,13 @@ const optionsFor = (entry) => {
...props.options,
];
};
const formatModes = (entry) => {
const volumeMode = entry.volumeMode || VOLUME_MODE.FILE_SYSTEM;
const accessModes = (entry.accessModes || []).join(', ');
return t('harvester.addons.vmMigration.storageDefaults.summary', { volumeMode, accessModes });
};
</script>
<template>
@@ -87,6 +101,29 @@ const optionsFor = (entry) => {
/>
</div>
</div>
<div
v-if="showVolumeSettings && entry.target"
class="storage-defaults-row"
>
<div class="storage-defaults">
<div class="storage-defaults-info">
<span class="storage-defaults-summary">{{ formatModes(entry) }}</span>
<span
v-if="inheritedProviderName && entry.inheritedFromProvider && !entry.overridden"
class="text-deemphasized storage-defaults-inherited"
>
{{ t('harvester.addons.vmMigration.storageDefaults.inherited', { provider: inheritedProviderName }) }}
</span>
</div>
<a
role="button"
class="storage-defaults-edit"
@click.prevent="emit('edit-defaults', entry)"
>
{{ t('harvester.addons.vmMigration.storageDefaults.edit') }}
</a>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
{{ t('harvester.addons.vmMigration.generic.usedBy') }} <b>{{ entry.usedBy.join(', ') }}</b>
@@ -173,6 +210,41 @@ const optionsFor = (entry) => {
line-height: 20px;
}
.storage-defaults-row {
width: 100%;
}
.storage-defaults {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-radius: 4px;
background-color: var(--body-bg);
.storage-defaults-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.storage-defaults-summary {
font-size: 13px;
line-height: 20px;
}
.storage-defaults-inherited {
font-size: 12px;
line-height: 18px;
}
.storage-defaults-edit {
flex-shrink: 0;
cursor: pointer;
}
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
@@ -25,8 +25,9 @@ const { discoveredVMs, selectedVMIds, tableRows } = toRefs(props.stepData);
const selectedVMs = ref([]);
const loading = ref(true);
const networkMap = ref({});
const datastoreMap = ref({});
// Restored from stepData so friendly names survive step navigation / remount.
const networkMap = ref(props.stepData.networkMap || {});
const datastoreMap = ref(props.stepData.datastoreMap || {});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const errors = ref([]);
@@ -94,7 +95,7 @@ const showSelectAllBanner = computed(() => {
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = [
const headers = ref([
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
@@ -139,7 +140,7 @@ const headers = [
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
];
]);
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
@@ -190,6 +191,7 @@ const buildTableRows = () => {
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
selectedSort: selectedVMIds.value.has(vm.id) ? 0 : 1,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
@@ -239,6 +241,10 @@ const clearSelection = () => {
if (table) {
table.clearSelection();
if (typeof table.setPage === 'function') {
table.setPage(1);
}
}
});
};
@@ -249,6 +255,49 @@ const selectAllVMs = () => {
selectedVMs.value = discoveredVMs.value.slice();
};
// Moves currently-selected VMs to the first page(s) and re-checks them.
const sortSelectedToFront = () => {
if (selectedVMIds.value.size === 0) {
return;
}
tableRows.value.forEach((row) => {
row.selectedSort = selectedVMIds.value.has(row._original?.id) ? 0 : 1;
});
headers.value = headers.value.map((h) => (
h.name === 'vmName' ? { ...h, sort: ['selectedSort', 'vmName'] } : h
));
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
skipNextSelectionEvent = false;
return;
}
if (typeof table.changeSort === 'function') {
table.changeSort('vmName', false);
} else if (typeof table.setPage === 'function') {
table.setPage(1);
}
nextTick(() => {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
});
};
watch(
() => sortableTableRef.value?.page,
() => {
@@ -318,6 +367,9 @@ const fetchVMs = async() => {
return map;
}, {});
props.stepData.networkMap = networkMap.value;
props.stepData.datastoreMap = datastoreMap.value;
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
@@ -374,6 +426,10 @@ const init = async() => {
tableRows.value = buildTableRows();
loading.value = false;
// Returning to the step with an existing selection: bring selected VMs forward and re-check them.
await nextTick();
sortSelectedToFront();
return;
}
@@ -441,7 +497,6 @@ init();
:row-actions="false"
:groupable="false"
:paging="true"
:rows-per-page="20"
key-field="_key"
@selection="onSelect"
>
@@ -450,7 +505,20 @@ init();
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
<span class="selected-actions">
<span class="text-deemphasized">
{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}
</span>
<template v-if="selectedCount > 0">
<span class="text-deemphasized">|</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.clearAll') }}
</a>
</template>
</span>
</div>
</template>
<template #cell:vmName="{ row }">
@@ -466,7 +534,7 @@ init();
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
:color="row.powerStateClass === 'power-on' ? 'bg-success' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
@@ -533,6 +601,23 @@ init();
.text-deemphasized {
font-size: 13px;
}
.selected-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
a {
cursor: pointer;
&.disabled {
color: var(--muted);
cursor: default;
pointer-events: none;
}
}
}
}
.vm-name-cell {
@@ -0,0 +1,175 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import AppModal from '@shell/components/AppModal';
import { useI18n } from '@shell/composables/useI18n';
import { VOLUME_MODE, ACCESS_MODE } from '../../config/types';
const VOLUME_MODE_OPTIONS = [VOLUME_MODE.FILE_SYSTEM, VOLUME_MODE.BLOCK];
const ACCESS_MODE_OPTIONS = [ACCESS_MODE.READ_WRITE_ONCE, ACCESS_MODE.READ_WRITE_MANY, ACCESS_MODE.READ_ONLY_MANY];
const props = defineProps({
storageClassName: { type: String, default: '' },
providerName: { type: String, default: '' },
showInherited: { type: Boolean, default: false },
volumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
accessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
inheritedVolumeMode: { type: String, default: VOLUME_MODE.FILE_SYSTEM },
inheritedAccessModes: { type: Array, default: () => [ACCESS_MODE.READ_WRITE_MANY] },
});
const emit = defineEmits(['apply', 'close']);
const store = useStore();
const { t } = useI18n(store);
const localVolumeMode = ref(props.volumeMode || VOLUME_MODE.FILE_SYSTEM);
const localAccessMode = ref(props.accessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY);
const volumeModeOptions = VOLUME_MODE_OPTIONS.map((value) => ({ label: value, value }));
const accessModeOptions = ACCESS_MODE_OPTIONS.map((value) => ({ label: value, value }));
const apply = () => {
emit('apply', {
volumeMode: localVolumeMode.value,
accessModes: [localAccessMode.value],
});
emit('close');
};
// Repopulate the dropdowns with the provider default values; the user then applies.
const reset = () => {
localVolumeMode.value = props.inheritedVolumeMode || VOLUME_MODE.FILE_SYSTEM;
localAccessMode.value = props.inheritedAccessModes?.[0] || ACCESS_MODE.READ_WRITE_MANY;
};
const cancel = () => {
emit('close');
};
</script>
<template>
<app-modal
class="storage-defaults-modal"
name="storageDefaultsDialog"
:width="620"
height="auto"
:click-to-close="false"
@close="cancel"
>
<Card
class="storage-defaults-card"
:show-highlight-border="false"
>
<template #title>
<h4 class="text-default-text">
{{ t('harvester.addons.vmMigration.storageDefaults.title', { name: storageClassName }) }}
</h4>
</template>
<template #body>
<p class="text-deemphasized description">
<template v-if="showInherited">
{{ t('harvester.addons.vmMigration.storageDefaults.descriptionInherited', { name: storageClassName, provider: providerName }) }}
</template>
<template v-else>
{{ t('harvester.addons.vmMigration.storageDefaults.description', { name: storageClassName }) }}
</template>
</p>
<div class="settings-box">
<LabeledSelect
v-model:value="localVolumeMode"
class="volume-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.volumeMode')"
:options="volumeModeOptions"
:searchable="false"
:clearable="false"
/>
<LabeledSelect
v-model:value="localAccessMode"
class="access-mode"
:label="t('harvester.addons.vmMigration.storageDefaults.accessMode')"
:options="accessModeOptions"
:searchable="false"
:clearable="false"
/>
</div>
</template>
<template #actions>
<div class="buttons">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('harvester.addons.vmMigration.storageDefaults.cancel') }}
</button>
<div class="right-buttons">
<button
v-if="showInherited"
class="btn role-secondary mr-10"
@click="reset"
>
{{ t('harvester.addons.vmMigration.storageDefaults.resetToProviderDefault') }}
</button>
<button
class="btn role-primary"
:disabled="!localAccessMode"
@click="apply"
>
{{ showInherited ? t('harvester.addons.vmMigration.storageDefaults.applyOverride') : t('harvester.addons.vmMigration.storageDefaults.apply') }}
</button>
</div>
</div>
</template>
</Card>
</app-modal>
</template>
<style lang="scss" scoped>
.storage-defaults-modal {
z-index: 45;
}
.storage-defaults-card {
margin: 0;
padding: 20px;
}
.description {
margin-bottom: 16px;
line-height: 20px;
}
.settings-box {
display: flex;
flex-direction: column;
gap: 20px;
padding: 16px;
border-radius: 6px;
background-color: var(--category-active);
}
.volume-mode {
max-width: 280px;
}
.access-mode {
max-width: 280px;
}
.buttons {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.right-buttons {
display: flex;
}
}
</style>
@@ -68,12 +68,6 @@ export default {
},
watch: {
osType(neu) {
if (neu === 'windows') {
this.id = '';
}
},
value(neu) {
this.yamlScript = neu;
},
@@ -1062,7 +1062,6 @@ export default {
</div>
<CloudConfig
v-if="!isWindows"
ref="yamlEditor"
:user-script="userScript"
:mode="mode"
@@ -132,16 +132,28 @@ export default {
</script>
<template>
<div v-if="showIP">
<span
<div
v-if="showIP"
class="ip-list"
>
<div
v-for="{ ip, name, isCustom } in ips"
:key="`${ip}-${name}`"
class="ip-item"
>
<CopyToClipboardText
v-clean-tooltip="isCustom ? t('harvester.formatters.harvesterIpAddress.customIpTooltip') : name"
:text="ip"
:plain="isCustom"
/>
</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.ip-list {
display: flex;
flex-direction: column;
gap: 6px;
}
</style>
+14
View File
@@ -1922,6 +1922,7 @@ harvester:
refreshNow: Refresh now
availableVms: Available Virtual Machines
selected: selected
clearAll: Clear All
saveSelection: Save Selection and Continue
selectAllBanner:
pageOnly: "Your current selection includes only VMs currently shown on the page."
@@ -1982,6 +1983,19 @@ harvester:
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Harvester Storage Class
template: Use existing storage mapping as template
storageDefaults:
title: 'Storage defaults — {name}'
description: 'Set the volume mode and access modes applied to every datastore in this storage map that maps to {name}.'
descriptionInherited: 'Applies to every datastore in this storage map that maps to {name}. The provider default for {provider} stays unchanged.'
summary: 'Volume mode: {volumeMode} · Access mode: {accessModes}'
inherited: 'Inherited from provider {provider}'
volumeMode: Volume Mode
accessMode: Access Mode
edit: Edit
cancel: Cancel
apply: Apply
resetToProviderDefault: Reset to Provider Default
applyOverride: Apply Override
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
+87 -43
View File
@@ -791,18 +791,41 @@ export default {
}
const staticIpPrefix = `${ HCI_ANNOTATIONS.STATIC_IP }/`;
const annotations = vm.metadata.annotations;
Object.keys(vm.metadata.annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
delete vm.metadata.annotations[key];
}
});
const desired = {};
this.networkRows.forEach((row) => {
if (row.name && row.staticIp) {
vm.metadata.annotations[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
desired[`${ staticIpPrefix }${ row.name }`] = row.staticIp;
}
});
const current = {};
Object.keys(annotations).forEach((key) => {
if (key.startsWith(staticIpPrefix)) {
current[key] = annotations[key];
}
});
// Skip mutation when already in sync to avoid triggering a reactive update loop.
const desiredKeys = Object.keys(desired);
const currentKeys = Object.keys(current);
const isSame = desiredKeys.length === currentKeys.length &&
desiredKeys.every((key) => current[key] === desired[key]);
if (isSame) {
return;
}
currentKeys.forEach((key) => {
delete annotations[key];
});
Object.entries(desired).forEach(([key, value]) => {
annotations[key] = value;
});
},
setCPUAndMemory() {
@@ -917,32 +940,30 @@ export default {
}
if (!disks.find( (D) => D.name === 'cloudinitdisk') && (this.userData || this.networkScript)) {
if (!this.isWindows) {
disks.push({
name: 'cloudinitdisk',
disk: { bus: 'virtio' }
});
disks.push({
name: 'cloudinitdisk',
disk: { bus: 'virtio' }
});
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
};
const userData = this.getUserData({ osType: this.osType, installAgent: this.installAgent });
const cloudinitdisk = {
name: 'cloudinitdisk',
cloudInitNoCloud: {}
};
if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData;
} else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
if (this.saveUserDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.userData = userData;
} else {
cloudinitdisk.cloudInitNoCloud.secretRef = { name: this.secretName };
}
if (this.saveNetworkDataAsClearText) {
cloudinitdisk.cloudInitNoCloud.networkData = this.networkScript;
} else {
cloudinitdisk.cloudInitNoCloud.networkDataSecretRef = { name: this.secretName };
}
volumes.push(cloudinitdisk);
}
const specDisks = this.spec?.template?.spec?.domain?.devices?.disks;
@@ -1144,6 +1165,16 @@ export default {
},
getInitUserData(config) {
// Windows guests don't use qemu-guest-agent via systemd (VMDP installs
// the QGA as a Windows service), and the Linux `runcmd` recipe would
// fail on Cloudbase-Init. Return an empty string so `this.userData`
// stays falsy on Windows until the user actually enters cloud-config
// content — this prevents emitting a spurious cloudinitdisk volume
// and Secret for every Windows VM (Copilot review comment on #984).
if (config.osType === 'windows') {
return '';
}
const _QGA_JSON = this.getMatchQGA(config.osType);
const out = jsyaml.dump(_QGA_JSON);
@@ -1346,13 +1377,22 @@ export default {
*/
deleteYamlDocProp(doc, paths) {
try {
const item = doc.getIn([])?.items[0];
const key = item?.key;
const hasCloudConfigComment = !!key?.commentBefore?.includes('cloud-config');
const isMatchProp = key.source === paths[paths.length - 1];
const items = doc.getIn([])?.items;
const firstKey = items?.[0]?.key;
const hasCloudConfigComment = !!firstKey?.commentBefore?.includes('cloud-config');
const isFirstProp = firstKey?.source === paths[paths.length - 1];
if (key && hasCloudConfigComment && isMatchProp) {
// Comments are mounted on the next node and we should not delete the node containing cloud-config
if (firstKey && hasCloudConfigComment && isFirstProp) {
const comment = firstKey.commentBefore;
doc.deleteIn(paths);
// Move the comment to the new first key; if no keys remain the comment is lost.
const newFirstKey = doc.getIn([])?.items?.[0]?.key;
if (newFirstKey) {
newFirstKey.commentBefore = comment;
}
} else {
doc.deleteIn(paths);
}
@@ -1403,7 +1443,6 @@ export default {
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
userDataDoc.setIn(['packages'], []); // It needs to be set empty first, as it is possible that cloud-init comments are mounted on this node
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
@@ -1418,7 +1457,7 @@ export default {
},
deleteQGA(config) {
const { osType, userDataDoc, deletePackage = false } = config;
const { osType, userDataDoc } = config;
const userDataTemplateValue = this.$store.getters['harvester/byId'](CONFIG_MAP, this.userDataTemplateId)?.data?.cloudInit || '';
@@ -1427,6 +1466,15 @@ export default {
const packages = userDataJSON?.packages || [];
const runcmd = userDataJSON?.runcmd || [];
// Special handling of OS types.
let deletePackage = config.deletePackage ?? false;
switch (osType) {
case 'windows':
deletePackage = true;
break;
}
if (Array.isArray(packages) && deletePackage) {
const templateHasQGAPackage = this.convertToJson(userDataTemplateValue);
@@ -1452,7 +1500,6 @@ export default {
if (packages.length > 0) {
userDataDoc.setIn(['packages'], packages);
} else {
userDataDoc.setIn(['packages'], []);
this.deleteYamlDocProp(userDataDoc, ['packages']);
this.deleteYamlDocProp(userDataDoc, ['package_update']);
}
@@ -1485,7 +1532,7 @@ export default {
},
async saveSecret(vm) {
if (!vm?.spec || !this.secretName || this.isWindows) {
if (!vm?.spec || !this.secretName) {
return true;
}
@@ -1906,8 +1953,6 @@ export default {
isWindows(val) {
if (val) {
this['sshKey'] = [];
this['userScript'] = undefined;
this['networkScript'] = undefined;
this['installAgent'] = false;
}
},
@@ -1967,9 +2012,8 @@ export default {
osType(neu, old) {
this.installAgent = old === 'windows' ? true : this.installAgent;
const out = old === 'windows' ? this.getInitUserData({ osType: neu }) : this.getUserData({ installAgent: this.installAgent, osType: neu });
this.userScript = this.getUserData({ installAgent: this.installAgent, osType: neu });
this['userScript'] = out;
this.refreshYamlEditor();
},
@@ -5,6 +5,7 @@ import CruResource from '@shell/components/CruResource';
import Loading from '@shell/components/Loading';
import { SECRET } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/ConfigureProviderStep.vue';
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
@@ -163,7 +164,7 @@ const onFinish = async(buttonCb) => {
buttonCb(true);
currentRouter().push(providerListLocation);
} catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)];
errors.value = exceptionToErrorsArray(err).map((e) => (typeof e === 'string' ? e : stringify(e)));
buttonCb(false);
}
};
@@ -233,7 +234,7 @@ const init = async() => {
// Maps may not exist yet
}
} catch (err) {
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || err })];
errors.value = [t('harvester.addons.vmMigration.errors.failedLoadProvider', { error: err.message || stringify(err) })];
}
initialLoading.value = false;
+21 -11
View File
@@ -24,13 +24,14 @@ export function registerAddonSideNav(store, productName, {
const kickSideNav = () => {
const TRIGGER = 'ui.refresh.trigger';
store.dispatch('type-map/addFavorite', TRIGGER);
// SideNav component seem to ignore rapid state changes.
// Wait 600ms to ensure the toggle event triggers a re-render.
setTimeout(() => {
store.dispatch('type-map/removeFavorite', TRIGGER);
}, 600);
// Toggle the trigger a few times so an early kick (fired before the SideNav
// has mounted on first login) is retried once the component is listening.
[0, 600, 1500].forEach((delay) => {
setTimeout(() => {
store.dispatch('type-map/addFavorite', TRIGGER);
setTimeout(() => store.dispatch('type-map/removeFavorite', TRIGGER), 300);
}, delay);
});
};
const hasAccessibleSchema = (t) => {
@@ -92,16 +93,25 @@ export function registerAddonSideNav(store, productName, {
// Store is ready. Stop polling.
clearInterval(waitForStore);
// Watch the specific addon resource for changes to its enabled status.
// Watch the addon's enabled status together with the schema availability
// of the gated types. Schemas (e.g. forklift CRDs) can load after the
// addon is already enabled, so the watcher must also re-run when they
// become accessible; otherwise the menu never updates until a refresh.
store.watch(
(state, getters) => {
const addons = getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
const isEnabled = addon?.spec?.enabled === true;
return addon?.spec?.enabled === true;
const schemaReady = requireSchema ? types.every(hasAccessibleSchema) : true;
return `${ isEnabled }:${ schemaReady }`;
},
(isEnabled) => {
setMenuVisibility(isEnabled);
() => {
const addons = store.getters[`${ productName }/all`](resourceType);
const addon = addons.find((a) => a.metadata.name === addonName);
setMenuVisibility(addon?.spec?.enabled === true);
},
{ immediate: true, deep: true }
);
+17 -4
View File
@@ -73,8 +73,21 @@ export function buildNetworkMapEntries(entries = [], defaultNamespace) {
export function buildStorageMapEntries(entries = []) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
}));
.map((entry) => {
const destination = { storageClass: entry.target };
if (entry.volumeMode) {
destination.volumeMode = entry.volumeMode;
}
// Forklift StorageMap destination expects a single `accessMode` value.
if (Array.isArray(entry.accessModes) && entry.accessModes.length) {
destination.accessMode = entry.accessModes[0];
}
return {
source: { name: entry.name, id: entry.id },
destination,
};
});
}