diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue new file mode 100644 index 0000000000000000000000000000000000000000..4ffd1e6085c9d08d6ebefd06c65ca87b229ac80c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_protection_rules.vue @@ -0,0 +1,226 @@ + + + + + {{ $options.i18n.settingBlockTitle }} + + + {{ $options.i18n.settingBlockDescription }} + + + + + + + {{ $options.i18n.settingBlockTitle }} + + + + + + {{ alertErrorMessage }} + + + + + + + + + + + + + + + + diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 8e4c50b199be1acb40e1450fcd64244ef9d7e2ab..ffc8d884cb84a26affeeeb39f47e5e414aa006b5 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -7,12 +7,14 @@ import { UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/packages_and_registries/settings/project/constants'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import ContainerProtectionRules from '~/packages_and_registries/settings/project/components/container_protection_rules.vue'; import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { ContainerExpirationPolicy, + ContainerProtectionRules, DependencyProxyPackagesSettings: () => import( 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue' @@ -40,6 +42,11 @@ export default { showProtectedPackagesSettings() { return this.showPackageRegistrySettings && this.glFeatures.packagesProtectedPackages; }, + showProtectedContainersSettings() { + return ( + this.glFeatures.containerRegistryProtectedContainers && this.showContainerRegistrySettings + ); + }, }, mounted() { this.checkAlert(); @@ -71,6 +78,7 @@ export default { + diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..29c9c55bbb71f3372f6fc9a144142519a4512881 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql @@ -0,0 +1,24 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getProjectContainerProtectionRules( + $projectPath: ID! + $first: Int + $last: Int + $after: String + $before: String +) { + project(fullPath: $projectPath) { + id + containerRegistryProtectionRules(first: $first, last: $last, after: $after, before: $before) { + nodes { + id + repositoryPathPattern + pushProtectedUpToAccessLevel + deleteProtectedUpToAccessLevel + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb index 5c352866c8dfd1df64a84ae5167f6ec104f9ba63..4a3235bf9f30eb287e3497be7645bbca9b27ab40 100644 --- a/app/controllers/projects/settings/packages_and_registries_controller.rb +++ b/app/controllers/projects/settings/packages_and_registries_controller.rb @@ -8,6 +8,7 @@ class PackagesAndRegistriesController < Projects::ApplicationController before_action :authorize_admin_project! before_action :packages_and_registries_settings_enabled! before_action :set_feature_flag_packages_protected_packages, only: :show + before_action :set_feature_flag_container_registry_protected_containers, only: :show feature_category :package_registry urgency :low @@ -35,6 +36,10 @@ def registry_settings_enabled! def set_feature_flag_packages_protected_packages push_frontend_feature_flag(:packages_protected_packages, project) end + + def set_feature_flag_container_registry_protected_containers + push_frontend_feature_flag(:container_registry_protected_containers, project) + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 58a0ed42db8a8cd28248e21bde121a319b2611f4..eb78bf4db1353ebbfed7e38e0cac231ad354a125 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13736,6 +13736,9 @@ msgstr "" msgid "ContainerRegistry|Delete image repository?" msgstr "" +msgid "ContainerRegistry|Delete protected up to access level" +msgstr "" + msgid "ContainerRegistry|Delete selected tags" msgstr "" @@ -13817,6 +13820,9 @@ msgstr "" msgid "ContainerRegistry|Please try different search criteria" msgstr "" +msgid "ContainerRegistry|Protected containers" +msgstr "" + msgid "ContainerRegistry|Published %{timeInfo}" msgstr "" @@ -13826,6 +13832,9 @@ msgstr "" msgid "ContainerRegistry|Push an image" msgstr "" +msgid "ContainerRegistry|Push protected up to access level" +msgstr "" + msgid "ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage." msgstr "" @@ -13846,6 +13855,9 @@ msgstr "" msgid "ContainerRegistry|Remove these tags" msgstr "" +msgid "ContainerRegistry|Repository path pattern" +msgstr "" + msgid "ContainerRegistry|Run cleanup:" msgstr "" @@ -13966,6 +13978,9 @@ msgstr "" msgid "ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}." msgstr "" +msgid "ContainerRegistry|When a container is protected then only certain user roles are able to update and delete the protected container. This helps to avoid tampering with the container." +msgstr "" + msgid "ContainerRegistry|While the rename is in progress, new uploads to the container registry are blocked. Ongoing uploads may fail and need to be retried." msgstr "" diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_rules_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e8f9cb7d27c5a66738616088af9b20d6cb6e313b --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_protection_rules_spec.js @@ -0,0 +1,277 @@ +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ContainerProtectionRules from '~/packages_and_registries/settings/project/components/container_protection_rules.vue'; +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import ContainerProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_container_protection_rules.query.graphql'; +import { containerProtectionRulesData, containerProtectionRuleQueryPayload } from '../mock_data'; + +Vue.use(VueApollo); + +describe('Container protection rules project settings', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const $toast = { show: jest.fn() }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findTable = () => extendedWrapper(wrapper.findByRole('table', /protected Container/i)); + const findTableBody = () => extendedWrapper(findTable().findAllByRole('rowgroup').at(1)); + const findTableRow = (i) => extendedWrapper(findTableBody().findAllByRole('row').at(i)); + const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findByRole('alert'); + + const mountComponent = (mountFn = mountExtended, provide = defaultProvidedValues, config) => { + wrapper = mountFn(ContainerProtectionRules, { + stubs: { + SettingsBlock, + GlModal: true, + }, + mocks: { + $toast, + }, + provide, + ...config, + }); + }; + + const createComponent = ({ + mountFn = mountExtended, + provide = defaultProvidedValues, + containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(containerProtectionRuleQueryPayload()), + config = {}, + } = {}) => { + const requestHandlers = [[ContainerProtectionRuleQuery, containerProtectionRuleQueryResolver]]; + + fakeApollo = createMockApollo(requestHandlers); + + mountComponent(mountFn, provide, { + apolloProvider: fakeApollo, + ...config, + }); + }; + + it('renders the setting block with table', async () => { + createComponent(); + + await waitForPromises(); + + expect(findSettingsBlock().exists()).toBe(true); + expect(findTable().exists()).toBe(true); + }); + + describe('table "package protection rules"', () => { + it('renders table with Container protection rules', async () => { + createComponent(); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + + containerProtectionRuleQueryPayload().data.project.containerRegistryProtectionRules.nodes.forEach( + (protectionRule, i) => { + expect(findTableRow(i).text()).toContain(protectionRule.repositoryPathPattern); + expect(findTableRow(i).text()).toContain('Maintainer'); + expect(findTableRow(i).text()).toContain('Maintainer'); + }, + ); + }); + + it('displays table in busy state and shows loading icon inside table', async () => { + createComponent(); + + expect(findTableLoadingIcon().exists()).toBe(true); + expect(findTableLoadingIcon().attributes('aria-label')).toBe('Loading'); + + expect(findTable().attributes('aria-busy')).toBe('true'); + + await waitForPromises(); + + expect(findTableLoadingIcon().exists()).toBe(false); + expect(findTable().attributes('aria-busy')).toBe('false'); + }); + + it('calls graphql api query', () => { + const containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(containerProtectionRuleQueryPayload()); + createComponent({ containerProtectionRuleQueryResolver }); + + expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith( + expect.objectContaining({ projectPath: defaultProvidedValues.projectPath }), + ); + }); + + it('shows alert when graphql api query failed', async () => { + const graphqlErrorMessage = 'Error when requesting graphql api'; + const containerProtectionRuleQueryResolver = jest + .fn() + .mockRejectedValue(new Error(graphqlErrorMessage)); + createComponent({ containerProtectionRuleQueryResolver }); + + await waitForPromises(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(graphqlErrorMessage); + }); + + describe('table pagination', () => { + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + it('renders pagination', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toMatchObject({ + endCursor: '10', + startCursor: '0', + hasNextPage: true, + hasPreviousPage: false, + }); + }); + + it('calls initial graphql api query with pagination information', () => { + const containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(containerProtectionRuleQueryPayload()); + createComponent({ containerProtectionRuleQueryResolver }); + + expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith( + expect.objectContaining({ + projectPath: defaultProvidedValues.projectPath, + first: 10, + }), + ); + }); + + it('show alert when grapqhl fails', () => { + const containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValue(containerProtectionRuleQueryPayload()); + createComponent({ containerProtectionRuleQueryResolver }); + + expect(containerProtectionRuleQueryResolver).toHaveBeenCalledWith( + expect.objectContaining({ + projectPath: defaultProvidedValues.projectPath, + first: 10, + }), + ); + }); + + describe('when button "Previous" is clicked', () => { + const containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValueOnce( + containerProtectionRuleQueryPayload({ + nodes: containerProtectionRulesData.slice(10), + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + startCursor: '10', + endCursor: '16', + }, + }), + ) + .mockResolvedValueOnce(containerProtectionRuleQueryPayload()); + + const findPaginationButtonPrev = () => + extendedWrapper(findPagination()).findByRole('button', { name: 'Previous' }); + + beforeEach(async () => { + createComponent({ containerProtectionRuleQueryResolver }); + + await waitForPromises(); + + findPaginationButtonPrev().trigger('click'); + }); + + it('sends a second graphql api query with new pagination params', () => { + expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); + expect(containerProtectionRuleQueryResolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + before: '10', + last: 10, + projectPath: 'path', + }), + ); + }); + }); + + describe('when button "Next" is clicked', () => { + const containerProtectionRuleQueryResolver = jest + .fn() + .mockResolvedValueOnce(containerProtectionRuleQueryPayload()) + .mockResolvedValueOnce( + containerProtectionRuleQueryPayload({ + nodes: containerProtectionRulesData.slice(10), + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: '1', + endCursor: '10', + }, + }), + ); + + const findPaginationButtonNext = () => + extendedWrapper(findPagination()).findByRole('button', { name: 'Next' }); + + beforeEach(async () => { + createComponent({ containerProtectionRuleQueryResolver }); + + await waitForPromises(); + + findPaginationButtonNext().trigger('click'); + }); + + it('sends a second graphql api query with new pagination params', () => { + expect(containerProtectionRuleQueryResolver).toHaveBeenCalledTimes(2); + expect(containerProtectionRuleQueryResolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + after: '10', + first: 10, + projectPath: 'path', + }), + ); + }); + }); + }); + }); + + describe('alert "errorMessage"', () => { + const findAlertButtonDismiss = () => wrapper.findByRole('button', { name: /dismiss/i }); + + it('renders alert and dismisses it correctly', async () => { + const alertErrorMessage = 'Error message'; + createComponent({ + config: { + data() { + return { + alertErrorMessage, + }; + }, + }, + }); + + await waitForPromises(); + + expect(findAlert().isVisible()).toBe(true); + expect(findAlert().text()).toBe(alertErrorMessage); + + await findAlertButtonDismiss().trigger('click'); + + expect(findAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 1afc9b62ba22a29528ff8e8b17e40a87f715d36a..cc75e6151ad5668036c449d6a4ab4b29e4d91f3b 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -5,6 +5,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import * as commonUtils from '~/lib/utils/common_utils'; import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue'; +import ContainerProtectionRules from '~/packages_and_registries/settings/project/components/container_protection_rules.vue'; import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue'; import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue'; import DependencyProxyPackagesSettings from 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue'; @@ -19,6 +20,7 @@ describe('Registry Settings app', () => { let wrapper; const findContainerExpirationPolicy = () => wrapper.findComponent(ContainerExpirationPolicy); + const findContainerProtectionRules = () => wrapper.findComponent(ContainerProtectionRules); const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy); const findPackagesProtectionRules = () => wrapper.findComponent(PackagesProtectionRules); const findDependencyProxyPackagesSettings = () => @@ -31,7 +33,10 @@ describe('Registry Settings app', () => { showPackageRegistrySettings: true, showDependencyProxySettings: false, ...(IS_EE && { showDependencyProxySettings: true }), - glFeatures: { packagesProtectedPackages: true }, + glFeatures: { + containerRegistryProtectedContainers: true, + packagesProtectedPackages: true, + }, }; const mountComponent = (provide = defaultProvide) => { @@ -97,6 +102,7 @@ describe('Registry Settings app', () => { }); expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings); + expect(findContainerProtectionRules().exists()).toBe(showContainerRegistrySettings); expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings); expect(findPackagesProtectionRules().exists()).toBe(showPackageRegistrySettings); }, @@ -127,5 +133,20 @@ describe('Registry Settings app', () => { }, ); }); + + describe('when feature flag "containerRegistryProtectedContainers" is disabled', () => { + it.each([true, false])( + 'container protection rules settings is hidden if showContainerRegistrySettings is %s', + (showContainerRegistrySettings) => { + mountComponent({ + ...defaultProvide, + showContainerRegistrySettings, + glFeatures: { containerRegistryProtectedContainers: false }, + }); + + expect(findPackagesProtectionRules().exists()).toBe(false); + }, + ); + }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js index 0b6bdc76f2c428dd11b75a7cd58a1f969dc2f4c4..902dda7835d782690ecd594ef5154c195267b396 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -165,3 +165,40 @@ export const updatePackagesProtectionRuleMutationPayload = ({ }, }, }); + +export const containerProtectionRulesData = [ + ...Array.from(Array(15)).map((_e, i) => ({ + id: `gid://gitlab/ContainerRegistry::Protection::Rule/${i}`, + repositoryPathPattern: `@flight/flight/maintainer-${i}-*`, + pushProtectedUpToAccessLevel: 'MAINTAINER', + deleteProtectedUpToAccessLevel: 'MAINTAINER', + })), + { + id: 'gid://gitlab/ContainerRegistry::Protection::Rule/16', + repositoryPathPattern: '@flight/flight/owner-16-*', + pushProtectedUpToAccessLevel: 'OWNER', + deleteProtectedUpToAccessLevel: 'OWNER', + }, +]; + +export const containerProtectionRuleQueryPayload = ({ + errors = [], + nodes = containerProtectionRulesData.slice(0, 10), + pageInfo = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: '0', + endCursor: '10', + }, +} = {}) => ({ + data: { + project: { + id: '1', + containerRegistryProtectionRules: { + nodes, + pageInfo: { __typename: 'PageInfo', ...pageInfo }, + }, + errors, + }, + }, +}); diff --git a/spec/requests/projects/settings/packages_and_registries_controller_spec.rb b/spec/requests/projects/settings/packages_and_registries_controller_spec.rb index c660be0f3bfcc507697ae60ecb2a81be92756271..aa8d41cf5f81705137573c014124db471d06589c 100644 --- a/spec/requests/projects/settings/packages_and_registries_controller_spec.rb +++ b/spec/requests/projects/settings/packages_and_registries_controller_spec.rb @@ -43,6 +43,24 @@ expect(response.body).not_to have_pushed_frontend_feature_flags(packagesProtectedPackages: true) end end + + it 'pushes the feature flag "container_registry_protected_containers" to the view' do + subject + + expect(response.body).to have_pushed_frontend_feature_flags(containerRegistryProtectedContainers: true) + end + + context 'when feature flag "container_registry_protected_containers" is disabled' do + before do + stub_feature_flags(container_registry_protected_containers: false) + end + + it 'does not push the feature flag "container_registry_protected_containers" to the view' do + subject + + expect(response.body).not_to have_pushed_frontend_feature_flags(containerRegistryProtectedContainers: true) + end + end end end