Skip to content

NAS-135491 / 25.10 / Allow specifying image OS for virt VMs in UI #11938

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/app/enums/virtualization.enum.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { marker as T } from '@biesbjerg/ngx-translate-extract-marker';
import { iconMarker } from 'app/modules/ix-icon/icon-marker.util';

export enum ImageOs {
Linux = 'Linux',
FreeBsd = 'FreeBSD',
Windows = 'Windows',
}

export type AllowedImageOs = ImageOs | string | null;

export const imageOsLabels = new Map<ImageOs, string>([
[ImageOs.Linux, 'Linux'],
[ImageOs.FreeBsd, 'FreeBSD'],
[ImageOs.Windows, 'Windows'],
]);

export enum VirtualizationType {
Container = 'CONTAINER',
Vm = 'VM',
Expand Down
34 changes: 34 additions & 0 deletions src/app/helpers/detect-image-os.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ImageOs } from 'app/enums/virtualization.enum';
import { detectImageOs } from 'app/helpers/detect-image-os.utils';

describe('detectImageOs', () => {
it('returns null for null or undefined or empty string', () => {
expect(detectImageOs(null)).toBeNull();
expect(detectImageOs(undefined)).toBeNull();
expect(detectImageOs('')).toBeNull();
});

it('detects Windows OS', () => {
expect(detectImageOs('win10')).toBe(ImageOs.Windows);
expect(detectImageOs('Windows Server')).toBe(ImageOs.Windows);
expect(detectImageOs('my_windows_image')).toBe(ImageOs.Windows);
});

it('detects Linux OS', () => {
expect(detectImageOs('ubuntu-22.04')).toBe(ImageOs.Linux);
expect(detectImageOs('debian_x64')).toBe(ImageOs.Linux);
expect(detectImageOs('my-centos-template')).toBe(ImageOs.Linux);
expect(detectImageOs('archlinux')).toBe(ImageOs.Linux);
});

it('detects FreeBSD OS', () => {
expect(detectImageOs('FreeBSD 13.1')).toBe(ImageOs.FreeBsd);
expect(detectImageOs('free bsd minimal')).toBe(ImageOs.FreeBsd);
expect(detectImageOs('custom-bsd-image')).toBe(ImageOs.FreeBsd);
});

it('returns null when no known OS keyword is matched', () => {
expect(detectImageOs('customOS-xyz')).toBeNull();
expect(detectImageOs('macos-big-sur')).toBeNull();
});
});
23 changes: 23 additions & 0 deletions src/app/helpers/detect-image-os.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AllowedImageOs, ImageOs } from 'app/enums/virtualization.enum';

export function detectImageOs(value: string | null | undefined): AllowedImageOs {
if (!value) {
return null;
}

const normalized = value.toLowerCase();

const osMappings: { keywords: string[]; os: ImageOs }[] = [
{ keywords: ['win', 'windows'], os: ImageOs.Windows },
{ keywords: ['ubuntu', 'debian', 'fedora', 'centos', 'linux', 'arch', 'archlinux'], os: ImageOs.Linux },
{ keywords: ['freebsd', 'free bsd', 'bsd'], os: ImageOs.FreeBsd },
];

for (const mapping of osMappings) {
if (mapping.keywords.some((keyword) => normalized.includes(keyword))) {
return mapping.os;
}
}

return null;
}
8 changes: 8 additions & 0 deletions src/app/helptext/instances/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,12 @@ Ideal for legacy applications, full-featured desktops, or software with strict O
moveTooltip: T('Renames the ZFS dataset to a path in the `ix-virt` dataset in which the zvol is located.'),
description: T('Importing a zvol as Instances volume allows its lifecycle to be managed, including backups, restores, and snapshots. This allows portability between systems using standard tools.'),
},

