src: Add on prem blueprints import support

This commit is contained in:
Anna Vítová 2024-11-05 10:45:22 +02:00 committed by Klara Simickova
parent edb274bf8c
commit 63e9610beb
5 changed files with 358 additions and 42 deletions

9
package-lock.json generated
View file

@ -27,7 +27,8 @@
"react-redux": "9.1.2",
"react-router-dom": "6.27.0",
"redux": "5.0.1",
"redux-promise-middleware": "6.2.0"
"redux-promise-middleware": "6.2.0",
"toml": "^3.0.0"
},
"devDependencies": {
"@babel/core": "7.26.0",
@ -19621,6 +19622,12 @@
"node": ">=0.6"
}
},
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==",
"license": "MIT"
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",

View file

@ -25,7 +25,8 @@
"react-redux": "9.1.2",
"react-router-dom": "6.27.0",
"redux": "5.0.1",
"redux-promise-middleware": "6.2.0"
"redux-promise-middleware": "6.2.0",
"toml": "3.0.0"
},
"devDependencies": {
"@babel/core": "7.26.0",

View file

@ -1,6 +1,7 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { clickNext } from '../../test/Components/CreateImageWizard/wizardTestUtils';
import { renderCustomRoutesWithReduxRouter } from '../../test/testUtils';
const BLUEPRINT_JSON = `{
@ -121,6 +122,58 @@ const INVALID_JSON = `{
"name": "Blueprint test"
}`;
const ONPREM_BLUEPRINT_TOML = `
name = "tmux"
description = "tmux image with openssh"
version = "1.2.16"
distro = "rhel-93"
[[packages]]
name = "tmux"
version = "*"
[[packages]]
name = "openssh-server"
version = "*"
[[groups]]
name = "anaconda-tools"
[customizations]
hostname = "baseimage"
fips = true
[[customizations.sshkey]]
user = "root"
key = "PUBLIC SSH KEY"
[customizations.services]
enabled = ["sshd", "cockpit.socket", "httpd"]
disabled = ["postfix", "telnetd"]
masked = ["rpcbind"]
[[customizations.files]]
data = "W1VuaXRdCkRlc2NyaXB0aW9uPVJ1biBmaXJzdCBib290IHNjcmlwdApDb25kaXRpb25QYXRoRXhpc3RzPS91c3IvbG9jYWwvc2Jpbi9jdXN0b20tZmlyc3QtYm9vdApXYW50cz1uZXR3b3JrLW9ubGluZS50YXJnZXQKQWZ0ZXI9bmV0d29yay1vbmxpbmUudGFyZ2V0CkFmdGVyPW9zYnVpbGQtZmlyc3QtYm9vdC5zZXJ2aWNlCgpbU2VydmljZV0KVHlwZT1vbmVzaG90CkV4ZWNTdGFydD0vdXNyL2xvY2FsL3NiaW4vY3VzdG9tLWZpcnN0LWJvb3QKRXhlY1N0YXJ0UG9zdD1tdiAvdXNyL2xvY2FsL3NiaW4vY3VzdG9tLWZpcnN0LWJvb3QgL3Vzci9sb2NhbC9zYmluL2N1c3RvbS1maXJzdC1ib290LmRvbmUKCltJbnN0YWxsXQpXYW50ZWRCeT1tdWx0aS11c2VyLnRhcmdldAo="
data_encoding = "base64"
ensure_parents = true
path = "/etc/systemd/system/custom-first-boot.service"
[[customizations.files]]
data = "IyEvYmluL2Jhc2gKZmlyc3Rib290IHNjcmlwdCB0byB0ZXN0IGltcG9ydA=="
data_encoding = "base64"
ensure_parents = true
mode = "0774"
path = "/usr/local/sbin/custom-first-boot"
[[customizations.filesystem]]
mountpoint = "/var"
minsize = 2147483648
[customizations.installer]
unattended = true
sudo-nopasswd = ["user", "%wheel"]
`;
const uploadFile = async (filename: string, content: string): Promise<void> => {
const user = userEvent.setup();
const fileInput: HTMLElement | null =
@ -207,4 +260,79 @@ describe('Import modal', () => {
).toBeInTheDocument()
);
});
const getSourceDropdown = async () => {
const sourceDropdown = await screen.findByRole('textbox', {
name: /select source/i,
});
await waitFor(() => expect(sourceDropdown).toBeEnabled());
return sourceDropdown;
};
test('should enable button on toml blueprint and go to wizard', async () => {
await setUp();
await uploadFile(`blueprints.toml`, ONPREM_BLUEPRINT_TOML);
const reviewButton = screen.getByTestId('import-blueprint-finish');
await waitFor(() => expect(reviewButton).not.toHaveClass('pf-m-disabled'));
user.click(reviewButton);
await waitFor(async () =>
expect(
await screen.findByText('Image output', { selector: 'h1' })
).toBeInTheDocument()
);
// Image output
await waitFor(
async () => await user.click(await screen.findByTestId('upload-aws'))
);
await clickNext();
// Target environment aws
const radioButton = await screen.findByRole('radio', {
name: /use an account configured from sources\./i,
});
await waitFor(() => user.click(radioButton));
const awsSourceDropdown = await getSourceDropdown();
await waitFor(() => expect(awsSourceDropdown).toBeEnabled());
await waitFor(() => user.click(awsSourceDropdown));
const awsSource = await screen.findByRole('option', {
name: /my_source/i,
});
await waitFor(() => user.click(awsSource));
await clickNext();
// Registration
await screen.findByText(
'Automatically register and enable advanced capabilities'
);
const registrationCheckbox = await screen.findByTestId(
'automatically-register-checkbox'
);
expect(registrationCheckbox).toHaveFocus();
await screen.findByRole('textbox', {
name: 'Select activation key',
});
await clickNext();
// OpenScap
await clickNext();
//File system configuration
await clickNext();
// Custom Repos step
await clickNext();
// Packages step
await clickNext();
await waitFor(
async () =>
await user.click(await screen.findByTestId('packages-selected-toggle'))
);
await clickNext();
}, 20000);
});

View file

@ -15,6 +15,9 @@ import {
import { DropEvent } from '@patternfly/react-core/dist/esm/helpers';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useNavigate } from 'react-router-dom';
import { parse } from 'toml';
import { mapOnPremToHosted } from './helpers/onPremToHostedBlueprintMapper';
import { useAppDispatch } from '../../store/hooks';
import { BlueprintExportResponse } from '../../store/imageBuilderApi';
@ -33,69 +36,86 @@ export const ImportBlueprintModal: React.FunctionComponent<
const onImportClose = () => {
setShowImportModal(false);
setFilename('');
setJsonContent('');
setFileContent('');
setIsOnPrem(false);
setIsRejected(false);
setIsInvalidFormat(false);
};
const [jsonContent, setJsonContent] = React.useState('');
const [fileContent, setFileContent] = React.useState('');
const [importedBlueprint, setImportedBlueprint] =
React.useState<wizardState>();
const [isInvalidFormat, setIsInvalidFormat] = React.useState(false);
const [filename, setFilename] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const [isRejected, setIsRejected] = React.useState(false);
const [isOnPrem, setIsOnPrem] = React.useState(false);
const dispatch = useAppDispatch();
const handleFileInputChange = (
_event: React.ChangeEvent<HTMLInputElement> | React.DragEvent<HTMLElement>,
file: File
) => {
setFileContent('');
setFilename(file.name);
setIsRejected(false);
setIsInvalidFormat(false);
};
React.useEffect(() => {
if (filename && fileContent) {
try {
const isToml = filename.endsWith('.toml');
const isJson = filename.endsWith('.json');
if (isToml) {
setIsOnPrem(true);
const tomlBlueprint = parse(fileContent);
const blueprintFromFile = mapOnPremToHosted(tomlBlueprint);
const importBlueprintState = mapExportRequestToState(
blueprintFromFile,
[]
);
setImportedBlueprint(importBlueprintState);
} else if (isJson) {
setIsOnPrem(false);
const blueprintFromFile = JSON.parse(fileContent);
const blueprintExportedResponse: BlueprintExportResponse = {
name: blueprintFromFile.name,
description: blueprintFromFile.description,
distribution: blueprintFromFile.distribution,
customizations: blueprintFromFile.customizations,
metadata: blueprintFromFile.metadata,
};
const importBlueprintState = mapExportRequestToState(
blueprintExportedResponse,
blueprintFromFile.image_requests || []
);
setImportedBlueprint(importBlueprintState);
}
} catch (error) {
setIsInvalidFormat(true);
dispatch(
addNotification({
variant: 'warning',
title: 'File is not a valid blueprint',
description: error?.data?.error?.message,
})
);
}
}
}, [filename, fileContent]);
const handleClear = () => {
setFilename('');
setJsonContent('');
setFileContent('');
setIsOnPrem(false);
setIsRejected(false);
setIsInvalidFormat(false);
};
const handleTextChange = (
_: React.ChangeEvent<HTMLTextAreaElement>,
value: string
) => {
setJsonContent(value);
};
const handleDataChange = (_: DropEvent, value: string) => {
try {
const blueprintFromFile = JSON.parse(value);
const blueprintExportedResponse: BlueprintExportResponse = {
name: blueprintFromFile.name,
description: blueprintFromFile.description,
distribution: blueprintFromFile.distribution,
customizations: blueprintFromFile.customizations,
metadata: blueprintFromFile.metadata,
};
const importBlueprintState = mapExportRequestToState(
blueprintExportedResponse,
blueprintFromFile.image_requests || []
);
setImportedBlueprint(importBlueprintState);
setJsonContent(value);
} catch (error) {
setIsInvalidFormat(true);
dispatch(
addNotification({
variant: 'warning',
title: 'No blueprint was build',
description: error?.data?.error?.message,
})
);
}
setFileContent(value);
};
const handleFileRejected = () => {
setIsRejected(true);
setJsonContent('');
setIsOnPrem(false);
setFileContent('');
setFilename('');
};
const handleFileReadStarted = () => {
@ -119,12 +139,11 @@ export const ImportBlueprintModal: React.FunctionComponent<
<FileUpload
id="import-blueprint-file-upload"
type="text"
value={jsonContent}
value={fileContent}
filename={filename}
filenamePlaceholder="Drag and drop a file or upload one"
onFileInputChange={handleFileInputChange}
onDataChange={handleDataChange}
onTextChange={handleTextChange}
onReadStarted={handleFileReadStarted}
onReadFinished={handleFileReadFinished}
onClearClick={handleClear}
@ -132,7 +151,7 @@ export const ImportBlueprintModal: React.FunctionComponent<
isReadOnly={true}
browseButtonText="Upload"
dropzoneProps={{
accept: { 'text/json': ['.json'] },
accept: { 'text/json': ['.json'], 'text/plain': ['.toml'] },
maxSize: 25000,
onDropRejected: handleFileRejected,
}}
@ -140,11 +159,17 @@ export const ImportBlueprintModal: React.FunctionComponent<
/>
<FormHelperText>
<HelperText>
<HelperTextItem variant={isRejected ? 'error' : 'default'}>
<HelperTextItem
variant={
isRejected ? 'error' : isOnPrem ? 'warning' : 'default'
}
>
{isRejected
? 'Must be a valid Blueprint JSON file no larger than 25 KB'
: isInvalidFormat
? 'Not compatible with the blueprints format.'
: isOnPrem
? 'Importing on-premises blueprints is currently in beta. Results may vary.'
: 'Upload a JSON file'}
</HelperTextItem>
</HelperText>
@ -153,7 +178,7 @@ export const ImportBlueprintModal: React.FunctionComponent<
<ActionGroup>
<Button
type="button"
isDisabled={isRejected || isInvalidFormat || !jsonContent}
isDisabled={isRejected || isInvalidFormat || !fileContent}
onClick={() =>
navigate(resolveRelPath(`imagewizard/import`), {
state: { blueprint: importedBlueprint },

View file

@ -0,0 +1,155 @@
import {
BlueprintExportResponse,
Container,
Directory,
Distributions,
Fdo,
File,
FirewallCustomization,
Ignition,
Installer,
Kernel,
Locale,
OpenScap,
Services,
Timezone,
} from '../../../store/imageBuilderApi';
export type BlueprintOnPrem = {
name: string;
description?: string;
packages?: PackagesOnPrem[];
groups?: GroupsPackagesOnPrem[];
distro: Distributions;
customizations?: CustomizationsOnPrem;
containers?: Container[];
};
export type PackagesOnPrem = {
name: string;
version?: string;
};
export type GroupsPackagesOnPrem = {
name: string;
};
export type FileSystemOnPrem = {
mountpoint: string;
minsize: number | undefined;
};
export type CustomRepositoryOnPrem = {
id: string;
name?: string;
filename?: string;
baseurls?: string[];
mirrorlist?: string;
metalink?: string;
gpgkey?: string[];
check_gpg?: boolean;
check_repo_gpg?: boolean;
enabled?: boolean;
priority?: number;
ssl_verify?: boolean;
module_hotfixes?: boolean;
};
export type CustomizationsOnPrem = {
directories?: Directory[];
files?: File[];
repositories?: CustomRepositoryOnPrem[];
openscap?: OpenScap;
filesystem?: FileSystemOnPrem[];
services?: Services;
ssh_key?: SshKeyOnPrem[];
hostname?: string;
kernel?: Kernel;
user?: UserOnPrem[];
groups?: GroupOnPrem[];
timezone?: Timezone;
locale?: Locale;
firewall?: FirewallCustomization;
installation_device?: string;
fdo?: Fdo;
ignition?: Ignition;
partitioning_mode?: 'raw' | 'lvm' | 'auto-lvm';
fips?: boolean;
installer?: Installer;
};
export type UserOnPrem = {
name: string;
key: string;
};
export type GroupOnPrem = {
name: string;
gid: number;
};
export type SshKeyOnPrem = {
user: string;
key: string;
};
export const mapOnPremToHosted = (
blueprint: BlueprintOnPrem
): BlueprintExportResponse => {
const users = blueprint.customizations?.user?.map((u) => ({
name: u.name,
ssh_key: u.key,
}));
const user_keys = blueprint.customizations?.ssh_key?.map((k) => ({
name: k.user,
ssh_key: k.key,
}));
const packages =
blueprint.packages !== undefined
? blueprint.packages.map((p) => p.name)
: undefined;
const groups =
blueprint.customizations?.groups !== undefined
? blueprint.customizations.groups.map((p) => `@${p.name}`)
: undefined;
return {
name: blueprint.name,
description: blueprint.description || '',
distribution: blueprint.distro,
customizations: {
...blueprint.customizations,
containers: blueprint.containers,
custom_repositories: blueprint.customizations?.repositories?.map(
({ baseurls, ...fs }) => ({
baseurl: baseurls,
...fs,
})
),
packages:
packages !== undefined || groups !== undefined
? [...(packages ? packages : []), ...(groups ? groups : [])]
: undefined,
users:
users !== undefined || user_keys !== undefined
? [...(users ? users : []), ...(user_keys ? user_keys : [])]
: undefined,
groups: blueprint.customizations?.groups,
filesystem: blueprint.customizations?.filesystem?.map(
({ minsize, ...fs }) => ({
min_size: minsize,
...fs,
})
),
fips:
blueprint.customizations?.fips !== undefined
? {
enabled: blueprint.customizations?.fips,
}
: undefined,
},
metadata: {
parent_id: null,
exported_at: '',
},
};
};