Skip to content

Commit

Permalink
src: Add on prem blueprints import support
Browse files Browse the repository at this point in the history
  • Loading branch information
avitova committed Nov 12, 2024
1 parent 88b1272 commit 683af5b
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 43 deletions.
9 changes: 8 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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",
Expand Down
128 changes: 128 additions & 0 deletions src/Components/Blueprints/ImportBlueprintModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = `{
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
});
102 changes: 62 additions & 40 deletions src/Components/Blueprints/ImportBlueprintModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,69 +36,83 @@ export const ImportBlueprintModal: React.FunctionComponent<
const onImportClose = () => {
setShowImportModal(false);
setFilename('');
setJsonContent('');
setFileContent('');
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]);

Check warning on line 103 in src/Components/Blueprints/ImportBlueprintModal.tsx

View workflow job for this annotation

GitHub Actions / dev-check

React Hook React.useEffect has a missing dependency: 'dispatch'. Either include it or remove the dependency array
const handleClear = () => {
setFilename('');
setJsonContent('');
setFileContent('');
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('');
setFileContent('');
setFilename('');
};
const handleFileReadStarted = () => {
Expand All @@ -119,32 +136,37 @@ 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}
isLoading={isLoading}
isReadOnly={true}
browseButtonText="Upload"
dropzoneProps={{
accept: { 'text/json': ['.json'] },
accept: { 'text/json': ['.json'], 'text/plain': ['.toml'] },
maxSize: 25000,
onDropRejected: handleFileRejected,
}}
validated={isRejected || isInvalidFormat ? 'error' : 'default'}
/>
<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>
Expand All @@ -153,7 +175,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 },
Expand Down
Loading

0 comments on commit 683af5b

Please sign in to comment.