Przeglądaj źródła

Add default migration image mappings

Makes temporary worker image mappings required and pre-selects the
default ones.

Makes all windows images disabled in source options selection dropdowns.

*Openstack - source*
- Makes the export image field required when Coriolis Backups export
mechanism is selected.

*OVM - source*
- Makes the export template field required when 'Use OVM Exporter' is
disabled.

*AWS - source*
- Makes the migration linux image map required after selecting an export
region.

*Openstack - target*, *VMWare - target*, *OVM - target*,
*OLVM - target*, *OCI - target*
- Makes the migration linux image field required.
- Makes the migration windows image field required when at least 1
migrated VM is windows.
Sergiu Miclea 4 lat temu
rodzic
commit
a09a518eec

+ 3 - 10
.vscode/launch.json

@@ -4,21 +4,14 @@
   // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
   "version": "0.2.0",
   "configurations": [
-    // Launch Chrome like this: "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
     {
-      "type": "chrome",
+      "type": "pwa-msedge",
       "request": "attach",
-      "name": "Attach to Chrome",
+      "runtimeExecutable": "stable",
+      "name": "Attach to Edge",
       "port": 9222,
       "urlFilter": "http://localhost:3001/*",
       "webRoot": "${workspaceFolder}"
     },
-    {
-      "type": "chrome",
-      "request": "launch",
-      "name": "Launch Chrome against localhost",
-      "url": "http://localhost:3001",
-      "webRoot": "${workspaceFolder}"
-    },
   ]
 }

+ 2 - 1
.vscode/settings.json

@@ -17,5 +17,6 @@
   },
   "files.eol": "\n",
   "typescript.tsdk": "node_modules/typescript/lib",
-  "typescript.preferences.importModuleSpecifier": "non-relative"
+  "typescript.preferences.importModuleSpecifier": "non-relative",
+  "debug.javascript.autoAttachFilter": "always"
 }

+ 4 - 2
src/components/modules/WizardModule/WizardOptions/WizardOptions.tsx

@@ -388,11 +388,13 @@ class WizardOptions extends React.Component<Props> {
 
     let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema()
 
-    fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => f.required))
+    const isRequired = (f: Field) => f.required || f.properties?.some(p => p.required)
+
+    fieldsSchema = fieldsSchema.concat(this.props.fields.filter(isRequired))
 
     if (this.props.useAdvancedOptions) {
       fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema())
-      fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => !f.required))
+      fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => !isRequired(f)))
     }
 
     const nonNullableBooleans: string[] = fieldsSchema.filter(f => f.type === 'boolean' && f.nullableBoolean === false).map(f => f.name)

+ 15 - 8
src/components/modules/WizardModule/WizardPageContent/WizardPageContent.tsx

