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 ( +
+ + Ansible Automation Platform + + + + ); +}; + +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; }