From 2a9943600e5741454738fa8b417e4801cc8940fb Mon Sep 17 00:00:00 2001 From: "Alan (Maciej) Paruszewski" <mparuszewski@gitlab.com> Date: Wed, 11 Sep 2024 13:05:15 +0000 Subject: [PATCH] Bring back required instance ci template setting in database Changelog: added EE: true --- .rubocop_todo/rspec/named_subject.yml | 1 + .../admin/application_settings_controller.rb | 6 +- .../application_settings/ci_cd.html.haml | 2 + .../feature_flags/beta/required_pipelines.yml | 9 ++ ...gs_required_instance_ci_template_column.rb | 20 ++++ db/schema_migrations/20240904220102 | 1 + db/structure.sql | 2 + .../settings/continuous_integration.md | 43 +++++++++ doc/ci/yaml/index.md | 4 +- .../ci_cd/ci_template_dropdown.vue | 80 ++++++++++++++++ .../application_settings/ci_cd/helpers.js | 20 ++++ .../admin/application_settings/ci_cd/index.js | 18 ++++ .../admin/application_settings_controller.rb | 1 + ee/app/models/ee/application_setting.rb | 2 + .../_required_instance_ci_setting.html.haml | 19 ++++ ee/lib/ee/gitlab/ci/config_ee.rb | 16 ++++ ee/lib/gitlab/ci/config/required/processor.rb | 46 +++++++++ .../application_settings_controller_spec.rb | 37 +++++++ .../ci_cd/ci_template_dropdown_spec.js | 96 +++++++++++++++++++ .../ci_cd/helpers_spec.js | 18 ++++ .../application_settings/ci_cd/mock_data.js | 38 ++++++++ ee/spec/lib/ee/gitlab/ci/config_spec.rb | 42 ++++++++ .../ci/config/required/processor_spec.rb | 61 ++++++++++++ ee/spec/models/application_setting_spec.rb | 7 ++ locale/gitlab.pot | 23 +++++ spec/support/rspec_order_todo.yml | 1 + 26 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 config/feature_flags/beta/required_pipelines.yml create mode 100644 db/migrate/20240904220102_add_application_settings_required_instance_ci_template_column.rb create mode 100644 db/schema_migrations/20240904220102 create mode 100644 ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue create mode 100644 ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/helpers.js create mode 100644 ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js create mode 100644 ee/app/views/admin/application_settings/_required_instance_ci_setting.html.haml create mode 100644 ee/lib/gitlab/ci/config/required/processor.rb create mode 100644 ee/spec/frontend/pages/admin/application_settings/ci_cd/ci_template_dropdown_spec.js create mode 100644 ee/spec/frontend/pages/admin/application_settings/ci_cd/helpers_spec.js create mode 100644 ee/spec/frontend/pages/admin/application_settings/ci_cd/mock_data.js create mode 100644 ee/spec/lib/gitlab/ci/config/required/processor_spec.rb diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml index b2c47ebaf3b55..660dc3a94f264 100644 --- a/.rubocop_todo/rspec/named_subject.yml +++ b/.rubocop_todo/rspec/named_subject.yml @@ -361,6 +361,7 @@ RSpec/NamedSubject: - 'ee/spec/lib/gitlab/auth_spec.rb' - 'ee/spec/lib/gitlab/checks/changes_access_spec.rb' - 'ee/spec/lib/gitlab/checks/diff_check_spec.rb' + - 'ee/spec/lib/gitlab/ci/config/required/processor_spec.rb' - 'ee/spec/lib/gitlab/ci/minutes/cached_quota_spec.rb' - 'ee/spec/lib/gitlab/ci/minutes/consumption_spec.rb' - 'ee/spec/lib/gitlab/ci/minutes/cost_factor_spec.rb' diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index f3cbe074904d0..85242380356b9 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -136,7 +136,7 @@ def disable_query_limiting Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/29418') end - def application_setting_params # rubocop:disable Metrics/AbcSize + def application_setting_params # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity params[:application_setting] ||= {} if params[:application_setting].key?(:enabled_oauth_sign_in_sources) @@ -157,6 +157,10 @@ def application_setting_params # rubocop:disable Metrics/AbcSize normalize_default_branch_params!(:application_setting) + if params[:application_setting][:required_instance_ci_template].blank? + params[:application_setting][:required_instance_ci_template] = nil + end + remove_blank_params_for!(:elasticsearch_aws_secret_access_key, :eks_secret_access_key) # TODO Remove domain_denylist_raw in APIv5 (See https://gitlab.com/gitlab-org/gitlab-foss/issues/67204) diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index bc880fa016782..205affe75f73e 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -26,6 +26,8 @@ - c.with_body do = render 'ci_cd' += render_if_exists 'admin/application_settings/required_instance_ci_setting', expanded: expanded_by_default? + = render_if_exists 'admin/application_settings/package_registry', expanded: expanded_by_default? - if Gitlab.config.registry.enabled diff --git a/config/feature_flags/beta/required_pipelines.yml b/config/feature_flags/beta/required_pipelines.yml new file mode 100644 index 0000000000000..2123b7d352f93 --- /dev/null +++ b/config/feature_flags/beta/required_pipelines.yml @@ -0,0 +1,9 @@ +--- +name: required_pipelines +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/482815 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165111 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/483550 +milestone: '17.4' +group: group::compliance +type: beta +default_enabled: false diff --git a/db/migrate/20240904220102_add_application_settings_required_instance_ci_template_column.rb b/db/migrate/20240904220102_add_application_settings_required_instance_ci_template_column.rb new file mode 100644 index 0000000000000..460b2174aeafb --- /dev/null +++ b/db/migrate/20240904220102_add_application_settings_required_instance_ci_template_column.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddApplicationSettingsRequiredInstanceCiTemplateColumn < Gitlab::Database::Migration[2.2] + milestone '17.4' + disable_ddl_transaction! + + def up + with_lock_retries do + add_column :application_settings, :required_instance_ci_template, :text, if_not_exists: true + end + + add_text_limit :application_settings, :required_instance_ci_template, 1024 + end + + def down + with_lock_retries do + remove_column :application_settings, :required_instance_ci_template, if_exists: true + end + end +end diff --git a/db/schema_migrations/20240904220102 b/db/schema_migrations/20240904220102 new file mode 100644 index 0000000000000..37344ff3c1a97 --- /dev/null +++ b/db/schema_migrations/20240904220102 @@ -0,0 +1 @@ +82591c32f352801552edc0b741e640eb74c9429d889c797f4deb670f43ecc76a \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index c3461e08e9883..88348f02c2543 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -6069,6 +6069,7 @@ CREATE TABLE application_settings ( security_policies jsonb DEFAULT '{}'::jsonb NOT NULL, spp_repository_pipeline_access boolean DEFAULT false NOT NULL, lock_spp_repository_pipeline_access boolean DEFAULT false NOT NULL, + required_instance_ci_template text, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), @@ -6128,6 +6129,7 @@ CREATE TABLE application_settings ( CONSTRAINT check_application_settings_security_policies_is_hash CHECK ((jsonb_typeof(security_policies) = 'object'::text)), CONSTRAINT check_application_settings_service_ping_settings_is_hash CHECK ((jsonb_typeof(service_ping_settings) = 'object'::text)), CONSTRAINT check_b8c74ea5b3 CHECK ((char_length(deactivation_email_additional_text) <= 1000)), + CONSTRAINT check_bf5157a366 CHECK ((char_length(required_instance_ci_template) <= 1024)), CONSTRAINT check_cdfbd99405 CHECK ((char_length(security_txt_content) <= 2048)), CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)), CONSTRAINT check_d820146492 CHECK ((char_length(spam_check_endpoint_url) <= 255)), diff --git a/doc/administration/settings/continuous_integration.md b/doc/administration/settings/continuous_integration.md index 25959b692873a..e3704e7e0c24c 100644 --- a/doc/administration/settings/continuous_integration.md +++ b/doc/administration/settings/continuous_integration.md @@ -291,6 +291,49 @@ so you can view job artifact pages directly: 1. Expand **Continuous Integration and Deployment**. 1. Deselect **Enable the external redirect page for job artifacts**. +## Required pipeline configuration + +DETAILS: +**Tier:** Ultimate +**Offering:** Self-managed + +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/352316) from GitLab Premium to GitLab Ultimate in 15.0. +> - [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/389467) in GitLab 15.9. +> - [Removed](https://gitlab.com/gitlab-org/gitlab/-/issues/389467) in GitLab 17.0. +> - [Re-added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165111) behind the `required_pipelines` feature flag in GitLab 17.4. Disabled by default. + +WARNING: +This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/389467) in GitLab 15.9 +and was removed in 17.0. From 17.4, it is available only behind the feature flag `required_pipelines`, disabled by default. +Use [compliance pipelines](../../user/group/compliance_pipelines.md) instead. This change is a breaking change. + +You can set a [CI/CD template](../../ci/examples/index.md#cicd-templates) +as a required pipeline configuration for all projects on a GitLab instance. You can +use a template from: + +- The default CI/CD templates. +- A custom template stored in an [instance template repository](instance_template_repository.md). + + NOTE: + When you use a configuration defined in an instance template repository, + nested [`include:`](../../ci/yaml/index.md#include) keywords + (including `include:file`, `include:local`, `include:remote`, and `include:template`) + [do not work](https://gitlab.com/gitlab-org/gitlab/-/issues/35345). + +The project CI/CD configuration merges into the required pipeline configuration when +a pipeline runs. The merged configuration is the same as if the required pipeline configuration +added the project configuration with the [`include` keyword](../../ci/yaml/index.md#include). +To view a project's full merged configuration, [View full configuration](../../ci/pipeline_editor/index.md#view-full-configuration) +in the pipeline editor. + +To select a CI/CD template for the required pipeline configuration: + +1. On the left sidebar, at the bottom, select **Admin Area**. +1. Select **Settings > CI/CD**. +1. Expand the **Required pipeline configuration** section. +1. Select a CI/CD template from the dropdown list. +1. Select **Save changes**. + ## Package registry configuration ### Maven Forwarding diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index ab0a84169cf2f..7ec286739ee19 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -481,7 +481,9 @@ The order of the items in `stages` defines the execution order for jobs: - Jobs in the next stage run after the jobs from the previous stage complete successfully. If a pipeline contains only jobs in the `.pre` or `.post` stages, it does not run. -There must be at least one other job in a different stage. +There must be at least one other job in a different stage. `.pre` and `.post` stages +can be used in [required pipeline configuration](../../administration/settings/continuous_integration.md#required-pipeline-configuration) +to define compliance jobs that must run before or after project pipeline jobs. **Keyword type**: Global keyword. diff --git a/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue new file mode 100644 index 0000000000000..30285891b5664 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue @@ -0,0 +1,80 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { __, n__, s__ } from '~/locale'; +import { filterItems } from './helpers'; + +export default { + name: 'CiTemplateDropdown', + i18n: { + searchPlaceholder: s__('AdminSettings|No required configuration'), + headerText: s__('AdminSettings|Select a CI/CD template'), + searchSummaryText: s__('AdminSettings|templates found'), + resetButtonLabel: __('Reset'), + }, + components: { + GlCollapsibleListbox, + }, + inject: { + initialSelectedGitlabCiYmlName: { + default: null, + }, + gitlabCiYmls: { + default: {}, + }, + }, + data() { + return { + selected: this.initialSelectedGitlabCiYmlName, + searchTerm: '', + }; + }, + computed: { + items() { + return filterItems(this.gitlabCiYmls, this.searchTerm); + }, + toggleText() { + return this.selected || this.$options.i18n.searchPlaceholder; + }, + numberOfResults() { + return this.items.reduce((count, current) => count + current.options.length, 0); + }, + searchSummary() { + return n__(`%d template found`, `%d templates found`, this.numberOfResults); + }, + }, + methods: { + onReset() { + this.selected = null; + }, + onSearch(query) { + this.searchTerm = query.trim().toLowerCase(); + }, + }, +}; +</script> + +<template> + <div> + <input + id="required_instance_ci_template_name" + type="hidden" + name="application_setting[required_instance_ci_template]" + :value="selected" + /> + <gl-collapsible-listbox + v-model="selected" + searchable + :header-text="$options.i18n.headerText" + :items="items" + :reset-button-label="$options.i18n.resetButtonLabel" + :search-placeholder="$options.i18n.searchPlaceholder" + :toggle-text="toggleText" + @reset="onReset" + @search="onSearch" + > + <template #search-summary-sr-only> + {{ searchSummary }} + </template> + </gl-collapsible-listbox> + </div> +</template> diff --git a/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/helpers.js b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/helpers.js new file mode 100644 index 0000000000000..1ea357b9eba60 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/helpers.js @@ -0,0 +1,20 @@ +/** + * Filters [items] based on a given [searchTerm]. + * Catagories with no items after filtering are not included in the returned object. + * @param {Object} allItems - { <categoryName>: [{ name, id }] } + * @param {String} searchTerm + * @returns {Object} + */ +export function filterItems(allItems, searchTerm) { + return Object.entries(allItems) + .map(([key, items]) => ({ + text: key, + options: items + .filter((item) => item.name.toLowerCase().includes(searchTerm)) + .map((item) => ({ + text: item.name, + value: item.key, + })), + })) + .filter((group) => group.options.length > 0); +} diff --git a/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js new file mode 100644 index 0000000000000..562a7aefbf530 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js @@ -0,0 +1,18 @@ +import '~/pages/admin/application_settings/ci_cd/index'; +import Vue from 'vue'; +import CiTemplateDropdown from './ci_template_dropdown.vue'; + +const el = document.querySelector('.js-ci-template-dropdown'); +const { gitlabCiYmls, value } = el.dataset; + +// eslint-disable-next-line no-new +new Vue({ + el, + provide: { + gitlabCiYmls: JSON.parse(gitlabCiYmls), + initialSelectedGitlabCiYmlName: value, + }, + render(createElement) { + return createElement(CiTemplateDropdown); + }, +}); diff --git a/ee/app/controllers/ee/admin/application_settings_controller.rb b/ee/app/controllers/ee/admin/application_settings_controller.rb index dd067cee93eda..fdcbb803a5745 100644 --- a/ee/app/controllers/ee/admin/application_settings_controller.rb +++ b/ee/app/controllers/ee/admin/application_settings_controller.rb @@ -122,6 +122,7 @@ def visible_application_setting_attributes custom_file_templates: :file_template_project_id, default_project_deletion_protection: :default_project_deletion_protection, adjourned_deletion_for_projects_and_groups: :deletion_adjourned_period, + required_ci_templates: :required_instance_ci_template, disable_name_update_for_users: :updating_name_disabled_for_users, package_forwarding: [:npm_package_requests_forwarding, :lock_npm_package_requests_forwarding, diff --git a/ee/app/models/ee/application_setting.rb b/ee/app/models/ee/application_setting.rb index 9589507b3061d..0aa44fff2f21e 100644 --- a/ee/app/models/ee/application_setting.rb +++ b/ee/app/models/ee/application_setting.rb @@ -94,6 +94,8 @@ module ApplicationSetting attribute :future_subscriptions, ::Gitlab::Database::Type::IndifferentJsonb.new validates :future_subscriptions, json_schema: { filename: 'future_subscriptions' } + validates :required_instance_ci_template, presence: true, allow_nil: true + validates :geo_node_allowed_ips, length: { maximum: 255 }, presence: true validate :check_geo_node_allowed_ips diff --git a/ee/app/views/admin/application_settings/_required_instance_ci_setting.html.haml b/ee/app/views/admin/application_settings/_required_instance_ci_setting.html.haml new file mode 100644 index 0000000000000..459a4dab47e66 --- /dev/null +++ b/ee/app/views/admin/application_settings/_required_instance_ci_setting.html.haml @@ -0,0 +1,19 @@ +- if License.feature_available?(:required_ci_templates) && Feature.enabled?(:required_pipelines, @project) + = render ::Layouts::SettingsBlockComponent.new(s_('AdminSettings|Required pipeline configuration'), + id: 'js-required-pipeline-settings', + expanded: expanded_by_default?) do |c| + - c.with_description do + - config_link = link_to('', help_page_path('administration/settings/continuous_integration', anchor: 'required-pipeline-configuration')) + = safe_format(s_('AdminSettings|Set a CI/CD template as the required pipeline configuration for all projects in the instance. Project CI/CD configuration merges into the required pipeline configuration when the pipeline runs. %{link_start}What is a required pipeline configuration?%{link_end}'), tag_pair(config_link, :link_start, :link_end)) + - c.with_body do + %p + - instance_link = link_to('', help_page_path('administration/settings/instance_template_repository')) + = safe_format(s_('AdminSettings|The template for the required pipeline configuration can be one of the GitLab-provided templates, or a custom template added to an instance template repository. %{link_start}How do I create an instance template repository?%{link_end}'), tag_pair(instance_link, :link_start, :link_end)) + = gitlab_ui_form_for @application_setting, url: ci_cd_admin_application_settings_path(anchor: 'js-required-pipeline-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + .form-group.col-md-9.gl-p-0 + = f.label :required_instance_ci_template, s_('AdminSettings|Select a CI/CD template') + .js-ci-template-dropdown{ data: { gitlab_ci_ymls: gitlab_ci_ymls(@project).to_json, value: @application_setting.required_instance_ci_template } } + + = f.submit _('Save changes'), pajamas_button: true diff --git a/ee/lib/ee/gitlab/ci/config_ee.rb b/ee/lib/ee/gitlab/ci/config_ee.rb index d4cb28330d137..a5ad4607f1fbf 100644 --- a/ee/lib/ee/gitlab/ci/config_ee.rb +++ b/ee/lib/ee/gitlab/ci/config_ee.rb @@ -8,13 +8,25 @@ module Ci module ConfigEE extend ::Gitlab::Utils::Override + override :rescue_errors + def rescue_errors + [*super, ::Gitlab::Ci::Config::Required::Processor::RequiredError] + end + override :build_config def build_config(config) super + .then { |config| process_required_includes(config) } .then { |config| inject_pipeline_execution_policy_stages(config) } .then { |config| process_security_orchestration_policy_includes(config) } end + def process_required_includes(config) + return config unless required_pipelines_enabled? + + ::Gitlab::Ci::Config::Required::Processor.new(config).perform + end + def inject_pipeline_execution_policy_stages(config) return config unless pipeline_policy_context&.inject_policy_reserved_stages? @@ -34,6 +46,10 @@ def process_security_orchestration_policy_includes(config) source).perform end end + + def required_pipelines_enabled? + @project.present? && ::Feature.enabled?(:required_pipelines, @project) # rubocop:disable Gitlab/ModuleWithInstanceVariables -- temporary usage + end end end end diff --git a/ee/lib/gitlab/ci/config/required/processor.rb b/ee/lib/gitlab/ci/config/required/processor.rb new file mode 100644 index 0000000000000..cef68157fac29 --- /dev/null +++ b/ee/lib/gitlab/ci/config/required/processor.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Required + class Processor + RequiredError = Class.new(StandardError) + + def initialize(config) + @config = config + end + + def perform + return @config unless ::License.feature_available?(:required_ci_templates) + return @config unless required_ci_template_name.present? + + merge_required_template + end + + def merge_required_template + raise RequiredError, "Required template '#{required_ci_template_name}' not found!" unless required_template + + @config.deep_merge(required_template_hash) + end + + private + + def required_template_hash + ci_yaml = Gitlab::Ci::Config::Yaml::Loader.new(required_template.content).load + + ci_yaml.content + end + + def required_template + ::TemplateFinder.build(:gitlab_ci_ymls, nil, name: required_ci_template_name).execute + end + + def required_ci_template_name + ::Gitlab::CurrentSettings.current_application_settings.required_instance_ci_template + end + end + end + end + end +end diff --git a/ee/spec/controllers/admin/application_settings_controller_spec.rb b/ee/spec/controllers/admin/application_settings_controller_spec.rb index e4e9285c86fec..e9caa8142f68b 100644 --- a/ee/spec/controllers/admin/application_settings_controller_spec.rb +++ b/ee/spec/controllers/admin/application_settings_controller_spec.rb @@ -278,6 +278,43 @@ it_behaves_like 'settings for licensed features' end + context 'required instance ci template' do + let(:settings) { { required_instance_ci_template: 'Auto-DevOps' } } + let(:feature) { :required_ci_templates } + + it_behaves_like 'settings for licensed features' + + context 'when ApplicationSetting already has a required_instance_ci_template value' do + before do + ApplicationSetting.current.update!(required_instance_ci_template: 'Auto-DevOps') + end + + context 'with a valid value' do + let(:settings) { { required_instance_ci_template: 'Code-Quality' } } + + it_behaves_like 'settings for licensed features' + end + + context 'with an empty value' do + it 'sets required_instance_ci_template as nil' do + stub_licensed_features(required_ci_templates: true) + + put :update, params: { application_setting: { required_instance_ci_template: '' } } + + expect(ApplicationSetting.current.required_instance_ci_template).to be_nil + end + end + + context 'without key' do + it 'does not set required_instance_ci_template to nil' do + put :update, params: { application_setting: {} } + + expect(ApplicationSetting.current.required_instance_ci_template).to be == 'Auto-DevOps' + end + end + end + end + context 'secret detection settings' do let(:settings) { { pre_receive_secret_detection_enabled: true } } let(:feature) { :pre_receive_secret_detection } diff --git a/ee/spec/frontend/pages/admin/application_settings/ci_cd/ci_template_dropdown_spec.js b/ee/spec/frontend/pages/admin/application_settings/ci_cd/ci_template_dropdown_spec.js new file mode 100644 index 0000000000000..fd096791d282b --- /dev/null +++ b/ee/spec/frontend/pages/admin/application_settings/ci_cd/ci_template_dropdown_spec.js @@ -0,0 +1,96 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import CiTemplateDropdown from 'ee/pages/admin/application_settings/ci_cd/ci_template_dropdown.vue'; +import { MOCK_CI_YMLS, initialSelectedName } from './mock_data'; + +describe('CiTemplateDropdown', () => { + let wrapper; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const templates = MOCK_CI_YMLS.Security; + + const createComponent = (provide) => { + wrapper = shallowMount(CiTemplateDropdown, { + provide: { gitlabCiYmls: MOCK_CI_YMLS, ...provide }, + }); + }; + + describe('renders', () => { + beforeEach(() => { + createComponent(); + }); + + it('dropdown', () => { + expect(findListbox().exists()).toBe(true); + }); + + it('renders the correct default text', () => { + expect(findListbox().props('headerText')).toBe('Select a CI/CD template'); + expect(findListbox().props('searchPlaceholder')).toBe('No required configuration'); + expect(findListbox().props('resetButtonLabel')).toBe('Reset'); + }); + }); + + describe('when initial value is not provided', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders listbox toggle button with no selected item', () => { + expect(findListbox().props('toggleText')).toBe('No required configuration'); + }); + + it('no selected item is checked', () => { + expect(findListbox().props('selected')).toBe(null); + }); + + describe('when item is selected', () => { + it('populates correct props', async () => { + await findListbox().vm.$emit('select', templates[0].key); + expect(findListbox().props('selected')).toBe(templates[0].key); + expect(findListbox().props('toggleText')).toBe(templates[0].key); + }); + }); + }); + + describe('when initial value is provided', () => { + beforeEach(() => { + createComponent({ initialSelectedGitlabCiYmlName: initialSelectedName }); + }); + + it('renders listbox toggle button with selected template name', () => { + expect(findListbox().props('toggleText')).toBe(initialSelectedName); + }); + + it('selected template is checked', () => { + expect(findListbox().props('selected')).toBe(initialSelectedName); + }); + + describe('when dropdown is reset', () => { + it('clears selected', async () => { + expect(findListbox().props('selected')).toBe(initialSelectedName); + await findListbox().vm.$emit('reset'); + expect(findListbox().props('selected')).toBe(null); + }); + }); + }); + + describe('when searching with filter', () => { + const searchTerm = 'fi'; + + beforeEach(() => { + createComponent(); + findListbox().vm.$emit('search', searchTerm); + }); + + it('filters items correctly', () => { + const expected = [ + { + text: 'Security', + options: [{ value: 'fizz', text: 'fizz' }], + }, + ]; + expect(findListbox().props('items')).toEqual(expected); + }); + }); +}); diff --git a/ee/spec/frontend/pages/admin/application_settings/ci_cd/helpers_spec.js b/ee/spec/frontend/pages/admin/application_settings/ci_cd/helpers_spec.js new file mode 100644 index 0000000000000..99c24f6366985 --- /dev/null +++ b/ee/spec/frontend/pages/admin/application_settings/ci_cd/helpers_spec.js @@ -0,0 +1,18 @@ +import { filterItems } from 'ee/pages/admin/application_settings/ci_cd/helpers'; + +describe('CI/CD helpers', () => { + const Yml = (name) => ({ name, id: name, key: name }); + it.each` + allItems | searchTerm | result + ${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'t'} | ${[{ text: 'CatA', options: [{ text: 'test', value: 'test' }] }, { text: 'CatB', options: [{ text: 'test', value: 'test' }] }]} + ${{ CatA: [Yml('test'), Yml('tether')], CatB: [Yml('test')] }} | ${'tet'} | ${[{ text: 'CatA', options: [{ text: 'tether', value: 'tether' }] }]} + ${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'n'} | ${[{ text: 'CatA', options: [{ text: 'node', value: 'node' }] }]} + ${{ CatA: [Yml('test'), Yml('node')], CatB: [Yml('test')] }} | ${'asd'} | ${[]} + ${[]} | ${'x'} | ${[]} + `( + 'returns filtered list with correct categories when search term is $searchTerm', + ({ allItems, searchTerm, result }) => { + expect(filterItems(allItems, searchTerm)).toEqual(result); + }, + ); +}); diff --git a/ee/spec/frontend/pages/admin/application_settings/ci_cd/mock_data.js b/ee/spec/frontend/pages/admin/application_settings/ci_cd/mock_data.js new file mode 100644 index 0000000000000..98b461095b525 --- /dev/null +++ b/ee/spec/frontend/pages/admin/application_settings/ci_cd/mock_data.js @@ -0,0 +1,38 @@ +export const initialSelectedName = 'ruby'; + +export const MOCK_CI_YMLS = { + General: [ + { + name: 'test', + id: 'test', + key: 'test', + }, + { + name: 'node', + id: 'node', + key: 'node', + }, + { + name: 'ruby', + id: 'ruby', + key: 'ruby', + }, + ], + Security: [ + { + name: 'fizz', + id: 'fizz', + key: 'fizz', + }, + { + name: 'buzz', + id: 'buzz', + key: 'buzz', + }, + { + name: 'bar', + id: 'bar', + key: 'bar', + }, + ], +}; diff --git a/ee/spec/lib/ee/gitlab/ci/config_spec.rb b/ee/spec/lib/ee/gitlab/ci/config_spec.rb index 6c66711dc9c21..1fe6ef8a8fceb 100644 --- a/ee/spec/lib/ee/gitlab/ci/config_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/config_spec.rb @@ -11,6 +11,48 @@ EOS end + describe 'with required instance template' do + let(:template_name) { 'test_template' } + let(:template_repository) { create(:project, :custom_repo, files: { "gitlab-ci/#{template_name}.yml" => template_yml }) } + + let(:template_yml) do + <<-EOS + sample_job: + script: + - echo 'not test' + EOS + end + + let_it_be_with_refind(:project) { create(:project, :repository) } + + subject(:config) { described_class.new(ci_yml, project: project) } + + before do + stub_application_setting(file_template_project: template_repository, required_instance_ci_template: template_name) + stub_licensed_features(custom_file_templates: true, required_ci_templates: true) + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(required_pipelines: true) + end + + it 'processes the required includes' do + expect(config.to_hash[:sample_job][:script]).to eq(["echo 'not test'"]) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(required_pipelines: false) + end + + it 'does not process the required includes' do + expect(config.to_hash[:sample_job][:script]).to eq(["echo 'test'"]) + end + end + end + describe 'with security orchestration policy' do let(:source) { 'push' } diff --git a/ee/spec/lib/gitlab/ci/config/required/processor_spec.rb b/ee/spec/lib/gitlab/ci/config/required/processor_spec.rb new file mode 100644 index 0000000000000..9789e7648462f --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/required/processor_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Required::Processor, feature_category: :pipeline_composition do + subject { described_class.new(config).perform } + + let(:config) { { image: 'image:1.0.0' } } + + context 'when feature is available' do + before do + stub_licensed_features(required_ci_templates: true) + + stub_application_setting(required_instance_ci_template: required_ci_template_name) + end + + context 'when template is set' do + context 'when template can not be found' do + let(:required_ci_template_name) { 'invalid_template_name' } + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Ci::Config::Required::Processor::RequiredError) + end + end + + context 'when template can be found' do + let(:required_ci_template_name) { 'Android' } + + it 'merges the template content with the config' do + expect(subject).to include(image: 'eclipse-temurin:17-jdk-jammy') + end + end + end + + context 'when template is not set' do + let(:required_ci_template_name) { nil } + + it 'returns the unmodified config' do + expect(subject).to eq(config) + end + end + + context 'when template is empty string' do + let(:required_ci_template_name) { "" } + + it 'returns the unmodified config' do + expect(subject).to eq(config) + end + end + end + + context 'when feature is not available' do + before do + stub_licensed_features(required_ci_templates: false) + end + + it 'returns the unmodified config' do + expect(subject).to eq(config) + end + end +end diff --git a/ee/spec/models/application_setting_spec.rb b/ee/spec/models/application_setting_spec.rb index 676d2854749a9..d10199b563740 100644 --- a/ee/spec/models/application_setting_spec.rb +++ b/ee/spec/models/application_setting_spec.rb @@ -117,6 +117,13 @@ it { is_expected.not_to allow_value(nil).for(:future_subscriptions) } end + describe 'required_instance', feature_category: :pipeline_composition do + it { is_expected.to allow_value(nil).for(:required_instance_ci_template) } + it { is_expected.not_to allow_value("").for(:required_instance_ci_template) } + it { is_expected.not_to allow_value(" ").for(:required_instance_ci_template) } + it { is_expected.to allow_value("template_name").for(:required_instance_ci_template) } + end + describe 'max_personal_access_token', feature_category: :user_management do it { is_expected.to validate_numericality_of(:max_personal_access_token_lifetime).only_integer.is_greater_than(0).is_less_than_or_equal_to(365).allow_nil } end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2a7ff041d3d73..f32de10febb12 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -482,6 +482,11 @@ msgid_plural "%d tags per image name" msgstr[0] "" msgstr[1] "" +msgid "%d template found" +msgid_plural "%d templates found" +msgstr[0] "" +msgstr[1] "" + msgid "%d unresolved thread" msgid_plural "%d unresolved threads" msgstr[0] "" @@ -4231,6 +4236,9 @@ msgstr "" msgid "AdminSettings|New CI/CD variables in projects and groups default to protected." msgstr "" +msgid "AdminSettings|No required configuration" +msgstr "" + msgid "AdminSettings|Only enable search after installing the plugin, enabling indexing, and recreating the index." msgstr "" @@ -4261,6 +4269,9 @@ msgstr "" msgid "AdminSettings|Require users to prove ownership of custom domains" msgstr "" +msgid "AdminSettings|Required pipeline configuration" +msgstr "" + msgid "AdminSettings|Requires %{linkStart}email notifications%{linkEnd}" msgstr "" @@ -4276,6 +4287,9 @@ msgstr "" msgid "AdminSettings|Secret Push Protection" msgstr "" +msgid "AdminSettings|Select a CI/CD template" +msgstr "" + msgid "AdminSettings|Select a group to use as a source of custom templates for new projects. %{link_start}Learn more%{link_end}." msgstr "" @@ -4297,6 +4311,9 @@ msgstr "" msgid "AdminSettings|Session duration for Git operations when 2FA is enabled (minutes)" msgstr "" +msgid "AdminSettings|Set a CI/CD template as the required pipeline configuration for all projects in the instance. Project CI/CD configuration merges into the required pipeline configuration when the pipeline runs. %{link_start}What is a required pipeline configuration?%{link_end}" +msgstr "" + msgid "AdminSettings|Set options for cost factors of forks" msgstr "" @@ -4363,6 +4380,9 @@ msgstr "" msgid "AdminSettings|The selected level must be different from the selected default group and project visibility." msgstr "" +msgid "AdminSettings|The template for the required pipeline configuration can be one of the GitLab-provided templates, or a custom template added to an instance template repository. %{link_start}How do I create an instance template repository?%{link_end}" +msgstr "" + msgid "AdminSettings|There are Advanced Search migrations pending that require indexing to pause. Indexing must remain paused until GitLab completes the migrations." msgstr "" @@ -4408,6 +4428,9 @@ msgstr "" msgid "AdminSettings|You can't delete projects before the warning email is sent." msgstr "" +msgid "AdminSettings|templates found" +msgstr "" + msgid "AdminStatistics|Active Users" msgstr "" diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index bedb7dcd26322..dfc66539fed50 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -1065,6 +1065,7 @@ - './ee/spec/lib/gitlab/ci/config/entry/secret_spec.rb' - './ee/spec/lib/gitlab/ci/config/entry/vault/engine_spec.rb' - './ee/spec/lib/gitlab/ci/config/entry/vault/secret_spec.rb' +- './ee/spec/lib/gitlab/ci/config/required/processor_spec.rb' - './ee/spec/lib/gitlab/cidr_spec.rb' - './ee/spec/lib/gitlab/ci/minutes/cached_quota_spec.rb' - './ee/spec/lib/gitlab/ci/minutes/cost_factor_spec.rb' -- GitLab