Wizard: add satellite registration, add jwt-decode

The jwt decode dependency helps us to keep track of the token that is
present in the Satellite command. jwt-decode is the most popular
dependency for the job, and very easy to use.
This commit is contained in:
Anna Vítová 2025-03-05 09:55:17 +01:00 committed by Klara Simickova
parent 739c0538fe
commit a4034e8787
21 changed files with 20892 additions and 6818 deletions

File diff suppressed because it is too large Load diff

View file

@ -292,6 +292,62 @@ paths:
schema:
type: string
/composes/{id}/download:
get:
operationId: getComposeDownload
summary: Download the artifact for a compose.
security:
- Bearer: []
parameters:
- in: path
name: id
schema:
type: string
format: uuid
example: 123e4567-e89b-12d3-a456-426655440000
required: true
description: ID of compose to download
description: |-
Download the artifact of a finished compose.
responses:
'200':
description: The metadata for the given compose.
content:
application/octet-stream:
schema:
type: string
format: binary
'400':
description: Invalid compose id
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Auth token is invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Unauthorized to perform operation
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Unknown compose id
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Unexpected error occurred
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/composes/{id}/clone:
post:
operationId: postCloneCompose

View file

@ -18,9 +18,6 @@
"type": "array"
}
},
"required": [
"uploads"
],
"type": "object"
},
"api.Artifact": {
@ -518,6 +515,14 @@
"description": "Number of consecutive failed introspections",
"type": "integer"
},
"failed_snapshot_count": {
"description": "Number of consecutive failed snapshots",
"type": "integer"
},
"feature_name": {
"description": "The feature name this repo requires",
"type": "string"
},
"gpg_key": {
"description": "GPG key for repository",
"type": "string"
@ -775,6 +780,14 @@
"description": "Number of consecutive failed introspections",
"type": "integer"
},
"failed_snapshot_count": {
"description": "Number of consecutive failed snapshots",
"type": "integer"
},
"feature_name": {
"description": "The feature name this repo requires",
"type": "string"
},
"gpg_key": {
"description": "GPG key for repository",
"type": "string"

View file

@ -747,10 +747,10 @@ components:
properties:
version:
type: string
build_time:
type: string
build_commit:
type: string
build_time:
type: string
Readiness:
type: object
required:
@ -1654,6 +1654,8 @@ components:
$ref: '#/components/schemas/FIPS'
installer:
$ref: '#/components/schemas/Installer'
cacerts:
$ref: '#/components/schemas/CACertsCustomization'
Container:
type: object
required:
@ -1874,6 +1876,17 @@ components:
type: string
description: |
Enable passwordless sudo for users or groups (groups must be prefixed by %)
CACertsCustomization:
type: object
additionalProperties: false
required:
- pem_certs
properties:
pem_certs:
type: array
example: [ '---BEGIN CERTIFICATE---\nMIIC0DCCAbigAwIBAgIUI...\n---END CERTIFICATE---' ]
items:
type: string
Ignition:
type: object
additionalProperties: false

13677
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@
"@sentry/webpack-plugin": "3.2.2",
"@unleash/proxy-client-react": "5.0.0",
"classnames": "2.5.1",
"jwt-decode": "4.0.0",
"lodash": "4.17.21",
"react": "18.3.1",
"react-dom": "18.3.1",

View file

@ -21,7 +21,7 @@ test.describe.serial('test', () => {
await frame.getByRole('heading', {
name: 'Register systems using this image',
});
await page.getByTestId('automatically-register-checkbox').uncheck();
await page.getByTestId('register-later-radio').click();
await frame.getByRole('button', { name: 'Next', exact: true }).click();
}

View file

@ -104,7 +104,9 @@ export const ValidatedInputAndTextArea = ({
</HelperTextItem>
</HelperText>
)}
{hasError && <ErrorMessage errorMessage={errorMessage} />}
{validated === 'error' && hasError && (
<ErrorMessage errorMessage={errorMessage} />
)}
</>
);
};

View file

