diff --git a/src/Components/CreateImageWizardV2/steps/FileSystem/FileSystemPartition.tsx b/src/Components/CreateImageWizardV2/steps/FileSystem/FileSystemPartition.tsx index 32484bd3..19f30113 100644 --- a/src/Components/CreateImageWizardV2/steps/FileSystem/FileSystemPartition.tsx +++ b/src/Components/CreateImageWizardV2/steps/FileSystem/FileSystemPartition.tsx @@ -1,20 +1,23 @@ import React from 'react'; import { FormGroup, Label, Radio } from '@patternfly/react-core'; -import { v4 as uuidv4 } from 'uuid'; -import { UNIT_GIB } from '../../../../constants'; import { useAppDispatch, useAppSelector } from '../../../../store/hooks'; import { - changeFileSystemConfiguration, changeFileSystemPartitionMode, selectFileSystemPartitionMode, + selectProfile, } from '../../../../store/wizardSlice'; const FileSystemPartition = () => { - const id = uuidv4(); const dispatch = useAppDispatch(); const fileSystemPartitionMode = useAppSelector(selectFileSystemPartitionMode); + const hasOscapProfile = useAppSelector(selectProfile); + + if (hasOscapProfile) { + return undefined; + } + return ( { isChecked={fileSystemPartitionMode === 'automatic'} onChange={() => { dispatch(changeFileSystemPartitionMode('automatic')); - dispatch(changeFileSystemConfiguration([])); }} /> { isChecked={fileSystemPartitionMode === 'manual'} onChange={() => { dispatch(changeFileSystemPartitionMode('manual')); - dispatch( - changeFileSystemConfiguration([ - { - id: id, - mountpoint: '/', - min_size: (10 * UNIT_GIB).toString(), - unit: 'GiB', - }, - ]) - ); }} /> diff --git a/src/Components/CreateImageWizardV2/steps/Oscap/Oscap.tsx b/src/Components/CreateImageWizardV2/steps/Oscap/Oscap.tsx index 2efa5c37..a1bd5ff0 100644 --- a/src/Components/CreateImageWizardV2/steps/Oscap/Oscap.tsx +++ b/src/Components/CreateImageWizardV2/steps/Oscap/Oscap.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Alert, @@ -14,30 +14,38 @@ import { SelectVariant, } from '@patternfly/react-core/deprecated'; import { HelpIcon } from '@patternfly/react-icons'; +import { v4 as uuidv4 } from 'uuid'; import OscapProfileInformation from './OscapProfileInformation'; -import { useAppDispatch, useAppSelector } from '../../../../store/hooks'; +import { + useAppDispatch, + useAppSelector, + useServerStore, +} from '../../../../store/hooks'; import { DistributionProfileItem, + Filesystem, useGetOscapCustomizationsQuery, useGetOscapProfilesQuery, + useLazyGetOscapCustomizationsQuery, } from '../../../../store/imageBuilderApi'; import { changeOscapProfile, selectDistribution, selectProfile, - clearOscapPackages, addPackage, - selectPackages, + addPartition, + changeFileSystemPartitionMode, removePackage, + clearPartitions, } from '../../../../store/wizardSlice'; +import { Partition } from '../FileSystem/FileSystemConfiguration'; const ProfileSelector = () => { const oscapProfile = useAppSelector(selectProfile); - + const oscapData = useServerStore(); const release = useAppSelector(selectDistribution); - const packages = useAppSelector(selectPackages); const dispatch = useAppDispatch(); const [isOpen, setIsOpen] = useState(false); const { @@ -50,38 +58,9 @@ const ProfileSelector = () => { distribution: release, }); - const { data: oscapData } = useGetOscapCustomizationsQuery( - { - distribution: release, - // @ts-ignore if oscapProfile is undefined the query is going to get skipped, so it's safe here to ignore the linter here - profile: oscapProfile, - }, - { - skip: !oscapProfile, - } - ); - const profileName = oscapProfile ? oscapData?.openscap?.profile_name : 'None'; + const profileName = oscapProfile ? oscapData.profileName : 'None'; - useEffect(() => { - dispatch(clearOscapPackages()); - for (const pkg in oscapData?.packages) { - if ( - packages - .map((pkg) => pkg.name) - .includes(oscapData?.packages[Number(pkg)]) - ) { - dispatch(removePackage(oscapData?.packages[Number(pkg)])); - } - dispatch( - addPackage({ - name: oscapData?.packages[Number(pkg)], - summary: 'Required by chosen OpenSCAP profile', - repository: 'distro', - isRequiredByOpenScap: true, - }) - ); - } - }, [oscapData?.packages, dispatch]); + const [trigger] = useLazyGetOscapCustomizationsQuery(); const handleToggle = () => { if (!isOpen) { @@ -92,14 +71,79 @@ const ProfileSelector = () => { const handleClear = () => { dispatch(changeOscapProfile(undefined)); - dispatch(clearOscapPackages()); + clearOscapPackages(oscapData.packages || []); + dispatch(changeFileSystemPartitionMode('automatic')); + }; + + const handlePackages = ( + oldOscapPackages: string[], + newOscapPackages: string[] + ) => { + clearOscapPackages(oldOscapPackages); + + for (const pkg of newOscapPackages) { + dispatch( + addPackage({ + name: pkg, + summary: 'Required by chosen OpenSCAP profile', + repository: 'distro', + }) + ); + } + }; + + const clearOscapPackages = (oscapPackages: string[]) => { + for (const pkg of oscapPackages) { + dispatch(removePackage(pkg)); + } + }; + + const handlePartitions = (oscapPartitions: Filesystem[]) => { + dispatch(clearPartitions()); + + const newPartitions = oscapPartitions.map((filesystem) => { + const partition: Partition = { + mountpoint: filesystem.mountpoint, + min_size: filesystem.min_size.toString(), + unit: 'GiB', + id: uuidv4(), + }; + return partition; + }); + + if (newPartitions) { + dispatch(changeFileSystemPartitionMode('manual')); + for (const partition of newPartitions) { + dispatch(addPartition(partition)); + } + } }; const handleSelect = ( _event: React.MouseEvent, selection: OScapSelectOptionValueType ) => { - dispatch(changeOscapProfile(selection.id)); + if (selection.id === undefined) { + // handle user has selected 'None' case + handleClear(); + } else { + const oldOscapPackages = oscapData.packages || []; + trigger( + { + distribution: release, + profile: selection.id, + }, + true // preferCacheValue + ) + .unwrap() + .then((response) => { + const oscapPartitions = response.filesystem || []; + const newOscapPackages = response.packages || []; + handlePartitions(oscapPartitions); + handlePackages(oldOscapPackages, newOscapPackages); + dispatch(changeOscapProfile(selection.id)); + }); + } setIsOpen(false); }; diff --git a/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx index 5cb4eeaa..38a576ab 100644 --- a/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx +++ b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx @@ -67,7 +67,6 @@ export type IBPackageWithRepositoryInfo = { name: Package['name']; summary: Package['summary']; repository: PackageRepository; - isRequiredByOpenScap: boolean; }; const Packages = () => { @@ -386,7 +385,6 @@ const Packages = () => { transformedDistroData = dataDistroPackages.data.map((values) => ({ ...values, repository: 'distro', - isRequiredByOpenScap: false, })); } @@ -395,7 +393,6 @@ const Packages = () => { name: values.package_name!, summary: values.summary!, repository: 'custom', - isRequiredByOpenScap: false, })); } @@ -413,7 +410,6 @@ const Packages = () => { name: values.package_name!, summary: values.summary!, repository: 'recommended', - isRequiredByOpenScap: false, })); combinedPackageData = combinedPackageData.concat( diff --git a/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx b/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx index 1a9bc237..1b81a436 100644 --- a/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx +++ b/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx @@ -54,8 +54,8 @@ import { import { GcpAccountType } from '../steps/TargetEnvironment/Gcp'; type ServerStore = { - kernel?: { append?: string }; - services?: { enabled?: string[]; disabled?: string[] }; + kernel?: { append?: string }; // TODO use API types + services?: { enabled?: string[]; disabled?: string[]; masked?: string[] }; // TODO use API types }; /** @@ -172,7 +172,6 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => { name: pkg, summary: '', repository: '', - isRequiredByOpenScap: false, })) || [], stepValidations: {}, }; @@ -305,11 +304,13 @@ const getCustomizations = ( const getServices = (serverStore: ServerStore): Services | undefined => { const enabledServices = serverStore.services?.enabled; const disabledServices = serverStore.services?.disabled; + const maskedServices = serverStore.services?.masked; - if (enabledServices || disabledServices) { + if (enabledServices || disabledServices || maskedServices) { return { enabled: enabledServices, disabled: disabledServices, + masked: maskedServices, }; } return undefined; diff --git a/src/store/hooks.ts b/src/store/hooks.ts index cabdd719..30dcc88d 100644 --- a/src/store/hooks.ts +++ b/src/store/hooks.ts @@ -27,7 +27,11 @@ export const useOscapData = () => { services: { enabled: data?.services?.enabled, disabled: data?.services?.disabled, + masked: data?.services?.masked, }, + packages: data?.packages, + filesystem: data?.filesystem, + profileName: data?.openscap?.profile_name, }; }; diff --git a/src/store/wizardSlice.ts b/src/store/wizardSlice.ts index d30c74bc..e1f6af63 100644 --- a/src/store/wizardSlice.ts +++ b/src/store/wizardSlice.ts @@ -1,4 +1,5 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { v4 as uuidv4 } from 'uuid'; import { ApiRepositoryResponseRead } from './contentSourcesApi'; import { @@ -24,7 +25,7 @@ import { GcpShareMethod, } from '../Components/CreateImageWizardV2/steps/TargetEnvironment/Gcp'; import { V1ListSourceResponseItem } from '../Components/CreateImageWizardV2/types'; -import { RHEL_9, X86_64 } from '../constants'; +import { RHEL_9, UNIT_GIB, X86_64 } from '../constants'; import { RootState } from '.'; @@ -389,14 +390,47 @@ export const wizardSlice = createSlice({ setIsNextButtonTouched: (state, action: PayloadAction) => { state.fileSystem.isNextButtonTouched = action.payload; }, - changeFileSystemPartitionMode: ( state, action: PayloadAction ) => { - state.fileSystem.mode = action.payload; + const currentMode = state.fileSystem.mode; + + // Only trigger if mode is being *changed* + if (currentMode !== action.payload) { + state.fileSystem.mode = action.payload; + switch (action.payload) { + case 'automatic': + state.fileSystem.partitions = []; + break; + case 'manual': + state.fileSystem.partitions = [ + { + id: uuidv4(), + mountpoint: '/', + min_size: (10 * UNIT_GIB).toString(), + unit: 'GiB', + }, + ]; + } + } + }, + clearPartitions: (state) => { + const currentMode = state.fileSystem.mode; + + if (currentMode === 'manual') { + state.fileSystem.partitions = [ + { + id: uuidv4(), + mountpoint: '/', + min_size: (10 * UNIT_GIB).toString(), + unit: 'GiB', + }, + ]; + } }, addPartition: (state, action: PayloadAction) => { + // Duplicate partitions are allowed temporarily, the wizard is responsible for final validation state.fileSystem.partitions.push(action.payload); }, removePartition: (state, action: PayloadAction) => { @@ -407,6 +441,17 @@ export const wizardSlice = createSlice({ 1 ); }, + removePartitionByMountpoint: ( + state, + action: PayloadAction + ) => { + state.fileSystem.partitions.splice( + state.fileSystem.partitions.findIndex( + (partition) => partition.mountpoint === action.payload + ), + 1 + ); + }, changePartitionOrder: (state, action: PayloadAction) => { state.fileSystem.partitions = state.fileSystem.partitions.sort( (a, b) => action.payload.indexOf(a.id) - action.payload.indexOf(b.id) @@ -479,7 +524,15 @@ export const wizardSlice = createSlice({ ); }, addPackage: (state, action: PayloadAction) => { - state.packages.push(action.payload); + const existingPackageIndex = state.packages.findIndex( + (pkg) => pkg.name === action.payload.name + ); + + if (existingPackageIndex !== -1) { + state.packages[existingPackageIndex] = action.payload; + } else { + state.packages.push(action.payload); + } }, removePackage: ( state, @@ -490,11 +543,6 @@ export const wizardSlice = createSlice({ 1 ); }, - clearOscapPackages: (state) => { - state.packages = state.packages.filter( - (pkg) => pkg.isRequiredByOpenScap !== true - ); - }, changeBlueprintName: (state, action: PayloadAction) => { state.details.blueprintName = action.payload; }, @@ -556,8 +604,10 @@ export const { changeFileSystemConfiguration, setIsNextButtonTouched, changeFileSystemPartitionMode, + clearPartitions, addPartition, removePartition, + removePartitionByMountpoint, changePartitionMountpoint, changePartitionUnit, changePartitionMinSize, @@ -568,7 +618,6 @@ export const { removeRecommendedRepository, addPackage, removePackage, - clearOscapPackages, changeBlueprintName, changeBlueprintDescription, loadWizardState, diff --git a/src/test/Components/CreateImageWizard/CreateImageWizard.compliance.test.js b/src/test/Components/CreateImageWizard/CreateImageWizard.compliance.test.js index cfa9e995..7143131a 100644 --- a/src/test/Components/CreateImageWizard/CreateImageWizard.compliance.test.js +++ b/src/test/Components/CreateImageWizard/CreateImageWizard.compliance.test.js @@ -165,14 +165,14 @@ describe('Step Compliance', () => { await screen.findByRole('heading', { name: /File system configuration/i }); await screen.findByText(/tmp/i); - // check that the Packages contain a nftable package + // check that the Packages contains correct packages await clickNext(); await screen.findByRole('heading', { name: /Additional Red Hat packages/i, }); - await screen.findByText(/nftables/i); - await screen.findByText(/libselinux/i); + await screen.findByText(/aide/i); + await screen.findByText(/neovim/i); }); }); diff --git a/src/test/Components/CreateImageWizardV2/CreateImageWizard.compliance.test.tsx b/src/test/Components/CreateImageWizardV2/CreateImageWizard.compliance.test.tsx index dcc91f9f..044f196a 100644 --- a/src/test/Components/CreateImageWizardV2/CreateImageWizard.compliance.test.tsx +++ b/src/test/Components/CreateImageWizardV2/CreateImageWizard.compliance.test.tsx @@ -171,14 +171,14 @@ describe('Step Compliance', () => { await clickNext(); // skip Repositories - // check that the Packages contain a nftable package + // check that the Packages contains correct packages await clickNext(); await screen.findByRole('heading', { name: /Additional packages/i, }); await user.click(await screen.findByText(/Selected/)); - await screen.findByText(/nftables/i); - await screen.findByText(/libselinux/i); + await screen.findByText(/aide/i); + await screen.findByText(/neovim/i); }); }); // diff --git a/src/test/Components/CreateImageWizardV2/steps/Oscap/Oscap.test.tsx b/src/test/Components/CreateImageWizardV2/steps/Oscap/Oscap.test.tsx new file mode 100644 index 00000000..3ee955d6 --- /dev/null +++ b/src/test/Components/CreateImageWizardV2/steps/Oscap/Oscap.test.tsx @@ -0,0 +1,194 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { CREATE_BLUEPRINT } from '../../../../../constants'; +import { CreateBlueprintRequest } from '../../../../../store/imageBuilderApi'; +import { clickNext } from '../../../../testUtils'; +import { + blueprintRequest, + clickRegisterLater, + enterBlueprintName, + interceptBlueprintRequest, + render, +} from '../../wizardTestUtils'; + +jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ + useChrome: () => ({ + auth: { + getUser: () => { + return { + identity: { + internal: { + org_id: 5, + }, + }, + }; + }, + }, + isBeta: () => true, + isProd: () => true, + getEnvironment: () => 'prod', + }), +})); + +const goToOscapStep = async () => { + const bareMetalCheckBox = await screen.findByRole('checkbox', { + name: /bare metal installer checkbox/i, + }); + await userEvent.click(bareMetalCheckBox); + await clickNext(); // Registration + await clickRegisterLater(); + await clickNext(); // OpenSCAP +}; + +const selectProfile = async () => { + await userEvent.click( + await screen.findByRole('textbox', { + name: /select a profile/i, + }) + ); + + await userEvent.click( + await screen.findByText( + /cis red hat enterprise linux 8 benchmark for level 1 - workstation/i + ) + ); +}; + +const selectDifferentProfile = async () => { + await userEvent.click( + await screen.findByRole('textbox', { + name: /select a profile/i, + }) + ); + + await userEvent.click( + await screen.findByText( + /cis red hat enterprise linux 8 benchmark for level 2 - workstation/i + ) + ); +}; + +const selectNone = async () => { + await userEvent.click( + await screen.findByRole('textbox', { + name: /select a profile/i, + }) + ); + + await userEvent.click(await screen.findByText(/none/i)); +}; + +const goToReviewStep = async () => { + await clickNext(); // File system configuration + await clickNext(); // Custom repositories + await clickNext(); // Additional packages + await clickNext(); // Details + await enterBlueprintName(); + await clickNext(); // Review +}; + +const expectedOpenscapCisL1 = { + profile_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l1', +}; + +const expectedPackagesCisL1 = ['aide', 'neovim']; + +const expectedServicesCisL1 = { + enabled: ['crond', 'neovim-service'], + masked: ['nfs-server', 'emacs-service'], +}; + +const expectedKernelCisL1 = { + append: 'audit_backlog_limit=8192 audit=1', +}; + +const expectedFilesystemCisL1 = [ + { min_size: 10737418240, mountpoint: '/' }, + { min_size: 1073741824, mountpoint: '/tmp' }, + { min_size: 1073741824, mountpoint: '/home' }, +]; + +const expectedOpenscapCisL2 = { + profile_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l2', +}; + +const expectedPackagesCisL2 = ['aide', 'emacs']; + +const expectedServicesCisL2 = { + enabled: ['crond', 'emacs-service'], + masked: ['nfs-server', 'neovim-service'], +}; + +const expectedKernelCisL2 = { + append: 'audit_backlog_limit=8192 audit=2', +}; + +const expectedFilesystemCisL2 = [ + { min_size: 10737418240, mountpoint: '/' }, + { min_size: 1073741824, mountpoint: '/tmp' }, + { min_size: 1073741824, mountpoint: '/app' }, +]; + +describe('oscap', () => { + test('add a profile', async () => { + await render(); + await goToOscapStep(); + await selectProfile(); + await goToReviewStep(); + + const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT); + + const expectedRequest: CreateBlueprintRequest = { + ...blueprintRequest, + customizations: { + packages: expectedPackagesCisL1, + openscap: expectedOpenscapCisL1, + services: expectedServicesCisL1, + kernel: expectedKernelCisL1, + filesystem: expectedFilesystemCisL1, + }, + }; + + expect(receivedRequest).toEqual(expectedRequest); + }); + + test('remove a profile', async () => { + await render(); + await goToOscapStep(); + await selectProfile(); + await selectNone(); + await goToReviewStep(); + + const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT); + + const expectedRequest: CreateBlueprintRequest = { + ...blueprintRequest, + }; + + expect(receivedRequest).toEqual(expectedRequest); + }); + + test('change profile', async () => { + await render(); + await goToOscapStep(); + await selectProfile(); + await selectDifferentProfile(); + await goToReviewStep(); + + const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT); + + const expectedRequest: CreateBlueprintRequest = { + ...blueprintRequest, + customizations: { + packages: expectedPackagesCisL2, + openscap: expectedOpenscapCisL2, + services: expectedServicesCisL2, + kernel: expectedKernelCisL2, + filesystem: expectedFilesystemCisL2, + }, + }; + + expect(receivedRequest).toEqual(expectedRequest); + }); +}); diff --git a/src/test/fixtures/oscap.ts b/src/test/fixtures/oscap.ts index d8404c55..da85aec5 100644 --- a/src/test/fixtures/oscap.ts +++ b/src/test/fixtures/oscap.ts @@ -16,7 +16,10 @@ export const oscapCustomizations = ( ): GetOscapCustomizationsApiResponse => { if (profile === 'xccdf_org.ssgproject.content_profile_cis_workstation_l1') { return { - filesystem: [{ min_size: 1073741824, mountpoint: '/tmp' }], + filesystem: [ + { min_size: 1073741824, mountpoint: '/tmp' }, + { min_size: 1073741824, mountpoint: '/home' }, + ], openscap: { profile_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l1', profile_name: @@ -24,26 +27,22 @@ export const oscapCustomizations = ( profile_description: 'This is a mocked profile description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean posuere velit enim, tincidunt porttitor nisl elementum eu.', }, - packages: [ - 'aide', - 'sudo', - 'rsyslog', - 'firewalld', - 'nftables', - 'libselinux', - ], + packages: ['aide', 'neovim'], kernel: { append: 'audit_backlog_limit=8192 audit=1', }, services: { - masked: ['nfs-server'], - enabled: ['crond'], + masked: ['nfs-server', 'emacs-service'], + enabled: ['crond', 'neovim-service'], }, }; } if (profile === 'xccdf_org.ssgproject.content_profile_cis_workstation_l2') { return { - filesystem: [{ min_size: 1073741824, mountpoint: '/tmp' }], + filesystem: [ + { min_size: 1073741824, mountpoint: '/tmp' }, + { min_size: 1073741824, mountpoint: '/app' }, + ], openscap: { profile_id: 'content_profile_cis_workstation_l2', profile_name: @@ -51,20 +50,13 @@ export const oscapCustomizations = ( profile_description: 'This is a mocked profile description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean posuere velit enim, tincidunt porttitor nisl elementum eu.', }, - packages: [ - 'aide', - 'sudo', - 'rsyslog', - 'firewalld', - 'nftables', - 'libselinux', - ], + packages: ['aide', 'emacs'], kernel: { - append: 'audit_backlog_limit=8192 audit=1', + append: 'audit_backlog_limit=8192 audit=2', }, services: { - disabled: ['nfs-server', 'nftables'], - enabled: ['crond', 'firewalld'], + masked: ['nfs-server', 'neovim-service'], + enabled: ['crond', 'emacs-service'], }, }; } @@ -88,7 +80,7 @@ export const oscapCustomizations = ( append: 'audit_backlog_limit=8192 audit=1', }, services: { - disabled: ['nfs-server', 'rpcbind', 'autofs', 'nftables'], + masked: ['nfs-server', 'rpcbind', 'autofs', 'nftables'], enabled: ['crond', 'firewalld', 'systemd-journald', 'rsyslog', 'auditd'], }, };