1
0
mirror of https://github.com/quay/quay.git synced 2026-01-26 06:21:37 +03:00

[redhat-3.13] ui: Add proxy cache config UI to org settings (PROJQUAY-7697) (#3428)

* ui: Add proxy cache config UI to org settings (PROJQUAY-7697)

* Fix alerts + reset input fields on user action

* Add cypress test for proxy cache config

* enable proxy cache for cypress test

* Propagate backend api error to UI

* Add additional cypress test coverage

* Fix eslint error

---------

Signed-off-by: harishsurf <hgovinda@redhat.com>
Co-authored-by: harishsurf <hgovinda@redhat.com>
This commit is contained in:
OpenShift Cherrypick Robot
2024-11-27 10:13:20 +01:00
committed by GitHub
parent 046b16eacb
commit e97bbca8a0
8 changed files with 627 additions and 7 deletions

View File

@@ -0,0 +1,109 @@
/// <reference types="cypress" />
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');
});
});

View File

@@ -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,

View File

@@ -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);
--

View File

@@ -9,7 +9,13 @@ export function useAlerts() {
}
setAlerts([...alerts, alert]);
};
const clearAllAlerts = () => {
setAlerts([]);
};
return {
addAlert: addAlert,
addAlert,
clearAllAlerts,
};
}

View File

@@ -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<IProxyCacheConfig>(
['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,
};
}

View File

@@ -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;
}

View File

@@ -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<IProxyCacheConfig>(
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 <Spinner size="md" />;
}
return (
<Form id="form-form" maxWidth="70%">
<FormGroup
isInline
label="Remote Registry"
fieldId="form-remote-registry"
>
<TextInput
isDisabled={!!fetchedProxyCacheConfig?.upstream_registry}
type="text"
id="form-name"
data-testid="remote-registry-input"
value={proxyCacheConfig?.upstream_registry || ''}
onChange={(_event, registryName) =>
handleRemoteRegistryInput(registryName)
}
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
Remote registry that is to be cached. (Eg: For docker hub,
docker.io, docker.io/library)
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup
isInline
label="Remote Registry username"
fieldId="form-username"
>
<TextInput
isDisabled={!!fetchedProxyCacheConfig?.upstream_registry_username}
type="text"
id="remote-registry-username"
data-testid="remote-registry-username"
value={proxyCacheConfig?.upstream_registry_username || ''}
onChange={(_event, registryUsername) =>
handleRemoteRegistryUsername(registryUsername)
}
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
Username for authenticating into the entered remote registry. For
anonymous pulls from the upstream, leave this empty.
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup
isInline
label="Remote Registry password"
fieldId="form-password"
>
<TextInput
isDisabled={!!fetchedProxyCacheConfig?.upstream_registry_password}
type="password"
id="remote-registry-password"
data-testid="remote-registry-password"
value={proxyCacheConfig?.upstream_registry_password || ''}
onChange={(_event, password) =>
handleRemoteRegistryPassword(password)
}
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
Password for authenticating into the entered remote registry. For
anonymous pulls from the upstream, leave this empty.{' '}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup isInline label="Expiration" fieldId="form-username">
<TextInput
type="text"
id="remote-registry-expiration"
data-testid="remote-registry-expiration"
value={proxyCacheConfig?.expiration_s}
placeholder={tagExpirationInSecsForProxyCache.toString()}
onChange={(_event, inputSecs) =>
handleRemoteRegistryExpiration(inputSecs)
}
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
Default tag expiration for cached images, in seconds. This value
is refreshed on every pull. Default is 86400 i.e, 24 hours.{' '}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<FormGroup isInline label="Insecure" fieldId="form-insecure">
<Checkbox
label="http"
isChecked={proxyCacheConfig?.insecure}
onChange={handleInsecureProtocol}
id="controlled-check-2"
data-testid="remote-registry-insecure"
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
If set, http (unsecure protocol) will be used. If not set, https
(secure protocol) will be used to request the remote registry.
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<ActionGroup>
<Flex
justifyContent={{default: 'justifyContentFlexEnd'}}
width={'100%'}
>
<Button
id="save-proxy-cache"
data-testid="save-proxy-cache-btn"
variant="primary"
type="submit"
onClick={(event) => {
event.preventDefault();
proxyCacheConfigValidation();
}}
isDisabled={
!proxyCacheConfig?.upstream_registry ||
!!fetchedProxyCacheConfig?.upstream_registry
}
>
Save
</Button>
<Button
isDisabled={!fetchedProxyCacheConfig?.upstream_registry}
id="delete-proxy-cache"
data-testid="delete-proxy-cache-btn"
variant="danger"
type="submit"
onClick={(event) => {
event.preventDefault();
deleteProxyCacheConfigMutation();
}}
>
Delete
</Button>
</Flex>
</ActionGroup>
<Alerts />
</Form>
);
};

View File

@@ -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: (
<ProxyCacheConfig
organizationName={props.organizationName}
isUser={props.isUserOrganization}
/>
),
visible: quayConfig?.features?.PROXY_CACHE && !props.isUserOrganization,
},
];
return (
@@ -66,6 +78,7 @@ export default function Settings(props: SettingsProps) {
<Tab
key={tab.id}
id={tab.id}
data-testid={tab.name}
eventKey={tabIndex}
title={<TabTitleText>{tab.name}</TabTitleText>}
/>