Wizard: Add kernel append input

This adds the kernel append input. New arguments can be added by pressing the "Add" button or hitting Enter after the argument.

The kernel arguments linked to a selected OpenSCAP profile are rendered in a category marked as "Required by OpenSCAP" and are read only.
This commit is contained in:
regexowl 2025-01-07 11:43:24 +01:00 committed by Klara Simickova
parent 199dd3d5d7
commit 1b21852518
13 changed files with 305 additions and 40 deletions

View file

@ -19,6 +19,7 @@ type ChippingInputProps = {
ariaLabel: string;
placeholder: string;
validator: (value: string) => boolean;
requiredList?: string[] | undefined;
list: string[] | undefined;
item: string;
addAction: (value: string) => UnknownAction;
@ -30,6 +31,7 @@ const ChippingInput = ({
placeholder,
validator,
list,
requiredList,
item,
addAction,
removeAction,
@ -64,7 +66,7 @@ const ChippingInput = ({
};
const handleKeyDown = (e: React.KeyboardEvent, value: string) => {
if (e.key === ' ' || e.key === 'Enter' || e.key === ',') {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
addItem(value);
}
@ -112,7 +114,24 @@ const ChippingInput = ({
<HelperTextItem variant={'error'}>{errorText}</HelperTextItem>
</HelperText>
)}
<ChipGroup numChips={5} className="pf-v5-u-mt-sm pf-v5-u-w-100">
{requiredList && requiredList.length > 0 && (
<ChipGroup
categoryName="Required by OpenSCAP"
numChips={20}
className="pf-v5-u-mt-sm pf-v5-u-w-100"
>
{requiredList.map((item) => (
<Chip
key={item}
onClick={() => dispatch(removeAction(item))}
isReadOnly
>
{item}
</Chip>
))}
</ChipGroup>
)}
<ChipGroup numChips={20} className="pf-v5-u-mt-sm pf-v5-u-w-100">
{list?.map((item) => (
<Chip key={item} onClick={() => dispatch(removeAction(item))}>
{item}

View file

@ -2,8 +2,57 @@ import React from 'react';
import { FormGroup } from '@patternfly/react-core';
import { useAppSelector } from '../../../../../store/hooks';
import { useGetOscapCustomizationsQuery } from '../../../../../store/imageBuilderApi';
import {
addKernelArg,
removeKernelArg,
selectComplianceProfileID,
selectDistribution,
selectKernel,
} from '../../../../../store/wizardSlice';
import ChippingInput from '../../../ChippingInput';
import { isKernelArgumentValid } from '../../../validators';
const KernelArguments = () => {
return <FormGroup isRequired={false} label="Append"></FormGroup>;
const kernelAppend = useAppSelector(selectKernel).append;
const release = useAppSelector(selectDistribution);
const complianceProfileID = useAppSelector(selectComplianceProfileID);
const { data: oscapProfileInfo } = useGetOscapCustomizationsQuery(
{
distribution: release,
// @ts-ignore if complianceProfileID is undefined the query is going to get skipped, so it's safe here to ignore the linter here
profile: complianceProfileID,
},
{
skip: !complianceProfileID,
}
);
const requiredByOpenSCAP = kernelAppend.filter((arg) =>
oscapProfileInfo?.kernel?.append?.split(' ').includes(arg)
);
const notRequiredByOpenSCAP = kernelAppend.filter(
(arg) => !oscapProfileInfo?.kernel?.append?.split(' ').includes(arg)
);
return (
<FormGroup isRequired={false} label="Append">
<ChippingInput
ariaLabel="Add kernel argument"
placeholder="Add kernel argument"
validator={isKernelArgumentValid}
list={notRequiredByOpenSCAP}
requiredList={requiredByOpenSCAP}
item="Kernel argument"
addAction={addKernelArg}
removeAction={removeKernelArg}
/>
</FormGroup>
);
};
export default KernelArguments;

View file

@ -51,8 +51,9 @@ import {
changeEnabledServices,
changeMaskedServices,
changeDisabledServices,
changeKernelAppend,
selectComplianceType,
clearKernelAppend,
addKernelArg,
} from '../../../../store/wizardSlice';
import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly';
import { parseSizeUnit } from '../../utilities/parseSizeUnit';
@ -174,7 +175,7 @@ const ProfileSelector = () => {
clearOscapPackages(currentProfileData?.packages || []);
dispatch(changeFileSystemConfigurationType('automatic'));
handleServices(undefined);
dispatch(changeKernelAppend(''));
dispatch(clearKernelAppend());
};
const handlePackages = (
@ -228,6 +229,17 @@ const ProfileSelector = () => {
dispatch(changeDisabledServices(services?.disabled || []));
};
const handleKernelAppend = (kernelAppend: string | undefined) => {
dispatch(clearKernelAppend());
if (kernelAppend) {
const kernelArgsArray = kernelAppend.split(' ');
for (const arg in kernelArgsArray) {
dispatch(addKernelArg(kernelArgsArray[arg]));
}
}
};
const handleSelect = (
_event: React.MouseEvent<Element, MouseEvent>,
selection: OScapSelectOptionValueType | ComplianceSelectOptionValueType
@ -251,7 +263,7 @@ const ProfileSelector = () => {
handlePartitions(oscapPartitions);
handlePackages(oldOscapPackages, newOscapPackages);
handleServices(response.services);
dispatch(changeKernelAppend(response.kernel?.append || ''));
handleKernelAppend(response.kernel?.append);
if (complianceType === 'openscap') {
dispatch(
changeCompliance({

View file

@ -130,19 +130,6 @@ export const OscapProfileInformation = ({
>
{oscapProfile?.profile_id}
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-v5-u-min-width"
>
Kernel arguments:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{oscapProfileInfo?.kernel?.append}
</CodeBlockCode>
</CodeBlock>
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-v5-u-min-width"

View file

@ -31,9 +31,9 @@ import {
changeDisabledServices,
removePackage,
changeFileSystemConfigurationType,
changeKernelAppend,
selectDistribution,
selectComplianceType,
clearKernelAppend,
} from '../../../../store/wizardSlice';
import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment';
@ -84,7 +84,7 @@ const OscapStep = () => {
dispatch(changeEnabledServices([]));
dispatch(changeMaskedServices([]));
dispatch(changeDisabledServices([]));
dispatch(changeKernelAppend(''));
dispatch(clearKernelAppend());
};
return (

View file

@ -336,7 +336,7 @@ function commonRequestToState(
},
kernel: {
name: request.customizations.kernel?.name || '',
append: request.customizations?.kernel?.append || '',
append: request.customizations?.kernel?.append?.split(' ') || [],
},
timezone: {
timezone: request.customizations.timezone?.timezone || '',
@ -763,13 +763,25 @@ const getPayloadRepositories = (state: RootState) => {
const getKernel = (state: RootState) => {
const kernel = selectKernel(state);
const kernelAppendString = selectKernel(state).append.join(' ');
if (!kernel.name && !kernel.append) {
const kernelRequest = {};
if (!kernel.name && kernel.append.length === 0) {
return undefined;
}
return {
name: selectKernel(state).name || undefined,
append: selectKernel(state).append || undefined,
};
if (kernel.name) {
Object.assign(kernelRequest, {
name: kernel.name,
});
}
if (kernelAppendString !== '') {
Object.assign(kernelRequest, {
append: kernelAppendString,
});
}
return Object.keys(kernelRequest).length > 0 ? kernelRequest : undefined;
};

View file

@ -124,6 +124,14 @@ export const isKernelNameValid = (kernelName: string) => {
);
};
export const isKernelArgumentValid = (arg: string) => {
if (!arg) {
return true;
}
return /^[a-zA-Z0-9=-_,"']*$/.test(arg);
};
export const isPortValid = (port: string) => {
return /^(\d{1,5}|[a-z]{1,6})(-\d{1,5})?:[a-z]{1,6}$/.test(port);
};

View file

@ -135,7 +135,7 @@ export type wizardState = {
};
kernel: {
name: string;
append: string;
append: string[];
};
locale: Locale;
details: {
@ -213,7 +213,7 @@ export const initialState: wizardState = {
},
kernel: {
name: '',
append: '',
append: [],
},
locale: {
languages: [],
@ -817,8 +817,27 @@ export const wizardSlice = createSlice({
changeKernelName: (state, action: PayloadAction<string>) => {
state.kernel.name = action.payload;
},
changeKernelAppend: (state, action: PayloadAction<string>) => {
state.kernel.append = action.payload;
addKernelArg: (state, action: PayloadAction<string>) => {
const existingArgIndex = state.kernel.append.findIndex(
(arg) => arg === action.payload
);
if (existingArgIndex !== -1) {
state.kernel.append[existingArgIndex] = action.payload;
} else {
state.kernel.append.push(action.payload);
}
},
removeKernelArg: (state, action: PayloadAction<string>) => {
if (state.kernel.append.length > 0) {
state.kernel.append.splice(
state.kernel.append.findIndex((arg) => arg === action.payload),
1
);
}
},
clearKernelAppend: (state) => {
state.kernel.append = [];
},
changeTimezone: (state, action: PayloadAction<string>) => {
state.timezone.timezone = action.payload;
@ -954,7 +973,9 @@ export const {
changeMaskedServices,
changeDisabledServices,
changeKernelName,
changeKernelAppend,
addKernelArg,
removeKernelArg,
clearKernelAppend,
changeTimezone,
addNtpServer,
removeNtpServer,

View file

@ -48,7 +48,7 @@ const goToReviewStep = async () => {
const addPort = async (port: string) => {
const user = userEvent.setup();
const portsInput = await screen.findByPlaceholderText(/add port/i);
await waitFor(() => user.type(portsInput, port.concat(',')));
await waitFor(() => user.type(portsInput, port.concat(' ')));
};
describe('Step Firewall', () => {

View file

@ -39,6 +39,29 @@ const goToKernelStep = async () => {
await clickNext(); // Kernel
};
const goToOpenSCAPStep = async () => {
const user = userEvent.setup();
const guestImageCheckBox = await screen.findByRole('checkbox', {
name: /virtualization guest image checkbox/i,
});
await waitFor(() => user.click(guestImageCheckBox));
await clickNext(); // Registration
await clickRegisterLater();
await clickNext(); // OpenSCAP
};
const goFromOpenSCAPToKernel = async () => {
await clickNext(); // File system configuration
await clickNext(); // Snapshots
await clickNext(); // Custom repositories
await clickNext(); // Additional packages
await clickNext(); // Users
await clickNext(); // Timezone
await clickNext(); // Locale
await clickNext(); // Hostname
await clickNext(); // Kernel
};
const goToReviewStep = async () => {
await clickNext(); // Firewall
await clickNext(); // Services
@ -77,10 +100,44 @@ const selectCustomKernelName = async (kernelName: string) => {
const clearKernelName = async () => {
const user = userEvent.setup();
const kernelNameClearBtn = await screen.findByRole('button', {
const kernelNameClearBtn = await screen.findAllByRole('button', {
name: /clear input/i,
});
await waitFor(() => user.click(kernelNameClearBtn));
await waitFor(() => user.click(kernelNameClearBtn[0]));
};
const addKernelAppend = async (kernelArg: string) => {
const user = userEvent.setup();
const kernelAppendInput = await screen.findByPlaceholderText(
'Add kernel argument'
);
await waitFor(() => user.click(kernelAppendInput));
await waitFor(() => user.type(kernelAppendInput, kernelArg));
const addKernelArg = await screen.findByRole('button', {
name: /Add kernel argument/,
});
await waitFor(() => user.click(addKernelArg));
};
const removeKernelArg = async (kernelArg: string) => {
const user = userEvent.setup();
const removeNosmtArgButton = await screen.findByRole('button', {
name: `close ${kernelArg}`,
});
await waitFor(() => user.click(removeNosmtArgButton));
};
const selectProfile = async () => {
const user = userEvent.setup();
const selectProfileDropdown = await screen.findByRole('textbox', {
name: /select a profile/i,
});
await waitFor(() => user.click(selectProfileDropdown));
const cis1Profile = await screen.findByText(/Kernel append only profile/i);
await waitFor(() => user.click(cis1Profile));
};
describe('Step Kernel', () => {
@ -137,6 +194,31 @@ describe('Step Kernel', () => {
expect(screen.queryByText(/Invalid format/)).not.toBeInTheDocument();
expect(nextButton).toBeEnabled();
});
test('kernel argument can be added and removed', async () => {
await renderCreateMode();
await goToKernelStep();
await addKernelAppend('nosmt=force');
await addKernelAppend('page_poison=1');
await removeKernelArg('nosmt=force');
expect(screen.queryByText('nosmt=force')).not.toBeInTheDocument();
});
test('kernel append from OpenSCAP gets added correctly and cannot be removed', async () => {
await renderCreateMode();
await goToOpenSCAPStep();
await selectProfile();
await goFromOpenSCAPToKernel();
await screen.findByText('Required by OpenSCAP');
await screen.findByText('audit_backlog_limit=8192');
await screen.findByText('audit=1');
expect(
screen.queryByRole('button', { name: /close audit_backlog_limit=8192/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /close audit=1/i })
).not.toBeInTheDocument();
});
});
describe('Kernel request generated correctly', () => {
@ -184,9 +266,72 @@ describe('Kernel request generated correctly', () => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with kernel arg added and removed', async () => {
await renderCreateMode();
await goToKernelStep();
await addKernelAppend('nosmt=force');
await removeKernelArg('nosmt=force');
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with kernel append', async () => {
await renderCreateMode();
await goToKernelStep();
await addKernelAppend('nosmt=force');
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
kernel: {
append: 'nosmt=force',
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with OpenSCAP profile that includes kernel append', async () => {
await renderCreateMode();
await goToOpenSCAPStep();
await selectProfile();
await goFromOpenSCAPToKernel();
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
filesystem: [{ min_size: 10737418240, mountpoint: '/' }],
openscap: {
profile_id: 'xccdf_org.ssgproject.content_profile_ccn_basic',
},
kernel: {
append: 'audit_backlog_limit=8192 audit=1',
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
});
// TO DO 'Kernel step' -> 'revisit step button on Review works'
// TO DO 'Kernel request generated correctly' -> 'with valid kernel append'
// TO DO 'Kernel request generated correctly' -> 'with valid kernel name and kernel append'
// TO DO 'Kernel edit mode'

View file

@ -161,8 +161,6 @@ describe('Step OpenSCAP', () => {
await goToOscapStep();
await selectProfile();
await screen.findByText(/kernel arguments:/i);
await screen.findByText(/audit_backlog_limit=8192 audit=1/i);
await screen.findByText(/disabled services:/i);
await screen.findByText(
/rpcbind autofs nftables nfs-server emacs-service/i

View file

@ -64,7 +64,7 @@ const addNtpServerViaKeyDown = async (ntpServer: string) => {
const ntpServersInput = await screen.findByPlaceholderText(
/add ntp servers/i
);
await waitFor(() => user.type(ntpServersInput, ntpServer.concat(',')));
await waitFor(() => user.type(ntpServersInput, ntpServer.concat(' ')));
};
const addNtpServerViaAddButton = async (ntpServer: string) => {

View file

@ -9,6 +9,7 @@ export const distributionOscapProfiles = (): GetOscapProfilesApiResponse => {
'xccdf_org.ssgproject.content_profile_cis_workstation_l2',
'xccdf_org.ssgproject.content_profile_standard',
'xccdf_org.ssgproject.content_profile_stig_gui',
'xccdf_org.ssgproject.content_profile_ccn_basic',
];
};
@ -75,6 +76,19 @@ export const oscapCustomizations = (
},
};
}
if (profile === 'xccdf_org.ssgproject.content_profile_ccn_basic') {
return {
openscap: {
profile_id: 'content_profile_ccn_basic',
profile_name: 'Kernel append only profile',
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.',
},
kernel: {
append: 'audit_backlog_limit=8192 audit=1',
},
};
}
return {
filesystem: [{ min_size: 1073741824, mountpoint: '/tmp' }],
openscap: {