HMS-4459: Use export endpoint

This commit is contained in:
Anna Vítová 2024-07-29 15:50:36 +02:00 committed by Ondřej Ezr
parent c6553d4cfa
commit c385417e93
9 changed files with 293 additions and 155 deletions

View file

@ -22,6 +22,7 @@ const config: ConfigFile = {
'updateBlueprint',
'composeBlueprint',
'getBlueprints',
'exportBlueprint',
'getBlueprintComposes',
'deleteBlueprint',
'getBlueprint',

View file

@ -12,8 +12,8 @@ import { EllipsisVIcon } from '@patternfly/react-icons';
import { selectSelectedBlueprintId } from '../../store/BlueprintSlice';
import { useAppSelector } from '../../store/hooks';
import {
BlueprintResponse,
useLazyGetBlueprintQuery,
BlueprintExportResponse,
useLazyExportBlueprintQuery,
} from '../../store/imageBuilderApi';
import { useFlagWithEphemDefault } from '../../Utilities/useGetEnvironment';
import BetaLabel from '../sharedComponents/BetaLabel';
@ -34,7 +34,7 @@ export const BlueprintActionsMenu: React.FunctionComponent<
'image-builder.import.enabled'
);
const [trigger] = useLazyGetBlueprintQuery();
const [trigger] = useLazyExportBlueprintQuery();
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
if (selectedBlueprintId === undefined) {
return null;
@ -42,7 +42,7 @@ export const BlueprintActionsMenu: React.FunctionComponent<
const handleClick = () => {
trigger({ id: selectedBlueprintId })
.unwrap()
.then((response: BlueprintResponse) => {
.then((response: BlueprintExportResponse) => {
handleExportBlueprint(response.name, response);
});
};
@ -85,7 +85,7 @@ export const BlueprintActionsMenu: React.FunctionComponent<
async function handleExportBlueprint(
blueprintName: string,
blueprint: BlueprintResponse
blueprint: BlueprintExportResponse
) {
const jsonData = JSON.stringify(blueprint, null, 2);
const blob = new Blob([jsonData], { type: 'application/json' });

View file

@ -4,107 +4,123 @@ import userEvent from '@testing-library/user-event';
import { renderCustomRoutesWithReduxRouter } from '../../test/testUtils';
const BLUEPRINT_JSON = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aarch64",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
"customizations": {
"packages": [],
"subscription": {
"activation-key": "",
"base-url": "",
"insights": false,
"organization": 0,
"server-url": ""
}
},
"description": "Lorem ipsum dolor 2211 sit amet, consectetur adipiscing elit. Pellentesque malesuada ultricies diam ac eleifend. Proin ipsum ante, consequat vel justo vel, tristique vestibulum lorem. Vestibulum sit amet pulvinar orci. Vivamus vel ipsum.",
"distribution": "rhel-8",
"metadata": {
"exported_at": "2024-07-29 17:26:51.666952376 +0000 UTC",
"parent_id": "b3385e6d-ecc4-485c-b33c-f65131c46f52"
},
"name": "Crustless New York Cheesecake 1"
}`;
const INVALID_BLUEPRINT_WITH_SUBSCRIPTION = `{
"customizations": {
"files": [
],
"name": "Blueprint test"
}`;
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
"activation-key": "",
"base-url": "",
"insights": false,
"organization": 0,
"server-url": ""
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"name": "Blueprint test"
}`;
const INVALID_BLUEPRINT_WITH_TARGETS = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
"activation-key": "",
"base-url": "",
"insights": false,
"organization": 0,
"server-url": ""
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aaaaa",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
],
"name": "Blueprint test"
}`;
const INVALID_JSON = `{
"name": "Blueprint test"
}`;
const INVALID_ARCHITECTURE_JSON = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aaaaa",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
],
"name": "Blueprint test"
}`;
const INVALID_IMAGE_TYPE_JSON = `{
"customizations": {
"files": [
@ -202,9 +218,20 @@ describe('Import model', () => {
await waitFor(() => expect(helperText).toBeInTheDocument());
});
test('should show alert on invalid blueprint incorrect architecture', async () => {
test('should show alert on invalid blueprint with targets', async () => {
await setUp();
await uploadFile(`blueprints.json`, INVALID_ARCHITECTURE_JSON);
await uploadFile(`blueprints.json`, INVALID_BLUEPRINT_WITH_TARGETS);
const reviewButton = screen.getByTestId('import-blueprint-finish');
expect(reviewButton).toHaveClass('pf-m-disabled');
const helperText = await screen.findByText(
/not compatible with the blueprints format\./i
);
await waitFor(() => expect(helperText).toBeInTheDocument());
});
test('should show alert on invalid blueprint with subscription', async () => {
await setUp();
await uploadFile(`blueprints.json`, INVALID_BLUEPRINT_WITH_SUBSCRIPTION);
const reviewButton = screen.getByTestId('import-blueprint-finish');
expect(reviewButton).toHaveClass('pf-m-disabled');
const helperText = await screen.findByText(

View file

@ -17,10 +17,10 @@ import { addNotification } from '@redhat-cloud-services/frontend-components-noti
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '../../store/hooks';
import { BlueprintResponse } from '../../store/imageBuilderApi';
import { BlueprintExportResponse } from '../../store/imageBuilderApi';
import { wizardState } from '../../store/wizardSlice';
import { resolveRelPath } from '../../Utilities/path';
import { mapRequestToState } from '../CreateImageWizard/utilities/requestMapper';
import { mapExportRequestToState } from '../CreateImageWizard/utilities/requestMapper';
interface ImportBlueprintModalProps {
setShowImportModal: React.Dispatch<React.SetStateAction<boolean>>;
@ -64,8 +64,8 @@ export const ImportBlueprintModal: React.FunctionComponent<
};
const handleDataChange = (_: DropEvent, value: string) => {
try {
const importedBlueprint: BlueprintResponse = JSON.parse(value);
const importBlueprintState = mapRequestToState(importedBlueprint);
const importedBlueprint: BlueprintExportResponse = JSON.parse(value);
const importBlueprintState = mapExportRequestToState(importedBlueprint);
setImportedBlueprint(importBlueprintState);
setJsonContent(value);
} catch (error) {

View file

@ -77,7 +77,7 @@ import {
} from '../../../../store/wizardSlice';
import useDebounce from '../../../../Utilities/useDebounce';
type PackageRepository = 'distro' | 'custom' | 'recommended' | '';
export type PackageRepository = 'distro' | 'custom' | 'recommended' | '';
export type IBPackageWithRepositoryInfo = {
name: Package['name'];

View file

@ -14,6 +14,7 @@ import { RootState } from '../../../store';
import {
AwsUploadRequestOptions,
AzureUploadRequestOptions,
BlueprintExportResponse,
BlueprintResponse,
CreateBlueprintRequest,
Customizations,
@ -62,6 +63,8 @@ import {
selectSnapshotDate,
selectUseLatest,
selectFirstBootScript,
selectMetadata,
initialState,
} from '../../../store/wizardSlice';
import {
convertMMDDYYYYToYYYYMMDD,
@ -73,6 +76,7 @@ import {
Partition,
Units,
} from '../steps/FileSystem/FileSystemConfiguration';
import { PackageRepository } from '../steps/Packages/Packages';
import {
convertSchemaToIBCustomRepo,
convertSchemaToIBPayloadRepo,
@ -102,6 +106,7 @@ export const mapRequestFromState = (
return {
name: selectBlueprintName(state),
metadata: selectMetadata(state),
description: selectBlueprintDescription(state),
distribution: selectDistribution(state),
image_requests: imageRequests,
@ -137,6 +142,58 @@ const getLatestRelease = (distribution: Distributions) => {
: distribution;
};
function commonRequestToState(
request: BlueprintResponse | BlueprintExportResponse
) {
return {
details: {
blueprintName: request.name,
blueprintDescription: request.description,
},
openScap: {
profile: request.customizations.openscap
?.profile_id as DistributionProfileItem,
},
fileSystem: request.customizations.filesystem
? {
mode: 'manual' as FileSystemConfigurationType,
partitions: request.customizations.filesystem.map((fs) =>
convertFilesystemToPartition(fs)
),
}
: {
mode: 'automatic' as FileSystemConfigurationType,
partitions: [],
},
firstBoot: {
script: getFirstBootScript(request.customizations.files),
},
distribution: getLatestRelease(request.distribution),
repositories: {
customRepositories: request.customizations.custom_repositories || [],
payloadRepositories: request.customizations.payload_repositories || [],
recommendedRepositories: [],
},
packages:
request.customizations.packages
?.filter((pkg) => !pkg.startsWith('@'))
.map((pkg) => ({
name: pkg,
summary: '',
repository: '' as PackageRepository,
})) || [],
groups:
request.customizations.packages
?.filter((grp) => grp.startsWith('@'))
.map((grp) => ({
name: grp.substr(1),
description: '',
repository: '' as PackageRepository,
package_list: [],
})) || [],
};
}
/**
* This function maps the blueprint response to the wizard state, used to populate the wizard with the blueprint details
* @param request BlueprintResponse
@ -168,18 +225,6 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
const azureUploadOptions = azure?.upload_request
.options as AzureUploadRequestOptions;
const fileSystem = request.customizations.filesystem
? {
mode: 'manual' as FileSystemConfigurationType,
partitions: request.customizations.filesystem.map((fs) =>
convertFilesystemToPartition(fs)
),
}
: {
mode: 'automatic' as FileSystemConfigurationType,
partitions: [],
};
const arch = request.image_requests[0].architecture;
if (arch !== 'x86_64' && arch !== 'aarch64') {
throw new Error(`image type: ${arch} has no implementation yet`);
@ -187,24 +232,11 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
return {
wizardMode,
blueprintId: request.id,
details: {
blueprintName: request.name,
blueprintDescription: request.description,
},
env: {
serverUrl: request.customizations.subscription?.['server-url'] || '',
baseUrl: request.customizations.subscription?.['base-url'] || '',
},
openScap: {
profile: request.customizations.openscap
?.profile_id as DistributionProfileItem,
},
fileSystem: fileSystem,
firstBoot: {
script: getFirstBootScript(request.customizations.files),
},
architecture: arch,
distribution: getLatestRelease(request.distribution),
imageTypes: request.image_requests.map((image) => image.image_type),
azure: {
shareMethod: azureUploadOptions?.source_id ? 'sources' : 'manual',
@ -232,11 +264,6 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
useLatest: !snapshot_date,
snapshotDate: snapshot_date,
},
repositories: {
customRepositories: request.customizations.custom_repositories || [],
payloadRepositories: request.customizations.payload_repositories || [],
recommendedRepositories: [],
},
registration: {
registrationType: request.customizations?.subscription
? request.customizations.subscription.rhc
@ -245,23 +272,35 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
: 'register-later',
activationKey: request.customizations.subscription?.['activation-key'],
},
packages:
request.customizations.packages
?.filter((pkg) => !pkg.startsWith('@'))
.map((pkg) => ({
name: pkg,
summary: '',
repository: '',
})) || [],
groups:
request.customizations.packages
?.filter((grp) => grp.startsWith('@'))
.map((grp) => ({
name: grp.substr(1),
description: '',
repository: '',
package_list: [],
})) || [],
...commonRequestToState(request),
};
};
/**
* This function maps the blueprint response to the wizard state, used to populate the wizard with the blueprint details
* @param request BlueprintExportResponse
* @returns wizardState
*/
export const mapExportRequestToState = (
request: BlueprintExportResponse
): wizardState => {
const wizardMode = 'create';
return {
wizardMode,
metadata: {
parent_id: request.metadata.parent_id || '',
exported_at: request.metadata.exported_at,
},
env: initialState.env,
gcp: initialState.gcp,
aws: initialState.aws,
azure: initialState.azure,
architecture: initialState.architecture,
imageTypes: initialState.imageTypes,
snapshotting: initialState.snapshotting,
registration: initialState.registration,
...commonRequestToState(request),
};
};

View file

@ -53,6 +53,12 @@ const injectedRtkApi = api.injectEndpoints({
method: "DELETE",
}),
}),
exportBlueprint: build.query<
ExportBlueprintApiResponse,
ExportBlueprintApiArg
>({
query: (queryArg) => ({ url: `/blueprints/${queryArg.id}/export` }),
}),
composeBlueprint: build.mutation<
ComposeBlueprintApiResponse,
ComposeBlueprintApiArg
@ -212,6 +218,12 @@ export type DeleteBlueprintApiArg = {
/** UUID of a blueprint */
id: string;
};
export type ExportBlueprintApiResponse =
/** status 200 detail of a blueprint */ BlueprintExportResponse;
export type ExportBlueprintApiArg = {
/** UUID of a blueprint */
id: string;
};
export type ComposeBlueprintApiResponse =
/** status 201 compose was created */ ComposeResponse[];
export type ComposeBlueprintApiArg = {
@ -714,6 +726,13 @@ export type BlueprintResponse = {
image_requests: ImageRequest[];
customizations: Customizations;
};
export type BlueprintExportResponse = {
name: string;
description: string;
distribution: Distributions;
customizations: Customizations;
metadata: BlueprintMetadata;
};
export type ComposeResponse = {
id: string;
};
@ -864,6 +883,8 @@ export const {
useGetBlueprintQuery,
useLazyGetBlueprintQuery,
useDeleteBlueprintMutation,
useExportBlueprintQuery,
useLazyExportBlueprintQuery,
useComposeBlueprintMutation,
useGetBlueprintComposesQuery,
useLazyGetBlueprintComposesQuery,

View file

@ -97,9 +97,13 @@ export type wizardState = {
blueprintName: string;
blueprintDescription: string;
};
metadata?: {
parent_id: string;
exported_at: string;
};
};
const initialState: wizardState = {
export const initialState: wizardState = {
env: {
serverUrl: '',
baseUrl: '',
@ -277,6 +281,10 @@ export const selectBlueprintName = (state: RootState) => {
return state.wizard.details.blueprintName;
};
export const selectMetadata = (state: RootState) => {
return state.wizard.metadata;
};
export const selectBlueprintDescription = (state: RootState) => {
return state.wizard.details.blueprintDescription;
};

View file

@ -11,6 +11,30 @@ import { emptyGetBlueprints } from '../../fixtures/blueprints';
import { server } from '../../mocks/server';
import { renderCustomRoutesWithReduxRouter } from '../../testUtils';
vi.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
isBeta: () => true,
isProd: () => true,
getEnvironment: () => 'prod',
}),
}));
vi.mock('@unleash/proxy-client-react', () => ({
useUnleashContext: () => vi.fn(),
useFlag: vi.fn((flag) => {
switch (flag) {
case 'image-builder.firstboot.enabled':
return true;
case 'image-builder.snapshots.enabled':
return true;
case 'image-builder.import.enabled':
return true;
default:
return false;
}
}),
}));
const selectBlueprintById = async (bpId: string) => {
const user = userEvent.setup();
const blueprint = await screen.findByTestId(bpId);
@ -305,4 +329,22 @@ describe('Blueprints', () => {
);
});
});
describe('import/export blueprint', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('exporting blueprint', async () => {
renderCustomRoutesWithReduxRouter();
await selectBlueprintById(blueprintIdWithComposes);
const toggleButton = screen.getByTestId('blueprint-action-menu-toggle');
await user.click(toggleButton);
const downloadButton = screen.getByRole('menuitem', {
name: /download blueprint \(\.json\) preview/i,
});
expect(downloadButton).toBeInTheDocument();
});
});
});