Wizard: add AAP step

This commit is contained in:
Lucas Garfield 2025-05-14 17:45:09 -05:00 committed by Gianluca Zuccarelli
parent 11e352440f
commit 04adcc133c
16 changed files with 776 additions and 14 deletions

View file

@ -0,0 +1,214 @@
import { expect } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { test } from '../fixtures/customizations';
import { isHosted } from '../helpers/helpers';
import { ensureAuthenticated } from '../helpers/login';
import {
ibFrame,
navigateToLandingPage,
navigateToOptionalSteps,
} from '../helpers/navHelpers';
import {
createBlueprint,
deleteBlueprint,
exportBlueprint,
fillInDetails,
fillInImageOutputGuest,
importBlueprint,
registerLater,
} from '../helpers/wizardHelpers';
const validCallbackUrl =
'https://controller.url/api/controller/v2/job_templates/9/callback/';
const validHttpCallbackUrl =
'http://controller.url/api/controller/v2/job_templates/9/callback/';
const validHostConfigKey = 'hostconfigkey';
const validCertificate = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOEzx5ezZ9EIMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAklOMQswCQYDVQQIDAJLUjEMMAoGA1UEBwwDS1JHMRAwDgYDVQQKDAdUZXN0
IENBMB4XDTI1MDUxNTEyMDAwMFoXDTI2MDUxNTEyMDAwMFowRTELMAkGA1UEBhMC
SU4xCzAJBgNVBAgMAktSMQwwCgYDVQQHDANSR0sxEDAOBgNVBAoMB1Rlc3QgQ0Ew
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+R4gfN5pyJQo5qBTTtN+7
eE9CSXZJ8SVVaE3U54IgqQoqsSoBY5QtExy7v5C6l6mW4E6dzK/JecmvTTO/BvlG
A5k2hxB6bOQxtxYwfgElH+RFWN9P4xxhtEiQgHoG1rDfnXuDJk1U3YEkCQELUebz
fF3EIDU1yR0Sz2bA+Sl2VXe8og1MEZfytq8VZUVltxtn2PfW7zI5gOllBR2sKeUc
K6h8HXN7qMgfEvsLIXxTw7fU/zA3ibcxfRCl3m6QhF8hwRh6F9Wtz2s8hCzGegV5
z0M39nY7X8C3GZQ4Ly8v8DdY+FbEix7K3SSBRbWtdPfAHRFlX9Er2Wf8DAr7O2hH
AgMBAAGjUDBOMB0GA1UdDgQWBBTXXz2eIDgK+BhzDUAGzptn0OMcpDAfBgNVHSME
GDAWgBTXXz2eIDgK+BhzDUAGzptn0OMcpDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQAoUgY4jsuBMB3el9cc7JS2rcOhhJzn47Hj2UANfJq52g5lbjo7
XDc7Wb3VDcV+1LzjdzayT1qO1WzHb6FDPW9L9f6h4s8lj6MvJ+xhOWgD11srdIt3
vbQaQW4zDfeVRcKXzqbcUX8BLXAdzJPqVwZ+Z4EDjYrJ7lF9k+IqfZm0MsYX7el9
kvdRHbLuF4Q0sZ05CXMFkhM0Ulhu4MZ+1FcsQa7nWfZzTmbjHOuWJPB4z5WwrB7z
U8YYvWJ3qxToWGbATqJxkRKGGqLrNrmwcfzgPqkpuCRYi0Kky6gJ1RvL+DRopY9x
uD+ckf3oH2wYAB6RpPRMkfVxe7lGMvq/yEZ6
-----END CERTIFICATE-----`;
const invalidCertificate = `-----BEGIN CERTIFICATE-----
ThisIs*Not+Valid/Base64==
-----END CERTIFICATE-----`;
test('Create a blueprint with AAP registration customization', async ({
page,
cleanup,
}) => {
const blueprintName = 'test-' + uuidv4();
// Skip entirely in Cockpit/on-premise where AAP customization is unavailable
test.skip(!isHosted(), 'AAP customization is not available in the plugin');
// Delete the blueprint after the run fixture
await cleanup.add(() => deleteBlueprint(page, blueprintName));
await ensureAuthenticated(page);
// Navigate to IB landing page and get the frame
await navigateToLandingPage(page);
const frame = await ibFrame(page);
await test.step('Navigate to optional steps in Wizard', async () => {
await navigateToOptionalSteps(frame);
await registerLater(frame);
});
await test.step('Select and fill the AAP step with valid configuration', async () => {
await frame
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
await frame
.getByRole('textbox', { name: 'host config key' })
.fill(validHostConfigKey);
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByRole('button', { name: 'Next' })).toBeEnabled();
});
await test.step('Test TLS confirmation checkbox for HTTPS URLs', async () => {
// TLS confirmation checkbox should appear for HTTPS URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeVisible();
// Check TLS confirmation and verify CA input is hidden
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.check();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeHidden();
await frame
.getByRole('checkbox', {
name: 'Insecure',
})
.uncheck();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
});
await test.step('Test certificate validation', async () => {
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(invalidCertificate);
await expect(frame.getByText(/Certificate.*is not valid/)).toBeVisible();
await frame.getByRole('textbox', { name: 'File upload' }).clear();
await frame
.getByRole('textbox', { name: 'File upload' })
.fill(validCertificate);
await expect(frame.getByText('Certificate was uploaded')).toBeVisible();
});
await test.step('Test HTTP URL behavior', async () => {
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validHttpCallbackUrl);
// TLS confirmation checkbox should NOT appear for HTTP URLs
await expect(
frame.getByRole('checkbox', {
name: 'Insecure',
}),
).toBeHidden();
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toBeVisible();
await frame.getByRole('textbox', { name: 'ansible callback url' }).clear();
await frame
.getByRole('textbox', { name: 'ansible callback url' })
.fill(validCallbackUrl);
});
await test.step('Complete AAP configuration and proceed to review', async () => {
await frame.getByRole('button', { name: 'Review and finish' }).click();
});
await test.step('Fill the BP details', async () => {
await fillInDetails(frame, blueprintName);
});
await test.step('Create BP', async () => {
await createBlueprint(frame, blueprintName);
});
await test.step('Edit BP and verify AAP configuration persists', async () => {
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
await frame.getByLabel('Revisit Ansible Automation Platform step').click();
await expect(
frame.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
frame.getByRole('textbox', { name: 'host config key' }),
).toHaveValue(validHostConfigKey);
await expect(
frame.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await frame.getByRole('button', { name: 'Review and finish' }).click();
await frame
.getByRole('button', { name: 'Save changes to blueprint' })
.click();
});
// This is for hosted service only as these features are not available in cockpit plugin
await test.step('Export BP', async (step) => {
step.skip(!isHosted(), 'Exporting is not available in the plugin');
await exportBlueprint(page, blueprintName);
});
await test.step('Import BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await importBlueprint(page, blueprintName);
});
await test.step('Review imported BP', async (step) => {
step.skip(!isHosted(), 'Importing is not available in the plugin');
await fillInImageOutputGuest(page);
await page
.getByRole('button', { name: 'Ansible Automation Platform' })
.click();
await expect(
page.getByRole('textbox', { name: 'ansible callback url' }),
).toHaveValue(validCallbackUrl);
await expect(
page.getByRole('textbox', { name: 'host config key' }),
).toBeEmpty();
await expect(
page.getByRole('textbox', { name: 'File upload' }),
).toHaveValue(validCertificate);
await page.getByRole('button', { name: 'Cancel' }).click();
});
});

View file

@ -72,6 +72,11 @@ test.describe.serial('test', () => {
frame.getByRole('heading', { name: 'Systemd services' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
if (isHosted()) {
frame.getByRole('heading', { name: 'Ansible Automation Platform' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}
if (isHosted()) {
frame.getByRole('heading', { name: 'First boot configuration' });
await frame.getByRole('button', { name: 'Next', exact: true }).click();

View file

@ -15,6 +15,7 @@ import { WizardStepType } from '@patternfly/react-core/dist/esm/components/Wizar
import useChrome from '@redhat-cloud-services/frontend-components/useChrome';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AAPStep from './steps/AAP';
import DetailsStep from './steps/Details';
import FileSystemStep from './steps/FileSystem';
import { FileSystemContext } from './steps/FileSystem/components/FileSystemTable';
@ -40,6 +41,7 @@ import UsersStep from './steps/Users';
import { getHostArch, getHostDistro } from './utilities/getHostInfo';
import { useHasSpecificTargetOnly } from './utilities/hasSpecificTargetOnly';
import {
useAAPValidation,
useDetailsValidation,
useFilesystemValidation,
useFirewallValidation,
@ -197,6 +199,7 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
// Feature flags
const complianceEnabled = useFlag('image-builder.compliance.enabled');
const isAAPRegistrationEnabled = useFlag('image-builder.aap.enabled');
// IMPORTANT: Ensure the wizard starts with a fresh initial state
useEffect(() => {
@ -283,6 +286,8 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const firewallValidation = useFirewallValidation();
// Services
const servicesValidation = useServicesValidation();
// AAP
const aapValidation = useAAPValidation();
// Firstboot
const firstBootValidation = useFirstBootValidation();
// Details
@ -293,8 +298,10 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
let startIndex = 1; // default index
const JUMP_TO_REVIEW_STEP = 23;
if (isEdit) {
startIndex = 22;
startIndex = JUMP_TO_REVIEW_STEP;
}
const [wasRegisterVisited, setWasRegisterVisited] = useState(false);
@ -655,6 +662,22 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
>
<ServicesStep />
</WizardStep>,
<WizardStep
name='Ansible Automation Platform'
id='wizard-aap'
isHidden={!isAAPRegistrationEnabled}
key='wizard-aap'
navItem={CustomStatusNavItem}
status={aapValidation.disabledNext ? 'error' : 'default'}
footer={
<CustomWizardFooter
disableNext={aapValidation.disabledNext}
optional={true}
/>
}
>
<AAPStep />
</WizardStep>,
<WizardStep
name='First boot script configuration'
id='wizard-first-boot'

View file

@ -23,7 +23,7 @@ type ValidatedTextInputPropTypes = TextInputProps & {
type ValidationInputProp = TextInputProps &
TextAreaProps & {
value: string;
placeholder: string;
placeholder?: string;
stepValidation: StepValidation;
dataTestId?: string;
fieldName: string;
@ -91,7 +91,7 @@ export const ValidatedInputAndTextArea = ({
onChange={onChange}
validated={validated}
onBlur={handleBlur}
placeholder={placeholder}
placeholder={placeholder || ''}
aria-label={ariaLabel}
data-testid={dataTestId}
/>
@ -138,6 +138,7 @@ export const ValidatedInput = ({
value,
placeholder,
onChange,
...props
}: ValidatedTextInputPropTypes) => {
const [isPristine, setIsPristine] = useState(!value ? true : false);
@ -164,6 +165,7 @@ export const ValidatedInput = ({
aria-label={ariaLabel || ''}
onBlur={handleBlur}
placeholder={placeholder || ''}
{...props}
/>
{!isPristine && !validator(value) && (
<HelperText>

View file

@ -0,0 +1,178 @@
import React from 'react';
import {
Checkbox,
DropEvent,
FileUpload,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeAapCallbackUrl,
changeAapHostConfigKey,
changeAapTlsCertificateAuthority,
changeAapTlsConfirmation,
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
} from '../../../../../store/wizardSlice';
import { useAAPValidation } from '../../../utilities/useValidation';
import { ValidatedInputAndTextArea } from '../../../ValidatedInput';
import { validateMultipleCertificates } from '../../../validators';
const AAPRegistration = () => {
const dispatch = useAppDispatch();
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const tlsConfirmation = useAppSelector(selectAapTlsConfirmation);
const [isRejected, setIsRejected] = React.useState(false);
const stepValidation = useAAPValidation();
const isHttpsUrl = callbackUrl?.toLowerCase().startsWith('https://') || false;
const shouldShowCaInput = !isHttpsUrl || (isHttpsUrl && !tlsConfirmation);
const validated = stepValidation.errors['certificate']
? 'error'
: stepValidation.errors['certificate'] === undefined &&
tlsCertificateAuthority &&
validateMultipleCertificates(tlsCertificateAuthority).validCertificates
.length > 0
? 'success'
: 'default';
const handleCallbackUrlChange = (value: string) => {
dispatch(changeAapCallbackUrl(value));
};
const handleHostConfigKeyChange = (value: string) => {
dispatch(changeAapHostConfigKey(value));
};
const handleClear = () => {
dispatch(changeAapTlsCertificateAuthority(''));
};
const handleTextChange = (
_event: React.ChangeEvent<HTMLTextAreaElement>,
value: string,
) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleDataChange = (_: DropEvent, value: string) => {
dispatch(changeAapTlsCertificateAuthority(value));
setIsRejected(false);
};
const handleFileRejected = () => {
dispatch(changeAapTlsCertificateAuthority(''));
setIsRejected(true);
};
const handleTlsConfirmationChange = (checked: boolean) => {
dispatch(changeAapTlsConfirmation(checked));
};
return (
<>
<FormGroup label='Ansible Callback URL' isRequired>
<ValidatedInputAndTextArea
value={callbackUrl || ''}
onChange={(_event, value) => handleCallbackUrlChange(value.trim())}
ariaLabel='ansible callback url'
isRequired
stepValidation={stepValidation}
fieldName='callbackUrl'
/>
</FormGroup>
<FormGroup label='Host Config Key' isRequired>
<ValidatedInputAndTextArea
value={hostConfigKey || ''}
onChange={(_event, value) => handleHostConfigKeyChange(value.trim())}
ariaLabel='host config key'
isRequired
stepValidation={stepValidation}
fieldName='hostConfigKey'
/>
</FormGroup>
{shouldShowCaInput && (
<FormGroup label='Certificate authority (CA) for Ansible Controller'>
<FileUpload
id='aap-certificate-upload'
type='text'
value={tlsCertificateAuthority || ''}
filename={tlsCertificateAuthority ? 'CA detected' : ''}
onDataChange={handleDataChange}
onTextChange={handleTextChange}
onClearClick={handleClear}
dropzoneProps={{
accept: {
'application/x-pem-file': ['.pem'],
'application/x-x509-ca-cert': ['.cer', '.crt'],
'application/pkix-cert': ['.der'],
},
maxSize: 512000,
onDropRejected: handleFileRejected,
}}
validated={isRejected ? 'error' : validated}
browseButtonText='Upload'
allowEditingUploadedText={true}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
variant={
isRejected || validated === 'error'
? 'error'
: validated === 'success'
? 'success'
: 'default'
}
>
{isRejected
? 'Must be a .PEM/.CER/.CRT file'
: validated === 'error'
? stepValidation.errors['certificate']
: validated === 'success'
? 'Certificate was uploaded'
: 'Drag and drop a valid certificate file or upload one'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
)}
{isHttpsUrl && (
<FormGroup>
<Checkbox
id='tls-confirmation-checkbox'
label='Insecure'
isChecked={tlsConfirmation || false}
onChange={(_event, checked) => handleTlsConfirmationChange(checked)}
/>
{stepValidation.errors['tlsConfirmation'] && (
<FormHelperText>
<HelperText>
<HelperTextItem variant='error'>
{stepValidation.errors['tlsConfirmation']}
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
)}
</>
);
};
export default AAPRegistration;

View file

@ -0,0 +1,18 @@
import React from 'react';
import { Form, Title } from '@patternfly/react-core';
import AAPRegistration from './components/AAPRegistration';
const AAPStep = () => {
return (
<Form>
<Title headingLevel='h1' size='xl'>
Ansible Automation Platform
</Title>
<AAPRegistration />
</Form>
);
};
export default AAPStep;

View file

@ -25,6 +25,7 @@ import {
KernelList,
LocaleList,
OscapList,
RegisterAapList,
RegisterLaterList,
RegisterNowList,
RegisterSatelliteList,
@ -42,6 +43,7 @@ import isRhel from '../../../../../src/Utilities/isRhel';
import { targetOptions } from '../../../../constants';
import { useAppSelector } from '../../../../store/hooks';
import {
selectAapRegistration,
selectBlueprintDescription,
selectBlueprintName,
selectCompliancePolicyID,
@ -65,6 +67,7 @@ import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly'
const Review = () => {
const { goToStepById } = useWizardContext();
const aapRegistration = useAppSelector(selectAapRegistration);
const blueprintName = useAppSelector(selectBlueprintName);
const blueprintDescription = useAppSelector(selectBlueprintDescription);
const distribution = useAppSelector(selectDistribution);
@ -83,6 +86,7 @@ const Review = () => {
const users = useAppSelector(selectUsers);
const kernel = useAppSelector(selectKernel);
const [isExpandedAap, setIsExpandedAap] = useState(true);
const [isExpandedImageOutput, setIsExpandedImageOutput] = useState(true);
const [isExpandedTargetEnvs, setIsExpandedTargetEnvs] = useState(true);
const [isExpandedFSC, setIsExpandedFSC] = useState(true);
@ -101,6 +105,8 @@ const Review = () => {
const [isExpandableFirstBoot, setIsExpandedFirstBoot] = useState(true);
const [isExpandedUsers, setIsExpandedUsers] = useState(true);
const onToggleAap = (isExpandedAap: boolean) =>
setIsExpandedAap(isExpandedAap);
const onToggleImageOutput = (isExpandedImageOutput: boolean) =>
setIsExpandedImageOutput(isExpandedImageOutput);
const onToggleTargetEnvs = (isExpandedTargetEnvs: boolean) =>
@ -499,6 +505,21 @@ const Review = () => {
<ServicesList />
</ExpandableSection>
)}
{aapRegistration.callbackUrl && (
<ExpandableSection
toggleContent={composeExpandable(
'Ansible Automation Platform',
'revisit-aap',
'wizard-aap',
)}
onToggle={(_event, isExpandableAap) => onToggleAap(isExpandableAap)}
isExpanded={isExpandedAap}
isIndented
data-testid='aap-expandable'
>
<RegisterAapList />
</ExpandableSection>
)}
{!process.env.IS_ON_PREMISE && (
<ExpandableSection
toggleContent={composeExpandable(

View file

@ -41,6 +41,10 @@ import { useAppSelector } from '../../../../store/hooks';
import { useGetSourceListQuery } from '../../../../store/provisioningApi';
import { useShowActivationKeyQuery } from '../../../../store/rhsmApi';
import {
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectArchitecture,
selectAwsAccountId,
@ -660,6 +664,45 @@ export const RegisterSatelliteList = () => {
);
};
export const RegisterAapList = () => {
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const skipTlsVerification = useAppSelector(selectAapTlsConfirmation);
const getTlsStatus = () => {
if (skipTlsVerification) {
return 'Insecure (TLS verification skipped)';
}
return tlsCertificateAuthority ? 'Configured' : 'None';
};
return (
<Content>
<Content component={ContentVariants.dl} className='review-step-dl'>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
Ansible Callback URL
</Content>
<Content component={ContentVariants.dd}>
{callbackUrl || 'None'}
</Content>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
Host Config Key
</Content>
<Content component={ContentVariants.dd}>
{hostConfigKey || 'None'}
</Content>
<Content component={ContentVariants.dt} className='pf-v6-u-min-width'>
TLS Certificate
</Content>
<Content component={ContentVariants.dd}>{getTlsStatus()}</Content>
</Content>
</Content>
);
};
export const RegisterNowList = () => {
const activationKey = useAppSelector(selectActivationKey);
const registrationType = useAppSelector(selectRegistrationType);

View file

@ -23,6 +23,7 @@ import {
CockpitUploadTypes,
} from '../../../store/cockpit/types';
import {
AapRegistration,
AwsUploadRequestOptions,
AzureUploadRequestOptions,
BlueprintExportResponse,
@ -49,6 +50,11 @@ import { ApiRepositoryImportResponseRead } from '../../../store/service/contentS
import {
ComplianceType,
initialState,
RegistrationType,
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectArchitecture,
selectAwsAccountId,
@ -387,14 +393,7 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
baseUrl: request.customizations.subscription?.['base-url'] || '',
},
registration: {
registrationType:
request.customizations?.subscription && isRhel(request.distribution)
? request.customizations.subscription.rhc
? 'register-now-rhc'
: 'register-now-insights'
: getSatelliteCommand(request.customizations.files)
? 'register-satellite'
: 'register-later',
registrationType: getRegistrationType(request),
activationKey: isRhel(request.distribution)
? request.customizations.subscription?.['activation-key']
: undefined,
@ -403,6 +402,15 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
caCert: request.customizations.cacerts?.pem_certs[0],
},
},
aapRegistration: {
callbackUrl:
request.customizations?.aap_registration?.ansible_callback_url,
hostConfigKey: request.customizations?.aap_registration?.host_config_key,
tlsCertificateAuthority:
request.customizations?.aap_registration?.tls_certificate_authority,
skipTlsVerification:
request.customizations?.aap_registration?.skip_tls_verification,
},
...commonRequestToState(request),
};
};
@ -452,6 +460,15 @@ export const mapExportRequestToState = (
},
env: initialState.env,
registration: initialState.registration,
aapRegistration: {
callbackUrl:
request.customizations?.aap_registration?.ansible_callback_url,
hostConfigKey: request.customizations?.aap_registration?.host_config_key,
tlsCertificateAuthority:
request.customizations?.aap_registration?.tls_certificate_authority,
skipTlsVerification:
request.customizations?.aap_registration?.skip_tls_verification,
},
...commonRequestToState(blueprintResponse),
};
};
@ -461,6 +478,24 @@ const getFirstBootScript = (files?: File[]): string => {
return firstBootFile?.data ? atob(firstBootFile.data) : '';
};
const getAapRegistration = (state: RootState): AapRegistration | undefined => {
const callbackUrl = selectAapCallbackUrl(state);
const hostConfigKey = selectAapHostConfigKey(state);
const tlsCertificateAuthority = selectAapTlsCertificateAuthority(state);
const skipTlsVerification = selectAapTlsConfirmation(state);
if (!callbackUrl && !hostConfigKey && !tlsCertificateAuthority) {
return undefined;
}
return {
ansible_callback_url: callbackUrl || '',
host_config_key: hostConfigKey || '',
tls_certificate_authority: tlsCertificateAuthority || undefined,
skip_tls_verification: skipTlsVerification || undefined,
};
};
const getImageRequests = (
state: RootState,
): ImageRequest[] | CockpitImageRequest[] => {
@ -482,6 +517,24 @@ const getImageRequests = (
}));
};
const getRegistrationType = (request: BlueprintResponse): RegistrationType => {
const subscription = request.customizations.subscription;
const distribution = request.distribution;
const files = request.customizations.files;
if (subscription && isRhel(distribution)) {
if (subscription.rhc) {
return 'register-now-rhc';
} else {
return 'register-now-insights';
}
} else if (getSatelliteCommand(files)) {
return 'register-satellite';
} else {
return 'register-later';
}
};
const getSatelliteCommand = (files?: File[]): string => {
const satelliteCommandFile = files?.find(
(file) => file.path === SATELLITE_PATH,
@ -642,6 +695,7 @@ const getCustomizations = (state: RootState, orgID: string): Customizations => {
pem_certs: [satCert],
}
: undefined,
aap_registration: getAapRegistration(state),
};
};

View file

@ -11,6 +11,10 @@ import { useAppSelector } from '../../../store/hooks';
import { BlueprintsResponse } from '../../../store/imageBuilderApi';
import { useShowActivationKeyQuery } from '../../../store/rhsmApi';
import {
selectAapCallbackUrl,
selectAapHostConfigKey,
selectAapTlsCertificateAuthority,
selectAapTlsConfirmation,
selectActivationKey,
selectBlueprintDescription,
selectBlueprintId,
@ -54,6 +58,8 @@ import {
isSshKeyValid,
isUserGroupValid,
isUserNameValid,
isValidUrl,
validateMultipleCertificates,
} from '../validators';
export type StepValidation = {
@ -205,6 +211,62 @@ export function useRegistrationValidation(): StepValidation {
return { errors: {}, disabledNext: false };
}
export function useAAPValidation(): StepValidation {
const errors: Record<string, string> = {};
const callbackUrl = useAppSelector(selectAapCallbackUrl);
const hostConfigKey = useAppSelector(selectAapHostConfigKey);
const tlsCertificateAuthority = useAppSelector(
selectAapTlsCertificateAuthority,
);
const tlsConfirmation = useAppSelector(selectAapTlsConfirmation);
if (!callbackUrl && !hostConfigKey && !tlsCertificateAuthority) {
return { errors: {}, disabledNext: false };
}
if (!callbackUrl || callbackUrl.trim() === '') {
errors.callbackUrl = 'Ansible Callback URL is required';
} else if (!isValidUrl(callbackUrl)) {
errors.callbackUrl = 'Callback URL must be a valid URL';
}
if (!hostConfigKey || hostConfigKey.trim() === '') {
errors.hostConfigKey = 'Host Config Key is required';
}
if (tlsCertificateAuthority && tlsCertificateAuthority.trim() !== '') {
const validation = validateMultipleCertificates(tlsCertificateAuthority);
if (validation.errors.length > 0) {
errors.certificate = validation.errors.join(' ');
} else if (validation.validCertificates.length === 0) {
errors.certificate = 'No valid certificates found in the input.';
}
}
if (callbackUrl && callbackUrl.trim() !== '') {
const isHttpsUrl = callbackUrl.toLowerCase().startsWith('https://');
// If URL is HTTP, require TLS certificate
if (
!isHttpsUrl &&
(!tlsCertificateAuthority || tlsCertificateAuthority.trim() === '')
) {
errors.certificate = 'HTTP URL requires a custom TLS certificate';
return { errors, disabledNext: true };
}
// For HTTPS URL, if the TLS confirmation is not checked, require certificate
if (
!tlsConfirmation &&
(!tlsCertificateAuthority || tlsCertificateAuthority.trim() === '')
) {
errors.certificate =
'HTTPS URL requires either a custom TLS certificate or confirmation that no custom certificate is needed';
}
}
return { errors, disabledNext: Object.keys(errors).length > 0 };
}
export function useFilesystemValidation(): StepValidation {
const mode = useAppSelector(selectFileSystemConfigurationType);
const partitions = useAppSelector(selectPartitions);

View file

@ -138,3 +138,85 @@ export const isServiceValid = (service: string) => {
/[a-zA-Z]+/.test(service) // contains at least one letter
);
};
export const isValidUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
const isHttpOrHttps = ['http:', 'https:'].includes(parsedUrl.protocol);
const hostname = parsedUrl.hostname;
const hasValidDomain =
/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})*$/.test(hostname);
return isHttpOrHttps && hasValidDomain;
} catch {
return false;
}
};
export const isValidCA = (ca: string) => {
if (!ca || typeof ca !== 'string') return false;
const trimmed = ca.trim();
const pemPattern =
/^-----BEGIN CERTIFICATE-----[\r\n]+([\s\S]*?)[\r\n]+-----END CERTIFICATE-----$/;
if (!pemPattern.test(trimmed)) {
return false;
}
const match = trimmed.match(pemPattern);
if (!match || !match[1]) {
return false;
}
const base64Content = match[1].replace(/[\r\n\s]/g, '');
const base64Pattern = /^[A-Za-z0-9+/]+(=*)$/;
return base64Pattern.test(base64Content) && base64Content.length > 0;
};
export const parseMultipleCertificates = (input: string): string[] => {
if (!input || typeof input !== 'string') return [];
const blockPattern =
/-----BEGIN CERTIFICATE-----[\s\S]*?(?=-----BEGIN CERTIFICATE-----|$)/g;
const matches = input.match(blockPattern);
return matches ? matches.map((m) => m.trim()) : [];
};
export const validateMultipleCertificates = (
input: string,
): {
certificates: string[];
validCertificates: string[];
invalidCertificates: string[];
errors: string[];
} => {
const certificates = parseMultipleCertificates(input);
const validCertificates: string[] = [];
const invalidCertificates: string[] = [];
const errors: string[] = [];
if (certificates.length === 0 && input.trim() !== '') {
errors.push(
'No valid certificate format found. Certificates must be in PEM/DER/CER format.',
);
return { certificates, validCertificates, invalidCertificates, errors };
}
certificates.forEach((cert, index) => {
if (isValidCA(cert)) {
validCertificates.push(cert);
} else {
invalidCertificates.push(cert);
errors.push(
`Certificate ${index + 1} is not valid. Must be in PEM/DER/CER format.`,
);
}
});
return { certificates, validCertificates, invalidCertificates, errors };
};

View file

@ -44,7 +44,8 @@ export type RegistrationType =
| 'register-now'
| 'register-now-insights'
| 'register-now-rhc'
| 'register-satellite';
| 'register-satellite'
| 'register-aap';
export type ComplianceType = 'openscap' | 'compliance';
@ -89,6 +90,12 @@ export type wizardState = {
architecture: ImageRequest['architecture'];
distribution: Distributions;
imageTypes: ImageTypes[];
aapRegistration: {
callbackUrl: string | undefined;
hostConfigKey: string | undefined;
tlsCertificateAuthority: string | undefined;
skipTlsVerification: boolean | undefined;
};
aws: {
accountId: string;
shareMethod: AwsShareMethod;
@ -189,6 +196,12 @@ export const initialState: wizardState = {
architecture: X86_64,
distribution: RHEL_10,
imageTypes: [],
aapRegistration: {
callbackUrl: undefined,
hostConfigKey: undefined,
tlsCertificateAuthority: undefined,
skipTlsVerification: undefined,
},
aws: {
accountId: '',
shareMethod: 'sources',
@ -376,6 +389,26 @@ export const selectSatelliteCaCertificate = (state: RootState) => {
return state.wizard.registration.satelliteRegistration.caCert;
};
export const selectAapRegistration = (state: RootState) => {
return state.wizard.aapRegistration;
};
export const selectAapCallbackUrl = (state: RootState) => {
return state.wizard.aapRegistration?.callbackUrl;
};
export const selectAapHostConfigKey = (state: RootState) => {
return state.wizard.aapRegistration?.hostConfigKey;
};
export const selectAapTlsCertificateAuthority = (state: RootState) => {
return state.wizard.aapRegistration?.tlsCertificateAuthority;
};
export const selectAapTlsConfirmation = (state: RootState) => {
return state.wizard.aapRegistration?.skipTlsVerification;
};
export const selectComplianceProfileID = (state: RootState) => {
return state.wizard.compliance.profileID;
};
@ -627,6 +660,22 @@ export const wizardSlice = createSlice({
changeSatelliteCaCertificate: (state, action: PayloadAction<string>) => {
state.registration.satelliteRegistration.caCert = action.payload;
},
changeAapCallbackUrl: (state, action: PayloadAction<string>) => {
state.aapRegistration.callbackUrl = action.payload;
},
changeAapHostConfigKey: (state, action: PayloadAction<string>) => {
state.aapRegistration.hostConfigKey = action.payload;
},
changeAapTlsCertificateAuthority: (
state,
action: PayloadAction<string>,
) => {
state.aapRegistration.tlsCertificateAuthority = action.payload;
},
changeAapTlsConfirmation: (state, action: PayloadAction<boolean>) => {
state.aapRegistration.skipTlsVerification = action.payload;
},
changeActivationKey: (
state,
action: PayloadAction<ActivationKeys['name']>,
@ -1230,6 +1279,10 @@ export const {
changeTimezone,
changeSatelliteRegistrationCommand,
changeSatelliteCaCertificate,
changeAapCallbackUrl,
changeAapHostConfigKey,
changeAapTlsCertificateAuthority,
changeAapTlsConfirmation,
addNtpServer,
removeNtpServer,
changeHostname,

View file

@ -714,6 +714,9 @@ describe('Import modal', () => {
),
);
// AAP
await clickNext();
// Firstboot
await clickNext();
expect(

View file

@ -24,6 +24,8 @@ vi.mock('@unleash/proxy-client-react', () => ({
switch (flag) {
case 'image-builder.compliance.enabled':
return true;
case 'image-builder.aap.enabled':
return true;
default:
return false;
}

View file

@ -109,12 +109,12 @@ describe('Step Services', () => {
router = undefined;
});
test('clicking Next loads First boot script', async () => {
test('clicking Next loads Ansible Automation Platform', async () => {
await renderCreateMode();
await goToServicesStep();
await clickNext();
await screen.findByRole('heading', {
name: 'First boot configuration',
name: 'Ansible Automation Platform',
});
});

View file

@ -64,6 +64,8 @@ vi.mock('@unleash/proxy-client-react', () => ({
return true;
case 'image-builder.templates.enabled':
return true;
case 'image-builder.aap.enabled':
return true;
default:
return false;
}