debian-image-builder-frontend/src/Components/CreateImageWizard/CreateImageWizard.js
lucasgarfield 89f1da11bf API: Move notification dispatch to Image Builder API slice
This commit moves the notification dispatching for creating composes and
clones into a more sensible location – the Image Builder API slice.

It is more sensible because it separates the logic of the React
component (the wizard or share images modal) from the logic of handling
the request life cycle (which is now handled entirely in the slice).

There is a subtle but significant change – a new request will be
dispatched for every request. This is the correct way to do things as it
is possible that some requests succeed, and that others fail. Insights
causes the notifications to stack on top of each other neatly, so there
is no UI problem.

To facilitate this, we also need to use use Promise.allSettled instead
of Promise.all.
2023-10-13 11:44:33 +02:00

663 lines
20 KiB
JavaScript

import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { useFlag } from '@unleash/proxy-client-react';
import { useNavigate, useParams } from 'react-router-dom';
import ImageCreator from './ImageCreator';
import {
awsTarget,
fileSystemConfiguration,
googleCloudTarget,
imageName,
imageOutput,
msAzureTarget,
packages,
packagesContentSources,
registration,
repositories,
review,
oscap,
} from './steps';
import {
fileSystemConfigurationValidator,
targetEnvironmentValidator,
} from './validators';
import './CreateImageWizard.scss';
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../constants';
import {
useComposeImageMutation,
useGetArchitecturesQuery,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
import { useGetEnvironment } from '../../Utilities/useGetEnvironment';
import DocumentationButton from '../sharedComponents/DocumentationButton';
const handleKeyDown = (e, handleClose) => {
if (e.key === 'Escape') {
handleClose();
}
};
const onSave = (values) => {
const customizations = {
packages: values['selected-packages']?.map((p) => p.name),
};
if (values['payload-repositories']?.length > 0) {
customizations['payload_repositories'] = [
...values['payload-repositories'],
];
}
if (values['custom-repositories']?.length > 0) {
customizations['custom_repositories'] = [...values['custom-repositories']];
}
if (values['register-system'] === 'register-now-rhc') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
rhc: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now-insights') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: false,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
}
if (values['file-system-config-radio'] === 'manual') {
customizations.filesystem = [];
for (const fsc of values['file-system-configuration']) {
customizations.filesystem.push({
mountpoint: fsc.mountpoint,
min_size: fsc.size * fsc.unit,
});
}
}
if (values['oscap-policy']) {
customizations.openscap = {
profile_id: values['oscap-policy'],
};
}
const requests = [];
if (values['target-environment']?.aws) {
const options =
values['aws-target-type'] === 'aws-target-type-source'
? { share_with_sources: [values['aws-sources-select']] }
: { share_with_accounts: [values['aws-account-id']] };
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options,
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
if (values['image_sharing'] === 'gcp-account') {
switch (values['google-account-type']) {
case 'googleAccount':
share = `user:${values['google-email']}`;
break;
case 'serviceAccount':
share = `serviceAccount:${values['google-email']}`;
break;
case 'googleGroup':
share = `group:${values['google-email']}`;
break;
case 'domain':
share = `domain:${values['google-domain']}`;
break;
// no default
}
}
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {},
},
},
],
customizations,
};
if (share !== '') {
request.options = [share];
}
requests.push(request);
}
if (values['target-environment']?.azure) {
const upload_options =
values['azure-type'] === 'azure-type-source'
? { source_id: values['azure-sources-select'] }
: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
};
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
...upload_options,
resource_group: values['azure-resource-group'],
},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.vsphere) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['vsphere-ova']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere-ova',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['guest-image']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['image-installer']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.wsl) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'wsl',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
return requests;
};
const parseSizeUnit = (bytesize) => {
let size;
let unit;
if (bytesize % UNIT_GIB === 0) {
size = bytesize / UNIT_GIB;
unit = UNIT_GIB;
} else if (bytesize % UNIT_MIB === 0) {
size = bytesize / UNIT_MIB;
unit = UNIT_MIB;
} else if (bytesize % UNIT_KIB === 0) {
size = bytesize / UNIT_KIB;
unit = UNIT_KIB;
}
return [size, unit];
};
// map the compose request object to the expected form state
const requestToState = (composeRequest, distroInfo, isBeta, isProd) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
const formState = {};
formState['image-name'] = composeRequest.image_name;
formState['image-description'] = composeRequest.image_description;
formState.release = composeRequest?.distribution;
// set defaults for target environment first
formState['target-environment'] = {
aws: false,
azure: false,
gcp: false,
'guest-image': false,
};
// then select the one from the request
// if the image type is to a cloud provider we use the upload_request.type
// or if the image is intended for download we use the image_type
let targetEnvironment;
if (uploadRequest.type === 'aws.s3') {
targetEnvironment = imageRequest.image_type;
} else {
targetEnvironment = uploadRequest.type;
}
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
const shareWithSource = uploadRequest?.options?.share_with_sources?.[0];
const shareWithAccount = uploadRequest?.options?.share_with_accounts?.[0];
formState['aws-sources-select'] = shareWithSource;
formState['aws-account-id'] = shareWithAccount;
if (shareWithAccount && !shareWithSource) {
formState['aws-target-type'] = 'aws-target-type-account-id';
} else {
// if both shareWithAccount & shareWithSource are present, set radio
// to sources - this is essentially an arbitrary decision
// additionally, note that the source is not validated against the actual
// sources
formState['aws-target-type'] = 'aws-target-type-source';
}
} else if (targetEnvironment === 'azure') {
if (uploadRequest?.options?.source_id) {
formState['azure-type'] = 'azure-type-source';
formState['azure-sources-select'] = uploadRequest?.options?.source_id;
} else {
formState['azure-type'] = 'azure-type-manual';
formState['azure-tenant-id'] = uploadRequest?.options?.tenant_id;
formState['azure-subscription-id'] =
uploadRequest?.options?.subscription_id;
}
formState['azure-resource-group'] =
uploadRequest?.options?.resource_group;
} else if (targetEnvironment === 'gcp') {
// parse google account info
// roughly in the format `accountType:accountEmail`
const accountInfo = uploadRequest?.options?.share_with_accounts[0];
const [accountTypePrefix, account] = accountInfo.split(':');
switch (accountTypePrefix) {
case 'user':
formState['google-account-type'] = 'googleAccount';
formState['google-email'] = account;
break;
case 'serviceAccount':
formState['google-account-type'] = 'serviceAccount';
formState['google-email'] = account;
break;
case 'group':
formState['google-account-type'] = 'googleGroup';
formState['google-email'] = account;
break;
case 'domain':
formState['google-account-type'] = 'domain';
formState['google-domain'] = account;
break;
// no default
}
}
// customizations
// packages
const packages = composeRequest?.customizations?.packages?.map((name) => {
return {
name: name,
summary: undefined,
};
});
formState['selected-packages'] = packages;
// repositories
// 'original-payload-repositories' is treated as read-only and is used to populate
// the table in the repositories step
// This is necessary because there may be repositories present in the request's
// json blob that are not managed using the content sources API. In that case,
// they are still displayed in the table of repositories but without any information
// from the content sources API (in other words, only the URL of the repository is
// displayed). This information needs to persist throughout the lifetime of the
// Wizard as it is needed every time the repositories step is visited.
formState['original-payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// 'payload-repositories' is mutable and is used to generate the request
// sent to image-builder
formState['payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// these will be overwritten by the repositories step if revisited, and generated from the payload repositories added there
formState['custom-repositories'] =
composeRequest?.customizations?.custom_repositories;
// filesystem
const fs = composeRequest?.customizations?.filesystem;
if (fs) {
formState['file-system-config-radio'] = 'manual';
const fileSystemConfiguration = [];
for (const fsc of fs) {
const [size, unit] = parseSizeUnit(fsc.min_size);
fileSystemConfiguration.push({
mountpoint: fsc.mountpoint,
size,
unit,
});
}
formState['file-system-configuration'] = fileSystemConfiguration;
}
// subscription
const subscription = composeRequest?.customizations?.subscription;
if (subscription) {
if (subscription.rhc) {
formState['register-system'] = 'register-now-rhc';
} else if (subscription.insights) {
formState['register-system'] = 'register-now-insights';
} else {
formState['register-system'] = 'register-now';
}
formState['subscription-activation-key'] = subscription['activation-key'];
formState['subscription-organization-id'] = subscription.organization;
if (isProd) {
formState['subscription-server-url'] = 'subscription.rhsm.redhat.com';
formState['subscription-base-url'] = 'https://cdn.redhat.com/';
} else {
formState['subscription-server-url'] =
'subscription.rhsm.stage.redhat.com';
formState['subscription-base-url'] = 'https://cdn.stage.redhat.com/';
}
} else {
formState['register-system'] = 'register-later';
}
// oscap policy
if (isBeta) {
formState['oscap-policy'] =
composeRequest?.customizations?.openscap?.profile_id;
}
return formState;
} else {
return;
}
};
const formStepHistory = (composeRequest, contentSourcesEnabled, isBeta) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
// the order of steps must match the order of the steps in the Wizard
const steps = ['image-output'];
if (uploadRequest.type === 'aws') {
steps.push('aws-target-env');
} else if (uploadRequest.type === 'azure') {
steps.push('ms-azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
if (isBeta) {
steps.push('Compliance');
}
if (contentSourcesEnabled) {
steps.push('File system configuration', 'packages', 'repositories');
const customRepositories =
composeRequest.customizations?.payload_repositories;
if (customRepositories) {
steps.push('packages-content-sources');
}
} else {
steps.push('File system configuration', 'packages');
}
steps.push('details');
return steps;
} else {
return [];
}
};
const CreateImageWizard = () => {
const [composeImage] = useComposeImageMutation();
const navigate = useNavigate();
// composeId is an optional param that is used for Recreate image
const { composeId } = useParams();
const { data } = useGetComposeStatusQuery(
{ composeId: composeId },
{
skip: composeId ? false : true,
}
);
const composeRequest = composeId ? data?.request : undefined;
const contentSourcesEnabled = useFlag('image-builder.enable-content-sources');
// TODO: This causes an annoying re-render when using Recreate image
const { data: distroInfo } = useGetArchitecturesQuery(
{ distribution: composeRequest?.distribution },
{
// distroInfo is only needed when recreating an image, skip otherwise
skip: composeId ? false : true,
}
);
// Assume that if a request is available that we should start on review step
// This will occur if 'Recreate image' is clicked
const initialStep = composeRequest ? 'review' : undefined;
const { isBeta, isProd } = useGetEnvironment();
let initialState = requestToState(
composeRequest,
distroInfo,
isBeta(),
isProd()
);
const stepHistory = formStepHistory(
composeRequest,
contentSourcesEnabled,
isBeta()
);
if (initialState) {
initialState.isBeta = isBeta();
initialState.contentSourcesEnabled = contentSourcesEnabled;
} else {
initialState = {
isBeta: isBeta(),
contentSourcesEnabled,
};
}
const handleClose = () => navigate(resolveRelPath(''));
// In case the `created_at` date is undefined when creating an image
// a temporary value with current date is added
const currentDate = new Date();
return (
<ImageCreator
onClose={handleClose}
onSubmit={async ({ values, setIsSaving }) => {
setIsSaving(true);
const requests = onSave(values);
await Promise.allSettled(
requests.map((composeRequest) => composeImage({ composeRequest }))
);
navigate(resolveRelPath(''));
}}
defaultArch="x86_64"
customValidatorMapper={{
fileSystemConfigurationValidator,
targetEnvironmentValidator,
}}
schema={{
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'imageBuilder',
isDynamic: true,
inModal: false,
onKeyDown: (e) => {
handleKeyDown(e, handleClose);
},
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
title: 'Image Builder',
crossroads: [
'target-environment',
'release',
'payload-repositories',
],
description: (
<>
Image builder allows you to create a custom image and push it to
target environments. <DocumentationButton />
</>
),
// order in this array does not reflect order in wizard nav, this order is managed inside
// of each step by `nextStep` property!
fields: [
imageOutput,
awsTarget,
googleCloudTarget,
msAzureTarget,
registration,
packages,
packagesContentSources,
repositories,
fileSystemConfiguration,
imageName,
review,
oscap,
],
initialState: {
activeStep: initialStep || 'image-output', // name of the active step
activeStepIndex: stepHistory.length, // active index
maxStepIndex: stepHistory.length, // max achieved index
prevSteps: stepHistory, // array with names of previously visited steps
},
},
],
}}
initialValues={initialState}
/>
);
};
export default CreateImageWizard;