@ -5,10 +5,12 @@ import {
Checkbox,
FormGroup,
Popover,
Radio,
Text,
TextContent,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
import { useFlag } from '@unleash/proxy-client-react';
import { INSIGHTS_URL, RHC_URL, RHEL_10_BETA } from '../../../../constants';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
@ -106,6 +108,10 @@ const Registration = () => {
registrationType === 'register-later'
);
const isSatelliteRegistrationEnabled = useFlag(
'image-builder.satellite.enabled'
);
// TO DO: Remove when rhc starts working for RHEL 10 Beta
useEffect(() => {
if (distribution === RHEL_10_BETA) {
@ -115,9 +121,9 @@ const Registration = () => {
return (
<FormGroup label="Registration method">
<Checkbox
<Radio
label="Automatically register and enable advanced capabilities"
data-testid="automatically-register-checkbox"
data-testid="automatically-register-radio"
isChecked={
registrationType === 'register-now' ||
registrationType === 'register-now-insights' ||
@ -129,9 +135,6 @@ const Registration = () => {
dispatch(changeRegistrationType('register-now-rhc'));
} else if (checked && distribution === RHEL_10_BETA) {
dispatch(changeRegistrationType('register-now-insights'));
} else {
dispatch(changeRegistrationType('register-later'));
setShowOptions(false);
}
}}
id="register-system-now"
@ -201,6 +204,30 @@ const Registration = () => {
)
}
/>
<Radio
label="Register later"
data-testid="register-later-radio"
isChecked={registrationType === 'register-later'}
onChange={() => {
dispatch(changeRegistrationType('register-later'));
setShowOptions(false);
}}
id="register-later"
name="register-later"
/>
{isSatelliteRegistrationEnabled && (
<Radio
label="Register with Satellite"
data-testid="register-satellite-radio"
isChecked={registrationType === 'register-satellite'}
onChange={() => {
dispatch(changeRegistrationType('register-satellite'));
setShowOptions(false);
}}
id="register-satellite"
name="register-satellite"
/>
)}
</FormGroup>
);
};

View file

@ -0,0 +1,100 @@
import React from 'react';
import {
DropEvent,
FileUpload,
Form,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import SatelliteRegistrationCommand from './components/SatelliteRegistrationCommand';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import {
changeSatelliteCaCertificate,
selectSatelliteCaCertificate,
} from '../../../../store/wizardSlice';
import { useRegistrationValidation } from '../../utilities/useValidation';
const SatelliteRegistration = () => {
const dispatch = useAppDispatch();
const caCertificate = useAppSelector(selectSatelliteCaCertificate);
const [isRejected, setIsRejected] = React.useState(false);
const stepValidation = useRegistrationValidation();
const validated =
stepValidation.errors['certificate'] === 'default'
? 'default'
: stepValidation.errors['certificate']
? 'error'
: 'success';
const handleClear = () => {
dispatch(changeSatelliteCaCertificate(''));
};
const handleTextChange = (
_event: React.ChangeEvent<HTMLTextAreaElement>,
value: string
) => {
dispatch(changeSatelliteCaCertificate(value));
};
const handleDataChange = (_: DropEvent, value: string) => {
dispatch(changeSatelliteCaCertificate(value));
};
const handleFileRejected = () => {
dispatch(changeSatelliteCaCertificate(''));
setIsRejected(true);
};
return (
<Form>
<SatelliteRegistrationCommand />
<FormGroup label="Certificate authority (CA)" isRequired>
<FileUpload
id="text-file-with-restrictions-example"
type="text"
value={caCertificate || ''}
filename={caCertificate ? 'CA detected' : ''}
onDataChange={handleDataChange}
onTextChange={handleTextChange}
onClearClick={handleClear}
isRequired={true}
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' : 'default'}
browseButtonText="Upload"
allowEditingUploadedText={true}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
variant={
isRejected || validated === 'error' ? 'error' : 'default'
}
hasIcon
>
{isRejected
? 'Must be a .PEM/.CER/.CRT file no larger than 512 KB'
: validated === 'error'
? stepValidation.errors['certificate']
: 'Drag and drop a file or upload one'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
</Form>
);
};
export default SatelliteRegistration;

View file

@ -0,0 +1,57 @@
import React from 'react';
import {
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
} from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeSatelliteRegistrationCommand,
selectSatelliteRegistrationCommand,
} from '../../../../../store/wizardSlice';
import { useRegistrationValidation } from '../../../utilities/useValidation';
import { ValidatedInputAndTextArea } from '../../../ValidatedInput';
const SatelliteRegistrationCommand = () => {
const dispatch = useAppDispatch();
const registrationCommand = useAppSelector(
selectSatelliteRegistrationCommand
);
const stepValidation = useRegistrationValidation();
const registrationDocs =
'https://docs.redhat.com/en/documentation/red_hat_satellite/6.16/html-single/managing_hosts/index#Customizing_the_Registration_Templates_managing-hosts';
const handleChange = (e: React.FormEvent, value: string) => {
dispatch(changeSatelliteRegistrationCommand(value));
};
return (
<FormGroup label="Registration command from Satellite" isRequired>
<ValidatedInputAndTextArea
inputType={'textArea'}
ariaLabel="registration command"
value={registrationCommand || ''}
onChange={handleChange}
placeholder="Registration command"
stepValidation={stepValidation}
fieldName="command"
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
To generate command from Satellite, follow the{' '}
<a href={registrationDocs} target="_blank" rel="noreferrer">
documentation
</a>
.
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
);
};
export default SatelliteRegistrationCommand;

View file

@ -5,6 +5,7 @@ import { Text, Form, Title, FormGroup } from '@patternfly/react-core';
import ActivationKeyInformation from './ActivationKeyInformation';
import ActivationKeysList from './ActivationKeysList';
import Registration from './Registration';
import SatelliteRegistration from './SatelliteRegistration';
import { useAppSelector } from '../../../../store/hooks';
import {
@ -26,10 +27,13 @@ const RegistrationStep = () => {
system during initial boot.
</Text>
<Registration />
{!process.env.IS_ON_PREMISE && <ActivationKeysList />}
{registrationType === 'register-satellite' && <SatelliteRegistration />}
{!process.env.IS_ON_PREMISE &&
registrationType !== 'register-satellite' && <ActivationKeysList />}
{!process.env.IS_ON_PREMISE &&
activationKey &&
registrationType !== 'register-later' && (
registrationType !== 'register-later' &&
registrationType !== 'register-satellite' && (
<FormGroup
label={'Selected activation key'}
data-testid="selected-activation-key"

View file

@ -0,0 +1,5 @@
export const isValidPEM = (cert: string): boolean => {
return /-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----/.test(
cert.trim()
);
};

View file

@ -371,10 +371,16 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
? request.customizations.subscription.rhc
? 'register-now-rhc'
: 'register-now-insights'
: request.customizations.cacerts
? 'register-satellite'
: 'register-later',
activationKey: isRhel(request.distribution)
? request.customizations.subscription?.['activation-key']
: undefined,
satelliteRegistration: {
command: getSatelliteCommand(request.customizations.files),
caCert: request.customizations.cacerts?.pem_certs[0],
},
},
...commonRequestToState(request),
};
@ -433,6 +439,13 @@ const getImageRequests = (state: RootState): ImageRequest[] => {
}));
};
const getSatelliteCommand = (files?: File[]): string => {
const satelliteCommandFile = files?.find(
(file) => file.path === '/usr/local/sbin/register-satellite-cmd'
);
return satelliteCommandFile?.data ? atob(satelliteCommandFile.data) : '';
};
const uploadTypeByTargetEnv = (imageType: ImageTypes): UploadTypes => {
switch (imageType) {
case 'aws':

View file

@ -1,6 +1,9 @@
import React, { useEffect, useState } from 'react';
import { CheckCircleIcon } from '@patternfly/react-icons';
import { jwtDecode } from 'jwt-decode';
import { isValidPEM } from './certificates';
import { UNIQUE_VALIDATION_DELAY } from '../../../constants';
import { useLazyGetBlueprintsQuery } from '../../../store/backendApi';
@ -34,6 +37,8 @@ import {
selectKeyboard,
selectTimezone,
selectImageTypes,
selectSatelliteCaCertificate,
selectSatelliteRegistrationCommand,
} from '../../../store/wizardSlice';
import { keyboardsList } from '../steps/Locale/keyboardsList';
import { languagesList } from '../steps/Locale/languagesList';
@ -114,6 +119,10 @@ type ValidationState = {
export function useRegistrationValidation(): StepValidation {
const registrationType = useAppSelector(selectRegistrationType);
const activationKey = useAppSelector(selectActivationKey);
const registrationCommand = useAppSelector(
selectSatelliteRegistrationCommand
);
const caCertificate = useAppSelector(selectSatelliteCaCertificate);
const {
isUninitialized,
@ -150,6 +159,57 @@ export function useRegistrationValidation(): StepValidation {
};
}
if (registrationType === 'register-satellite') {
const errors = {};
if (caCertificate && (caCertificate === '' || !isValidPEM(caCertificate))) {
Object.assign(errors, {
certificate:
'Valid certificate must be present if you are registering Satellite.',
});
}
if (registrationCommand === '' || !registrationCommand) {
Object.assign(errors, {
command: 'No registration command for Satellite registration',
});
}
try {
const match = registrationCommand?.match(
/Bearer\s+([\w-]+\.[\w-]+\.[\w-]+)/
);
if (!match) {
Object.assign(errors, { command: 'Invalid or missing token' });
} else {
const token = match[1];
const decoded = jwtDecode(token);
if (decoded.exp) {
const currentTime = Date.now() / 1000;
if (decoded.exp < currentTime) {
const expirationDate = new Date(decoded.exp * 1000);
Object.assign(errors, {
command:
'The token is already expired. Expiration date: ' +
expirationDate,
});
return {
errors: errors,
disabledNext:
caCertificate === undefined || !isValidPEM(caCertificate),
};
}
}
}
} catch {
Object.assign(errors, { command: 'Invalid or missing token' });
}
return {
errors: errors,
disabledNext:
Object.keys(errors).length > 0 ||
caCertificate === undefined ||
!isValidPEM(caCertificate),
};
}
return { errors: {}, disabledNext: false };
}

View file

@ -278,6 +278,10 @@ export type ApiRepositoryResponse = {
distribution_versions?: string[] | undefined;
/** Number of consecutive failed introspections */
failed_introspections_count?: number | undefined;
/** Number of consecutive failed snapshots */
failed_snapshot_count?: number | undefined;
/** The feature name this repo requires */
feature_name?: string | undefined;
/** GPG key for repository */
gpg_key?: string | undefined;
/** Label used to configure the yum repository on clients */
@ -328,6 +332,10 @@ export type ApiRepositoryResponseRead = {
distribution_versions?: string[] | undefined;
/** Number of consecutive failed introspections */
failed_introspections_count?: number | undefined;
/** Number of consecutive failed snapshots */
failed_snapshot_count?: number | undefined;
/** The feature name this repo requires */
feature_name?: string | undefined;
/** GPG key for repository */
gpg_key?: string | undefined;
/** Label used to configure the yum repository on clients */
@ -448,6 +456,10 @@ export type ApiRepositoryImportResponse = {
distribution_versions?: string[] | undefined;
/** Number of consecutive failed introspections */
failed_introspections_count?: number | undefined;
/** Number of consecutive failed snapshots */
failed_snapshot_count?: number | undefined;
/** The feature name this repo requires */
feature_name?: string | undefined;
/** GPG key for repository */
gpg_key?: string | undefined;
/** Label used to configure the yum repository on clients */
@ -504,6 +516,10 @@ export type ApiRepositoryImportResponseRead = {
distribution_versions?: string[] | undefined;
/** Number of consecutive failed introspections */
failed_introspections_count?: number | undefined;
/** Number of consecutive failed snapshots */
failed_snapshot_count?: number | undefined;
/** The feature name this repo requires */
feature_name?: string | undefined;
/** GPG key for repository */
gpg_key?: string | undefined;
/** Label used to configure the yum repository on clients */

View file

@ -702,6 +702,9 @@ export type Installer = {
unattended?: boolean | undefined;
"sudo-nopasswd"?: string[] | undefined;
};
export type CaCertsCustomization = {
pem_certs: string[];
};
export type Customizations = {
containers?: Container[] | undefined;
directories?: Directory[] | undefined;
@ -739,6 +742,7 @@ export type Customizations = {
partitioning_mode?: ("raw" | "lvm" | "auto-lvm") | undefined;
fips?: Fips | undefined;
installer?: Installer | undefined;
cacerts?: CaCertsCustomization | undefined;
};
export type BlueprintMetadata = {
parent_id: string | null;

View file

@ -41,7 +41,8 @@ export type RegistrationType =
| 'register-later'
| 'register-now'
| 'register-now-insights'
| 'register-now-rhc';
| 'register-now-rhc'
| 'register-satellite';
export type ComplianceType = 'openscap' | 'compliance';
@ -108,6 +109,10 @@ export type wizardState = {
registration: {
registrationType: RegistrationType;
activationKey: ActivationKeys['name'];
satelliteRegistration: {
command: string | undefined;
caCert: string | undefined;
};
};
compliance: {
complianceType: ComplianceType;
@ -197,6 +202,10 @@ export const initialState: wizardState = {
? 'register-later'
: 'register-now-rhc',
activationKey: undefined,
satelliteRegistration: {
command: undefined,
caCert: undefined,
},
},
compliance: {
complianceType: 'openscap',
@ -337,6 +346,14 @@ export const selectActivationKey = (state: RootState) => {
return state.wizard.registration.activationKey;
};
export const selectSatelliteRegistrationCommand = (state: RootState) => {
return state.wizard.registration.satelliteRegistration.command;
};
export const selectSatelliteCaCertificate = (state: RootState) => {
return state.wizard.registration.satelliteRegistration.caCert;
};
export const selectComplianceProfileID = (state: RootState) => {
return state.wizard.compliance.profileID;
};
@ -579,6 +596,15 @@ export const wizardSlice = createSlice({
) => {
state.registration.registrationType = action.payload;
},
changeSatelliteRegistrationCommand: (
state,
action: PayloadAction<string>
) => {
state.registration.satelliteRegistration.command = action.payload;
},
changeSatelliteCaCertificate: (state, action: PayloadAction<string>) => {
state.registration.satelliteRegistration.caCert = action.payload;
},
changeActivationKey: (
state,
action: PayloadAction<ActivationKeys['name']>
@ -1131,6 +1157,8 @@ export const {
addEnabledFirewallService,
removeEnabledFirewallService,
changeTimezone,
changeSatelliteRegistrationCommand,
changeSatelliteCaCertificate,
addNtpServer,
removeNtpServer,
changeHostname,

View file

@ -408,7 +408,7 @@ describe('Import modal', () => {
'Automatically register and enable advanced capabilities'
);
const registrationCheckbox = await screen.findByTestId(
'automatically-register-checkbox'
'automatically-register-radio'
);
expect(registrationCheckbox).toHaveFocus();
await screen.findByRole('textbox', {

View file

@ -141,7 +141,7 @@ describe('Keyboard accessibility', () => {
'Automatically register and enable advanced capabilities'
);
const registrationCheckbox = await screen.findByTestId(
'automatically-register-checkbox'
'automatically-register-radio'
);
expect(registrationCheckbox).toHaveFocus();
await screen.findByRole('textbox', {

View file

@ -116,10 +116,10 @@ export const clickRegisterLater = async () => {
await screen.findByRole('heading', {
name: /Register systems using this image/,
});
const registrationCheckbox = await screen.findByRole('checkbox', {
name: /automatically register and enable advanced capabilities/i,
const registerLaterRadio = await screen.findByRole('radio', {
name: /register later/i,
});
await waitFor(() => user.click(registrationCheckbox));
await waitFor(() => user.click(registerLaterRadio));
};
export const goToOscapStep = async () => {