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