Add custom repository management for cockpit-image-builder

This PR adds custom repository management functionality to cockpit-image-builder,
reintroducing 'sources' functionality that was available in cockpit-composer.

Changes:
- Add ManageRepositoriesButton component for on-premise/hosted environments
- Add ManageRepositoriesModal for custom repository creation
- Extend cockpit contentSourcesApi with repository endpoints
- Add environment-specific API hooks in contentSourcesApi
- Include comprehensive test coverage for new components
This commit is contained in:
Michal Gold 2025-08-17 16:47:19 +03:00
parent 54e413f459
commit 2adf5197fe
6 changed files with 494 additions and 6 deletions

View file

@ -1,11 +1,35 @@
import React from 'react';
import React, { useState } from 'react';
import { Button } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { ExternalLinkAltIcon, PlusIcon } from '@patternfly/react-icons';
import ManageRepositoriesModal from './ManageRepositoriesModal';
import { CONTENT_URL } from '../../../../../constants';
const ManageRepositoriesButton = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
if (process.env.IS_ON_PREMISE) {
return (
<>
<Button
variant='link'
iconPosition='right'
isInline
icon={<PlusIcon />}
onClick={() => setIsModalOpen(true)}
>
Add custom repository
</Button>
<ManageRepositoriesModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
}
return (
<Button
component='a'

View file

@ -0,0 +1,172 @@
import React, { useState } from 'react';
import {
Alert,
Button,
Form,
FormGroup,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Tab,
Tabs,
TabTitleText,
TextArea,
TextInput,
} from '@patternfly/react-core';
import { useCreateRepositoryMutation } from '../../../../../store/contentSourcesApi';
interface ManageRepositoriesModalProps {
isOpen: boolean;
onClose: () => void;
}
const ManageRepositoriesModal: React.FC<ManageRepositoriesModalProps> = ({
isOpen,
onClose,
}) => {
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [gpgKey, setGpgKey] = useState('');
const [metadataVerification, setMetadataVerification] = useState(true);
const [error, setError] = useState('');
const [createRepository, { isLoading }] = useCreateRepositoryMutation();
const handleTabClick = (
_event: React.MouseEvent<HTMLElement> | React.KeyboardEvent | MouseEvent,
tabIndex: string | number,
) => {
setActiveTabKey(tabIndex);
};
const resetForm = () => {
setName('');
setUrl('');
setGpgKey('');
setMetadataVerification(true);
setError('');
};
const handleClose = () => {
resetForm();
onClose();
};
const handleSubmit = async () => {
setError('');
if (!name.trim() || !url.trim()) {
setError('Name and URL are required fields.');
return;
}
try {
await createRepository({
apiRepositoryRequest: {
name: name.trim(),
url: url.trim(),
gpg_key: gpgKey.trim() || undefined,
metadata_verification: metadataVerification,
},
}).unwrap();
resetForm();
onClose();
} catch (err: unknown) {
setError(
(err as { data?: { message?: string } })?.data?.message ||
'Failed to create repository. Please try again.',
);
}
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title='Manage Custom Repositories'
variant='medium'
>
<ModalHeader title='Manage Custom Repositories' />
<ModalBody>
<Tabs
activeKey={activeTabKey}
onSelect={handleTabClick}
aria-label='Repository management tabs'
>
<Tab
eventKey={0}
title={<TabTitleText>Add Repository</TabTitleText>}
aria-label='Add repository tab'
>
<div style={{ marginTop: '16px' }}>
{error && (
<Alert variant='danger' isInline title='Error'>
{error}
</Alert>
)}
<Form>
<FormGroup
label='Repository Name'
isRequired
fieldId='repo-name'
>
<TextInput
isRequired
type='text'
id='repo-name'
name='repo-name'
value={name}
onChange={(_, value) => setName(value)}
placeholder='Enter repository name'
/>
</FormGroup>
<FormGroup label='Repository URL' isRequired fieldId='repo-url'>
<TextInput
isRequired
type='url'
id='repo-url'
name='repo-url'
value={url}
onChange={(_, value) => setUrl(value)}
placeholder='https://example.com/repo/'
/>
</FormGroup>
<FormGroup label='GPG Key' fieldId='repo-gpg-key'>
<TextArea
id='repo-gpg-key'
name='repo-gpg-key'
value={gpgKey}
onChange={(_, value) => setGpgKey(value)}
placeholder='-----BEGIN PGP PUBLIC KEY BLOCK-----'
rows={8}
/>
</FormGroup>
</Form>
</div>
</Tab>
</Tabs>
</ModalBody>
<ModalFooter>
<Button
key='create'
variant='primary'
onClick={handleSubmit}
isLoading={isLoading}
isDisabled={!name.trim() || !url.trim()}
>
{isLoading ? 'Creating...' : 'Create Repository'}
</Button>
<Button key='cancel' variant='link' onClick={handleClose}>
Cancel
</Button>
</ModalFooter>
</Modal>
);
};
export default ManageRepositoriesModal;

View file

@ -2,6 +2,11 @@ import { emptyCockpitApi } from './emptyCockpitApi';
import type { Package, SearchRpmApiArg } from './types';
import type {
ApiRepositoryRequest,
CreateRepositoryApiArg,
CreateRepositoryApiResponse,
ListRepositoriesApiArg,
ListRepositoriesApiResponse,
ListSnapshotsByDateApiArg,
ListSnapshotsByDateApiResponse,
SearchRpmApiResponse,
@ -62,9 +67,60 @@ export const contentSourcesApi = emptyCockpitApi.injectEndpoints({
},
}),
}),
listRepositories: builder.query<
ListRepositoriesApiResponse,
ListRepositoriesApiArg
>({
queryFn: async (queryArgs, _, __, baseQuery) => {
const result = await baseQuery({
url: '/repositories',
method: 'GET',
params: {
limit: queryArgs.limit,
offset: queryArgs.offset,
search: queryArgs.search,
origin: queryArgs.origin,
content_type: queryArgs.contentType,
available_for_arch: queryArgs.availableForArch,
available_for_version: queryArgs.availableForVersion,
},
});
if (result?.error) {
return { error: result.error };
}
return { data: result.data };
},
}),
createRepository: builder.mutation<
CreateRepositoryApiResponse,
CreateRepositoryApiArg
>({
queryFn: async (queryArgs, _, __, baseQuery) => {
const result = await baseQuery({
url: '/repositories',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(queryArgs.apiRepositoryRequest),
});
if (result?.error) {
return { error: result.error };
}
return { data: result.data };
},
}),
}),
overrideExisting: false,
});
export const { useSearchRpmMutation, useListSnapshotsByDateMutation } =
contentSourcesApi;
export const {
useSearchRpmMutation,
useListSnapshotsByDateMutation,
useListRepositoriesQuery,
useCreateRepositoryMutation,
} = contentSourcesApi;

