src: Add on prem blueprints import support
This commit is contained in:
parent
edb274bf8c
commit
63e9610beb
5 changed files with 358 additions and 42 deletions
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
},
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue