Skip to content
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
47 changes: 44 additions & 3 deletions src/components/create-vm-dialog/createVmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
} from "./createVmDialogUtils.js";
import { domainCreate } from '../../libvirtApi/domain.js';
import { storagePoolRefresh, storagePoolGetAll } from '../../libvirtApi/storagePool.js';
import { getInstallMediaPools } from '../../libvirtApi/installMediaPools.js';
import { getAccessToken } from '../../libvirtApi/rhel-images.js';
import { getOsInfoList } from '../../libvirtApi/common.js';
import { PasswordFormFields } from 'cockpit-components-password.jsx';
Expand Down Expand Up @@ -335,7 +336,8 @@ const SourceRow = ({
downloadOSSupported,
offlineToken,
onValueChanged,
validationFailed
validationFailed,
installMediaSourceKey,
} : {
connectionName: ConnectionName,
source: optString,
Expand All @@ -349,6 +351,7 @@ const SourceRow = ({
offlineToken: optString,
onValueChanged: OnValueChanged,
validationFailed: ValidationFailed,
installMediaSourceKey: number,
}) => {
let installationSource;
let installationSourceId;
Expand All @@ -359,7 +362,8 @@ const SourceRow = ({
case LOCAL_INSTALL_MEDIA_SOURCE:
installationSourceId = "source-file";
installationSource = (
<FileAutoComplete id={installationSourceId}
<FileAutoComplete key={installMediaSourceKey}
id={installationSourceId}
placeholder={_("Path to ISO file on host's file system")}
onChange={(value: string) => onValueChanged('source', value)}
value={source || ''}
Expand Down Expand Up @@ -1218,6 +1222,9 @@ interface CreateVmModalState extends VmParams {
minimumStorage: number;
unattendedInstallation?: boolean;
sourceMediaID?: string;
// bumped once when the install source is pre-filled, to remount the
// FileAutoComplete so it picks up the new value (it only reads `value` on mount)
installMediaSourceKey: number;
}

export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmModalState> {
Expand Down Expand Up @@ -1255,6 +1262,7 @@ export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmM
: LIBVIRT_SYSTEM_CONNECTION),
sourceType: defaultSourceType,
source: props.initialSource || '',
installMediaSourceKey: 0,
os: undefined,
...getMemoryDefaults(props.nodeMaxMemory),
...getStorageDefaults(),
Expand Down Expand Up @@ -1308,6 +1316,11 @@ export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmM

this.setState({ osInfoListLoading: false, osInfoList });

// Pre-fill the local install-media source from a pool the user marked
// for installation media (also runs when switching to that source type).
if (this.state.sourceType == LOCAL_INSTALL_MEDIA_SOURCE)
await this.prefillInstallMediaSource();

// If initialOS was provided via props, find and set the matching OS
if (this.props.initialOS) {
const matchingOS = osInfoList.find(os => os.shortId === this.props.initialOS);
Expand All @@ -1317,6 +1330,31 @@ export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmM
}
}

// Default the local install-media source to the directory of a storage pool
// the user marked for installation media, so the path picker opens there
// instead of at the filesystem root. Only when no source was provided/typed.
prefillInstallMediaSource = async () => {
if (this.props.initialSource || this.state.source)
return;

try {
const markedUuids = await getInstallMediaPools(this.state.connectionName);
const pool = appState.storagePools.find(
p => p.connectionName == this.state.connectionName &&
!!p.uuid && markedUuids.includes(p.uuid) &&
!!(p.target && p.target.path));
if (pool && pool.target && pool.target.path && !this.state.source) {
const path = pool.target.path.replace(/\/?$/, "/");
this.setState(state => ({
source: path,
installMediaSourceKey: state.installMediaSourceKey + 1,
}));
}
} catch (ex) {
console.warn("Could not pre-fill installation media source:", String(ex));
}
};

handleTabClick = (event: React.MouseEvent, tabIndex: number | string) => {
// Prevent the form from being submitted.
event.preventDefault();
Expand Down Expand Up @@ -1350,6 +1388,8 @@ export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmM
// all the other choices are string set by the user
this.setState({ source: '' });
}
if (value == LOCAL_INSTALL_MEDIA_SOURCE)
this.prefillInstallMediaSource();
break;
case 'storagePool': {
cockpit.assert(typeof value == "string");
Expand Down Expand Up @@ -1572,7 +1612,8 @@ export class CreateVmModal extends React.Component<CreateVmModalProps, CreateVmM
cloudInitSupported={this.props.cloudInitSupported}
downloadOSSupported={this.props.downloadOSSupported}
onValueChanged={this.onValueChanged}
validationFailed={validationFailed} />
validationFailed={validationFailed}
installMediaSourceKey={this.state.installMediaSourceKey} />

{this.state.sourceType != DOWNLOAD_AN_OS &&
<OSRow
Expand Down
48 changes: 47 additions & 1 deletion src/components/storagePools/storagePoolOverviewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,56 @@
*
* Copyright (C) 2018 Red Hat, Inc.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';

import type { StoragePool } from '../../types';

import { DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm } from "@patternfly/react-core/dist/esm/components/DescriptionList";
import { Switch } from "@patternfly/react-core/dist/esm/components/Switch";

import { storagePoolId } from '../../helpers.js';
import { getInstallMediaPools, setInstallMediaPool } from '../../libvirtApi/installMediaPools.js';
import cockpit from 'cockpit';

const _ = cockpit.gettext;

// Pool types whose target is a plain host directory that can hold ISO images.
const INSTALL_MEDIA_POOL_TYPES = ['dir', 'netfs'];

const InstallMediaSwitch = ({ storagePool, idPrefix } : { storagePool: StoragePool, idPrefix: string }) => {
// null while the on-disk config is still loading, so the Switch stays disabled
const [enabled, setEnabled] = useState<boolean | null>(null);
const { connectionName, uuid } = storagePool;

useEffect(() => {
let active = true;
getInstallMediaPools(connectionName).then(pools => {
if (active)
setEnabled(!!uuid && pools.includes(uuid));
});
return () => { active = false };
}, [connectionName, uuid]);

const onChange = (_event: React.FormEvent, checked: boolean) => {
if (!uuid)
return;
// optimistic update; revert if the write fails
setEnabled(checked);
setInstallMediaPool(connectionName, uuid, checked).catch(ex => {
console.warn("Could not update installation-media pool config:", String(ex));
setEnabled(!checked);
});
};

return (
<Switch id={`${idPrefix}-install-media-switch`}
label={_("Use for installation media")}
isChecked={!!enabled}
isDisabled={enabled === null || !uuid}
onChange={onChange} />
);
};

export const StoragePoolOverviewTab = ({ storagePool } : { storagePool: StoragePool }) => {
const idPrefix = `${storagePoolId(storagePool.name, storagePool.connectionName)}`;

Expand Down Expand Up @@ -65,6 +104,13 @@ export const StoragePoolOverviewTab = ({ storagePool } : { storagePool: StorageP
<DescriptionListTerm> {_("Type")} </DescriptionListTerm>
<DescriptionListDescription id={`${idPrefix}-type`}> {storagePool.type} </DescriptionListDescription>
</DescriptionListGroup>

{INSTALL_MEDIA_POOL_TYPES.includes(storagePool.type) && storagePool.uuid && <DescriptionListGroup>
<DescriptionListTerm> {_("Installation media")} </DescriptionListTerm>
<DescriptionListDescription>
<InstallMediaSwitch storagePool={storagePool} idPrefix={idPrefix} />
</DescriptionListDescription>
</DescriptionListGroup>}
</DescriptionList>
);
};
93 changes: 93 additions & 0 deletions src/libvirtApi/installMediaPools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2026 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <https://www.gnu.org/licenses/>.
*/

/* Persistence for "installation media" storage pools.
*
* The user can mark a storage pool whose volumes are installation media (ISO
* images), so the "Create VM" dialog can default its installation source to
* that pool's directory instead of the filesystem root.
*
* libvirt storage pools have no <metadata> element to persist such a flag (and
* the pool 'type' is a fixed enum), so the marks live in a small Cockpit-owned
* file on the host, keyed by pool UUID. Keeping it host-side rather than in the
* browser's localStorage means the choice is shared across browsers and admins.
*/

import cockpit from 'cockpit';

import type { ConnectionName } from '../types';

interface InstallMediaConfig {
pools: string[];
}

const SYSTEM_PATH = "/etc/cockpit/machines/install-media-pools.json";

async function configPath(connectionName: ConnectionName): Promise<string> {
if (connectionName === "system")
return SYSTEM_PATH;

const user = await cockpit.user();
return `${user.home}/.config/cockpit/machines/install-media-pools.json`;
}

function readPools(content: InstallMediaConfig | null): string[] {
return Array.isArray(content?.pools) ? content.pools : [];
}

// Return the UUIDs of pools the user marked as installation-media sources.
// A missing or unreadable config means "nothing marked".
export async function getInstallMediaPools(connectionName: ConnectionName): Promise<string[]> {
try {
const path = await configPath(connectionName);
const opts = connectionName === "system"
? { syntax: JSON, superuser: "try" as const }
: { syntax: JSON };
const content = await cockpit.file<InstallMediaConfig>(path, opts).read();
return readPools(content);
} catch (ex) {
console.warn("Could not read installation-media pool config:", String(ex));
return [];
}
}

// Add (enabled) or remove (!enabled) a pool UUID from the config, rewriting it
// atomically. The containing directory is created on demand.
export async function setInstallMediaPool(
connectionName: ConnectionName,
uuid: string,
enabled: boolean,
): Promise<void> {
const path = await configPath(connectionName);
const dir = path.replace(/\/[^/]+$/, "");
await cockpit.spawn(["mkdir", "-p", dir],
connectionName === "system" ? { superuser: "try" } : {});

const opts = connectionName === "system"
? { syntax: JSON, superuser: "try" as const }
: { syntax: JSON };
await cockpit.file<InstallMediaConfig>(path, opts).modify(content => {
const pools = new Set(readPools(content));
if (enabled)
pools.add(uuid);
else
pools.delete(uuid);
return { pools: Array.from(pools) };
});
}