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.
663 lines
20 KiB
JavaScript
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;
|