@@ -120,7 +120,7 @@ const WizardTypeIcon = styled.div<any>`
 export const isOptionsPageValid = (data: any, schema: Field[]) => {
   const isValid = (field: Field): boolean => {
     if (data) {
-      const fieldValue = data[field.name]
+      const fieldValue = field.groupName ? data[field.groupName]?.[field.name] : data[field.name]
       if (fieldValue === null) {
         return false
       }
@@ -138,15 +138,22 @@ export const isOptionsPageValid = (data: any, schema: Field[]) => {
 
   let required = schema.filter(f => f.required && f.type !== 'object')
   schema.forEach(f => {
-    if (f.type === 'object' && f.properties && f.properties.filter && f.properties.filter(p => isValid(p)).length > 0) {
-      required = required.concat(f.properties.filter(p => p.required))
+    if (f.type === 'object' && f.properties) {
+      required = required.concat(f.properties?.filter(p => p.required).map(p => ({ ...p, groupName: f.name })))
     }
 
-    if (f.enum && f.subFields) {
-      const value = data && data[f.name]
-      const subField = f.subFields.find(sf => sf.name === `${String(value)}_options`)
-      if (subField && subField.properties) {
-        required = required.concat(subField.properties.filter(p => p.required))
+    if (f.subFields) {
+      if (f.enum) {
+        const value = data && data[f.name]
+        const subField = f.subFields.find(sf => sf.name === `${String(value)}_options`)
+        if (subField?.properties) {
+          required = required.concat(subField.properties.filter(p => p.required))
+        }
+      } else if (f.type === 'boolean') {
+        const subField = data?.[f.name] ? f.subFields[1] : f.subFields[0]
+        if (subField.properties) {
+          required = required.concat(subField.properties.filter(p => p.required))
+        }
       }
     }
   })

+ 15 - 0
src/components/smart/WizardPage/WizardPage.tsx

@@ -129,6 +129,10 @@ class WizardPage extends React.Component<Props, State> {
       .filter(p => p.id !== 'storage' || hasStorageMapping())
   }
 
+  get requiresWindowsImage() {
+    return Boolean(wizardStore.data.selectedInstances?.find(i => i.os_type === 'windows'))
+  }
+
   setTransferItemTitle() {
     const selectedInstance = wizardStore.data?.selectedInstances?.[0]
     let title = selectedInstance?.name || selectedInstance?.instance_name || selectedInstance?.id
@@ -278,6 +282,7 @@ class WizardPage extends React.Component<Props, State> {
       providerName: target.type,
       optionsType: 'destination',
       useCache: true,
+      requiresWindowsImage: this.requiresWindowsImage,
     })
     wizardStore.fillWithDefaultValues('destination', providerStore.destinationSchema)
     // Preload destination options values
@@ -286,6 +291,7 @@ class WizardPage extends React.Component<Props, State> {
       endpointId: target.id,
       providerName: target.type,
       useCache: true,
+      requiresWindowsImage: this.requiresWindowsImage,
     })
     wizardStore.fillWithDefaultValues('destination', providerStore.destinationSchema)
     await this.loadExtraOptions(null, 'destination')
@@ -358,6 +364,9 @@ class WizardPage extends React.Component<Props, State> {
   handleSourceOptionsChange(field: Field, value: any, parentFieldName?: string) {
     wizardStore.updateData({ selectedInstances: [] })
     wizardStore.updateSourceOptions({ field, value, parentFieldName })
+    if (field.subFields) {
+      wizardStore.fillWithDefaultValues('source', providerStore.sourceSchema)
+    }
     if (field.type !== 'string' || field.enum) {
       this.loadExtraOptions(field, 'source')
     }
@@ -409,6 +418,7 @@ class WizardPage extends React.Component<Props, State> {
     await providerStore.loadOptionsSchema({
       providerName: endpoint.type,
       optionsType,
+      requiresWindowsImage: this.requiresWindowsImage,
     })
     const getSchema = () => (optionsType === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema)
     wizardStore.fillWithDefaultValues(optionsType, getSchema())
@@ -417,6 +427,7 @@ class WizardPage extends React.Component<Props, State> {
       optionsType,
       endpointId: endpoint.id,
       providerName: endpoint.type,
+      requiresWindowsImage: this.requiresWindowsImage,
     })
     wizardStore.fillWithDefaultValues(optionsType, getSchema())
 
@@ -454,6 +465,7 @@ class WizardPage extends React.Component<Props, State> {
       providerName: endpoint.type,
       envData,
       useCache,
+      requiresWindowsImage: this.requiresWindowsImage,
     })
     wizardStore.fillWithDefaultValues(type, getSchema())
   }
@@ -464,6 +476,7 @@ class WizardPage extends React.Component<Props, State> {
         providerName: endpoint.type,
         optionsType,
         useCache: true,
+        requiresWindowsImage: this.requiresWindowsImage,
       })
       const getSchema = () => (optionsType === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema)
       wizardStore.fillWithDefaultValues(optionsType, getSchema())
@@ -472,6 +485,7 @@ class WizardPage extends React.Component<Props, State> {
         optionsType,
         endpointId: endpoint.id,
         providerName: endpoint.type,
+        requiresWindowsImage: this.requiresWindowsImage,
         useCache: true,
       })
       wizardStore.fillWithDefaultValues(optionsType, getSchema())
@@ -581,6 +595,7 @@ class WizardPage extends React.Component<Props, State> {
       }
       this.handleCreationSuccess([item])
     } catch (err) {
+      console.error(err)
       this.setState({ nextButtonDisabled: false })
     }
   }

+ 22 - 0
src/components/ui/Dropdowns/AutocompleteDropdown/AutocompleteDropdown.tsx

@@ -100,6 +100,20 @@ const ListItem = styled.div<any>`
     background: ${ThemePalette.primary};
     color: white;
   }
+
+  ${props => (props.disabled ? css`
+    cursor: default;
+    color: ${ThemePalette.grayscale[3]};
+    &:hover {
+      background: white;
+      color: ${ThemePalette.grayscale[3]};
+    }
+  ` : '')}
+
+`
+const SubtitleLabel = styled.div`
+  display: flex;
+  font-size: 11px;
 `
 const DuplicatedLabel = styled.div<any>`
   display: flex;
@@ -257,6 +271,10 @@ class AutocompleteDropdown extends React.Component<Props, State> {
   }
 
   handleItemClick(item: any) {
+    if (item.disabled) {
+      return
+    }
+
     this.setState({
       showDropdownList: false,
       firstItemHover: false,
@@ -405,8 +423,12 @@ class AutocompleteDropdown extends React.Component<Props, State> {
               selected={value !== null && value === selectedValue}
               dim={this.props.dimNullValue && value == null}
               arrowSelected={i === this.state.arrowSelection}
+              disabled={item.disabled}
             >
               {label}
+              {item.subtitleLabel ? (
+                <SubtitleLabel>{item.subtitleLabel}</SubtitleLabel>
+              ) : null}
               {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
             </ListItem>
           )

+ 8 - 1
src/components/ui/Dropdowns/Dropdown/Dropdown.tsx

@@ -140,7 +140,6 @@ const ListItem = styled.div<any>`
   transition: all ${ThemeProps.animations.swift};
   padding-left: ${(props: any) => props.paddingLeft}px;
   word-break: break-word;
-  ${props => (props.disabled ? css`cursor: default;` : '')}
 
   &:first-child {
     border-top-left-radius: ${ThemeProps.borderRadius};
@@ -159,6 +158,14 @@ const ListItem = styled.div<any>`
       stroke: white;
     }
   }
+
+  ${props => (props.disabled ? css`
+    cursor: default;
+    &:hover {
+      background: white;
+      color: ${ThemePalette.grayscale[3]};
+    }
+  ` : '')}
 `
 const SubtitleLabel = styled.div`
   display: flex;

+ 1 - 1
src/components/ui/FieldInput/FieldInput.tsx

@@ -195,7 +195,7 @@ class FieldInput extends React.Component<Props> {
           }
         }}
         labelRenderer={this.props.labelRenderer}
