diff --git a/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx b/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx
new file mode 100644
index 000000000..8652fa660
--- /dev/null
+++ b/src/Components/CreateImageWizard/steps/Services/components/ServicesInputs.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+
+import { FormGroup } from '@patternfly/react-core';
+
+import { useAppSelector } from '../../../../../store/hooks';
+import { useGetOscapCustomizationsQuery } from '../../../../../store/imageBuilderApi';
+import {
+ addDisabledService,
+ addEnabledService,
+ removeDisabledService,
+ removeEnabledService,
+ selectComplianceProfileID,
+ selectDistribution,
+ selectServices,
+} from '../../../../../store/wizardSlice';
+import ChippingInput from '../../../ChippingInput';
+import { isServiceValid } from '../../../validators';
+
+const ServicesInput = () => {
+ const disabledServices = useAppSelector(selectServices).disabled;
+ const maskedServices = useAppSelector(selectServices).masked;
+ const enabledServices = useAppSelector(selectServices).enabled;
+
+ 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 disabledAndMaskedRequiredByOpenSCAP = disabledServices
+ .concat(maskedServices)
+ .filter(
+ (service) =>
+ oscapProfileInfo?.services?.disabled?.includes(service) ||
+ oscapProfileInfo?.services?.masked?.includes(service)
+ );
+
+ const enabledRequiredByOpenSCAP = enabledServices.filter((service) =>
+ oscapProfileInfo?.services?.enabled?.includes(service)
+ );
+
+ return (
+ <>
+
+
+ !disabledAndMaskedRequiredByOpenSCAP.includes(service)
+ )}
+ requiredList={disabledAndMaskedRequiredByOpenSCAP}
+ item="Disabled service"
+ addAction={addDisabledService}
+ removeAction={removeDisabledService}
+ />
+
+
+ !enabledRequiredByOpenSCAP.includes(service)
+ )}
+ requiredList={enabledRequiredByOpenSCAP}
+ item="Enabled service"
+ addAction={addEnabledService}
+ removeAction={removeEnabledService}
+ />
+
+ >
+ );
+};
+
+export default ServicesInput;
diff --git a/src/Components/CreateImageWizard/steps/Services/index.tsx b/src/Components/CreateImageWizard/steps/Services/index.tsx
index 4b3e47674..a0d4cb7c0 100644
--- a/src/Components/CreateImageWizard/steps/Services/index.tsx
+++ b/src/Components/CreateImageWizard/steps/Services/index.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { Text, Form, Title } from '@patternfly/react-core';
+import ServicesInput from './components/ServicesInputs';
+
const ServicesStep = () => {
return (
);
};
diff --git a/src/Components/CreateImageWizard/validators.ts b/src/Components/CreateImageWizard/validators.ts
index 037d070f5..e1465801c 100644
--- a/src/Components/CreateImageWizard/validators.ts
+++ b/src/Components/CreateImageWizard/validators.ts
@@ -135,3 +135,13 @@ export const isKernelArgumentValid = (arg: string) => {
export const isPortValid = (port: string) => {
return /^(\d{1,5}|[a-z]{1,6})(-\d{1,5})?:[a-z]{1,6}$/.test(port);
};
+
+export const isServiceValid = (service: string) => {
+ // Restraints taken from service name syntax reference
+ // https://www.rfc-editor.org/rfc/rfc6335#section-5.1
+ return (
+ service.length <= 15 &&
+ /^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z]$/.test(service) &&
+ !/--/.test(service)
+ );
+};
diff --git a/src/store/wizardSlice.ts b/src/store/wizardSlice.ts
index fee3ecabf..520cc764a 100644
--- a/src/store/wizardSlice.ts
+++ b/src/store/wizardSlice.ts
@@ -808,12 +808,57 @@ export const wizardSlice = createSlice({
changeEnabledServices: (state, action: PayloadAction) => {
state.services.enabled = action.payload;
},
+ addEnabledService: (state, action: PayloadAction) => {
+ if (
+ !state.services.enabled.some((service) => service === action.payload)
+ ) {
+ state.services.enabled.push(action.payload);
+ }
+ },
+ removeEnabledService: (state, action: PayloadAction) => {
+ state.services.enabled.splice(
+ state.services.enabled.findIndex(
+ (service) => service === action.payload
+ ),
+ 1
+ );
+ },
changeMaskedServices: (state, action: PayloadAction) => {
state.services.masked = action.payload;
},
+ addMaskedService: (state, action: PayloadAction) => {
+ if (
+ !state.services.masked.some((service) => service === action.payload)
+ ) {
+ state.services.masked.push(action.payload);
+ }
+ },
+ removeMaskedService: (state, action: PayloadAction) => {
+ state.services.masked.splice(
+ state.services.masked.findIndex(
+ (service) => service === action.payload
+ ),
+ 1
+ );
+ },
changeDisabledServices: (state, action: PayloadAction) => {
state.services.disabled = action.payload;
},
+ addDisabledService: (state, action: PayloadAction) => {
+ if (
+ !state.services.disabled.some((service) => service === action.payload)
+ ) {
+ state.services.disabled.push(action.payload);
+ }
+ },
+ removeDisabledService: (state, action: PayloadAction) => {
+ state.services.disabled.splice(
+ state.services.disabled.findIndex(
+ (service) => service === action.payload
+ ),
+ 1
+ );
+ },
changeKernelName: (state, action: PayloadAction) => {
state.kernel.name = action.payload;
},
@@ -973,8 +1018,14 @@ export const {
loadWizardState,
setFirstBootScript,
changeEnabledServices,
+ addEnabledService,
+ removeEnabledService,
changeMaskedServices,
+ addMaskedService,
+ removeMaskedService,
changeDisabledServices,
+ addDisabledService,
+ removeDisabledService,
changeKernelName,
addKernelArg,
removeKernelArg,
diff --git a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
index 6e1461044..4634203b4 100644
--- a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
+++ b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
@@ -2,9 +2,14 @@ import type { Router as RemixRouter } from '@remix-run/router';
import { screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
+import { CREATE_BLUEPRINT } from '../../../../../constants';
import {
+ blueprintRequest,
clickBack,
clickNext,
+ enterBlueprintName,
+ interceptBlueprintRequest,
+ openAndDismissSaveAndBuildModal,
verifyCancelButton,
} from '../../wizardTestUtils';
import { clickRegisterLater, renderCreateMode } from '../../wizardTestUtils';
@@ -33,6 +38,75 @@ const goToServicesStep = async () => {
await clickNext(); // Services
};
+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 goFromOpenSCAPToServices = 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
+ await clickNext(); // Firewall
+ await clickNext(); // Services
+};
+
+const goToReviewStep = async () => {
+ await clickNext(); // First boot script
+ await clickNext(); // Details
+ await enterBlueprintName();
+ await clickNext(); // Review
+};
+
+const addDisabledService = async (service: string) => {
+ const user = userEvent.setup();
+ const disabledServiceInput = await screen.findByPlaceholderText(
+ 'Add disabled service'
+ );
+ await waitFor(() => user.type(disabledServiceInput, service.concat(' ')));
+};
+
+const addEnabledService = async (service: string) => {
+ const user = userEvent.setup();
+ const enabledServiceInput = await screen.findByPlaceholderText(
+ 'Add enabled service'
+ );
+ await waitFor(() => user.type(enabledServiceInput, service.concat(' ')));
+};
+
+const removeService = async (service: string) => {
+ const user = userEvent.setup();
+ const removeServiceButton = await screen.findByRole('button', {
+ name: `close ${service}`,
+ });
+ await waitFor(() => user.click(removeServiceButton));
+};
+
+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(
+ /CIS Red Hat Enterprise Linux 8 Benchmark for Level 1 - Workstation/i
+ );
+ await waitFor(() => user.click(cis1Profile));
+};
+
describe('Step Services', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -60,8 +134,132 @@ describe('Step Services', () => {
await goToServicesStep();
await verifyCancelButton(router);
});
+
+ test('services can be added and removed', async () => {
+ await renderCreateMode();
+ await goToServicesStep();
+ await addDisabledService('telnet');
+ await addDisabledService('https');
+ await removeService('telnet');
+ expect(screen.queryByText('telnet')).not.toBeInTheDocument();
+ });
+
+ test('services from OpenSCAP get added correctly and cannot be removed', async () => {
+ await renderCreateMode();
+ await goToOpenSCAPStep();
+ await selectProfile();
+ await goFromOpenSCAPToServices();
+ await screen.findAllByText('Required by OpenSCAP');
+ // disabled services
+ await screen.findByText('nfs-server');
+ await screen.findByText('emacs-service');
+ expect(
+ screen.queryByRole('button', { name: /close nfs-server/i })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /close emacs-service/i })
+ ).not.toBeInTheDocument();
+ // enabled services
+ await screen.findByText('crond');
+ await screen.findByText('neovim-service');
+ expect(
+ screen.queryByRole('button', { name: /close crond/i })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /close neovim-service/i })
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('Services request generated correctly', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ });
+
+ test('with services', async () => {
+ await renderCreateMode();
+ await goToServicesStep();
+ await addDisabledService('telnet');
+ await addEnabledService('httpd');
+ await goToReviewStep();
+ // informational modal pops up in the first test only as it's tied
+ // to a 'imageBuilder.saveAndBuildModalSeen' variable in localStorage
+ await openAndDismissSaveAndBuildModal();
+ const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
+ const expectedRequest = {
+ ...blueprintRequest,
+ customizations: {
+ services: {
+ disabled: ['telnet'],
+ enabled: ['httpd'],
+ },
+ },
+ };
+
+ await waitFor(() => {
+ expect(receivedRequest).toEqual(expectedRequest);
+ });
+ });
+
+ test('with added and removed services', async () => {
+ await renderCreateMode();
+ await goToServicesStep();
+ await addDisabledService('telnet');
+ await addEnabledService('httpd');
+ await removeService('telnet');
+ await removeService('httpd');
+ await goToReviewStep();
+ const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
+ const expectedRequest = {
+ ...blueprintRequest,
+ };
+
+ await waitFor(() => {
+ expect(receivedRequest).toEqual(expectedRequest);
+ });
+ });
+
+ test('with OpenSCAP profile that includes services', async () => {
+ await renderCreateMode();
+ await goToOpenSCAPStep();
+ await selectProfile();
+ await goFromOpenSCAPToServices();
+ await goToReviewStep();
+ const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
+
+ const expectedRequest = {
+ ...blueprintRequest,
+ customizations: {
+ filesystem: [
+ {
+ min_size: 10737418240,
+ mountpoint: '/',
+ },
+ { min_size: 1073741824, mountpoint: '/tmp' },
+ { min_size: 1073741824, mountpoint: '/home' },
+ ],
+ openscap: {
+ profile_id: 'xccdf_org.ssgproject.content_profile_cis_workstation_l1',
+ },
+ packages: ['aide', 'neovim'],
+ kernel: {
+ append: 'audit_backlog_limit=8192 audit=1',
+ },
+ services: {
+ masked: ['nfs-server', 'emacs-service'],
+ disabled: ['rpcbind', 'autofs', 'nftables'],
+ enabled: ['crond', 'neovim-service'],
+ },
+ },
+ };
+
+ await waitFor(() => {
+ expect(receivedRequest).toEqual(expectedRequest);
+ });
+ });
});
// TO DO 'Services step' -> 'revisit step button on Review works'
-// TO DO 'Services request generated correctly'
// TO DO 'Services edit mode'