Skip to content

Commit

Permalink
fix(instance) prevent local volume attach to instances on another clu…
Browse files Browse the repository at this point in the history
…ster member

Signed-off-by: David Edler <[email protected]>
  • Loading branch information
edlerd committed Oct 16, 2024
1 parent 6b4dc10 commit ae9d3e8
Show file tree
Hide file tree
Showing 12 changed files with 140 additions and 16 deletions.
14 changes: 10 additions & 4 deletions src/api/storage-pools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,18 @@ export const createStorageVolume = (
pool: string,
project: string,
volume: Partial<LxdStorageVolume>,
target?: string,
): Promise<void> => {
const targetParam = target ? `&target=${target}` : "";

return new Promise((resolve, reject) => {
fetch(`/1.0/storage-pools/${pool}/volumes?project=${project}`, {
method: "POST",
body: JSON.stringify(volume),
})
fetch(
`/1.0/storage-pools/${pool}/volumes?project=${project}${targetParam}`,
{
method: "POST",
body: JSON.stringify(volume),
},
)
.then(handleResponse)
.then(resolve)
.catch(reject);
Expand Down
7 changes: 6 additions & 1 deletion src/components/forms/DiskDeviceFormCustom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const DiskDeviceFormCustom: FC<Props> = ({
</div>
{!readOnly && (
<CustomVolumeSelectBtn
formik={formik}
project={project}
setValue={(volume) => changeVolume(volume, formVolume, index)}
buttonProps={{
Expand Down Expand Up @@ -263,7 +264,11 @@ const DiskDeviceFormCustom: FC<Props> = ({
</>
)}
{!readOnly && (
<CustomVolumeSelectBtn project={project} setValue={addVolume}>
<CustomVolumeSelectBtn
formik={formik}
project={project}
setValue={addVolume}
>
<Icon name="plus" />
<span>Attach disk device</span>
</CustomVolumeSelectBtn>
Expand Down
1 change: 1 addition & 0 deletions src/pages/instances/CreateInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ const CreateInstance: FC = () => {
devices: [],
readOnly: false,
entityType: "instance",
isCreating: true,
},
validationSchema: InstanceSchema,
onSubmit: (values) => {
Expand Down
1 change: 1 addition & 0 deletions src/pages/instances/EditInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface InstanceEditDetailsFormValues {
profiles: string[];
entityType: "instance";
readOnly: boolean;
isCreating: boolean;
}

export type EditInstanceFormValues = InstanceEditDetailsFormValues &
Expand Down
1 change: 1 addition & 0 deletions src/pages/instances/forms/InstanceCreateDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface InstanceDetailsFormValues {
target?: string;
entityType: "instance";
readOnly: boolean;
isCreating: boolean;
}

export const instanceDetailPayload = (values: CreateInstanceFormValues) => {
Expand Down
40 changes: 35 additions & 5 deletions src/pages/storage/CustomVolumeCreateModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,41 @@ import {
volumeFormToPayload,
} from "pages/storage/forms/StorageVolumeForm";
import { useFormik } from "formik";
import { createStorageVolume } from "api/storage-pools";
import { createStorageVolume, fetchStoragePools } from "api/storage-pools";
import { queryKeys } from "util/queryKeys";
import * as Yup from "yup";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import StorageVolumeFormMain from "pages/storage/forms/StorageVolumeFormMain";
import { updateMaxHeight } from "util/updateMaxHeight";
import useEventListener from "@use-it/event-listener";
import { testDuplicateStorageVolumeName } from "util/storageVolume";
import { LxdStorageVolume } from "types/storage";
import { useSettings } from "context/useSettings";

interface Props {
project: string;
instanceLocation?: string;
onCancel: () => void;
onFinish: (volume: LxdStorageVolume) => void;
}

const CustomVolumeCreateModal: FC<Props> = ({
project,
instanceLocation,
onCancel,
onFinish,
}) => {
const notify = useNotify();
const queryClient = useQueryClient();
const controllerState = useState<AbortController | null>(null);

const { data: settings } = useSettings();

const { data: pools = [] } = useQuery({
queryKey: [queryKeys.storage],
queryFn: () => fetchStoragePools(project),
});

const StorageVolumeSchema = Yup.object().shape({
name: Yup.string()
.test(
Expand All @@ -38,6 +48,14 @@ const CustomVolumeCreateModal: FC<Props> = ({
.required("This field is required"),
});

const isLocalPool = (poolName: string) => {
const pool = pools.find((pool) => pool.name === poolName);
const driverDetails = settings?.environment?.storage_supported_drivers.find(
(driver) => driver.Name === pool?.driver,
);
return !driverDetails?.Remote ?? false;
};

const formik = useFormik<StorageVolumeFormValues>({
initialValues: {
content_type: "filesystem",
Expand All @@ -53,7 +71,9 @@ const CustomVolumeCreateModal: FC<Props> = ({
validationSchema: StorageVolumeSchema,
onSubmit: (values) => {
const volume = volumeFormToPayload(values, project);
createStorageVolume(values.pool, project, volume)
const target = isLocalPool(values.pool) ? instanceLocation : undefined;

createStorageVolume(values.pool, project, volume, target)
.then(() => {
void queryClient.invalidateQueries({
queryKey: [queryKeys.storage],
Expand All @@ -71,6 +91,12 @@ const CustomVolumeCreateModal: FC<Props> = ({
},
});

const validPool =
!isLocalPool(formik.values.pool) || instanceLocation !== "any";
const poolError = validPool
? undefined
: "Please select a remote storage pool, or set a cluster member for the instance";

const updateFormHeight = () => {
updateMaxHeight("volume-create-form", "p-modal__footer", 32, undefined, []);
};
Expand All @@ -80,7 +106,11 @@ const CustomVolumeCreateModal: FC<Props> = ({
return (
<>
<div className="volume-create-form">
<StorageVolumeFormMain formik={formik} project={project} />
<StorageVolumeFormMain
formik={formik}
project={project}
poolError={poolError}
/>
</div>
<footer className="p-modal__footer">
<Button
Expand All @@ -94,7 +124,7 @@ const CustomVolumeCreateModal: FC<Props> = ({
appearance="positive"
className="u-no-margin--bottom"
onClick={() => void formik.submitForm()}
disabled={!formik.isValid}
disabled={!formik.isValid || !validPool}
loading={formik.isSubmitting}
>
Create volume
Expand Down
14 changes: 13 additions & 1 deletion src/pages/storage/CustomVolumeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import CustomVolumeSelectModal from "pages/storage/CustomVolumeSelectModal";
import CustomVolumeCreateModal from "pages/storage/CustomVolumeCreateModal";
import { Modal } from "@canonical/react-components";
import { LxdStorageVolume } from "types/storage";
import { InstanceAndProfileFormikProps } from "components/forms/instanceAndProfileFormValues";
import { getInstanceLocation } from "util/instanceLocation";

interface Props {
formik: InstanceAndProfileFormikProps;
project: string;
onFinish: (volume: LxdStorageVolume) => void;
onCancel: () => void;
Expand All @@ -13,12 +16,19 @@ interface Props {
const SELECT_VOLUME = "selectVolume";
const CREATE_VOLUME = "createVolume";

const CustomVolumeModal: FC<Props> = ({ project, onFinish, onCancel }) => {
const CustomVolumeModal: FC<Props> = ({
formik,
project,
onFinish,
onCancel,
}) => {
const [content, setContent] = useState(SELECT_VOLUME);
const [primaryVolume, setPrimaryVolume] = useState<
LxdStorageVolume | undefined
>(undefined);

const instanceLocation = getInstanceLocation(formik);

const handleCreateVolume = (volume: LxdStorageVolume) => {
setContent(SELECT_VOLUME);
setPrimaryVolume(volume);
Expand All @@ -36,6 +46,7 @@ const CustomVolumeModal: FC<Props> = ({ project, onFinish, onCancel }) => {
<CustomVolumeSelectModal
project={project}
primaryVolume={primaryVolume}
instanceLocation={instanceLocation}
onFinish={onFinish}
onCancel={onCancel}
onCreate={() => setContent(CREATE_VOLUME)}
Expand All @@ -44,6 +55,7 @@ const CustomVolumeModal: FC<Props> = ({ project, onFinish, onCancel }) => {
{content === CREATE_VOLUME && (
<CustomVolumeCreateModal
project={project}
instanceLocation={instanceLocation}
onCancel={() => setContent(SELECT_VOLUME)}
onFinish={handleCreateVolume}
/>
Expand Down
4 changes: 4 additions & 0 deletions src/pages/storage/CustomVolumeSelectBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import { Button, ButtonProps } from "@canonical/react-components";
import usePortal from "react-useportal";
import CustomVolumeModal from "pages/storage/CustomVolumeModal";
import { LxdStorageVolume } from "types/storage";
import { InstanceAndProfileFormikProps } from "components/forms/instanceAndProfileFormValues";

interface Props {
formik: InstanceAndProfileFormikProps;
children: ReactNode;
buttonProps?: ButtonProps;
project: string;
setValue: (volume: LxdStorageVolume) => void;
}

const CustomVolumeSelectBtn: FC<Props> = ({
formik,
children,
buttonProps,
project,
Expand All @@ -34,6 +37,7 @@ const CustomVolumeSelectBtn: FC<Props> = ({
{isOpen && (
<Portal>
<CustomVolumeModal
formik={formik}
project={project}
onFinish={handleFinish}
onCancel={handleCancel}
Expand Down
39 changes: 36 additions & 3 deletions src/pages/storage/CustomVolumeSelectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { LxdStorageVolume } from "types/storage";
import NotificationRow from "components/NotificationRow";
import { renderContentType } from "util/storageVolume";
import { useSupportedFeatures } from "context/useSupportedFeatures";
import classnames from "classnames";

interface Props {
project: string;
primaryVolume?: LxdStorageVolume;
instanceLocation?: string;
onFinish: (volume: LxdStorageVolume) => void;
onCancel: () => void;
onCreate: () => void;
Expand All @@ -21,6 +23,7 @@ interface Props {
const CustomVolumeSelectModal: FC<Props> = ({
project,
primaryVolume,
instanceLocation,
onFinish,
onCancel,
onCreate,
Expand Down Expand Up @@ -51,6 +54,7 @@ const CustomVolumeSelectModal: FC<Props> = ({
const headers = [
{ content: "Name" },
{ content: "Pool" },
...(instanceLocation ? [{ content: "Location" }] : []),
{ content: "Content type" },
{ content: "Used by" },
{ "aria-label": "Actions", className: "actions" },
Expand All @@ -61,10 +65,28 @@ const CustomVolumeSelectModal: FC<Props> = ({
: volumes
.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.map((volume) => {
const selectVolume = () => handleSelect(volume);
const isLocalVolume = !!volume.location; // if location is set, it's a local volume, otherwise a remote volume
const isDisabled =
instanceLocation !== undefined &&
isLocalVolume &&
instanceLocation !== volume.location;

const disableReason = isDisabled
? `Instance location (${instanceLocation}) does not match local volume location (${volume.location}). `
: undefined;

const selectVolume = () => {
if (disableReason) {
return;
}
handleSelect(volume);
};

return {
className: "u-row",
className: classnames("u-row", {
"u-text--muted": isDisabled,
"u-row--disabled": isDisabled,
}),
columns: [
{
content: (
Expand All @@ -85,6 +107,16 @@ const CustomVolumeSelectModal: FC<Props> = ({
"aria-label": "Storage pool",
onClick: selectVolume,
},
...(instanceLocation
? [
{
content: volume.location,
role: "cell",
"aria-label": "Location",
onClick: selectVolume,
},
]
: []),
{
content: renderContentType(volume),
role: "cell",
Expand All @@ -109,7 +141,8 @@ const CustomVolumeSelectModal: FC<Props> = ({
? "positive"
: ""
}
aria-label={`Select ${volume.name}`}
title={isDisabled ? disableReason : `Select ${volume.name}`}
disabled={isDisabled}
>
Select
</Button>
Expand Down
6 changes: 5 additions & 1 deletion src/pages/storage/forms/StorageVolumeFormMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import ScrollableForm from "components/ScrollableForm";
interface Props {
formik: FormikProps<StorageVolumeFormValues>;
project: string;
poolError?: string;
}

const StorageVolumeFormMain: FC<Props> = ({ formik, project }) => {
const StorageVolumeFormMain: FC<Props> = ({ formik, project, poolError }) => {
return (
<ScrollableForm>
<Row>
Expand All @@ -32,6 +33,9 @@ const StorageVolumeFormMain: FC<Props> = ({ formik, project }) => {
value={formik.values.pool}
setValue={(val) => void formik.setFieldValue("pool", val)}
hidePoolsWithUnsupportedDrivers
selectProps={{
error: poolError,
}}
/>
</>
)}
Expand Down
27 changes: 27 additions & 0 deletions src/util/instanceLocation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FormikProps } from "formik/dist/types";
import { CreateInstanceFormValues } from "pages/instances/CreateInstance";
import { EditInstanceFormValues } from "pages/instances/EditInstance";
import { InstanceAndProfileFormikProps } from "components/forms/instanceAndProfileFormValues";
import { useSettings } from "context/useSettings";
import { isClusteredServer } from "util/settings";

export const getInstanceLocation = (formik: InstanceAndProfileFormikProps) => {
const { data: settings } = useSettings();
const isClustered = isClusteredServer(settings);

if (!isClustered) {
return undefined;
}

if (formik.values.entityType !== "instance") {
return undefined;
}

if (formik.values.isCreating) {
const createFormik = formik as FormikProps<CreateInstanceFormValues>;
return createFormik.values.target ?? "any";
}

const editFormik = formik as FormikProps<EditInstanceFormValues>;
return editFormik.values.location;
};
2 changes: 1 addition & 1 deletion tests/helpers/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const attachVolume = async (
await page.getByRole("button", { name: "Create volume" }).click();
await page.getByPlaceholder("Enter name").fill(volume);
await page.getByRole("button", { name: "Create volume" }).click();
await page.getByLabel(`Select ${volume}`, { exact: true }).click();
await page.getByTitle(`Select ${volume}`, { exact: true }).click();
await page.getByPlaceholder("Enter full path (e.g. /data)").last().fill(path);
};

Expand Down

0 comments on commit ae9d3e8

Please sign in to comment.