View file

@ -9,11 +9,17 @@ export const useListSnapshotsByDateMutation = process.env.IS_ON_PREMISE
? cockpitQueries.useListSnapshotsByDateMutation
: serviceQueries.useListSnapshotsByDateMutation;
export const useCreateRepositoryMutation = process.env.IS_ON_PREMISE
? cockpitQueries.useCreateRepositoryMutation
: serviceQueries.useCreateRepositoryMutation;
export const useListRepositoriesQuery = process.env.IS_ON_PREMISE
? cockpitQueries.useListRepositoriesQuery
: serviceQueries.useListRepositoriesQuery;
export const {
useListFeaturesQuery,
useSearchPackageGroupMutation,
useListRepositoriesQuery,
useCreateRepositoryMutation,
useBulkImportRepositoriesMutation,
useListRepositoriesRpmsQuery,
useListRepositoryParametersQuery,

View file

@ -0,0 +1,87 @@
import React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import ManageRepositoriesButton from '../../../../../Components/CreateImageWizard/steps/Repositories/components/ManageRepositoriesButton';
import {
onPremMiddleware,
onPremReducer,
serviceMiddleware,
serviceReducer,
} from '../../../../../store';
const user = userEvent.setup();
// Mock environment variable
const originalEnv = process.env;
const createTestStore = () => {
const mw = process.env.IS_ON_PREMISE ? onPremMiddleware : serviceMiddleware;
const red = process.env.IS_ON_PREMISE ? onPremReducer : serviceReducer;
return configureStore({
reducer: red,
middleware: mw,
});
};
const renderWithStore = (component: React.ReactElement) => {
const store = createTestStore();
return render(<Provider store={store}>{component}</Provider>);
};
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
describe('ManageRepositoriesButton', () => {
test('renders external link for hosted service', () => {
process.env.IS_ON_PREMISE = undefined;
renderWithStore(<ManageRepositoriesButton />);
const externalLink = screen.getByRole('link', {
name: /create and manage repositories here/i,
});
expect(externalLink).toBeInTheDocument();
expect(externalLink).toHaveAttribute(
'href',
'/insights/content/repositories',
);
expect(externalLink).toHaveAttribute('target', '_blank');
});
test('renders modal button for on-premise', async () => {
process.env.IS_ON_PREMISE = 'true';
renderWithStore(<ManageRepositoriesButton />);
const modalButton = screen.getByRole('button', {
name: /add custom repository/i,
});
expect(modalButton).toBeInTheDocument();
// Click the button to open modal
await user.click(modalButton);
// Check that modal opens
expect(
screen.getByRole('dialog', { name: /manage custom repositories/i }),
).toBeInTheDocument();
});
test('does not show modal initially', () => {
process.env.IS_ON_PREMISE = 'true';
renderWithStore(<ManageRepositoriesButton />);
// Modal should not be visible initially
expect(
screen.queryByRole('dialog', { name: /manage custom repositories/i }),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,143 @@
import React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import ManageRepositoriesModal from '../../../../../Components/CreateImageWizard/steps/Repositories/components/ManageRepositoriesModal';
import { serviceMiddleware, serviceReducer } from '../../../../../store';
const user = userEvent.setup();
const mockCreateRepository = vi.fn();
// Mock the hook
vi.mock('../../../../../store/contentSourcesApi', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useCreateRepositoryMutation: () => [
mockCreateRepository,
{ isLoading: false },
],
};
});
const createTestStore = () => {
// For this test, we'll use the service store since the mock is already set up for service API
return configureStore({
reducer: serviceReducer,
middleware: serviceMiddleware,
});
};
const setup = (isOpen = true) => {
const onClose = vi.fn();
const store = createTestStore();
render(
<Provider store={store}>
<ManageRepositoriesModal isOpen={isOpen} onClose={onClose} />
</Provider>,
);
return { onClose };
};
describe('ManageRepositoriesModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('renders modal when open', () => {
setup();
expect(
screen.getByRole('dialog', { name: /manage custom repositories/i }),
).toBeInTheDocument();
expect(screen.getByText('Add Repository')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Enter repository name'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('https://example.com/repo/'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('-----BEGIN PGP PUBLIC KEY BLOCK-----'),
).toBeInTheDocument();
});
test('does not render modal when closed', () => {
setup(false);
expect(
screen.queryByRole('dialog', { name: /manage custom repositories/i }),
).not.toBeInTheDocument();
});
test('validates required fields', async () => {
setup();
const createButton = screen.getByRole('button', {
name: /create repository/i,
});
// Button should be disabled when fields are empty
expect(createButton).toBeDisabled();
// Fill name but not URL
await user.type(
screen.getByPlaceholderText('Enter repository name'),
'Test Repo',
);
expect(createButton).toBeDisabled();
// Fill URL
await user.type(
screen.getByPlaceholderText('https://example.com/repo/'),
'https://example.com/repo',
);
expect(createButton).toBeEnabled();
});
test('keeps button disabled with whitespace-only fields', async () => {
setup();
const nameInput = screen.getByPlaceholderText('Enter repository name');
const urlInput = screen.getByPlaceholderText('https://example.com/repo/');
const createButton = screen.getByRole('button', {
name: /create repository/i,
});
// Initially disabled
expect(createButton).toBeDisabled();
// Fill with whitespace only - button should remain disabled
await user.type(nameInput, ' ');
await user.type(urlInput, ' ');
// Button should still be disabled since trim() makes them empty
expect(createButton).toBeDisabled();
});
test('calls onClose when cancel is clicked', async () => {
const { onClose } = setup();
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('fills form fields correctly', async () => {
setup();
const nameInput = screen.getByPlaceholderText('Enter repository name');
const urlInput = screen.getByPlaceholderText('https://example.com/repo/');
const gpgKeyInput = screen.getByPlaceholderText(
'-----BEGIN PGP PUBLIC KEY BLOCK-----',
);
await user.type(nameInput, 'Test Repository');
await user.type(urlInput, 'https://example.com/repo/');
await user.type(gpgKeyInput, '-----BEGIN PGP PUBLIC KEY BLOCK-----');
expect(nameInput).toHaveValue('Test Repository');
expect(urlInput).toHaveValue('https://example.com/repo/');
expect(gpgKeyInput).toHaveValue('-----BEGIN PGP PUBLIC KEY BLOCK-----');
});
});