Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ PulpUI is a community driven single-page application that talks to a running [pu
### Plugins
- Ansible (repositories, remotes, collections, namespaces, approvals, imports)
- File (repositories, remotes)
- RPM (content)
- RPM (repositories, remotes, content)
- Container (execution environments, tags, manifests)

## Requirements
Expand Down
23 changes: 23 additions & 0 deletions e2e/smoke/pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ test.describe('pages of a signed-in session', () => {
await expect(page.locator('#architectures')).toBeVisible();
});

test('RPM repositories', async ({ page }) => {
await page.goto('rpm/repositories');
await expect(title(page, 'Repositories')).toBeVisible();
await expect(page.getByText('No repositories yet')).toBeVisible();

// pulp_rpm repositories carry publishing and retention settings the other
// plugins have no use for, and a new one must not publish until asked to
await page.getByRole('button', { name: 'Add repository' }).click();
await expect(page.locator('#autopublish')).not.toBeChecked();
await expect(page.locator('#retain_package_versions')).toBeVisible();
});

test('RPM remotes', async ({ page }) => {
await page.goto('rpm/remotes');
await expect(title(page, 'Remotes')).toBeVisible();
await expect(page.getByText('No remotes yet')).toBeVisible();

// the download policy decides whether packages are fetched during sync or
// on first request, so an RPM remote offers it in the form
await page.getByRole('button', { name: 'Add remote' }).click();
await expect(page.locator('#policy')).toBeVisible();
});

test('RPM packages', async ({ page }) => {
await page.goto('rpm/rpms');
await expect(title(page, 'Packages')).toBeVisible();
Expand Down
7 changes: 7 additions & 0 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ export { fileRepositoryCreateAction } from './file-repository-create';
export { fileRepositoryDeleteAction } from './file-repository-delete';
export { fileRepositoryEditAction } from './file-repository-edit';
export { fileRepositorySyncAction } from './file-repository-sync';
export { rpmRemoteCreateAction } from './rpm-remote-create';
export { rpmRemoteDeleteAction } from './rpm-remote-delete';
export { rpmRemoteEditAction } from './rpm-remote-edit';
export { rpmRepositoryCreateAction } from './rpm-repository-create';
export { rpmRepositoryEditAction } from './rpm-repository-edit';
export { rpmRepositoryDeleteAction } from './rpm-repository-delete';
export { rpmRepositorySyncAction } from './rpm-repository-sync';
9 changes: 9 additions & 0 deletions src/actions/rpm-remote-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { msg } from '@lingui/core/macro';
import { Paths, formatPath } from 'src/paths';
import { Action } from './action';

export const rpmRemoteCreateAction = Action({
title: msg`Add remote`,
onClick: (item, { navigate }) =>
navigate(formatPath(Paths.rpm.remote.edit, { name: '_' })),
});
44 changes: 44 additions & 0 deletions src/actions/rpm-remote-delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { msg, t } from '@lingui/core/macro';
import { RPMRemoteAPI } from 'src/api';

Check warning on line 2 in src/actions/rpm-remote-delete.tsx

View workflow job for this annotation

GitHub Actions / pr-checks

'src/api' import is restricted from being used. Import the specific file and not the barrel
import { DeleteRemoteModal } from 'src/components';
import {

Check warning on line 4 in src/actions/rpm-remote-delete.tsx

View workflow job for this annotation

GitHub Actions / pr-checks

'src/utilities' import is restricted from being used. Import the specific file and not the barrel
handleHttpError,
parsePulpIDFromURL,
taskAlert,
waitForTaskUrl,
} from 'src/utilities';
import { Action } from './action';

export const rpmRemoteDeleteAction = Action({
title: msg`Delete`,
modal: ({ addAlert, listQuery, setState, state }) =>
state.deleteModalOpen ? (
<DeleteRemoteModal
closeAction={() => setState({ deleteModalOpen: null })}
deleteAction={() =>
deleteRemote(state.deleteModalOpen, { addAlert, setState, listQuery })
}
name={state.deleteModalOpen.name}
/>
) : null,
onClick: (
{ name, id, pulp_href }: { name: string; id?: string; pulp_href?: string },
{ setState },
) =>
setState({
deleteModalOpen: { pulpId: id || parsePulpIDFromURL(pulp_href), name },
}),
});

function deleteRemote({ name, pulpId }, { addAlert, setState, listQuery }) {
return RPMRemoteAPI.delete(pulpId)
.then(({ data }) => {
addAlert(taskAlert(data.task, t`Removal started for remote ${name}`));
setState({ deleteModalOpen: null });
return waitForTaskUrl(data.task);
})
.then(() => listQuery())
.catch(
handleHttpError(t`Failed to remove remote ${name}`, () => null, addAlert),
);
}
9 changes: 9 additions & 0 deletions src/actions/rpm-remote-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { msg } from '@lingui/core/macro';
import { Paths, formatPath } from 'src/paths';
import { Action } from './action';

export const rpmRemoteEditAction = Action({
title: msg`Edit`,
onClick: ({ name }, { navigate }) =>
navigate(formatPath(Paths.rpm.remote.edit, { name })),
});
9 changes: 9 additions & 0 deletions src/actions/rpm-repository-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { msg } from '@lingui/core/macro';
import { Paths, formatPath } from 'src/paths';
import { Action } from './action';

export const rpmRepositoryCreateAction = Action({
title: msg`Add repository`,
onClick: (item, { navigate }) =>
navigate(formatPath(Paths.rpm.repository.edit, { name: '_' })),
});
123 changes: 123 additions & 0 deletions src/actions/rpm-repository-delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { msg, t } from '@lingui/core/macro';
import { RPMDistributionAPI, RPMRepositoryAPI } from 'src/api';

Check warning on line 2 in src/actions/rpm-repository-delete.tsx

View workflow job for this annotation

GitHub Actions / pr-checks

'src/api' import is restricted from being used. Import the specific file and not the barrel
import { DeleteRepositoryModal } from 'src/components';
import {

Check warning on line 4 in src/actions/rpm-repository-delete.tsx

View workflow job for this annotation

GitHub Actions / pr-checks

'src/utilities' import is restricted from being used. Import the specific file and not the barrel
handleHttpError,
parsePulpIDFromURL,
taskAlert,
waitForTaskUrl,
} from 'src/utilities';
import { Action } from './action';

export const rpmRepositoryDeleteAction = Action({
title: msg`Delete`,
modal: ({ addAlert, listQuery, setState, state }) =>
state.deleteModalOpen ? (
<DeleteRepositoryModal
closeAction={() => setState({ deleteModalOpen: null })}
deleteAction={() =>
deleteRepository(state.deleteModalOpen, {
addAlert,
listQuery,
setState,
})
}
name={state.deleteModalOpen.name}
/>
) : null,
onClick: (
{ name, id, pulp_href }: { name: string; id?: string; pulp_href?: string },
{ setState },
) =>
setState({
deleteModalOpen: {
pulpId: id || parsePulpIDFromURL(pulp_href),
name,
pulp_href,
},
}),
});

const DISTRIBUTION_PAGE_SIZE = 100;

// A repository can be serving more distributions than a single page holds, and
// any the lookup misses are left pointing at a repository that no longer exists.
async function listDistributions(repository) {
const distributions = [];
let page = 1;
let count = Infinity;

while (distributions.length < count) {
const { data } = await RPMDistributionAPI.list({
repository,
page,
page_size: DISTRIBUTION_PAGE_SIZE,
});

// Also stops the loop should count ever disagree with what the pages return.
if (!data.results?.length) {
break;
}

distributions.push(...data.results);
count = data.count;
page++;
}

return distributions;
}

async function deleteRepository(
{ name, pulp_href, pulpId },
{ addAlert, setState, listQuery },
) {
const distributionsToDelete = await listDistributions(pulp_href).catch(
(e) => {
handleHttpError(
t`Failed to list distributions, removing only the repository.`,
() => null,
addAlert,
)(e);
return [];
},
);

const deleteRepo = RPMRepositoryAPI.delete(pulpId)
.then(({ data }) => {
addAlert(taskAlert(data.task, t`Removal started for repository ${name}`));
return waitForTaskUrl(data.task);
})
.catch(
handleHttpError(
t`Failed to remove repository ${name}`,
() => setState({ deleteModalOpen: null }),
addAlert,
),
);

const deleteDistribution = ({ name, pulp_href }) => {
const distribution_id = parsePulpIDFromURL(pulp_href);
return RPMDistributionAPI.delete(distribution_id)
.then(({ data }) => {
addAlert(
taskAlert(data.task, t`Removal started for distribution ${name}`),
);
return waitForTaskUrl(data.task);
})
.catch(
handleHttpError(
t`Failed to remove distribution ${name}`,
() => null,
addAlert,
),
);
};

return Promise.all([
deleteRepo,
...distributionsToDelete.map(deleteDistribution),
]).then(() => {
setState({ deleteModalOpen: null });
listQuery();
});
}
9 changes: 9 additions & 0 deletions src/actions/rpm-repository-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { msg } from '@lingui/core/macro';
import { Paths, formatPath } from 'src/paths';
import { Action } from './action';

export const rpmRepositoryEditAction = Action({
title: msg`Edit`,
onClick: ({ name }, { navigate }) =>
navigate(formatPath(Paths.rpm.repository.edit, { name })),
});
72 changes: 72 additions & 0 deletions src/actions/rpm-repository-sync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { msg, t } from '@lingui/core/macro';
import { RPMRepositoryAPI } from 'src/api';
import { SyncModal } from 'src/components';
import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities';
import { Action } from './action';

// pulp_rpm's own API default, which never removes content. Mirroring is
// opted into, never defaulted to.
const DEFAULT_SYNC_POLICY = 'additive';

const syncPolicies = () => [
{ id: 'additive', label: t`Additive: only add new content` },
{ id: 'mirror_content_only', label: t`Mirror content only` },
{
id: 'mirror_complete',
label: t`Mirror complete (exact copy incl. metadata)`,
},
];

export const rpmRepositorySyncAction = Action({
title: msg`Sync`,
modal: ({ addAlert, query, setState, state }) =>
state.syncModalOpen ? (
<SyncModal
closeAction={() => setState({ syncModalOpen: null })}
defaultSyncPolicy={DEFAULT_SYNC_POLICY}
syncPolicies={syncPolicies()}
syncAction={(syncParams) =>
syncRepository(state.syncModalOpen, { addAlert, query }, syncParams)
}
name={state.syncModalOpen.name}
/>
) : null,
onClick: ({ name, pulp_href }, { setState }) =>
setState({
syncModalOpen: { name, pulp_href },
}),
visible: (_item, { hasPermission }) =>
hasPermission('rpm.change_rpmrepository'),
disabled: ({ remote, last_sync_task }) => {
if (!remote) {
return t`There are no remotes associated with this repository.`;
}

if (
last_sync_task &&
['running', 'waiting'].includes(last_sync_task.state)
) {
return t`Sync task is already queued.`;
}
},
});

function syncRepository({ name, pulp_href }, { addAlert, query }, syncParams) {
const pulpId = parsePulpIDFromURL(pulp_href);
return RPMRepositoryAPI.sync(pulpId, {
sync_policy: syncParams?.sync_policy || DEFAULT_SYNC_POLICY,
optimize: syncParams?.optimize ?? true,
})
.then(({ data }) => {
addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`));

query();
})
.catch(
handleHttpError(
t`Failed to sync repository "${name}"`,
() => null,
addAlert,
),
);
}
4 changes: 3 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ export {
} from './response-types/user';
export { RoleAPI } from './role';
export { RPMPackageAPI } from './rpm-package';
export { RPMRepositoryAPI } from './rpm-repository';
export { RPMDistributionAPI } from './rpm-distribution';
export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote';
export { RPMRepositoryAPI, type RPMRepositoryType } from './rpm-repository';
export { SignCollectionAPI } from './sign-collections';
export { SignContainersAPI } from './sign-containers';
export { SigningServiceAPI, type SigningServiceType } from './signing-service';
Expand Down
44 changes: 44 additions & 0 deletions src/api/rpm-remote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { GenericRemote } from './common';
import { PulpAPI } from './pulp';

/**
* RPM Remote Type.
*
* @see https://github.com/pulp/pulp_rpm/blob/main/pulp_rpm/app/serializers/repository.py
*/
interface RPMRemoteType extends GenericRemote {
sles_auth_token?: string | null;
}

// as in deb-remote / file-remote
function smartUpdate(remote: RPMRemoteType, unmodifiedRemote: RPMRemoteType) {
for (const field of Object.keys(remote)) {
if (remote[field] === '') {
remote[field] = null;
}

// API returns headers:null but doesn't accept it .. and we don't edit headers
if (remote[field] === null && unmodifiedRemote[field] === null) {
delete remote[field];
}
}

return remote;
}

const base = new PulpAPI();

export const RPMRemoteAPI = {
create: (data) => base.http.post(`remotes/rpm/rpm/`, data),

delete: (id) => base.http.delete(`remotes/rpm/rpm/${id}/`),

get: (id) => base.http.get(`remotes/rpm/rpm/${id}/`),

list: (params?) => base.list(`remotes/rpm/rpm/`, params),

smartUpdate: (id, newValue: RPMRemoteType, oldValue: RPMRemoteType) =>
base.http.put(`remotes/rpm/rpm/${id}/`, smartUpdate(newValue, oldValue)),
};

export type { RPMRemoteType };
Loading
Loading