debian-image-builder-frontend/src/Components/CreateImageWizard/CreateImageWizard.js
lucasgarfield a474163343 Wizard: Add ability to specify AWS target using sources
This commit adds the ability to specify AWS targets using the sources
service on insights.

This is the first commit to the codebase that makes use of the new RTK
Query endpoints, so I will provide a bit of additional context here:

The sources are obtained by calling the `useGetAWSSourcesQuery()` hook.
This hook can be called in any component where information about the
sources is needed.

A few tricks are used to make the user experience as responsive as
possible.

The `prefetch()` hook provided by RTK Query is called when the user
clicks on the AWS button on the image output step. This triggers the
initial request for the sources, which will then (hopefully) be ready by the
time the user clicks to the next step (the AWS target environment step)
where they are needed.

Because we anticipate a common user workflow to involve using the Create
image wizard in one browser tab and the sources service in another tab,
sources are also refetched every time the source dropdown is opened.
This means that if a user adds a source while in the middle of using the
wizard, they will be able to see it in the wizard's sources dropdown
without refreshing their browser.

Finally, because of the `Recreate image` feature, the
`useGetAWSSourcesQuery` hook also needs to be called on the review step.
2023-03-01 11:25:28 +01:00

604 lines
18 KiB
JavaScript

import React, { useEffect } from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useDispatch } from 'react-redux';
import { useLocation, useNavigate } from 'react-router-dom';
import ImageCreator from './ImageCreator';
import {
awsTarget,
fileSystemConfiguration,
googleCloudTarger,
imageName,
imageOutput,
msAzureTarget,
packages,
packagesContentSources,
registration,
repositories,
review,
} from './steps';
import {
fileSystemConfigurationValidator,
targetEnvironmentValidator,
} from './validators';
import './CreateImageWizard.scss';
import api from '../../api';
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../constants';
import { getDistroRepoUrls } from '../../repos';
import { composeAdded } from '../../store/composesSlice';
import { fetchRepositories } from '../../store/repositoriesSlice';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
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['custom-repositories']?.length > 0) {
customizations['payload_repositories'] = [...values['custom-repositories']];
}
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,
});
}
}
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_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: options,
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
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;
}
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: [share],
},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.azure) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
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_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
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_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_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
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];
};
const getPackageDescription = async (release, arch, repoUrls, packageName) => {
let pack;
// if the env is stage beta then use content-sources api
// else use image-builder api
if (insights.chrome.isBeta()) {
const data = await api.getPackagesContentSources(repoUrls, packageName);
pack = data.find((pack) => packageName === pack.name);
} else {
const args = [release, arch, packageName];
const response = await api.getPackages(...args);
let { data } = response;
const { meta } = response;
// the package should be found in the 0 index
// if not then fetch all package matches and search for the package
if (data[0]?.name === packageName) {
pack = data[0];
} else {
if (data?.length !== meta.count) {
({ data } = await api.getPackages(...args, meta.count));
}
pack = data.find((pack) => packageName === pack.name);
}
}
const summary = pack?.summary;
// if no matching package is found return an empty string for description
return summary || '';
};
// map the compose request object to the expected form state
const requestToState = (composeRequest) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
const formState = {};
formState['image-name'] = composeRequest.image_name;
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') {
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;
}
}
// customizations
// packages
const packs = [];
const distro = composeRequest?.distribution;
const distroRepoUrls = getDistroRepoUrls(distro);
const payloadRepositories =
composeRequest?.customizations?.payload_repositories?.map(
(repo) => repo.baseurl
);
const repoUrls = [...distroRepoUrls];
payloadRepositories ? repoUrls.push(...payloadRepositories) : null;
composeRequest?.customizations?.packages?.forEach(async (packName) => {
const packageDescription = await getPackageDescription(
distro,
imageRequest?.architecture,
repoUrls,
packName
);
const pack = {
name: packName,
summary: packageDescription,
};
packs.push(pack);
});
formState['selected-packages'] = packs;
// 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;
// 'custom-repositories' is mutable and is used to generate the request
// sent to image-builder
formState['custom-repositories'] =
composeRequest?.customizations?.payload_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.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 (insights.chrome.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';
}
return formState;
} else {
return;
}
};
const formStepHistory = (composeRequest) => {
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('azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
if (insights.chrome.isBeta()) {
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('image-name');
return steps;
} else {
return [];
}
};
const CreateImageWizard = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const composeRequest = location?.state?.composeRequest;
const initialState = requestToState(composeRequest);
const stepHistory = formStepHistory(composeRequest);
const handleClose = () => navigate(resolveRelPath(''));
useEffect(() => {
if (insights.chrome.isBeta()) {
dispatch(fetchRepositories());
}
}, []);
return (
<ImageCreator
onClose={handleClose}
onSubmit={({ values, setIsSaving }) => {
setIsSaving(() => true);
const requests = onSave(values);
Promise.all(
requests.map((request) =>
api.composeImage(request).then((response) => {
dispatch(
composeAdded({
compose: {
...response,
request,
image_status: { status: 'pending' },
},
insert: true,
})
);
})
)
)
.then(() => {
navigate(resolveRelPath(''));
dispatch(
addNotification({
variant: 'success',
title: 'Your image is being created',
})
);
setIsSaving(false);
})
.catch((err) => {
dispatch(
addNotification({
variant: 'danger',
title: 'Your image could not be created',
description:
'Status code ' +
err.response.status +
': ' +
err.response.statusText,
})
);
setIsSaving(false);
});
}}
defaultArch="x86_64"
customValidatorMapper={{
fileSystemConfigurationValidator,
targetEnvironmentValidator,
}}
schema={{
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'imageBuilder',
isDynamic: true,
inModal: true,
onKeyDown: (e) => {
handleKeyDown(e, handleClose);
},
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
title: 'Create image',
crossroads: [
'target-environment',
'release',
'custom-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,
googleCloudTarger,
msAzureTarget,
registration,
packages,
packagesContentSources,
repositories,
fileSystemConfiguration,
imageName,
review,
],
initialState: {
activeStep: location?.state?.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;