diff --git a/web/cypress/e2e/proxy-cache.cy.ts b/web/cypress/e2e/proxy-cache.cy.ts new file mode 100644 index 000000000..fa116fc29 --- /dev/null +++ b/web/cypress/e2e/proxy-cache.cy.ts @@ -0,0 +1,109 @@ +/// + +describe('Organization settings - Proxy-cache configuration', () => { + beforeEach(() => { + cy.exec('npm run quay:seed'); + cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`) + .then((response) => response.body.csrf_token) + .then((token) => { + cy.loginByCSRF(token); + }); + cy.intercept('GET', '/config', {fixture: 'config.json'}).as('getConfig'); + + // Intercept the /validateproxycache API call + cy.intercept('POST', '/api/v1/organization/*/validateproxycache', (req) => { + const {upstream_registry_username, upstream_registry_password} = req.body; + if (upstream_registry_username && upstream_registry_password) { + req.reply({ + statusCode: 202, + body: 'Valid', + }); + } else { + req.reply({ + statusCode: 202, + body: 'Anonymous', + }); + } + }).as('validateProxyCache'); + + // Intercept the /proxycache API call + cy.intercept('POST', '/api/v1/organization/*/proxycache', { + statusCode: 201, + body: 'Created', + }).as('createProxyCache'); + }); + + const createAnonymousProxyCacheConfig = (cy) => { + cy.get('[data-testid="remote-registry-input"]').type('docker.io'); + cy.get('[data-testid="save-proxy-cache-btn"]').click(); + }; + + it('can create anonymous proxy cache configuration for an organization', () => { + cy.visit('/organization/projectquay?tab=Settings'); + cy.get('[data-testid="Proxy Cache"]').click(); + createAnonymousProxyCacheConfig(cy); + + // Wait for the validateproxycache API call and assert the response + cy.wait('@validateProxyCache').then((interception) => { + expect(interception.response?.statusCode).to.eq(202); + expect(interception.response?.body).to.eq('Anonymous'); + }); + + // Wait for the proxycache API call and assert the response + cy.wait('@createProxyCache').then((interception) => { + expect(interception.response?.statusCode).to.eq(201); + expect(interception.response?.body).to.eq('Created'); + }); + + // verify success alert + cy.get('.pf-v5-c-alert.pf-m-success') + .contains('Successfully configured proxy cache') + .should('exist'); + }); + + it('can create proxy cache configuration with registry credentials for an organization', () => { + cy.visit('/organization/projectquay?tab=Settings'); + cy.get('[data-testid="Proxy Cache"]').click(); + + cy.get('[data-testid="remote-registry-input"]').type('docker.io'); + cy.get('[data-testid="remote-registry-username"]').type('testuser1'); + cy.get('[data-testid="remote-registry-password"]').type('testpass'); + cy.get('[data-testid="remote-registry-expiration"]').clear().type('76400'); + cy.get('[data-testid="remote-registry-insecure"]').check(); + + cy.get('[data-testid="save-proxy-cache-btn"]').click(); + + // Wait for the validateproxycache API call and assert the response + cy.wait('@validateProxyCache').then((interception) => { + expect(interception.response?.statusCode).to.eq(202); + expect(interception.response?.body).to.eq('Valid'); + }); + + // Wait for the proxycache API call and assert the response + cy.wait('@createProxyCache').then((interception) => { + expect(interception.response?.statusCode).to.eq(201); + expect(interception.response?.body).to.eq('Created'); + }); + + // verify success alert + cy.get('.pf-v5-c-alert.pf-m-success') + .contains('Successfully configured proxy cache') + .should('exist'); + }); + + it('can delete proxy cache configuration for an organization', () => { + cy.visit('/organization/prometheus?tab=Settings'); + cy.get('[data-testid="Proxy Cache"]').click(); + + cy.get('[data-testid="delete-proxy-cache-btn"]').click(); + // verify success alert + cy.get('.pf-v5-c-alert.pf-m-success') + .contains('Successfully deleted proxy cache configuration') + .should('exist'); + }); + + it('proxy cache config is not shown for user organization', () => { + cy.visit('/organization/user1?tab=Settings'); + cy.get('[data-testid="Proxy Cache"]').should('not.exist'); + }); +}); diff --git a/web/cypress/fixtures/config.json b/web/cypress/fixtures/config.json index 6a7f22a1f..639365b73 100644 --- a/web/cypress/fixtures/config.json +++ b/web/cypress/fixtures/config.json @@ -47,7 +47,7 @@ "DEBUG": false, "DOCUMENTATION_ROOT": "https://docs.projectquay.io/", "ENTERPRISE_LOGO_URL": "/static/img/quay-horizontal-color.svg", - "FEATURE_PROXY_CACHE": false, + "FEATURE_PROXY_CACHE": true, "FEATURE_QUOTA_MANAGEMENT": false, "FEATURE_EDIT_QUOTA": true, "FEATURE_REPO_MIRROR": false, @@ -106,7 +106,7 @@ "NONSUPERUSER_TEAM_SYNCING_SETUP": false, "PARTIAL_USER_AUTOCOMPLETE": true, "PERMANENT_SESSIONS": true, - "PROXY_CACHE": false, + "PROXY_CACHE": true, "PROXY_STORAGE": false, "PUBLIC_CATALOG": false, "QUOTA_MANAGEMENT": true, diff --git a/web/cypress/test/quay-db-data.txt b/web/cypress/test/quay-db-data.txt index fa9afa2c7..fdc53f63b 100644 --- a/web/cypress/test/quay-db-data.txt +++ b/web/cypress/test/quay-db-data.txt @@ -6301,6 +6301,7 @@ COPY public.logentry3 (id, kind_id, account_id, performer_id, repository_id, dat 201 31 32 29 \N 2023-11-07 19:54:08.826725 172.18.0.1 {"member": "user1", "team": "teamforreadonly"} 202 98 29 29 \N 2023-11-07 19:54:14.934915 172.18.0.1 {} 203 97 1 \N \N 2023-11-07 19:54:19.160078 172.18.0.1 {"type": "quayauth", "useragent": "Mozilla/5.0"} +204 76 6 1 \N 2024-11-26 13:28:52.179631 172.20.0.1 {"upstream_registry": "docker.io"} \. @@ -6683,6 +6684,7 @@ COPY public.permissionprototype (id, org_id, uuid, activating_user_id, delegate_ -- COPY public.proxycacheconfig (id, organization_id, creation_date, upstream_registry, upstream_registry_username, upstream_registry_password, expiration_s, insecure) FROM stdin; +1 6 2024-11-26 13:28:52.175714 docker.io \N \N 86400 f \. @@ -6715,8 +6717,8 @@ COPY public.quayservice (id, name) FROM stdin; -- COPY public.queueitem (id, queue_name, body, available_after, available, processing_expires, retries_remaining, state_id) FROM stdin; -2 namespacegc/3/ {"marker_id": 2, "original_username": "clair"} 2024-09-30 17:51:28.055159 t 2024-09-30 20:46:28.041972 5 245c3f97-bac6-4742-93eb-2b61f948c6b9 -1 namespacegc/2/ {"marker_id": 1, "original_username": "quay"} 2024-09-30 17:51:33.074743 t 2024-09-30 20:46:33.062514 5 daa3897c-92bf-4678-a49a-8dbf4efed18c +1 namespacegc/2/ {"marker_id": 1, "original_username": "quay"} 2024-11-26 13:33:24.969707 t 2024-11-26 16:28:24.944791 5 b26bfbb7-6d52-4adb-81bd-9674dfc4359d +2 namespacegc/3/ {"marker_id": 2, "original_username": "clair"} 2024-11-26 13:33:30.012937 t 2024-11-26 16:28:29.982092 5 be21614e-6c32-4f3c-9454-6c8c94a8b672 \. @@ -8286,7 +8288,7 @@ SELECT pg_catalog.setval('public.logentry2_id_seq', 1, false); -- Name: logentry3_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.logentry3_id_seq', 207, true); +SELECT pg_catalog.setval('public.logentry3_id_seq', 204, true); -- @@ -8440,7 +8442,7 @@ SELECT pg_catalog.setval('public.permissionprototype_id_seq', 3, true); -- Name: proxycacheconfig_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quay -- -SELECT pg_catalog.setval('public.proxycacheconfig_id_seq', 1, false); +SELECT pg_catalog.setval('public.proxycacheconfig_id_seq', 1, true); -- diff --git a/web/src/hooks/UseAlerts.ts b/web/src/hooks/UseAlerts.ts index 2a9fc4b74..68305dae3 100644 --- a/web/src/hooks/UseAlerts.ts +++ b/web/src/hooks/UseAlerts.ts @@ -9,7 +9,13 @@ export function useAlerts() { } setAlerts([...alerts, alert]); }; + + const clearAllAlerts = () => { + setAlerts([]); + }; + return { - addAlert: addAlert, + addAlert, + clearAllAlerts, }; } diff --git a/web/src/hooks/UseProxyCache.ts b/web/src/hooks/UseProxyCache.ts new file mode 100644 index 000000000..8a3e056a4 --- /dev/null +++ b/web/src/hooks/UseProxyCache.ts @@ -0,0 +1,111 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import { + createProxyCacheConfig, + deleteProxyCacheConfig, + fetchProxyCacheConfig, + validateProxyCacheConfig, +} from 'src/resources/ProxyCacheResource'; +import {useCurrentUser} from './UseCurrentUser'; +import {addDisplayError} from 'src/resources/ErrorHandling'; +import {AxiosError} from 'axios'; + +export interface IProxyCacheConfig { + upstream_registry: string; + expiration_s: number; + insecure?: boolean; + org_name?: string; + upstream_registry_username?: string; + upstream_registry_password?: string; +} + +export function useFetchProxyCacheConfig(orgName: string) { + const {user} = useCurrentUser(); + + const { + data: fetchedProxyCacheConfig, + isLoading: isLoadingProxyCacheConfig, + isSuccess: isSuccessLoadingProxyCacheConfig, + isError: errorLoadingProxyCacheConfig, + } = useQuery( + ['proxycacheconfig'], + ({signal}) => fetchProxyCacheConfig(orgName, signal), + { + enabled: !(user.username === orgName), + }, + ); + + return { + fetchedProxyCacheConfig, + isLoadingProxyCacheConfig, + errorLoadingProxyCacheConfig, + isSuccessLoadingProxyCacheConfig, + }; +} + +export function useValidateProxyCacheConfig( + proxyCacheConfig: IProxyCacheConfig, + {onSuccess, onError}, +) { + const queryClient = useQueryClient(); + + const {mutate: proxyCacheConfigValidation} = useMutation( + async () => { + return validateProxyCacheConfig(proxyCacheConfig); + }, + { + onSuccess: (response) => { + onSuccess(response); + queryClient.invalidateQueries(['proxycacheconfig']); + }, + onError: (err: AxiosError) => { + onError(addDisplayError('proxy cache validation error', err)); + }, + }, + ); + return { + proxyCacheConfigValidation, + }; +} + +export function useCreateProxyCacheConfig({onSuccess, onError}) { + const queryClient = useQueryClient(); + const {mutate: createProxyCacheConfigMutation} = useMutation( + async (proxyCacheConfig: IProxyCacheConfig) => { + return createProxyCacheConfig(proxyCacheConfig); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['proxycacheconfig']); + }, + onError: (err: AxiosError) => { + onError(addDisplayError('proxy cache creation error', err)); + }, + }, + ); + + return { + createProxyCacheConfigMutation, + }; +} + +export function useDeleteProxyCacheConfig(orgName, {onSuccess, onError}) { + const queryClient = useQueryClient(); + const {mutate: deleteProxyCacheConfigMutation} = useMutation( + async () => { + return deleteProxyCacheConfig(orgName); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['proxycacheconfig']); + }, + onError: (err: AxiosError) => { + onError(addDisplayError('proxy cache deletion error', err)); + }, + }, + ); + return { + deleteProxyCacheConfigMutation, + }; +} diff --git a/web/src/resources/ProxyCacheResource.ts b/web/src/resources/ProxyCacheResource.ts new file mode 100644 index 000000000..3f78e1443 --- /dev/null +++ b/web/src/resources/ProxyCacheResource.ts @@ -0,0 +1,56 @@ +import axios from 'src/libs/axios'; +import {assertHttpCode} from './ErrorHandling'; +import {IProxyCacheConfig} from 'src/hooks/UseProxyCache'; +import {AxiosResponse} from 'axios'; + +export async function fetchProxyCacheConfig(org: string, signal?: AbortSignal) { + const proxyCacheConfigUrl = `/api/v1/organization/${org}/proxycache`; + const proxyResponse = await axios.get(proxyCacheConfigUrl, {signal}); + assertHttpCode(proxyResponse.status, 200); + return proxyResponse.data; +} + +export async function validateProxyCacheConfig( + proxyCacheConfig: IProxyCacheConfig, +) { + const proxyCacheConfigUrl = `/api/v1/organization/${proxyCacheConfig.org_name}/validateproxycache`; + const payload = { + ...proxyCacheConfig, + upstream_registry_username: + proxyCacheConfig.upstream_registry_username || null, + upstream_registry_password: + proxyCacheConfig.upstream_registry_password || null, + }; + const proxyResponse = await axios.post(proxyCacheConfigUrl, payload); + assertHttpCode(proxyResponse.status, 202); + return proxyResponse.data; +} + +export async function deleteProxyCacheConfig( + org: string, + signal?: AbortSignal, +) { + const proxyCacheConfigUrl = `/api/v1/organization/${org}/proxycache`; + const proxyResponse = await axios.delete(proxyCacheConfigUrl, {signal}); + assertHttpCode(proxyResponse.status, 201); + return proxyResponse.data; +} + +export async function createProxyCacheConfig( + proxyCacheConfig: IProxyCacheConfig, +) { + const createProxyCacheConfigUrl = `/api/v1/organization/${proxyCacheConfig.org_name}/proxycache`; + const payload = { + ...proxyCacheConfig, + upstream_registry_username: + proxyCacheConfig.upstream_registry_username || null, + upstream_registry_password: + proxyCacheConfig.upstream_registry_password || null, + }; + const response: AxiosResponse = await axios.post( + createProxyCacheConfigUrl, + payload, + ); + assertHttpCode(response.status, 201); + return response.data; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/ProxyCacheConfig.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/ProxyCacheConfig.tsx new file mode 100644 index 000000000..647fec7d9 --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/ProxyCacheConfig.tsx @@ -0,0 +1,323 @@ +import { + ActionGroup, + Button, + Checkbox, + Flex, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + Spinner, + TextInput, +} from '@patternfly/react-core'; +import {useEffect, useState} from 'react'; +import {AlertVariant} from 'src/atoms/AlertState'; +import {useAlerts} from 'src/hooks/UseAlerts'; +import { + IProxyCacheConfig, + useCreateProxyCacheConfig, + useDeleteProxyCacheConfig, + useFetchProxyCacheConfig, + useValidateProxyCacheConfig, +} from 'src/hooks/UseProxyCache'; +import Alerts from 'src/routes/Alerts'; + +type ProxyCacheConfigProps = { + organizationName: string; + isUser: boolean; +}; + +const tagExpirationInSecsForProxyCache = 86400; + +export const ProxyCacheConfig = (props: ProxyCacheConfigProps) => { + const defaultProxyCacheConfig = { + upstream_registry: '', + expiration_s: tagExpirationInSecsForProxyCache, + insecure: false, + org_name: props.organizationName, + }; + + const [proxyCacheConfig, setProxyCacheConfig] = useState( + defaultProxyCacheConfig, + ); + const {addAlert, clearAllAlerts} = useAlerts(); + + const {fetchedProxyCacheConfig, isLoadingProxyCacheConfig} = + useFetchProxyCacheConfig(props.organizationName); + + useEffect(() => { + if (fetchedProxyCacheConfig) { + // only set values that are fetched + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + upstream_registry: fetchedProxyCacheConfig.upstream_registry, + expiration_s: + fetchedProxyCacheConfig.expiration_s || + tagExpirationInSecsForProxyCache, + insecure: fetchedProxyCacheConfig.insecure || false, + })); + } else { + // reset the config if there's no fetchedProxyCacheConfig data + setProxyCacheConfig(defaultProxyCacheConfig); + } + }, [fetchedProxyCacheConfig, props.organizationName]); + + const {createProxyCacheConfigMutation} = useCreateProxyCacheConfig({ + onSuccess: () => { + addAlert({ + variant: AlertVariant.Success, + title: `Successfully configured proxy cache`, + }); + }, + onError: (err) => { + addAlert({ + variant: AlertVariant.Failure, + title: err, + }); + }, + }); + + useEffect(() => { + // clear alerts when switching tabs + return () => { + clearAllAlerts(); + }; + }, []); + + const {proxyCacheConfigValidation} = useValidateProxyCacheConfig( + proxyCacheConfig, + { + onSuccess: (response) => { + if (response === 'Valid' || response === 'Anonymous') { + createProxyCacheConfigMutation(proxyCacheConfig); + setProxyCacheConfig(proxyCacheConfig); + } + }, + onError: (err) => { + addAlert({ + variant: AlertVariant.Failure, + title: err, + }); + }, + }, + ); + + const {deleteProxyCacheConfigMutation} = useDeleteProxyCacheConfig( + props.organizationName, + { + onSuccess: () => { + addAlert({ + variant: AlertVariant.Success, + title: 'Successfully deleted proxy cache configuration', + }); + }, + onError: (err) => { + addAlert({ + variant: AlertVariant.Failure, + title: err, + }); + }, + }, + ); + + const handleRemoteRegistryInput = (registryName: string) => { + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + upstream_registry: registryName, + })); + }; + + const handleRemoteRegistryUsername = (registryUsername: string) => { + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + upstream_registry_username: registryUsername, + })); + }; + + const handleRemoteRegistryPassword = (registryPass: string) => { + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + upstream_registry_password: registryPass, + })); + }; + + const handleRemoteRegistryExpiration = (expiration: string) => { + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + expiration_s: Number(expiration), + })); + }; + + const handleInsecureProtocol = (e, checked: boolean) => { + setProxyCacheConfig((prevConfig) => ({ + ...prevConfig, + insecure: checked, + })); + }; + + if (isLoadingProxyCacheConfig) { + return ; + } + + return ( +
+ + + handleRemoteRegistryInput(registryName) + } + /> + + + + + Remote registry that is to be cached. (Eg: For docker hub, + docker.io, docker.io/library) + + + + + + + + handleRemoteRegistryUsername(registryUsername) + } + /> + + + + + Username for authenticating into the entered remote registry. For + anonymous pulls from the upstream, leave this empty. + + + + + + + + handleRemoteRegistryPassword(password) + } + /> + + + + + Password for authenticating into the entered remote registry. For + anonymous pulls from the upstream, leave this empty.{' '} + + + + + + + + handleRemoteRegistryExpiration(inputSecs) + } + /> + + + + + Default tag expiration for cached images, in seconds. This value + is refreshed on every pull. Default is 86400 i.e, 24 hours.{' '} + + + + + + + + + + + If set, http (unsecure protocol) will be used. If not set, https + (secure protocol) will be used to request the remote registry. + + + + + + + + + + + + + + + ); +}; diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx index 9804833fc..9f4238fad 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx @@ -6,6 +6,7 @@ import AutoPruning from './AutoPruning'; import {BillingInformation} from './BillingInformation'; import {CliConfiguration} from './CLIConfiguration'; import {GeneralSettings} from './GeneralSettings'; +import {ProxyCacheConfig} from './ProxyCacheConfig'; export default function Settings(props: SettingsProps) { const organizationName = location.pathname.split('/')[2]; @@ -48,6 +49,17 @@ export default function Settings(props: SettingsProps) { ), visible: quayConfig?.features?.AUTO_PRUNE, }, + { + name: 'Proxy Cache', + id: 'proxycacheconfig', + content: ( + + ), + visible: quayConfig?.features?.PROXY_CACHE && !props.isUserOrganization, + }, ]; return ( @@ -66,6 +78,7 @@ export default function Settings(props: SettingsProps) { {tab.name}} />