diff --git a/README.md b/README.md index 12106d32..c1a4bae3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,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 (content) - Container (execution environments, tags, manifests) diff --git a/cypress/e2e/smoke.js b/cypress/e2e/smoke.js index 04a5bcf8..1969b3ee 100644 --- a/cypress/e2e/smoke.js +++ b/cypress/e2e/smoke.js @@ -86,6 +86,40 @@ describe('UI smoke tests', () => { // TODO }); + it('Python repositories', () => { + cy.ui('python/repositories'); + cy.assertTitle('Repositories'); + + cy.contains('No repositories yet'); + + // pulp_python repositories can refuse package substitution, so the form + // carries settings the other plugins have no use for + cy.contains('button', 'Add repository').click(); + cy.get('#allow_package_substitution'); + cy.get('#error_on_reject'); + }); + + it('Python remotes', () => { + cy.ui('python/remotes'); + cy.assertTitle('Remotes'); + + cy.contains('No remotes yet'); + + // a Python remote filters what it syncs by project, package type and platform + cy.contains('button', 'Add remote').click(); + cy.get('#includes'); + cy.get('#excludes'); + cy.get('#keep_latest_packages'); + cy.get('#package_types_sdist'); + }); + + it('Python packages', () => { + cy.ui('python/packages'); + cy.assertTitle('Packages'); + + cy.contains('No packages yet'); + }); + it('RPMs', () => { cy.ui('rpm/rpms'); cy.assertTitle('Packages'); diff --git a/src/actions/index.ts b/src/actions/index.ts index 8fac49b5..ee04a700 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -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 { 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'; 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 e39f6c98..332b0bdc 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 { RPMRepositoryAPI } from './rpm-repository'; export { SignCollectionAPI } from './sign-collections'; 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 552578fe..3771d0a0 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -50,6 +50,13 @@ import { NamespaceDetail, Partners, PulpStatus, + PythonPackageList, + PythonRemoteDetail, + PythonRemoteEdit, + PythonRemoteList, + PythonRepositoryDetail, + PythonRepositoryEdit, + PythonRepositoryList, RPMPackageList, RoleCreate, RoleList, @@ -339,6 +346,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-distributions.tsx b/src/components/lazy-distributions.tsx index 68eefd9d..ffefa7a7 100644 --- a/src/components/lazy-distributions.tsx +++ b/src/components/lazy-distributions.tsx @@ -11,7 +11,7 @@ export const LazyDistributions = ({ repositoryHref, }: { emptyText?: string; - plugin: 'ansible' | 'deb' | 'file' | 'rpm'; + plugin: 'ansible' | 'deb' | 'file' | 'python' | 'rpm'; repositoryHref: string; }) => { const [distributions, setDistributions] = useState([]); 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 45a37736..bb9cb741 100644 --- a/src/components/remote-form.tsx +++ b/src/components/remote-form.tsx @@ -13,6 +13,7 @@ import { InputGroupItem, Modal, Switch, + TextArea, TextInput, } from '@patternfly/react-core'; import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; @@ -37,7 +38,7 @@ interface IProps { allowEditName?: boolean; closeModal: () => void; errorMessages: ErrorMessagesType; - plugin: 'ansible' | 'container' | 'deb' | 'file'; + plugin: 'ansible' | 'container' | 'deb' | 'file' | 'python'; remote: RemoteType; saveRemote: () => void; showMain?: boolean; @@ -191,6 +192,7 @@ export class RemoteForm extends Component { case 'container': case 'deb': case 'file': + case 'python': disabledFields = disabledFields.concat([ 'auth_url', 'token', @@ -338,6 +340,182 @@ 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.` + } + /> + } + > +