diff --git a/playwright/Customizations/AAP.spec.ts b/playwright/Customizations/AAP.spec.ts
new file mode 100644
index 00000000..f550257c
--- /dev/null
+++ b/playwright/Customizations/AAP.spec.ts
@@ -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();
+ });
+});
diff --git a/playwright/test.spec.ts b/playwright/test.spec.ts
index cea02c9e..6538c09e 100644
--- a/playwright/test.spec.ts
+++ b/playwright/test.spec.ts
@@ -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();
diff --git a/src/Components/CreateImageWizard/CreateImageWizard.tsx b/src/Components/CreateImageWizard/CreateImageWizard.tsx
index a2e529d3..ade9e016 100644
--- a/src/Components/CreateImageWizard/CreateImageWizard.tsx
+++ b/src/Components/CreateImageWizard/CreateImageWizard.tsx
@@ -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) => {
>
,
+
+ }
+ >
+
+ ,
@@ -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) && (
diff --git a/src/Components/CreateImageWizard/steps/AAP/components/AAPRegistration.tsx b/src/Components/CreateImageWizard/steps/AAP/components/AAPRegistration.tsx
new file mode 100644
index 00000000..f4a78844
--- /dev/null
+++ b/src/Components/CreateImageWizard/steps/AAP/components/AAPRegistration.tsx
@@ -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,
+ 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 (
+ <>
+
+ handleCallbackUrlChange(value.trim())}
+ ariaLabel='ansible callback url'
+ isRequired
+ stepValidation={stepValidation}
+ fieldName='callbackUrl'
+ />
+
+
+
+ handleHostConfigKeyChange(value.trim())}
+ ariaLabel='host config key'
+ isRequired
+ stepValidation={stepValidation}
+ fieldName='hostConfigKey'
+ />
+
+
+ {shouldShowCaInput && (
+
+
+
+
+
+ {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'}
+
+
+
+
+ )}
+ {isHttpsUrl && (
+
+ handleTlsConfirmationChange(checked)}
+ />
+ {stepValidation.errors['tlsConfirmation'] && (
+
+
+
+ {stepValidation.errors['tlsConfirmation']}
+
+
+
+ )}
+
+ )}
+ >
+ );
+};
+
+export default AAPRegistration;
diff --git a/src/Components/CreateImageWizard/steps/AAP/index.tsx b/src/Components/CreateImageWizard/steps/AAP/index.tsx
new file mode 100644
index 00000000..7dbe0275
--- /dev/null
+++ b/src/Components/CreateImageWizard/steps/AAP/index.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+import { Form, Title } from '@patternfly/react-core';
+
+import AAPRegistration from './components/AAPRegistration';
+
+const AAPStep = () => {
+ return (
+
+ );
+};
+
+export default AAPStep;
diff --git a/src/Components/CreateImageWizard/steps/Review/ReviewStep.tsx b/src/Components/CreateImageWizard/steps/Review/ReviewStep.tsx
index f1c3a5e6..630a7fa1 100644
--- a/src/Components/CreateImageWizard/steps/Review/ReviewStep.tsx
+++ b/src/Components/CreateImageWizard/steps/Review/ReviewStep.tsx
@@ -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 = () => {
)}
+ {aapRegistration.callbackUrl && (
+ onToggleAap(isExpandableAap)}
+ isExpanded={isExpandedAap}
+ isIndented
+ data-testid='aap-expandable'
+ >
+
+
+ )}
{!process.env.IS_ON_PREMISE && (
{
);
};
+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 (
+
+
+
+ Ansible Callback URL
+
+
+ {callbackUrl || 'None'}
+
+
+ Host Config Key
+
+
+ {hostConfigKey || 'None'}
+
+
+ TLS Certificate
+
+ {getTlsStatus()}
+
+
+ );
+};
+
export const RegisterNowList = () => {
const activationKey = useAppSelector(selectActivationKey);
const registrationType = useAppSelector(selectRegistrationType);
diff --git a/src/Components/CreateImageWizard/utilities/requestMapper.ts b/src/Components/CreateImageWizard/utilities/requestMapper.ts
index ef7f1d87..bdb4e589 100644
--- a/src/Components/CreateImageWizard/utilities/requestMapper.ts
+++ b/src/Components/CreateImageWizard/utilities/requestMapper.ts
@@ -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),
};
};
diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx
index 70023391..1d43e7d0 100644
--- a/src/Components/CreateImageWizard/utilities/useValidation.tsx
+++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx
@@ -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 = {};
+ 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);
diff --git a/src/Components/CreateImageWizard/validators.ts b/src/Components/CreateImageWizard/validators.ts
index b5e971b2..686f846b 100644
--- a/src/Components/CreateImageWizard/validators.ts
+++ b/src/Components/CreateImageWizard/validators.ts
@@ -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 };
+};
diff --git a/src/store/wizardSlice.ts b/src/store/wizardSlice.ts
index 2fde3b5a..ebb85bb2 100644
--- a/src/store/wizardSlice.ts
+++ b/src/store/wizardSlice.ts
@@ -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) => {
state.registration.satelliteRegistration.caCert = action.payload;
},
+ changeAapCallbackUrl: (state, action: PayloadAction) => {
+ state.aapRegistration.callbackUrl = action.payload;
+ },
+
+ changeAapHostConfigKey: (state, action: PayloadAction) => {
+ state.aapRegistration.hostConfigKey = action.payload;
+ },
+ changeAapTlsCertificateAuthority: (
+ state,
+ action: PayloadAction,
+ ) => {
+ state.aapRegistration.tlsCertificateAuthority = action.payload;
+ },
+ changeAapTlsConfirmation: (state, action: PayloadAction) => {
+ state.aapRegistration.skipTlsVerification = action.payload;
+ },
changeActivationKey: (
state,
action: PayloadAction,
@@ -1230,6 +1279,10 @@ export const {
changeTimezone,
changeSatelliteRegistrationCommand,
changeSatelliteCaCertificate,
+ changeAapCallbackUrl,
+ changeAapHostConfigKey,
+ changeAapTlsCertificateAuthority,
+ changeAapTlsConfirmation,
addNtpServer,
removeNtpServer,
changeHostname,
diff --git a/src/test/Components/Blueprints/ImportBlueprintModal.test.tsx b/src/test/Components/Blueprints/ImportBlueprintModal.test.tsx
index c2d58300..ef2dc56e 100644
--- a/src/test/Components/Blueprints/ImportBlueprintModal.test.tsx
+++ b/src/test/Components/Blueprints/ImportBlueprintModal.test.tsx
@@ -714,6 +714,9 @@ describe('Import modal', () => {
),
);
+ // AAP
+ await clickNext();
+
// Firstboot
await clickNext();
expect(
diff --git a/src/test/Components/CreateImageWizard/steps/Oscap/Compliance.test.tsx b/src/test/Components/CreateImageWizard/steps/Oscap/Compliance.test.tsx
index 26d78723..9b438ad2 100644
--- a/src/test/Components/CreateImageWizard/steps/Oscap/Compliance.test.tsx
+++ b/src/test/Components/CreateImageWizard/steps/Oscap/Compliance.test.tsx
@@ -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;
}
diff --git a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
index 4a97876a..bdfac014 100644
--- a/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
+++ b/src/test/Components/CreateImageWizard/steps/Services/Services.test.tsx
@@ -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',
});
});
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 067ab8d1..e15d20b7 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -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;
}