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 (
+
+ );
+};
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}}
/>