-        hideRequiredSymbol={this.props.layout === 'page'}
+        // hideRequiredSymbol={this.props.layout === 'page'}
         disabledLoading={this.props.disabledLoading}
         disabled={this.props.disabled}
       />

+ 3 - 1
src/components/ui/PropertiesTable/PropertiesTable.tsx

@@ -193,11 +193,13 @@ class PropertiesTable extends React.Component<Props> {
   }
 
   render() {
+    const hasRequiredInputs = this.props.properties.some(prop => prop.required && prop.type === 'string')
+    const width = this.props.width && hasRequiredInputs ? this.props.width - 20 : this.props.width
     return (
       <Wrapper
         disabled={this.props.disabled}
         disabledLoading={this.props.disabledLoading}
-        width={this.props.width}
+        width={width}
       >
         {this.props.properties.map(prop => (
           <Row key={prop.name}>

+ 120 - 0
src/plugins/aws/OptionsSchemaPlugin.ts

@@ -0,0 +1,120 @@
+/*
+Copyright (C) 2022  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import type { InstanceScript } from '@src/@types/Instance'
+import { Field, isEnumSeparator } from '@src/@types/Field'
+import type { OptionValues, StorageMap } from '@src/@types/Endpoint'
+import type { SchemaProperties, SchemaDefinitions } from '@src/@types/Schema'
+import type { NetworkMap } from '@src/@types/Network'
+import { UserScriptData } from '@src/@types/MainItem'
+import DefaultOptionsSchemaPlugin, {
+  defaultGetDestinationEnv,
+  defaultGetMigrationImageMap,
+  defaultFillFieldValues,
+  defaultFillMigrationImageMapValues,
+} from '../default/OptionsSchemaPlugin'
+
+export default class OptionsSchemaParser {
+  static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
+
+  static parseSchemaToFields(opts: {
+    schema: SchemaProperties,
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
+    const exportImage = fields.find(f => f.name === 'export_image')
+    if (exportImage) {
+      exportImage.required = true
+    }
+    return fields
+  }
+
+  static sortFields(fields: Field[]) {
+    DefaultOptionsSchemaPlugin.sortFields(fields)
+    fields.sort((f1, f2) => {
+      // sort region first
+      if (f1.name === 'region') {
+        return -1
+      }
+      if (f2.name === 'region') {
+        return 1
+      }
+      return 0
+    })
+  }
+
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
+    const option = options.find(f => f.name === field.name)
+    if (!option) {
+      return
+    }
+    if (!defaultFillMigrationImageMapValues({
+      field,
+      option,
+      migrationImageMapFieldName: this.migrationImageMapFieldName,
+      requiresWindowsImage,
+    })) {
+      defaultFillFieldValues(field, option)
+
+      if (field.name === 'export_image') {
+        field.enum?.forEach(exportImageValue => {
+          if (typeof exportImageValue === 'string' || isEnumSeparator(exportImageValue)) {
+            return
+          }
+          // @ts-ignore
+          const osType = exportImageValue.os_type
+          if (osType !== 'unknown' && osType !== 'linux') {
+            exportImageValue.disabled = true
+            exportImageValue.subtitleLabel = `Source plugins rely on a Linux-based temporary virtual machine to perform data exports, but the platform reports this image to be of OS type '${osType}'.`
+          }
+        })
+      }
+    }
+  }
+
+  static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
+    const env = {
+      ...defaultGetDestinationEnv(options, oldOptions),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        this.migrationImageMapFieldName,
+      ),
+    }
+    return env
+  }
+
+  static getNetworkMap(networkMappings: NetworkMap[] | null) {
+    return DefaultOptionsSchemaPlugin.getNetworkMap(networkMappings)
+  }
+
+  static getStorageMap(
+    defaultStorage: { value: string | null, busType?: string | null },
+    storageMap: StorageMap[] | null,
+    configDefault?: string | null,
+  ) {
+    return DefaultOptionsSchemaPlugin.getStorageMap(defaultStorage, storageMap, configDefault)
+  }
+
+  static getUserScripts(
+    uploadedUserScripts: InstanceScript[],
+    removedUserScripts: InstanceScript[],
+    userScriptData: UserScriptData | null | undefined,
+  ) {
+    return DefaultOptionsSchemaPlugin.getUserScripts(uploadedUserScripts, removedUserScripts, userScriptData)
+  }
+}

+ 95 - 0
src/plugins/azure/OptionsSchemaPlugin.ts

@@ -0,0 +1,95 @@
+/*
+Copyright (C) 2022 Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import type { InstanceScript } from '@src/@types/Instance'
+import type { Field } from '@src/@types/Field'
+import type { OptionValues, StorageMap } from '@src/@types/Endpoint'
+import type { SchemaProperties, SchemaDefinitions } from '@src/@types/Schema'
+import type { NetworkMap } from '@src/@types/Network'
+import { UserScriptData } from '@src/@types/MainItem'
+import DefaultOptionsSchemaPlugin, {
+  defaultFillMigrationImageMapValues,
+  defaultFillFieldValues,
+  defaultGetDestinationEnv,
+  defaultGetMigrationImageMap,
+} from '../default/OptionsSchemaPlugin'
+
+export default class OptionsSchemaParser {
+  static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
+
+  static parseSchemaToFields(opts: {
+    schema: SchemaProperties,
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const { schemaDefinitions } = opts
+    if (schemaDefinitions?.azure_image?.required) {
+      schemaDefinitions.azure_image.required = []
+    }
+    return DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
+  }
+
+  static sortFields(fields: Field[]) {
+    DefaultOptionsSchemaPlugin.sortFields(fields)
+  }
+
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
+    const option = options.find(f => f.name === field.name)
+    if (!option) {
+      return
+    }
+    if (!defaultFillMigrationImageMapValues({
+      field,
+      option,
+      migrationImageMapFieldName: DefaultOptionsSchemaPlugin.migrationImageMapFieldName,
+      requiresWindowsImage,
+    })) {
+      defaultFillFieldValues(field, option)
+    }
+  }
+
+  static getDestinationEnv(options: { [prop: string]: any } | null, oldOptions?: any) {
+    const env = {
+      ...defaultGetDestinationEnv(options, oldOptions),
+      ...defaultGetMigrationImageMap(
+        options,
+        oldOptions,
+        DefaultOptionsSchemaPlugin.migrationImageMapFieldName,
+      ),
+    }
+    return env
+  }
+
+  static getNetworkMap(networkMappings: NetworkMap[] | null | undefined) {
+    return DefaultOptionsSchemaPlugin.getNetworkMap(networkMappings)
+  }
+
+  static getStorageMap(
+    defaultStorage: { value: string | null, busType?: string | null },
+    storageMap: StorageMap[] | null,
+    configDefault?: string | null,
+  ) {
+    return DefaultOptionsSchemaPlugin.getStorageMap(defaultStorage, storageMap, configDefault)
+  }
+
+  static getUserScripts(
+    uploadedUserScripts: InstanceScript[],
+    removedUserScripts: InstanceScript[],
+    userScriptData: UserScriptData | null | undefined,
+  ) {
+    return DefaultOptionsSchemaPlugin.getUserScripts(uploadedUserScripts, removedUserScripts, userScriptData)
+  }
+}

+ 38 - 9
src/plugins/default/OptionsSchemaPlugin.ts

@@ -42,11 +42,15 @@ export const defaultFillFieldValues = (field: Field, option: OptionValues) => {
   }
 }
 
-export const defaultFillMigrationImageMapValues = (
+export const defaultFillMigrationImageMapValues = (opts: {
   field: Field,
   option: OptionValues,
   migrationImageMapFieldName: string,
-): boolean => {
+  requiresWindowsImage: boolean,
+}): boolean => {
+  const {
+    field, option, migrationImageMapFieldName, requiresWindowsImage,
+  } = opts
   if (field.name !== migrationImageMapFieldName) {
     return false
   }
@@ -66,10 +70,18 @@ export const defaultFillMigrationImageMapValues = (
       values.splice(unknownIndex, 0, { separator: true })
     }
 
+    let defaultValue = null
+    if (option?.config_default && Object.prototype.hasOwnProperty.call(option.config_default, os)) {
+      // @ts-ignore
+      defaultValue = option.config_default[os]
+    }
+
     return {
       name: os,
       type: 'string',
       enum: values,
+      default: defaultValue,
+      required: os === 'linux' || (requiresWindowsImage && os === 'windows'),
     }
   })
   return true
@@ -154,7 +166,15 @@ export const defaultGetMigrationImageMap = (
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_image_map'
 
-  static parseSchemaToFields(schema: SchemaProperties, schemaDefinitions?: SchemaDefinitions | null, dictionaryKey?: string) {
+  static parseSchemaToFields(opts: {
+    schema: SchemaProperties,
+    schemaDefinitions?: SchemaDefinitions | null,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const {
+      schema, schemaDefinitions, dictionaryKey,
+    } = opts
     return defaultSchemaToFields(schema, schemaDefinitions, dictionaryKey)
   }
 
@@ -172,17 +192,26 @@ export default class OptionsSchemaParser {
     })
   }
 
-  static fillFieldValues(field: Field, options: OptionValues[], customFieldName?: string) {
-    const option = options
-      .find(f => (customFieldName ? f.name === customFieldName : f.name === field.name))
+  static fillFieldValues(opts: {
+    field: Field,
+    options: OptionValues[],
+    requiresWindowsImage: boolean,
+    customFieldName?: string,
+  }) {
+    const {
+      field, options, requiresWindowsImage, customFieldName,
+    } = opts
+
+    const option = options.find(f => (customFieldName ? f.name === customFieldName : f.name === field.name))
     if (!option) {
       return
     }
-    if (!defaultFillMigrationImageMapValues(
+    if (!defaultFillMigrationImageMapValues({
       field,
       option,
-      this.migrationImageMapFieldName,
-    )) {
+      migrationImageMapFieldName: this.migrationImageMapFieldName,
+      requiresWindowsImage,
+    })) {
       defaultFillFieldValues(field, option)
     }
   }

+ 4 - 0
src/plugins/index.ts

@@ -24,10 +24,12 @@ import AzureContentPlugin from './azure/ContentPlugin'
 import OpenstackContentPlugin from './openstack/ContentPlugin'
 
 import DefaultOptionsSchemaPlugin from './default/OptionsSchemaPlugin'
+import AwsOptionsSchemaPlugin from './aws/OptionsSchemaPlugin'
 import OvmOptionsSchemaPlugin from './ovm/OptionsSchemaPlugin'
 import VmwareOptionsSchemaPlugin from './vmware_vsphere/OptionsSchemaPlugin'
 import OpenstackOptionsSchemaPlugin from './openstack/OptionsSchemaPlugin'
 import OvirtOptionsSchemaPlugin from './ovirt/OptionsSchemaPlugin'
+import AzureOptionsSchemaPlugin from './azure/OptionsSchemaPlugin'
 
 import DefaultInstanceInfoPlugin from './default/InstanceInfoPlugin'
 import OciInstanceInfoPlugin from './oci/InstanceInfoPlugin'
@@ -57,10 +59,12 @@ export const OptionsSchemaPlugin = {
   for: (provider: ProviderTypes) => {
     const map = {
       default: DefaultOptionsSchemaPlugin,
+      aws: AwsOptionsSchemaPlugin,
       oracle_vm: OvmOptionsSchemaPlugin,
       openstack: OpenstackOptionsSchemaPlugin,
       vmware_vsphere: VmwareOptionsSchemaPlugin,
       ovirt: OvirtOptionsSchemaPlugin,
+      azure: AzureOptionsSchemaPlugin,
     }
     if (hasKey(map, provider)) {
       return map[provider]

+ 36 - 13
src/plugins/openstack/OptionsSchemaPlugin.ts

@@ -13,7 +13,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 import type { InstanceScript } from '@src/@types/Instance'
-import type { Field } from '@src/@types/Field'
+import { Field, isEnumSeparator } from '@src/@types/Field'
 import type { OptionValues, StorageMap } from '@src/@types/Endpoint'
 import type { SchemaProperties, SchemaDefinitions } from '@src/@types/Schema'
 import type { NetworkMap } from '@src/@types/Network'
@@ -28,18 +28,24 @@ import DefaultOptionsSchemaPlugin, {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = DefaultOptionsSchemaPlugin.migrationImageMapFieldName
 
-  static parseSchemaToFields(
+  static parseSchemaToFields(opts: {
     schema: SchemaProperties,
-    schemaDefinitions: SchemaDefinitions | null | undefined,
-    dictionaryKey: string,
-  ) {
-    const fields = DefaultOptionsSchemaPlugin.parseSchemaToFields(schema, schemaDefinitions, dictionaryKey)
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
+    const exportImage = fields.find(f => f.name === 'coriolis_backups_options')?.properties?.find(p => p.name === 'export_image')
+    if (exportImage) {
+      exportImage.required = true
+    }
+
     const exportMechField = fields.find(f => f.name === 'replica_export_mechanism')
     if (!exportMechField) {
       return fields
     }
     exportMechField.subFields = []
-    exportMechField.enum.forEach((exportType: any) => {
+    exportMechField.enum!.forEach((exportType: any) => {
       const exportTypeFieldIdx = fields.findIndex(f => f.name === `${exportType}_options`)
       if (exportTypeFieldIdx === -1) {
         return
@@ -48,7 +54,7 @@ export default class OptionsSchemaParser {
       if (subField.properties?.length) {
         subField.properties = subField.properties.map((p: Field) => ({ ...p, groupName: subField.name }))
       }
-      exportMechField.subFields.push(subField)
+      exportMechField.subFields!.push(subField)
       fields.splice(exportTypeFieldIdx, 1)
     })
     return fields
@@ -58,12 +64,28 @@ export default class OptionsSchemaParser {
     DefaultOptionsSchemaPlugin.sortFields(fields)
   }
 
-  static fillFieldValues(field: Field, options: OptionValues[]) {
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
     if (field.name === 'replica_export_mechanism' && field.subFields) {
       field.subFields.forEach(sf => {
         if (sf.properties) {
           sf.properties.forEach(f => {
-            DefaultOptionsSchemaPlugin.fillFieldValues(f, options, f.name.split('/')[1])
+            DefaultOptionsSchemaPlugin.fillFieldValues({
+              field: f, options, customFieldName: f.name.split('/')[1], requiresWindowsImage,
+            })
+            if (f.name === 'export_image') {
+              f.enum?.forEach(exportImageValue => {
+                if (typeof exportImageValue === 'string' || isEnumSeparator(exportImageValue)) {
+                  return
+                }
+                // @ts-ignore
+                const osType = exportImageValue.os_type
+                if (osType !== 'unknown' && osType !== 'linux') {
+                  exportImageValue.disabled = true
+                  exportImageValue.subtitleLabel = `Source plugins rely on a Linux-based temporary virtual machine to perform data exports, but the platform reports this image to be of OS type '${osType}'.`
+                }
+              })
+            }
           })
         }
       })
@@ -72,11 +94,12 @@ export default class OptionsSchemaParser {
       if (!option) {
         return
       }
-      if (!defaultFillMigrationImageMapValues(
+      if (!defaultFillMigrationImageMapValues({
         field,
         option,
-        this.migrationImageMapFieldName,
-      )) {
+        migrationImageMapFieldName: this.migrationImageMapFieldName,
+        requiresWindowsImage,
+      })) {
         defaultFillFieldValues(field, option)
       }
     }

+ 13 - 9
src/plugins/ovirt/OptionsSchemaPlugin.ts

@@ -28,12 +28,13 @@ import DefaultOptionsSchemaPlugin, {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_template_map'
 
-  static parseSchemaToFields(
+  static parseSchemaToFields(opts: {
     schema: SchemaProperties,
-    schemaDefinitions: SchemaDefinitions | null | undefined,
-    dictionaryKey: string,
-  ) {
-    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(schema, schemaDefinitions, dictionaryKey)
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
     fields.forEach(f => {
       if (
         f.name !== 'migr_template_username_map'
@@ -64,16 +65,19 @@ export default class OptionsSchemaParser {
     DefaultOptionsSchemaPlugin.sortFields(fields)
   }
 
-  static fillFieldValues(field: Field, options: OptionValues[]) {
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
+
     const option = options.find(f => f.name === field.name)
     if (!option) {
       return
     }
-    if (!defaultFillMigrationImageMapValues(
+    if (!defaultFillMigrationImageMapValues({
       field,
       option,
-      this.migrationImageMapFieldName,
-    )) {
+      migrationImageMapFieldName: this.migrationImageMapFieldName,
+      requiresWindowsImage,
+    })) {
       defaultFillFieldValues(field, option)
     }
   }

+ 38 - 10
src/plugins/ovm/OptionsSchemaPlugin.ts

@@ -28,12 +28,24 @@ import DefaultOptionsSchemaPlugin, {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_template_map'
 
-  static parseSchemaToFields(
+  static parseSchemaToFields(opts: {
     schema: SchemaProperties,
-    schemaDefinitions: SchemaDefinitions | null | undefined,
-    dictionaryKey: string,
-  ) {
-    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(schema, schemaDefinitions, dictionaryKey)
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const fields: Field[] = DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
+    const makeRequired = (fieldName: string) => {
+      const field = fields.find(f => f.name === fieldName)
+      if (field) {
+        field.required = true
+      }
+    }
+
+    makeRequired('export_template')
+    makeRequired('export_template_username')
+    makeRequired('export_template_password')
+
     const useCoriolisExporterField = fields.find(f => f.name === 'use_coriolis_exporter')
     if (useCoriolisExporterField) {
       const usableFields: Field[] = [
@@ -72,11 +84,13 @@ export default class OptionsSchemaParser {
         {
           type: 'string',
           name: 'windows',
+          required: opts.requiresWindowsImage,
           password,
         },
         {
           type: 'string',
           name: 'linux',
+          required: true,
           password,
         },
       ]
@@ -89,12 +103,25 @@ export default class OptionsSchemaParser {
     DefaultOptionsSchemaPlugin.sortFields(fields)
   }
 
-  static fillFieldValues(field: Field, options: OptionValues[]) {
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
+
     if (field.name === 'use_coriolis_exporter') {
       field.subFields?.forEach(sf => {
         if (sf.properties) {
           sf.properties.forEach(f => {
-            DefaultOptionsSchemaPlugin.fillFieldValues(f, options, f.name.split('/')[1])
+            DefaultOptionsSchemaPlugin.fillFieldValues({
+              field: f, options, customFieldName: f.name.split('/')[1], requiresWindowsImage,
+            })
+            if (f.name === 'export_template' && f.enum) {
+              f.enum = f.enum.map(newF => (typeof newF !== 'string' ? {
+                ...newF,
+                // @ts-ignore
+                disabled: newF.os_type !== 'linux' && newF.os_type !== 'unknown',
+                // @ts-ignore
+                subtitleLabel: newF.os_type !== 'linux' && newF.os_type !== 'unknown' ? `Source plugins rely on a Linux-based temporary virtual machine to perform data exports, but the platform reports this image to be of OS type '${newF.os_type}'.` : '',
+              } : newF))
+            }
           })
         }
       })
@@ -103,11 +130,12 @@ export default class OptionsSchemaParser {
       if (!option) {
         return
       }
-      if (!defaultFillMigrationImageMapValues(
+      if (!defaultFillMigrationImageMapValues({
         field,
         option,
-        this.migrationImageMapFieldName,
-      )) {
+        migrationImageMapFieldName: this.migrationImageMapFieldName,
+        requiresWindowsImage,
+      })) {
         defaultFillFieldValues(field, option)
       }
     }

+ 15 - 9
src/plugins/vmware_vsphere/OptionsSchemaPlugin.ts

@@ -28,12 +28,13 @@ import DefaultOptionsSchemaPlugin, {
 export default class OptionsSchemaParser {
   static migrationImageMapFieldName = 'migr_template_map'
 
-  static parseSchemaToFields(
+  static parseSchemaToFields(opts: {
     schema: SchemaProperties,
-    schemaDefinitions: SchemaDefinitions | null | undefined,
-    dictionaryKey: string,
-  ) {
-    const fields = DefaultOptionsSchemaPlugin.parseSchemaToFields(schema, schemaDefinitions, dictionaryKey)
+    schemaDefinitions?: SchemaDefinitions | null | undefined,
+    dictionaryKey?: string,
+    requiresWindowsImage?: boolean,
+  }) {
+    const fields = DefaultOptionsSchemaPlugin.parseSchemaToFields(opts)
     fields.forEach(f => {
       if (
         f.name !== 'migr_template_username_map'
@@ -48,11 +49,13 @@ export default class OptionsSchemaParser {
         {
           type: 'string',
           name: 'windows',
+          required: opts.requiresWindowsImage,
           password,
         },
         {
           type: 'string',
           name: 'linux',
+          required: true,
           password,
         },
       ]
@@ -65,16 +68,19 @@ export default class OptionsSchemaParser {
     DefaultOptionsSchemaPlugin.sortFields(fields)
   }
 
-  static fillFieldValues(field: Field, options: OptionValues[]) {
+  static fillFieldValues(opts: { field: Field, options: OptionValues[], requiresWindowsImage: boolean }) {
+    const { field, options, requiresWindowsImage } = opts
+
     const option = options.find(f => f.name === field.name)
     if (!option) {
       return
     }
-    if (!defaultFillMigrationImageMapValues(
+    if (!defaultFillMigrationImageMapValues({
       field,
       option,
-      this.migrationImageMapFieldName,
-    )) {
+      migrationImageMapFieldName: this.migrationImageMapFieldName,
+      requiresWindowsImage,
+    })) {
       defaultFillFieldValues(field, option)
     }
   }

+ 1 - 3
src/sources/MigrationSource.ts

@@ -180,9 +180,7 @@ class MigrationSource {
       || (opts.updatedStorageMappings && opts.updatedStorageMappings.length)) {
       payload.migration.storage_mappings = {
         ...opts.storageMappings,
-        ...destParser
-          .getStorageMap(opts.updatedDefaultStorage
-            || opts.defaultStorage, opts.updatedStorageMappings),
+        ...destParser.getStorageMap(opts.updatedDefaultStorage || opts.defaultStorage, opts.updatedStorageMappings),
       }
     }
     const { migration } = opts

+ 11 - 3
src/sources/ProviderSource.ts

@@ -34,9 +34,15 @@ class ProviderSource {
     return response.data.providers
   }
 
-  async loadOptionsSchema(opts: { providerName: ProviderTypes, optionsType: 'source' | 'destination', useCache?: boolean | null, quietError?: boolean | null }): Promise<Field[]> {
+  async loadOptionsSchema(opts: {
+    providerName: ProviderTypes,
+    optionsType: 'source' | 'destination',
+    useCache?: boolean | null,
+    quietError?: boolean | null,
+    requiresWindowsImage?: boolean,
+  }): Promise<Field[]> {
     const {
-      providerName, optionsType, useCache, quietError,
+      providerName, optionsType, useCache, quietError, requiresWindowsImage,
     } = opts
     const schemaTypeInt = optionsType === 'source' ? providerTypes.SOURCE_REPLICA : providerTypes.TARGET_REPLICA
 
@@ -49,7 +55,9 @@ class ProviderSource {
       const schema = optionsType === 'source' ? response?.data?.schemas?.source_environment_schema : response?.data?.schemas?.destination_environment_schema
       let fields = []
       if (schema) {
-        fields = SchemaParser.optionsSchemaToFields(providerName, schema, `${providerName}-${optionsType}`)
+        fields = SchemaParser.optionsSchemaToFields({
+          provider: providerName, schema, dictionaryKey: `${providerName}-${optionsType}`, requiresWindowsImage,
+        })
       }
       return fields
     } catch (err) {

+ 8 - 3
src/sources/Schemas.ts

@@ -32,10 +32,15 @@ class SchemaParser {
     return fields
   }
 
-  static optionsSchemaToFields(provider: ProviderTypes, schema: any, dictionaryKey: string) {
+  static optionsSchemaToFields(opts: { provider: ProviderTypes, schema: any, dictionaryKey: string, requiresWindowsImage?: boolean }) {
+    const {
+      provider, schema, dictionaryKey, requiresWindowsImage,
+    } = opts
     const parser = OptionsSchemaPlugin.for(provider)
     const schemaRoot = schema.oneOf ? schema.oneOf[0] : schema
-    const fields = parser.parseSchemaToFields(schemaRoot, schema.definitions, dictionaryKey)
+    const fields = parser.parseSchemaToFields({
+      schema: schemaRoot, schemaDefinitions: schema.definitions, dictionaryKey, requiresWindowsImage,
+    })
     parser.sortFields(fields)
     return fields
   }
@@ -59,7 +64,7 @@ class SchemaParser {
   }
 
   static minionPoolOptionsSchemaToFields(provider: ProviderTypes, schema: any, dictionaryKey: string) {
-    let fields = this.optionsSchemaToFields(provider, schema, dictionaryKey)
+    let fields = this.optionsSchemaToFields({ provider, schema, dictionaryKey })
     const parsers = MinionPoolSchemaPlugin.for(provider)
     fields = parsers.minionPoolTransformOptionsFields(fields)
     return fields

+ 1 - 1
src/stores/MinionPoolStore.ts

@@ -185,7 +185,7 @@ class MinionPoolStore {
     }
     this.minionPoolEnvSchema.forEach(field => {
       const parser = OptionsSchemaPlugin.for(provider)
-      parser.fillFieldValues(field, options)
+      parser.fillFieldValues({ field, options, requiresWindowsImage: false })
     })
     this.minionPoolEnvSchema = [...this.minionPoolEnvSchema]
   }

+ 10 - 5
src/stores/ProviderStore.ts

@@ -221,9 +221,10 @@ class ProviderStore {
     optionsType: 'source' | 'destination',
     useCache?: boolean,
     quietError?: boolean,
+    requiresWindowsImage?: boolean
   }): Promise<Field[]> {
     const {
-      providerName, optionsType, useCache, quietError,
+      providerName, optionsType, useCache, quietError, requiresWindowsImage,
     } = options
 
     if (optionsType === 'source') {
@@ -247,7 +248,7 @@ class ProviderStore {
 
     try {
       const fields: Field[] = await ProviderSource.loadOptionsSchema({
-        providerName, optionsType, useCache, quietError,
+        providerName, optionsType, useCache, quietError, requiresWindowsImage,
       })
       this.loadOptionsSchemaSuccess(fields, optionsType, isValid())
       return fields
@@ -290,13 +291,15 @@ class ProviderStore {
     optionsType: 'source' | 'destination',
     endpointId: string,
     providerName: ProviderTypes,
+    // when setting the image map, mark the windows image as required (usually done when the source is a windows image)
+    requiresWindowsImage?: boolean,
     envData?: { [prop: string]: any } | null,
     useCache?: boolean,
     quietError?: boolean,
     allowMultiple?: boolean,
   }): Promise<OptionValues[]> {
     const {
-      providerName, optionsType, endpointId, envData, useCache, quietError, allowMultiple,
+      providerName, optionsType, endpointId, envData, useCache, quietError, allowMultiple, requiresWindowsImage,
     } = config
     const providerType = optionsType === 'source' ? providerTypes.SOURCE_OPTIONS : providerTypes.DESTINATION_OPTIONS
 
@@ -338,6 +341,7 @@ class ProviderStore {
         provider: providerName,
         options,
         isValid: isValid(),
+        requiresWindowsImage: requiresWindowsImage || false,
       })
       return options
     } catch (e) {
@@ -406,9 +410,10 @@ class ProviderStore {
     provider: ProviderTypes,
     options: OptionValues[],
     isValid: boolean,
+    requiresWindowsImage: boolean,
   }) {
     const {
-      optionsType, provider, options, isValid,
+      optionsType, provider, options, isValid, requiresWindowsImage,
     } = opts
     if (!isValid) {
       return
@@ -416,7 +421,7 @@ class ProviderStore {
     const schema = optionsType === 'source' ? this.sourceSchema : this.destinationSchema
     schema.forEach(field => {
       const parser = OptionsSchemaPlugin.for(provider)
-      parser.fillFieldValues(field, options)
+      parser.fillFieldValues({ field, options, requiresWindowsImage })
     })
     if (optionsType === 'source') {
       this.sourceSchema = [...schema]

+ 34 - 6
src/stores/WizardStore.ts

@@ -91,13 +91,13 @@ class WizardStore {
   @action fillWithDefaultValues(direction: 'source' | 'destination', schema: Field[]) {
     const data: { [prop: string]: any } = (direction === 'source' ? this.data.sourceOptions : this.data.destOptions) || {}
 
-    schema.forEach(field => {
-      if (data[field.name] !== undefined) {
-        return
+    const shouldSetDefault = (field: Field, parentData: { [prop: string]: any }): { should: boolean, value?: any } => {
+      if (parentData[field.name] !== undefined) {
+        return { should: false }
       }
       const fieldDefault = field.default
       if (fieldDefault == null) {
-        return
+        return { should: false }
       }
       if (field.enum) {
         const isDefaultInEnum = field.enum.find(item => {
@@ -113,10 +113,38 @@ class WizardStore {
 
         // Don't use the default if it can't be found in the enum list.
         if (isDefaultInEnum) {
-          data[field.name] = field.default
+          return { should: true, value: field.default }
         }
       } else {
-        data[field.name] = field.default
+        return { should: true, value: field.default }
+      }
+      return { should: false }
+    }
+
+    const setObjectDefault = (subFieldProperty: Field, parentFieldName: string) => {
+      const shouldSetDefaultResult = shouldSetDefault(subFieldProperty, data[parentFieldName] || {})
+      if (shouldSetDefaultResult.should) {
+        data[parentFieldName] = data[parentFieldName] || {}
+        data[parentFieldName][subFieldProperty.name] = shouldSetDefaultResult.value
+      }
+    }
+
+    schema.forEach(field => {
+      if (field.subFields && data[field.name] !== undefined) {
+        const subField = field.subFields.find(sf => sf.name === `${data[field.name]}_options`)
+        if (subField) {
+          subField.properties?.forEach(subFieldProperty => {
+            setObjectDefault(subFieldProperty, subFieldProperty.groupName!)
+          })
+        }
+        return
+      }
+      field.properties?.forEach(subFieldProperty => {
+        setObjectDefault(subFieldProperty, field.name)
+      })
+      const shouldSetDefaultResult = shouldSetDefault(field, data)
+      if (shouldSetDefaultResult.should) {
+        data[field.name] = shouldSetDefaultResult.value
       }
     })