osImage: {
tooltip: T('Optionally specify the operating system for VM-based instances.\
Common options are Windows, Linux, FreeBSD but you can also enter a custom value.\
Leaving this field empty is allowed. \n \n When creating a Windows VM, make sure to set the this field to Windows.\
Doing so will tell us to expect Windows to be running inside of the virtual machine\
and to tweak behavior accordingly.'),
},
};
6 changes: 5 additions & 1 deletion src/app/interfaces/virtualization.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FormControl, FormGroup } from '@angular/forms';
import { NetworkInterfaceAliasType } from 'app/enums/network-interface.enum';
import {
AllowedImageOs,
DiskIoBus,
ImageOs,
VirtualizationDeviceType,
VirtualizationGlobalState,
VirtualizationGpuType,
Expand Down Expand Up @@ -86,6 +88,7 @@ export interface CreateVirtualizationInstance {
zvol_path?: string | null;
storage_pool: string | null;
volume?: string | null;
image_os?: AllowedImageOs;
}

export interface UpdateVirtualizationInstance {
Expand All @@ -99,6 +102,7 @@ export interface UpdateVirtualizationInstance {
root_disk_io_bus?: DiskIoBus;
vnc_password?: string | null;
root_disk_size?: number;
image_os?: AllowedImageOs;
}

export type VirtualizationDevice =
Expand Down Expand Up @@ -206,7 +210,7 @@ export interface VirtualizationImage {
archs: string[];
description: string;
label: string;
os: string;
os: ImageOs | null | string;
release: string;
variant: string;
instance_types: VirtualizationType[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,16 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit {

writeValue(value: string | number): void {
this.value = value;

if (!this.value) {
this.selectedOption = null;
}
if (this.value && this.options?.length) {
this.selectedOption = { ...(this.options.find((option: Option) => option.value === this.value)) };
let existingOption = this.options.find((option: Option) => option.value === this.value);
if (!existingOption && this.allowCustomValue()) {
existingOption = { label: this.value as string, value: this.value };
}
this.selectedOption = { ...existingOption };
}
this.cdr.markForCheck();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<h1 mat-dialog-title>
{{ 'Map User And Group IDs' | translate }}

<ix-tooltip class="tooltip" [message]="containersHelptext.idMapHint | translate"></ix-tooltip>
<ix-tooltip class="tooltip" [message]="instancesHelptext.idMapHint | translate"></ix-tooltip>
</h1>

<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import { ErrorHandlerService } from 'app/services/errors/error-handler.service';
})
export class MapUserGroupIdsDialog implements OnInit {
protected readonly columns = ['name', 'hostUidOrGid', 'instanceUidOrGid', 'actions'];
protected readonly containersHelptext = instancesHelptext;
protected readonly instancesHelptext = instancesHelptext;

protected readonly isLoading = signal(false);
protected readonly mappings = signal<IdMapping[]>([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
formControlName="mapDirectly"
class="default-checkbox"
[label]="isUserType() ? ('Map to the same UID in the instance' | translate) : ('Map to the same GID in the instance' | translate)"
[tooltip]="containersHelptext.mapDirectlyTooltip | translate"
[tooltip]="instancesHelptext.mapDirectlyTooltip | translate"
></ix-checkbox>

@if (!form.value.mapDirectly) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class NewMappingFormComponent implements OnChanges, OnInit {
readonly mappingAdded = output();

protected readonly ViewType = ViewType;
protected readonly containersHelptext = instancesHelptext;
protected readonly instancesHelptext = instancesHelptext;

protected form = this.formBuilder.group({
hostUidOrGid: [null as number | null, Validators.required],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h3 mat-card-title>
{{ rootDiskSize | ixFileSize }}

@if (instance().root_disk_io_bus) {
({{ instance().root_disk_io_bus | mapValue: diskIoBusLabels}})
({{ instance().root_disk_io_bus | mapValue: diskIoBusLabels }})
}
</span>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,40 @@
></ix-checkbox>
</ix-fieldset>

@if (isVm) {
<ix-fieldset [title]="'Operating System' | translate">
<ix-combobox
formControlName="image_os"
[label]="'OS Type' | translate"
[allowCustomValue]="true"
[tooltip]="instancesHelptext.osImage.tooltip| translate"
[provider]="imageOsProvider"
></ix-combobox>
</ix-fieldset>
}

<ix-fieldset [title]="'CPU & Memory' | translate">
<ix-input
formControlName="cpu"
[label]="'CPU Configuration' | translate"
[tooltip]="containersHelptext.cpuTooltip | translate"
[tooltip]="instancesHelptext.cpuTooltip | translate"
[required]="isVm"
[hint]="isVm ? undefined : (containersHelptext.cpuHint | translate)"
[hint]="isVm ? undefined : (instancesHelptext.cpuHint | translate)"
></ix-input>

<ix-input
formControlName="memory"
[label]="'Memory Size' | translate"
[tooltip]="containersHelptext.memoryTooltip | translate"
[tooltip]="instancesHelptext.memoryTooltip | translate"
[format]="formatter.memorySizeFormatting"
[parse]="formatter.memorySizeParsing"
[required]="isVm"
[hint]="isVm ? undefined : (containersHelptext.memoryHint | translate)"
[hint]="isVm ? undefined : (instancesHelptext.memoryHint | translate)"
></ix-input>
</ix-fieldset>

@if (isVm) {
<ix-fieldset [title]="'VNC' | translate">

<div [matTooltip]="isStopped ? '' : ('Instance must be stopped to update VNC.' | translate)">
<ix-checkbox
formControlName="enable_vnc"
Expand Down Expand Up @@ -95,7 +106,7 @@
<ix-checkbox
formControlName="secure_boot"
[label]="'Secure Boot' | translate"
[tooltip]="containersHelptext.secureBootTooltip | translate"
[tooltip]="instancesHelptext.secureBootTooltip | translate"
></ix-checkbox>
</ix-fieldset>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ describe('InstanceEditFormComponent', () => {
status: VirtualizationStatus.Stopped,
vnc_password: null,
secure_boot: true,
image: {
os: 'FreeBSD',
},
} as VirtualizationInstance;

const createComponent = createComponentFactory({
Expand Down Expand Up @@ -95,6 +98,7 @@ describe('InstanceEditFormComponent', () => {
'VNC Port': '9001',
'VNC Password': '',
'Secure Boot': true,
'OS Type': 'FreeBSD',
});
});

Expand All @@ -117,6 +121,7 @@ describe('InstanceEditFormComponent', () => {
memory: GiB,
enable_vnc: true,
vnc_port: 9000,
image_os: 'FreeBSD',
vnc_password: 'testing',
secure_boot: false,
}]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Role } from 'app/enums/role.enum';
import { VirtualizationStatus, VirtualizationType } from 'app/enums/virtualization.enum';
import {
ImageOs, imageOsLabels, VirtualizationStatus, VirtualizationType,
} from 'app/enums/virtualization.enum';
import { choicesToOptions } from 'app/helpers/operators/options.operators';
import { mapToOptions } from 'app/helpers/options.helper';
import { instancesHelptext } from 'app/helptext/instances/instances';
import {
InstanceEnvVariablesFormGroup,
UpdateVirtualizationInstance,
VirtualizationInstance,
} from 'app/interfaces/virtualization.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { SimpleAsyncComboboxProvider } from 'app/modules/forms/ix-forms/classes/simple-async-combobox-provider';
import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component';
import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component';
import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component';
import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component';
import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component';
Expand Down Expand Up @@ -49,6 +54,7 @@ import { defaultVncPort } from 'app/pages/instances/instances.constants';
IxListComponent,
IxListItemComponent,
MatTooltip,
IxComboboxComponent,
],
templateUrl: './instance-edit-form.component.html',
styleUrls: ['./instance-edit-form.component.scss'],
Expand All @@ -61,8 +67,9 @@ export class InstanceEditFormComponent {
title: string;
editingInstance: VirtualizationInstance;
poolOptions$ = this.api.call('virt.global.pool_choices').pipe(choicesToOptions());
readonly imageOsProvider = new SimpleAsyncComboboxProvider(of(mapToOptions(imageOsLabels, this.translate)));

protected readonly containersHelptext = instancesHelptext;
protected readonly instancesHelptext = instancesHelptext;

get isVm(): boolean {
return this.editingInstance.type === VirtualizationType.Vm;
Expand All @@ -84,6 +91,7 @@ export class InstanceEditFormComponent {
vnc_port: [defaultVncPort as number | null, [Validators.min(5900), Validators.max(65535)]],
vnc_password: [null as string | null],
secure_boot: [false],
image_os: ['' as ImageOs | null],
environmentVariables: new FormArray<InstanceEnvVariablesFormGroup>([]),
});

Expand Down Expand Up @@ -112,6 +120,7 @@ export class InstanceEditFormComponent {
vnc_port: this.editingInstance.vnc_port,
vnc_password: this.editingInstance.vnc_password,
secure_boot: this.editingInstance.secure_boot,
image_os: this.editingInstance?.image?.os as ImageOs,
});

this.setVncControls();
Expand Down Expand Up @@ -177,6 +186,7 @@ export class InstanceEditFormComponent {

if (this.isVm) {
payload.secure_boot = values.secure_boot;
payload.image_os = values.image_os;
}

return payload;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ <h3 mat-card-title>
</p>
<p>
<span class="label">{{ 'Base Image' | translate }}:</span>
{{ instance.image.description || '-' }}
{{ instance.image.description || instance.image.os || '-' }}
</p>
<p>
<span class="label">{{ 'CPU' | translate }}:</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@
(click)="onSelectRootVolume()"
>{{ 'Select Volume' | translate }}</button>
</div>

@if (form.value.volume_type === VolumeContentType.Iso) {
<ix-combobox
formControlName="image_os"
[label]="'OS Type' | translate"
[allowCustomValue]="true"
[tooltip]="instancesHelptext.osImage.tooltip| translate"
[provider]="imageOsProvider"
></ix-combobox>
}
}
}
</ix-form-section>
Expand Down
Loading
Loading