Wizard: support tailored customizations

This splits the policy and profile selectors as they're drifting apart.

Instead of querying the customizations attached to the profile, query
the customizations attached to the policy, as these take into account
tailoring. As a result unnecessary customizations won't be added.
This commit is contained in:
Sanne Raymaekers 2025-04-17 13:45:52 +02:00 committed by Klara Simickova
parent a69a09fa4f
commit 3d545ed8ae
3 changed files with 370 additions and 218 deletions

View file

@ -3,17 +3,20 @@ import React from 'react';
import { Alert } from '@patternfly/react-core';
import OscapProfileInformation from './components/OscapProfileInformation';
import PolicySelector from './components/PolicySelector';
import ProfileSelector from './components/ProfileSelector';
import { useAppSelector } from '../../../../store/hooks';
import {
selectComplianceProfileID,
selectComplianceType,
selectImageTypes,
} from '../../../../store/wizardSlice';
const Oscap = () => {
const oscapProfile = useAppSelector(selectComplianceProfileID);
const environments = useAppSelector(selectImageTypes);
const complianceType = useAppSelector(selectComplianceType);
return (
<>
@ -24,7 +27,7 @@ const Oscap = () => {
title="OpenSCAP profiles are not compatible with WSL images."
/>
)}
<ProfileSelector />
{complianceType === 'openscap' ? <ProfileSelector /> : <PolicySelector />}
{oscapProfile && <OscapProfileInformation />}
{oscapProfile && (
<Alert

View file

@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react';
import {
FormGroup,
MenuToggle,
MenuToggleElement,
Select,
SelectOption,
} from '@patternfly/react-core';
import { v4 as uuidv4 } from 'uuid';
import {
usePoliciesQuery,
PolicyRead,
} from '../../../../../store/complianceApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
Filesystem,
Services,
useGetOscapCustomizationsForPolicyQuery,
useLazyGetOscapCustomizationsForPolicyQuery,
} from '../../../../../store/imageBuilderApi';
import {
changeCompliance,
selectDistribution,
selectCompliancePolicyID,
selectCompliancePolicyTitle,
addPackage,
addPartition,
changeFileSystemConfigurationType,
removePackage,
clearPartitions,
changeEnabledServices,
changeMaskedServices,
changeDisabledServices,
clearKernelAppend,
addKernelArg,
} from '../../../../../store/wizardSlice';
import { useHasSpecificTargetOnly } from '../../../utilities/hasSpecificTargetOnly';
import { parseSizeUnit } from '../../../utilities/parseSizeUnit';
import { Partition, Units } from '../../FileSystem/FileSystemTable';
import { removeBetaFromRelease } from '../removeBetaFromRelease';
type ComplianceSelectOptionPropType = {
policy: PolicyRead;
};
type ComplianceSelectOptionValueType = {
policyID: string;
profileID: string;
toString: () => string;
};
const ComplianceSelectOption = ({ policy }: ComplianceSelectOptionPropType) => {
const selectObj = (
policyID: string,
profileID: string,
title: string
): ComplianceSelectOptionValueType => ({
policyID,
profileID,
toString: () => title,
});
const descr = (
<>
Threshold: {policy.compliance_threshold}
<br />
Active systems: {policy.total_system_count}
</>
);
return (
<SelectOption
key={policy.id}
value={selectObj(policy.id!, policy.ref_id!, policy.title!)}
description={descr}
>
{policy.title}
</SelectOption>
);
};
const PolicySelector = () => {
const policyID = useAppSelector(selectCompliancePolicyID);
const policyTitle = useAppSelector(selectCompliancePolicyTitle);
const release = removeBetaFromRelease(useAppSelector(selectDistribution));
const majorVersion = release.split('-')[1];
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState<string>('None');
const {
data: policies,
isFetching: isFetchingPolicies,
isSuccess: isSuccessPolicies,
} = usePoliciesQuery({
filter: `os_major_version=${majorVersion}`,
});
const { data: currentProfileData } = useGetOscapCustomizationsForPolicyQuery(
{
distribution: release,
policy: policyID!,
},
{ skip: !policyID }
);
const [trigger] = useLazyGetOscapCustomizationsForPolicyQuery();
useEffect(() => {
if (!policies || policies.data === undefined) {
return;
}
if (policyID && !policyTitle) {
for (const p of policies.data) {
const pol = p as PolicyRead;
if (pol.id === policyID) {
dispatch(
changeCompliance({
policyID: pol.id,
profileID: pol.ref_id,
policyTitle: pol.title,
})
);
}
}
}
}, [isSuccessPolicies]);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleClear = () => {
dispatch(
changeCompliance({
profileID: undefined,
policyID: undefined,
policyTitle: undefined,
})
);
clearOscapPackages(currentProfileData?.packages || []);
dispatch(changeFileSystemConfigurationType('automatic'));
handleServices(undefined);
dispatch(clearKernelAppend());
};
const handlePackages = (
oldOscapPackages: string[],
newOscapPackages: string[]
) => {
clearOscapPackages(oldOscapPackages);
for (const pkg of newOscapPackages) {
dispatch(
addPackage({
name: pkg,
summary: 'Required by chosen compliance policy',
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 [size, unit] = parseSizeUnit(filesystem.min_size);
const partition: Partition = {
mountpoint: filesystem.mountpoint,
min_size: size.toString(),
unit: unit as Units,
id: uuidv4(),
};
return partition;
});
if (newPartitions) {
dispatch(changeFileSystemConfigurationType('manual'));
for (const partition of newPartitions) {
dispatch(addPartition(partition));
}
}
};
const handleServices = (services: Services | undefined) => {
dispatch(changeEnabledServices(services?.enabled || []));
dispatch(changeMaskedServices(services?.masked || []));
dispatch(changeDisabledServices(services?.disabled || []));
};
const handleKernelAppend = (kernelAppend: string | undefined) => {
dispatch(clearKernelAppend());
if (kernelAppend) {
const kernelArgsArray = kernelAppend.split(' ');
for (const arg of kernelArgsArray) {
dispatch(addKernelArg(arg));
}
}
};
const applyChanges = (selection: ComplianceSelectOptionValueType) => {
if (selection.policyID === undefined) {
// handle user has selected 'None' case
handleClear();
} else {
const oldOscapPackages = currentProfileData?.packages || [];
trigger(
{
distribution: release,
policy: selection.policyID,
},
true // preferCacheValue
)
.unwrap()
.then((response) => {
const oscapPartitions = response.filesystem || [];
const newOscapPackages = response.packages || [];
handlePartitions(oscapPartitions);
handlePackages(oldOscapPackages, newOscapPackages);
handleServices(response.services);
handleKernelAppend(response.kernel?.append);
const compl = selection as ComplianceSelectOptionValueType;
setSelected(compl.toString());
dispatch(
changeCompliance({
profileID: compl.profileID,
policyID: compl.policyID,
policyTitle: compl.toString(),
})
);
});
}
};
const handleSelect = (
_event: React.MouseEvent<Element, MouseEvent>,
selection: string
) => {
if (selection) {
applyChanges(selection as unknown as ComplianceSelectOptionValueType);
setIsOpen(false);
}
};
const complianceOptions = () => {
if (!policies || policies.data === undefined) {
return [];
}
const res = [
<SelectOption
key="compliance-none-option"
value={{ toString: () => 'None', compareTo: () => false }}
>
None
</SelectOption>,
];
for (const p of policies.data) {
if (p === undefined) {
continue;
}
const pol = p as PolicyRead;
res.push(<ComplianceSelectOption key={pol.id} policy={pol} />);
}
return res;
};
const toggleCompliance = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ouiaId="compliancePolicySelect"
ref={toggleRef}
onClick={() => setIsOpen(!isOpen)}
isExpanded={isOpen}
isDisabled={isFetchingPolicies || hasWslTargetOnly}
style={
{
width: '200px',
} as React.CSSProperties
}
>
{selected}
</MenuToggle>
);
return (
<FormGroup data-testid="profiles-form-group" label="Policy">
<Select
isScrollable
isOpen={isOpen}
onSelect={handleSelect}
onOpenChange={handleToggle}
selected={selected}
toggle={toggleCompliance}
shouldFocusFirstItemOnOpen={false}
>
{complianceOptions()}
</Select>
</FormGroup>
);
};
export default PolicySelector;

View file

@ -25,10 +25,6 @@ import {
useLazyGetOscapCustomizationsQuery,
useBackendPrefetch,
} from '../../../../../store/backendApi';
import {
usePoliciesQuery,
PolicyRead,
} from '../../../../../store/complianceApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
DistributionProfileItem,
@ -40,8 +36,6 @@ import {
changeCompliance,
selectDistribution,
selectComplianceProfileID,
selectCompliancePolicyID,
selectCompliancePolicyTitle,
addPackage,
addPartition,
changeFileSystemConfigurationType,
@ -104,59 +98,15 @@ const OScapSelectOption = ({
);
};
type ComplianceSelectOptionPropType = {
policy: PolicyRead;
};
type ComplianceSelectOptionValueType = {
policyID: string;
profileID: string;
toString: () => string;
};
const ComplianceSelectOption = ({ policy }: ComplianceSelectOptionPropType) => {
const selectObj = (
policyID: string,
profileID: string,
title: string
): ComplianceSelectOptionValueType => ({
policyID,
profileID,
toString: () => title,
});
const descr = (
<>
Threshold: {policy.compliance_threshold}
<br />
Active systems: {policy.total_system_count}
</>
);
return (
<SelectOption
key={policy.id}
value={selectObj(policy.id!, policy.ref_id!, policy.title!)}
description={descr}
>
{policy.title}
</SelectOption>
);
};
const ProfileSelector = () => {
const policyID = useAppSelector(selectCompliancePolicyID);
const policyTitle = useAppSelector(selectCompliancePolicyTitle);
const profileID = useAppSelector(selectComplianceProfileID);
const release = removeBetaFromRelease(useAppSelector(selectDistribution));
const majorVersion = release.split('-')[1];
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState<string>('');
const [filterValue, setFilterValue] = useState<string>('');
const [selectOptions, setSelectOptions] = useState<string[]>([]);
const [selected, setSelected] = useState<string>('None');
const complianceType = useAppSelector(selectComplianceType);
const prefetchProfile = useBackendPrefetch('getOscapCustomizations');
@ -166,27 +116,9 @@ const ProfileSelector = () => {
isSuccess,
isError,
refetch,
} = useGetOscapProfilesQuery(
{
distribution: release,
},
{
skip: complianceType === 'compliance',
}
);
const {
data: policies,
isFetching: isFetchingPolicies,
isSuccess: isSuccessPolicies,
} = usePoliciesQuery(
{
filter: `os_major_version=${majorVersion}`,
},
{
skip: complianceType === 'openscap',
}
);
} = useGetOscapProfilesQuery({
distribution: release,
});
const { data: currentProfileData } = useGetOscapCustomizationsQuery(
{
@ -210,28 +142,6 @@ const ProfileSelector = () => {
});
});
}
useEffect(() => {
if (!policies || policies.data === undefined) {
return;
}
if (policyID && !policyTitle) {
for (const p of policies.data) {
const pol = p as PolicyRead;
if (pol.id === policyID) {
dispatch(
changeCompliance({
policyID: pol.id,
profileID: pol.ref_id,
policyTitle: pol.title,
})
);
}
}
}
}, [isSuccessPolicies]);
useEffect(() => {
let filteredProfiles = profiles;
@ -361,9 +271,7 @@ const ProfileSelector = () => {
}
};
const applyChanges = (
selection: OScapSelectOptionValueType | ComplianceSelectOptionValueType
) => {
const applyChanges = (selection: OScapSelectOptionValueType) => {
if (selection.profileID === undefined) {
// handle user has selected 'None' case
handleClear();
@ -384,25 +292,13 @@ const ProfileSelector = () => {
handlePackages(oldOscapPackages, newOscapPackages);
handleServices(response.services);
handleKernelAppend(response.kernel?.append);
if (complianceType === 'openscap') {
dispatch(
changeCompliance({
profileID: selection.profileID,
policyID: undefined,
policyTitle: undefined,
})
);
} else {
const compl = selection as ComplianceSelectOptionValueType;
setSelected(compl.toString());
dispatch(
changeCompliance({
profileID: compl.profileID,
policyID: compl.policyID,
policyTitle: compl.toString(),
})
);
}
dispatch(
changeCompliance({
profileID: selection.profileID,
policyID: undefined,
policyTitle: undefined,
})
);
});
}
};
@ -414,38 +310,11 @@ const ProfileSelector = () => {
if (selection) {
setInputValue(selection);
setFilterValue('');
applyChanges(
selection as unknown as
| OScapSelectOptionValueType
| ComplianceSelectOptionValueType
);
applyChanges(selection as unknown as OScapSelectOptionValueType);
setIsOpen(false);
}
};
const complianceOptions = () => {
if (!policies || policies.data === undefined) {
return [];
}
const res = [
<SelectOption
key="compliance-none-option"
value={{ toString: () => 'None', compareTo: () => false }}
>
None
</SelectOption>,
];
for (const p of policies.data) {
if (p === undefined) {
continue;
}
const pol = p as PolicyRead;
res.push(<ComplianceSelectOption key={pol.id} policy={pol} />);
}
return res;
};
const toggleOpenSCAP = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
data-testid="profileSelect"
@ -480,85 +349,51 @@ const ProfileSelector = () => {
</MenuToggle>
);
const toggleCompliance = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={() => setIsOpen(!isOpen)}
isExpanded={isOpen}
isDisabled={isFetchingPolicies}
style={
{
width: '200px',
} as React.CSSProperties
}
>
{selected}
</MenuToggle>
);
return (
<FormGroup
data-testid="profiles-form-group"
label={complianceType === 'openscap' ? <OpenSCAPFGLabel /> : <>Policy</>}
>
{complianceType === 'openscap' && (
<Select
isScrollable
isOpen={isOpen}
selected={profileID}
onSelect={handleSelect}
onOpenChange={handleToggle}
toggle={toggleOpenSCAP}
shouldFocusFirstItemOnOpen={false}
popperProps={{
maxWidth: '50vw',
}}
>
<SelectList>
{isFetching && (
<FormGroup data-testid="profiles-form-group" label={<OpenSCAPFGLabel />}>
<Select
isScrollable
isOpen={isOpen}
selected={profileID}
onSelect={handleSelect}
onOpenChange={handleToggle}
toggle={toggleOpenSCAP}
shouldFocusFirstItemOnOpen={false}
popperProps={{
maxWidth: '50vw',
}}
>
<SelectList>
{isFetching && (
<SelectOption
value="loader"
data-testid="openscap-profiles-fetching"
>
<Spinner size="lg" />
</SelectOption>
)}
{selectOptions.length > 0 &&
[
<SelectOption
value="loader"
data-testid="openscap-profiles-fetching"
key="oscap-none-option"
value={{ toString: () => 'None', compareTo: () => false }}
>
<Spinner size="lg" />
</SelectOption>
)}
{selectOptions.length > 0 &&
[
<SelectOption
key="oscap-none-option"
value={{ toString: () => 'None', compareTo: () => false }}
>
None
</SelectOption>,
].concat(
selectOptions.map(
(name: DistributionProfileItem, index: number) => (
<OScapSelectOption key={index} profile_id={name} />
)
None
</SelectOption>,
].concat(
selectOptions.map(
(name: DistributionProfileItem, index: number) => (
<OScapSelectOption key={index} profile_id={name} />
)
)}
{isSuccess && selectOptions.length === 0 && (
<SelectOption isDisabled>
{`No results found for "${filterValue}"`}
</SelectOption>
)
)}
</SelectList>
</Select>
)}
{complianceType === 'compliance' && (
<Select
isScrollable
isOpen={isOpen}
onSelect={handleSelect}
onOpenChange={handleToggle}
selected={selected}
toggle={toggleCompliance}
shouldFocusFirstItemOnOpen={false}
>
{complianceOptions()}
</Select>
)}
{isSuccess && selectOptions.length === 0 && (
<SelectOption isDisabled>
{`No results found for "${filterValue}"`}
</SelectOption>
)}
</SelectList>
</Select>
{isError && (
<Alert
title="Error fetching the profiles"