diff --git a/README.md b/README.md index cc87916b..e3df8118 100644 --- a/README.md +++ b/README.md @@ -29,6 +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) +- Python (repositories, remotes, packages) - RPM (repositories, remotes, content) - Container (execution environments, tags, manifests) diff --git a/e2e/smoke/pages.spec.ts b/e2e/smoke/pages.spec.ts index c51b7975..3e55d511 100644 --- a/e2e/smoke/pages.spec.ts +++ b/e2e/smoke/pages.spec.ts @@ -55,6 +55,39 @@ test.describe('pages of a signed-in session', () => { await expect(page.locator('#architectures')).toBeVisible(); }); + test('Python repositories', async ({ page }) => { + await page.goto('python/repositories'); + await expect(title(page, 'Repositories')).toBeVisible(); + await expect(page.getByText('No repositories yet')).toBeVisible(); + + // pulp_python repositories can refuse package substitution, so the form + // carries settings the other plugins have no use for + await page.getByRole('button', { name: 'Add repository' }).click(); + await expect(page.locator('#allow_package_substitution')).toBeVisible(); + await expect(page.locator('#error_on_reject')).toBeVisible(); + }); + + test('Python remotes', async ({ page }) => { + await page.goto('python/remotes'); + await expect(title(page, 'Remotes')).toBeVisible(); + await expect(page.getByText('No remotes yet')).toBeVisible(); + + // a Python remote filters what it syncs by project, package type and + // platform, and offers the download policy + await page.getByRole('button', { name: 'Add remote' }).click(); + await expect(page.locator('#includes')).toBeVisible(); + await expect(page.locator('#excludes')).toBeVisible(); + await expect(page.locator('#keep_latest_packages')).toBeVisible(); + await expect(page.locator('#package_types_sdist')).toBeVisible(); + await expect(page.locator('#policy')).toHaveValue('on_demand'); + }); + + test('Python packages', async ({ page }) => { + await page.goto('python/packages'); + await expect(title(page, 'Packages')).toBeVisible(); + await expect(page.getByText('No packages yet')).toBeVisible(); + }); + test('RPM repositories', async ({ page }) => { await page.goto('rpm/repositories'); await expect(title(page, 'Repositories')).toBeVisible(); diff --git a/src/actions/index.ts b/src/actions/index.ts index 3c949376..d20bd577 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -27,6 +27,13 @@ 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 { pythonRemoteCreateAction } from './python-remote-create'; +export { pythonRemoteDeleteAction } from './python-remote-delete'; +export { pythonRemoteEditAction } from './python-remote-edit'; +export { pythonRepositoryCreateAction } from './python-repository-create'; +export { pythonRepositoryDeleteAction } from './python-repository-delete'; +export { pythonRepositoryEditAction } from './python-repository-edit'; +export { pythonRepositorySyncAction } from './python-repository-sync'; export { rpmRemoteCreateAction } from './rpm-remote-create'; export { rpmRemoteDeleteAction } from './rpm-remote-delete'; export { rpmRemoteEditAction } from './rpm-remote-edit'; diff --git a/src/actions/python-remote-create.tsx b/src/actions/python-remote-create.tsx new file mode 100644 index 00000000..37dc42cc --- /dev/null +++ b/src/actions/python-remote-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const pythonRemoteCreateAction = Action({ + title: msg`Add remote`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.python.remote.edit, { name: '_' })), +}); diff --git a/src/actions/python-remote-delete.tsx b/src/actions/python-remote-delete.tsx new file mode 100644 index 00000000..ce78c167 --- /dev/null +++ b/src/actions/python-remote-delete.tsx @@ -0,0 +1,44 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonRemoteAPI } from 'src/api'; +import { DeleteRemoteModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const pythonRemoteDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + 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 PythonRemoteAPI.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), + ); +} diff --git a/src/actions/python-remote-edit.tsx b/src/actions/python-remote-edit.tsx new file mode 100644 index 00000000..00a16de0 --- /dev/null +++ b/src/actions/python-remote-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const pythonRemoteEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.python.remote.edit, { name })), +}); diff --git a/src/actions/python-repository-create.tsx b/src/actions/python-repository-create.tsx new file mode 100644 index 00000000..8c18cc61 --- /dev/null +++ b/src/actions/python-repository-create.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const pythonRepositoryCreateAction = Action({ + title: msg`Add repository`, + onClick: (item, { navigate }) => + navigate(formatPath(Paths.python.repository.edit, { name: '_' })), +}); diff --git a/src/actions/python-repository-delete.tsx b/src/actions/python-repository-delete.tsx new file mode 100644 index 00000000..d7fe52d9 --- /dev/null +++ b/src/actions/python-repository-delete.tsx @@ -0,0 +1,123 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonDistributionAPI, PythonRepositoryAPI } from 'src/api'; +import { DeleteRepositoryModal } from 'src/components'; +import { + handleHttpError, + parsePulpIDFromURL, + taskAlert, + waitForTaskUrl, +} from 'src/utilities'; +import { Action } from './action'; + +export const pythonRepositoryDeleteAction = Action({ + title: msg`Delete`, + modal: ({ addAlert, listQuery, setState, state }) => + state.deleteModalOpen ? ( + 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 PythonDistributionAPI.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 = PythonRepositoryAPI.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 PythonDistributionAPI.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(); + }); +} diff --git a/src/actions/python-repository-edit.tsx b/src/actions/python-repository-edit.tsx new file mode 100644 index 00000000..ee8c7adc --- /dev/null +++ b/src/actions/python-repository-edit.tsx @@ -0,0 +1,9 @@ +import { msg } from '@lingui/core/macro'; +import { Paths, formatPath } from 'src/paths'; +import { Action } from './action'; + +export const pythonRepositoryEditAction = Action({ + title: msg`Edit`, + onClick: ({ name }, { navigate }) => + navigate(formatPath(Paths.python.repository.edit, { name })), +}); diff --git a/src/actions/python-repository-sync.tsx b/src/actions/python-repository-sync.tsx new file mode 100644 index 00000000..0ae25fc7 --- /dev/null +++ b/src/actions/python-repository-sync.tsx @@ -0,0 +1,63 @@ +import { msg, t } from '@lingui/core/macro'; +import { PythonRepositoryAPI } from 'src/api'; +import { SyncModal } from 'src/components'; +import { handleHttpError, parsePulpIDFromURL, taskAlert } from 'src/utilities'; +import { Action } from './action'; + +// pulp_python syncs additively unless mirror is requested. Mirroring deletes +// local content the remote no longer has, so it is the direction to opt into +// rather than out of, as for deb. +const MIRROR_BY_DEFAULT = false; + +export const pythonRepositorySyncAction = Action({ + title: msg`Sync`, + modal: ({ addAlert, query, setState, state }) => + state.syncModalOpen ? ( + setState({ syncModalOpen: null })} + defaultMirror={MIRROR_BY_DEFAULT} + showOptimize={false} + 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('python.change_pythonrepository'), + 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 PythonRepositoryAPI.sync(pulpId, { + mirror: syncParams?.mirror ?? MIRROR_BY_DEFAULT, + }) + .then(({ data }) => { + addAlert(taskAlert(data.task, t`Sync started for repository "${name}".`)); + + query(); + }) + .catch( + handleHttpError( + t`Failed to sync repository "${name}"`, + () => null, + addAlert, + ), + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index 9e7b2efd..2e69b565 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -77,6 +77,18 @@ export { type UserType, } from './response-types/user'; export { RoleAPI } from './role'; +export { PythonDistributionAPI } from './python-distribution'; +export { + PythonRemoteAPI, + type PythonPackageType, + type PythonPlatform, + type PythonRemoteType, +} from './python-remote'; +export { + PythonRepositoryAPI, + type PythonRepositoryType, +} from './python-repository'; +export { PythonPackageAPI } from './python-package'; export { RPMPackageAPI } from './rpm-package'; export { RPMDistributionAPI } from './rpm-distribution'; export { RPMRemoteAPI, type RPMRemoteType } from './rpm-remote'; diff --git a/src/api/python-distribution.ts b/src/api/python-distribution.ts new file mode 100644 index 00000000..f200e930 --- /dev/null +++ b/src/api/python-distribution.ts @@ -0,0 +1,11 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const PythonDistributionAPI = { + create: (data) => base.http.post(`distributions/python/pypi/`, data), + + delete: (id) => base.http.delete(`distributions/python/pypi/${id}/`), + + list: (params?) => base.list(`distributions/python/pypi/`, params), +}; diff --git a/src/api/python-package.ts b/src/api/python-package.ts new file mode 100644 index 00000000..1e66e542 --- /dev/null +++ b/src/api/python-package.ts @@ -0,0 +1,7 @@ +import { PulpAPI } from './pulp'; + +const base = new PulpAPI(); + +export const PythonPackageAPI = { + list: (params?) => base.list(`content/python/packages/`, params), +}; diff --git a/src/api/python-remote.ts b/src/api/python-remote.ts new file mode 100644 index 00000000..312b6152 --- /dev/null +++ b/src/api/python-remote.ts @@ -0,0 +1,67 @@ +import type { GenericRemote } from './common'; +import { PulpAPI } from './pulp'; + +type PythonPackageType = + | 'bdist_dmg' + | 'bdist_dumb' + | 'bdist_egg' + | 'bdist_msi' + | 'bdist_rpm' + | 'bdist_wheel' + | 'bdist_wininst' + | 'sdist'; +type PythonPlatform = 'windows' | 'macos' | 'freebsd' | 'linux'; + +/** + * Python Remote Type. + * + * @see https://github.com/pulp/pulp_python/blob/main/pulp_python/app/serializers.py + */ +interface PythonRemoteType extends GenericRemote { + includes?: string[]; + excludes?: string[]; + prereleases?: boolean; + package_types?: PythonPackageType[]; + keep_latest_packages?: number; + exclude_platforms?: PythonPlatform[]; + provenance?: boolean; +} + +// as in deb-remote / file-remote +function smartUpdate( + remote: PythonRemoteType, + unmodifiedRemote: PythonRemoteType, +) { + 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 PythonRemoteAPI = { + create: (data) => base.http.post(`remotes/python/python/`, data), + + delete: (id) => base.http.delete(`remotes/python/python/${id}/`), + + get: (id) => base.http.get(`remotes/python/python/${id}/`), + + list: (params?) => base.list(`remotes/python/python/`, params), + + smartUpdate: (id, newValue: PythonRemoteType, oldValue: PythonRemoteType) => + base.http.put( + `remotes/python/python/${id}/`, + smartUpdate(newValue, oldValue), + ), +}; + +export type { PythonPackageType, PythonPlatform, PythonRemoteType }; diff --git a/src/api/python-repository.ts b/src/api/python-repository.ts new file mode 100644 index 00000000..d434bc2b --- /dev/null +++ b/src/api/python-repository.ts @@ -0,0 +1,40 @@ +import type { GenericRepository } from './common'; +import { PulpAPI } from './pulp'; + +/** + * Python Repository Type. + * + * @see https://github.com/pulp/pulp_python/blob/main/pulp_python/app/serializers.py + */ +interface PythonRepositoryType extends GenericRepository { + /** @deprecated by pulp_python; not offered in the UI */ + autopublish?: boolean; + allow_package_substitution?: boolean; + error_on_reject?: boolean; +} + +const base = new PulpAPI(); + +export const PythonRepositoryAPI = { + create: (data) => base.http.post(`repositories/python/python/`, data), + + delete: (id) => base.http.delete(`repositories/python/python/${id}/`), + + list: (params?) => base.list(`repositories/python/python/`, params), + + listVersions: (id: string, params?) => + base.list(`repositories/python/python/${id}/versions/`, params), + + revert: (id: string, version_href) => + base.http.post(`repositories/python/python/${id}/modify/`, { + base_version: version_href, + }), + + sync: (id: string, body = {}) => + base.http.post(`repositories/python/python/${id}/sync/`, body), + + update: (id: string, data) => + base.http.put(`repositories/python/python/${id}/`, data), +}; + +export type { PythonRepositoryType }; diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 986a5cf7..fa8a25ba 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -55,6 +55,13 @@ import { NamespaceDetail, Partners, PulpStatus, + PythonPackageList, + PythonRemoteDetail, + PythonRemoteEdit, + PythonRemoteList, + PythonRepositoryDetail, + PythonRepositoryEdit, + PythonRepositoryList, RPMPackageList, RPMRemoteDetail, RPMRemoteEdit, @@ -350,6 +357,34 @@ const routes: IRouteConfig[] = [ path: Paths.meta.about, noAuth: true, }, + { + component: PythonPackageList, + path: Paths.python.package.list, + }, + { + component: PythonRemoteDetail, + path: Paths.python.remote.detail, + }, + { + component: PythonRemoteEdit, + path: Paths.python.remote.edit, + }, + { + component: PythonRemoteList, + path: Paths.python.remote.list, + }, + { + component: PythonRepositoryDetail, + path: Paths.python.repository.detail, + }, + { + component: PythonRepositoryEdit, + path: Paths.python.repository.edit, + }, + { + component: PythonRepositoryList, + path: Paths.python.repository.list, + }, { component: RPMPackageList, path: Paths.rpm.package.list, diff --git a/src/components/lazy-repositories.tsx b/src/components/lazy-repositories.tsx index b3aaf150..c444b08b 100644 --- a/src/components/lazy-repositories.tsx +++ b/src/components/lazy-repositories.tsx @@ -14,7 +14,7 @@ export const LazyRepositories = ({ }: { content_href?: string; emptyText?: string; - plugin: 'ansible' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'python' | 'rpm'; remote_href?: string; }) => { const [repositories, setRepositories] = useState([]); diff --git a/src/components/remote-form.tsx b/src/components/remote-form.tsx index 9cde8cac..97e06b22 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -15,6 +15,7 @@ import { InputGroupItem, Modal, Switch, + TextArea, TextInput, } from '@patternfly/react-core'; import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; @@ -37,13 +38,13 @@ import { // Plugins whose remotes offer a download policy in this form. pulp_file and // pulp_deb accept the same three policies; add them here once tested. -const PLUGINS_WITH_POLICY = ['rpm']; +const PLUGINS_WITH_POLICY = ['python', 'rpm']; interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; - plugin: 'ansible' | 'container' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'container' | 'deb' | 'file' | 'python' | 'rpm'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -197,6 +198,7 @@ export class RemoteForm extends Component { case 'container': case 'deb': case 'file': + case 'python': case 'rpm': disabledFields = disabledFields.concat([ 'auth_url', @@ -371,6 +373,183 @@ export class RemoteForm extends Component { )} + + {plugin === 'python' ? ( + <> + {(['includes', 'excludes'] as const).map((field) => ( + =4.2". Leave empty to sync every project the remote offers.` + : t`Projects to skip, one specifier per line, for example "django<4". Applied after includes.` + } + /> + } + > +